checker-blacklist/checker/report.go

341 lines
10 KiB
Go

package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"sort"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders a generic, source-agnostic HTML report. The
// per-source rich detail (VT vendor table, URLhaus URL list, …) is
// rendered by sources implementing DetailRenderer; everything else
// (headline, action-required cards, summary table) walks the uniform
// SourceResult envelope without any source-specific switch.
func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data BlacklistData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("decode blacklist data: %w", err)
}
view := reportView{
Domain: data.Domain,
RegisteredDomain: data.RegisteredDomain,
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
TotalHits: data.TotalHits(),
Diagnoses: diagnose(&data),
Sections: buildSections(&data),
CSS: template.CSS(reportCSS),
}
view.Headline, view.HeadlineClass = headline(view.TotalHits)
var b bytes.Buffer
if err := reportTemplate.Execute(&b, view); err != nil {
return "", fmt.Errorf("render blacklist report: %w", err)
}
return b.String(), nil
}
type reportView struct {
Domain string
RegisteredDomain string
CollectedAt string
TotalHits int
Headline string
HeadlineClass string
Diagnoses []Diagnosis
Sections []sourceSection
CSS template.CSS
}
// sourceSection is one rendered card per Source (not per result): a
// multi-result source like DNSBL is collapsed to a single section that
// lists each subject as a row. Rich sources contribute extra HTML via
// their RenderDetail implementation; plain sources fall back to the
// generic Reasons/Evidence rendering.
type sourceSection struct {
SourceID string
SourceName string
StatusLabel string
StatusClass string
Subjects []subjectRow
RichHTML template.HTML
Reference string
}
type subjectRow struct {
Subject string
StatusLabel string
StatusClass string
Reasons []string
Evidence []Evidence
LookupURL string
RemovalURL string
Reference string
Error string
Disabled bool
}
func headline(hits int) (string, string) {
switch hits {
case 0:
return "Domain is clean across all configured reputation sources.", SeverityOK
case 1:
return "Domain is currently listed on 1 source. Act now: a single listing already breaks email delivery and browser access.", SeverityCrit
default:
return fmt.Sprintf("Domain is currently listed on %d sources. This is severe: most mail and browsers will block access.", hits), SeverityCrit
}
}
// diagnose builds the action-required cards by delegating to each
// listed result's source. The generic code only orders cards by
// severity; the wording and remediation are owned by the source.
func diagnose(d *BlacklistData) []Diagnosis {
byID := make(map[string]Source, len(Sources()))
for _, s := range Sources() {
byID[s.ID()] = s
}
var out []Diagnosis
for _, r := range d.Results {
if !r.Listed {
continue
}
if s, ok := byID[r.SourceID]; ok {
out = append(out, s.Diagnose(r))
}
}
// Errors are surfaced as warnings so a flaky source is visible
// without dominating the page.
for _, r := range d.Results {
if r.Error == "" {
continue
}
title := "Could not query " + r.SourceName
if r.Subject != "" && r.Subject != r.SourceName {
title = fmt.Sprintf("Could not query %s (%s)", r.SourceName, r.Subject)
}
out = append(out, Diagnosis{
Severity: SeverityWarn,
Title: title,
Detail: r.Error + ": the listing status of this source is unknown for this run.",
})
}
sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) })
return out
}
func sevRank(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
case SeverityInfo:
return 2
}
return 3
}
// buildSections groups results by source, collapses multi-result
// sources into a single card, and asks each source for its rich detail
// HTML when applicable.
func buildSections(d *BlacklistData) []sourceSection {
byID := make(map[string]Source, len(Sources()))
order := make([]string, 0, len(Sources()))
for _, s := range Sources() {
byID[s.ID()] = s
order = append(order, s.ID())
}
grouped := make(map[string][]SourceResult)
for _, r := range d.Results {
grouped[r.SourceID] = append(grouped[r.SourceID], r)
}
out := make([]sourceSection, 0, len(grouped))
for _, id := range order {
results := grouped[id]
if len(results) == 0 {
continue
}
section := sourceSection{
SourceID: id,
SourceName: byID[id].Name(),
}
section.StatusLabel, section.StatusClass = sectionStatus(results)
for _, r := range results {
if r.Reference != "" && section.Reference == "" {
section.Reference = r.Reference
}
section.Subjects = append(section.Subjects, subjectRow{
Subject: subjectLabel(byID[id].Name(), r),
StatusLabel: subjectStatusLabel(r),
StatusClass: subjectStatusClass(r),
Reasons: r.Reasons,
Evidence: r.Evidence,
LookupURL: r.LookupURL,
RemovalURL: r.RemovalURL,
Reference: r.Reference,
Error: r.Error,
Disabled: !r.Enabled,
})
}
// Rich detail: use the first listed result's payload (single-
// subject sources have at most one). Plain sources skip this.
if dr, ok := byID[id].(DetailRenderer); ok {
for _, r := range results {
if !r.Listed && len(r.Details) == 0 {
continue
}
html, err := dr.RenderDetail(r)
if err == nil && html != "" {
section.RichHTML = html
break
}
}
}
out = append(out, section)
}
return out
}
func sectionStatus(results []SourceResult) (string, string) {
listed, errs, enabled := 0, 0, 0
for _, r := range results {
if r.Enabled {
enabled++
}
if r.Listed {
listed++
} else if r.Error != "" {
errs++
}
}
switch {
case enabled == 0:
return "Disabled", "muted"
case listed > 0:
return fmt.Sprintf("LISTED (%d)", listed), "crit"
case errs > 0:
return "Errors", "warn"
}
return "Clean", "ok"
}
func subjectLabel(srcName string, r SourceResult) string {
if r.Subject != "" && r.Subject != srcName {
return r.Subject
}
return srcName
}
func subjectStatusLabel(r SourceResult) string {
switch {
case !r.Enabled:
return "Disabled"
case r.Listed:
return "LISTED"
case r.Error != "":
return "Error"
}
return "Clean"
}
func subjectStatusClass(r SourceResult) string {
switch {
case !r.Enabled:
return "muted"
case r.Listed:
return r.Severity
case r.Error != "":
return "warn"
}
return "ok"
}
var reportTemplate = template.Must(template.New("blacklist").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Blacklist report — {{.Domain}}</title>
<style>{{.CSS}}</style>
</head>
<body><main>
<h1>Blacklist & reputation</h1>
<p class="meta"><code>{{.Domain}}</code>{{if and .RegisteredDomain (ne .RegisteredDomain .Domain)}} (queried as <code>{{.RegisteredDomain}}</code>){{end}} · collected {{.CollectedAt}}</p>
<section class="headline status-{{.HeadlineClass}}"><strong>{{.Headline}}</strong></section>
{{with .Diagnoses}}<section class="diagnosis">
<h2>Action required</h2>
{{range .}}<article class="finding sev-{{.Severity}}">
<h3>{{.Title}}</h3>
<p>{{.Detail}}</p>
{{if .LookupURL}}<p><a href="{{.LookupURL}}" target="_blank" rel="noreferrer">Public lookup</a>{{if .RemovalURL}} · <a href="{{.RemovalURL}}" target="_blank" rel="noreferrer">Removal procedure</a>{{end}}</p>{{else if .FixIsURL}}<p><a href="{{.Fix}}" target="_blank" rel="noreferrer">{{.Fix}}</a></p>{{else if .Fix}}<pre class="fix">{{.Fix}}</pre>{{end}}
</article>
{{end}}</section>
{{end}}
{{range .Sections}}<section class="src">
<h2>{{.SourceName}} <span class="badge status-{{.StatusClass}}">{{.StatusLabel}}</span></h2>
{{if .RichHTML}}{{.RichHTML}}{{end}}
{{if .Subjects}}<table>
<thead><tr><th>Subject</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>
{{range .Subjects}}<tr class="row-{{.StatusClass}}">
<td><strong>{{.Subject}}</strong></td>
<td>{{.StatusLabel}}</td>
<td>
{{if .Disabled}}<span class="muted">disabled</span>
{{else if .Error}}{{.Error}}
{{else}}
{{range .Reasons}}<div>{{.}}</div>{{end}}
{{if .Evidence}}<details><summary>{{len .Evidence}} evidence item(s)</summary><ul>{{range .Evidence}}<li><code>{{.Value}}</code>{{with .Status}} <small>({{.}})</small>{{end}}</li>{{end}}</ul></details>{{end}}
{{if .LookupURL}}<div><a href="{{.LookupURL}}" target="_blank" rel="noreferrer">Lookup</a>{{if .RemovalURL}} · <a href="{{.RemovalURL}}" target="_blank" rel="noreferrer">Request removal</a>{{end}}</div>{{end}}
{{end}}
</td>
</tr>
{{end}}</tbody>
</table>{{end}}
</section>
{{end}}
</main></body></html>`))
const reportCSS = `body{font-family:system-ui,sans-serif;margin:0;background:#fafbfc;color:#1b1f23;}
main{max-width:980px;margin:0 auto;padding:1.5rem;}
h1{margin:0 0 .25rem 0;}
.meta{color:#586069;margin:0 0 1rem 0;}
section{margin-bottom:2rem;}
h2{border-bottom:1px solid #e1e4e8;padding-bottom:.25rem;}
.badge{font-size:.7rem;padding:.1rem .4rem;border-radius:3px;vertical-align:middle;background:#eee;color:#1b1f23;font-weight:600;}
.badge.status-crit{background:#ffeef0;color:#d73a49;}
.badge.status-warn{background:#fff5d4;color:#b08800;}
.badge.status-ok{background:#dcffe4;color:#22863a;}
.badge.status-muted{background:#eee;color:#586069;}
.headline{padding:.75rem 1rem;border-radius:4px;margin-bottom:1.5rem;}
.headline.status-ok{background:#dcffe4;border-left:4px solid #22863a;}
.headline.status-crit{background:#ffeef0;border-left:4px solid #d73a49;}
.finding{border-left:4px solid;padding:.75rem 1rem;margin:.75rem 0;background:#fff;border-radius:4px;}
.finding h3{margin:0 0 .25rem 0;font-size:1rem;}
.finding.sev-crit{border-color:#d73a49;}
.finding.sev-warn{border-color:#dbab09;}
.finding.sev-info{border-color:#0366d6;}
.fix{background:#1b1f23;color:#fafbfc;padding:.5rem .75rem;border-radius:4px;overflow-x:auto;font-size:.85rem;}
table{width:100%;border-collapse:collapse;background:#fff;}
th,td{padding:.5rem .75rem;border-bottom:1px solid #e1e4e8;text-align:left;vertical-align:top;}
tr.row-crit td:nth-child(2){color:#d73a49;font-weight:600;}
tr.row-warn td:nth-child(2){color:#b08800;font-weight:600;}
tr.row-ok td:nth-child(2){color:#22863a;font-weight:600;}
tr.row-muted td:nth-child(2){color:#586069;}
.ok{color:#22863a;}
.warn{color:#b08800;}
.muted{color:#586069;}
code{font-size:.85rem;}
small{color:#586069;}
details{margin:.25rem 0;}`