checker-resolver-propagation/checker/report.go

733 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport implements sdk.CheckerHTMLReporter.
//
// The report is laid out top-down by decreasing importance:
// 1. a "Fix these first" banner listing the common failures (drift,
// DNSSEC, NXDOMAIN, SERVFAIL, regional split, etc.) with a plain-English
// remediation for each;
// 2. a per-RRset consensus table that shows which answers dominate and
// which resolvers disagree: the meat of the check;
// 3. a per-region matrix (consensus / drift / error per region × RRset);
// 4. a detailed per-resolver table for operators who want the raw data.
func (p *resolverPropagationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data ResolverPropagationData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse resolver-propagation data: %w", err)
}
}
deriveView(&data)
findings := statesToFindings(ctx.States())
view := buildReportView(&data, findings)
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureOrder is the priority used by the "Fix these first" banner.
// Items at the top reflect more impactful / more actionable issues so the
// reader has a triage path.
var topFailureOrder = []string{
CodeAllResolversDown,
CodeUnexpectedSERVFAIL,
CodeDNSSECFailure,
CodeAnswerDrift,
CodeUnexpectedNXDOMAIN,
CodeSerialDrift,
CodeRegionalSplit,
CodePartialPropagation,
CodeDNSSECUnvalidated,
CodeStaleCache,
CodeResolverRewrote,
CodeResolverUnreachable,
CodeResolverHighLatency,
CodeResolverFilteredHit,
CodeNoResolvers,
}
// reportView is the flattened shape the HTML template consumes.
type reportView struct {
Zone string
OverallStatus string
OverallClass string
OverallMessage string
Stats Stats
TopFailures []topFailure
OtherFindings []Finding
RRsets []rrsetRow
Regions []regionRow
Resolvers []resolverRow
}
type topFailure struct {
Code string
Severity string
Message string
Remedy string
Count int
Class string
Headline string // short, human-readable label for the card
}
type rrsetRow struct {
Key string
Name string
Type string
MatchesExpected bool
Expected []string
HasExpected bool
Groups []groupRow
Agreeing int
Dissenting int
StatusClass string
StatusLabel string
}
type groupRow struct {
Rcode string
Records []string
Resolvers []string
IsConsensus bool
}
type regionRow struct {
Region string
Label string
Resolvers int
Reachable int
Agreeing int
Disagreeing int
Errored int
}
type resolverRow struct {
ID string
Name string
IP string
Region string
Transport string
Filtered bool
Reachable bool
AvgMs int64
Probes []probeRow
}
type probeRow struct {
Key string
Rcode string
Records []string
MinTTL uint32
AD bool
AgreesWithConsensus bool
Error string
LatencyMs int64
}
func buildReportView(d *ResolverPropagationData, findings []Finding) *reportView {
v := &reportView{
Zone: d.Zone,
Stats: d.Stats,
}
// Overall banner: worst severity drives colour.
worst := ""
for _, f := range findings {
switch f.Severity {
case SeverityCrit:
worst = "crit"
case SeverityWarn:
if worst == "" {
worst = "warn"
}
case SeverityInfo:
if worst == "" {
worst = "info"
}
}
if worst == "crit" {
break
}
}
switch worst {
case "crit":
v.OverallStatus = "Critical issues"
v.OverallClass = "banner-crit"
v.OverallMessage = fmt.Sprintf("%s is not propagating correctly across public resolvers.", d.Zone)
case "warn":
v.OverallStatus = "Warnings"
v.OverallClass = "banner-warn"
v.OverallMessage = fmt.Sprintf("%s is propagating, but some resolvers or resource sets disagree.", d.Zone)
case "info":
v.OverallStatus = "Informational"
v.OverallClass = "banner-info"
v.OverallMessage = fmt.Sprintf("%s looks healthy; a few advisory notes below.", d.Zone)
default:
v.OverallStatus = "OK"
v.OverallClass = "banner-ok"
v.OverallMessage = fmt.Sprintf("%s is propagated consistently across %d of %d unfiltered resolvers.",
d.Zone, d.Stats.UnfilteredAgreeing, d.Stats.UnfilteredProbed)
}
// Top failures: bucket findings by code, keep each code's most severe
// occurrence, render in topFailureOrder.
byCode := map[string][]Finding{}
for _, f := range findings {
byCode[f.Code] = append(byCode[f.Code], f)
}
order := map[string]int{}
for i, c := range topFailureOrder {
order[c] = i + 1
}
used := map[string]bool{}
for _, code := range topFailureOrder {
list, ok := byCode[code]
if !ok {
continue
}
used[code] = true
f := list[0]
tf := topFailure{
Code: code,
Severity: string(f.Severity),
Message: f.Message,
Remedy: f.Remedy,
Count: len(list),
Class: "severity-" + string(f.Severity),
Headline: headlineFor(code),
}
v.TopFailures = append(v.TopFailures, tf)
}
// Anything else → "Other findings"
for code, list := range byCode {
if used[code] {
continue
}
for _, f := range list {
v.OtherFindings = append(v.OtherFindings, f)
}
}
sort.SliceStable(v.OtherFindings, func(i, j int) bool {
return severityRank(v.OtherFindings[i].Severity) > severityRank(v.OtherFindings[j].Severity)
})
// RRset rows, sorted by "name/type".
keys := make([]string, 0, len(d.RRsets))
for k := range d.RRsets {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
rv := d.RRsets[k]
row := rrsetRow{
Key: k,
Name: rv.Name,
Type: rv.Type,
MatchesExpected: rv.MatchesExpected,
Expected: rv.ExpectedRecords,
HasExpected: rv.Expected != "",
Agreeing: len(rv.Agreeing),
Dissenting: len(rv.Dissenting),
}
for _, g := range rv.Groups {
row.Groups = append(row.Groups, groupRow{
Rcode: g.Rcode,
Records: g.Records,
Resolvers: g.Resolvers,
IsConsensus: g.Signature == rv.ConsensusSig,
})
}
switch {
case rv.Expected != "" && !rv.MatchesExpected:
row.StatusClass = "pill-crit"
row.StatusLabel = "drift"
case len(rv.Groups) > 1:
row.StatusClass = "pill-warn"
row.StatusLabel = "partial"
case len(rv.Groups) == 1:
row.StatusClass = "pill-ok"
row.StatusLabel = "consensus"
default:
row.StatusClass = "pill-info"
row.StatusLabel = "no data"
}
v.RRsets = append(v.RRsets, row)
}
// Per-region rollup.
byRegion := map[string]*regionRow{}
for _, rv := range d.Resolvers {
r, ok := byRegion[rv.Region]
if !ok {
r = &regionRow{Region: rv.Region, Label: regionLabel(rv.Region)}
byRegion[rv.Region] = r
}
r.Resolvers++
if rv.Reachable {
r.Reachable++
}
if rv.Reachable && !rv.Filtered {
ok := true
for key, p := range rv.Probes {
if p == nil || p.Error != "" {
r.Errored++
ok = false
break
}
cv := d.RRsets[key]
if cv == nil || cv.ConsensusSig == "" {
continue
}
if p.Signature != cv.ConsensusSig {
ok = false
break
}
}
if ok {
r.Agreeing++
} else {
r.Disagreeing++
}
}
}
for _, r := range byRegion {
v.Regions = append(v.Regions, *r)
}
sort.Slice(v.Regions, func(i, j int) bool { return v.Regions[i].Label < v.Regions[j].Label })
// Per-resolver rows.
rids := make([]string, 0, len(d.Resolvers))
for k := range d.Resolvers {
rids = append(rids, k)
}
sort.Strings(rids)
for _, rid := range rids {
rv := d.Resolvers[rid]
var total, n int64
probes := []probeRow{}
pkeys := make([]string, 0, len(rv.Probes))
for k := range rv.Probes {
pkeys = append(pkeys, k)
}
sort.Strings(pkeys)
for _, k := range pkeys {
p := rv.Probes[k]
pr := probeRow{
Key: k,
Rcode: p.Rcode,
Records: p.Records,
MinTTL: p.MinTTL,
AD: p.AD,
Error: p.Error,
LatencyMs: p.LatencyMs,
}
if cv := d.RRsets[k]; cv != nil && cv.ConsensusSig != "" {
pr.AgreesWithConsensus = p.Signature == cv.ConsensusSig
}
if p.Error == "" {
total += p.LatencyMs
n++
}
probes = append(probes, pr)
}
avg := int64(0)
if n > 0 {
avg = total / n
}
v.Resolvers = append(v.Resolvers, resolverRow{
ID: rv.ID,
Name: rv.Name,
IP: rv.IP,
Region: regionLabel(rv.Region),
Transport: string(rv.Transport),
Filtered: rv.Filtered,
Reachable: rv.Reachable,
AvgMs: avg,
Probes: probes,
})
}
return v
}
// Kept here (not in rules) so user-facing wording lives in one layer.
func headlineFor(code string) string {
switch code {
case CodeAllResolversDown:
return "No resolver could be reached"
case CodeUnexpectedSERVFAIL:
return "A resolver returns SERVFAIL"
case CodeDNSSECFailure:
return "DNSSEC validation fails"
case CodeAnswerDrift:
return "Public resolvers disagree with your authoritative answer"
case CodeUnexpectedNXDOMAIN:
return "A resolver sees your zone as non-existent"
case CodeSerialDrift:
return "SOA serial differs between resolvers"
case CodeRegionalSplit:
return "A whole region sees a different answer"
case CodePartialPropagation:
return "Change is mid-propagation"
case CodeDNSSECUnvalidated:
return "Validating resolver did not set AD"
case CodeStaleCache:
return "Resolvers still serve the previous SOA serial"
case CodeResolverRewrote:
return "Resolver rewrote the answer"
case CodeResolverUnreachable:
return "Resolver unreachable from the checker"
case CodeResolverHighLatency:
return "Slow resolver"
case CodeResolverFilteredHit:
return "Filtered resolver is blocking your zone"
case CodeNoResolvers:
return "No resolver matched the current selection"
default:
return code
}
}
// View-layer translation only: rules own severity/code/message, report adds remedy + subject scoping.
func statesToFindings(states []sdk.CheckState) []Finding {
if len(states) == 0 {
return nil
}
var out []Finding
for _, st := range states {
sev, ok := severityFromStatus(st.Status)
if !ok {
continue
}
f := Finding{
Code: st.Code,
Severity: sev,
Message: st.Message,
Remedy: remedyFor(st.Code),
}
if isResolverScopedCode(st.Code) {
f.Resolver = st.Subject
} else if st.Subject != "" && strings.Contains(st.Subject, "/") {
f.RRset = st.Subject
}
out = append(out, f)
}
sort.SliceStable(out, func(i, j int) bool {
if a, b := severityRank(out[i].Severity), severityRank(out[j].Severity); a != b {
return a > b
}
if out[i].Code != out[j].Code {
return out[i].Code < out[j].Code
}
if out[i].RRset != out[j].RRset {
return out[i].RRset < out[j].RRset
}
return out[i].Resolver < out[j].Resolver
})
return out
}
func severityFromStatus(s sdk.Status) (Severity, bool) {
switch s {
case sdk.StatusCrit:
return SeverityCrit, true
case sdk.StatusWarn:
return SeverityWarn, true
case sdk.StatusInfo:
return SeverityInfo, true
}
return "", false
}
func isResolverScopedCode(code string) bool {
switch code {
case CodeResolverUnreachable, CodeResolverTimeout, CodeResolverRewrote,
CodeResolverFilteredHit, CodeResolverHighLatency,
CodeDNSSECFailure, CodeDNSSECUnvalidated:
return true
}
return false
}
// Wording lives here, not in rules: severity is judgment, copy is presentation.
func remedyFor(code string) string {
switch code {
case CodeNoResolvers:
return "loosen the region filter or reset the allowlist in the checker options"
case CodeAllResolversDown:
return "retry later, or verify the checker host's outgoing UDP/53 connectivity"
case CodeSerialDrift:
return "usually transient caching right after a zone push"
case CodeStaleCache:
return "the resolvers cached the previous zone version"
case CodeDNSSECFailure:
return "check that the DS record at the parent matches the DNSKEY at the zone apex"
case CodeDNSSECUnvalidated:
return "enable DNSSEC signing at your provider to get full validation downstream"
case CodeRegionalSplit:
return "possible GeoDNS misconfiguration or regional censorship"
case CodePartialPropagation:
return "wait up to the previous TTL for the old cached answer to expire everywhere"
case CodeAnswerDrift:
return "wait for the old TTL to expire or force a flush on the affected resolvers"
case CodeUnexpectedNXDOMAIN:
return "a resolver returning NXDOMAIN while others return NOERROR usually means a poisoned cache or lame delegation"
case CodeUnexpectedSERVFAIL:
return "check DNSSEC signatures and that every authoritative NS is reachable over UDP and TCP"
case CodeResolverUnreachable:
return "the resolver might be blocking the checker's traffic, firewalled, or temporarily down"
case CodeResolverRewrote:
return "the resolver appears to rewrite answers; users relying on it will see a different zone"
case CodeResolverFilteredHit:
return "normal for a filtered resolver when the zone is on a blocklist"
case CodeResolverHighLatency:
return "usually reflects the checker-to-resolver network path"
}
return ""
}
// severityRank orders severities for sorting; higher = more severe.
func severityRank(s Severity) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
}
return 0
}
// reportFuncs exposes small helpers to the template so it can stay concise.
var reportFuncs = template.FuncMap{
"join": func(sep string, s []string) string { return strings.Join(s, sep) },
"len": func(s []string) int { return len(s) },
}
var reportTmpl = template.Must(template.New("report").Funcs(reportFuncs).Parse(reportTemplateHTML))
const reportTemplateHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Resolver propagation report — {{.Zone}}</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7fb;
--fg: #1c1f2a;
--muted: #6c7488;
--card: #ffffff;
--border: #e3e6ef;
--ok: #1e8a4a;
--info: #2563eb;
--warn: #b7791f;
--crit: #c0392b;
--code-bg: #f0f2f8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #11131a;
--fg: #eceff6;
--muted: #96a0b5;
--card: #181b24;
--border: #252a36;
--code-bg: #1f2330;
}
}
body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.45; }
h1 { font-size: 1.5rem; margin: 0 0 4px 0; }
h2 { font-size: 1.1rem; margin: 24px 0 12px 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
h3 { font-size: 0.95rem; margin: 16px 0 6px 0; }
.muted { color: var(--muted); }
.banner { padding: 14px 16px; border-radius: 10px; margin-bottom: 16px; }
.banner-ok { background: rgba(30,138,74,.1); border-left: 4px solid var(--ok); }
.banner-info { background: rgba(37,99,235,.1); border-left: 4px solid var(--info); }
.banner-warn { background: rgba(183,121,31,.12); border-left: 4px solid var(--warn); }
.banner-crit { background: rgba(192,57,43,.12); border-left: 4px solid var(--crit); }
.banner strong { display:block; font-size: 1.05rem; margin-bottom: 2px; }
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
.stat { background: var(--card); border:1px solid var(--border); border-radius:8px; padding: 10px 14px; min-width: 120px; }
.stat .n { font-size: 1.4rem; font-weight: 600; }
.stat .l { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
.card.severity-crit { border-left: 4px solid var(--crit); }
.card.severity-warn { border-left: 4px solid var(--warn); }
.card.severity-info { border-left: 4px solid var(--info); }
.card-title { display:flex; justify-content:space-between; align-items:center; gap: 12px; }
.card-title .title { font-weight: 600; }
.count-pill { background: var(--code-bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; }
.remedy { background: var(--code-bg); padding: 8px 10px; border-radius: 6px; margin-top: 8px; font-size: .9rem; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 16px 0; font-size: .9rem; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-weight: 600; color: var(--muted); text-transform: uppercase; font-size: .75rem; letter-spacing: .04em; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: .85em; }
pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: .8em; }
.pill { display:inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; }
.pill-ok { background: rgba(30,138,74,.15); color: var(--ok); }
.pill-warn { background: rgba(183,121,31,.18); color: var(--warn); }
.pill-crit { background: rgba(192,57,43,.18); color: var(--crit); }
.pill-info { background: rgba(37,99,235,.15); color: var(--info); }
.group-consensus { background: rgba(30,138,74,.07); }
.tag { display:inline-block; padding: 1px 6px; border-radius: 4px; font-size: .7rem; margin-right: 4px; background: var(--code-bg); color: var(--muted); }
.filtered { opacity: .7; }
details > summary { cursor: pointer; color: var(--muted); font-size: .85rem; }
.ok { color: var(--ok); }
.crit { color: var(--crit); }
.warn { color: var(--warn); }
.info { color: var(--info); }
.records { margin: 0; padding-left: 16px; font-family: ui-monospace, monospace; font-size: .8rem; }
.resolver-list { font-size: .75rem; color: var(--muted); }
</style>
</head>
<body>
<h1>Worldwide DNS propagation — <code>{{.Zone}}</code></h1>
<div class="muted">Probe across public recursive resolvers; consensus compared to the zone's own authoritative answer.</div>
<div class="banner {{.OverallClass}}">
<strong>{{.OverallStatus}}</strong>
{{.OverallMessage}}
</div>
<div class="stats">
<div class="stat"><div class="n">{{.Stats.ReachableResolvers}} / {{.Stats.TotalResolvers}}</div><div class="l">Resolvers reachable</div></div>
<div class="stat"><div class="n">{{.Stats.UnfilteredAgreeing}} / {{.Stats.UnfilteredProbed}}</div><div class="l">Unfiltered agreeing</div></div>
<div class="stat"><div class="n">{{.Stats.CountriesCovered}}</div><div class="l">Regions covered</div></div>
{{if .Stats.FilteredProbed}}<div class="stat"><div class="n">{{.Stats.FilteredProbed}}</div><div class="l">Filtered probed</div></div>{{end}}
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="card {{.Class}}">
<div class="card-title">
<span class="title">{{.Headline}}</span>
<span class="count-pill">{{.Count}}× · <span class="{{.Severity}}">{{.Severity}}</span></span>
</div>
<div>{{.Message}}</div>
{{if .Remedy}}<div class="remedy"><strong>What to do:</strong> {{.Remedy}}</div>{{end}}
</div>
{{end}}
{{end}}
<h2>Per-RRset consensus</h2>
<table>
<thead><tr><th>Record</th><th>Status</th><th>Expected (authoritative)</th><th>What resolvers see</th></tr></thead>
<tbody>
{{range .RRsets}}
<tr>
<td><code>{{.Name}}</code><br><span class="tag">{{.Type}}</span></td>
<td><span class="pill {{.StatusClass}}">{{.StatusLabel}}</span><br>
<span class="muted">{{.Agreeing}} ok · {{.Dissenting}} diff</span></td>
<td>
{{if .HasExpected}}
{{if .Expected}}<ul class="records">{{range .Expected}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(no data / NODATA)</span>{{end}}
{{else}}
<span class="muted">(auth unreachable)</span>
{{end}}
</td>
<td>
{{range .Groups}}
<div class="{{if .IsConsensus}}group-consensus{{end}}" style="margin-bottom:8px; padding:6px 8px; border-radius:6px;">
<span class="pill {{if eq .Rcode "NOERROR"}}pill-ok{{else if eq .Rcode "NXDOMAIN"}}pill-crit{{else if eq .Rcode "SERVFAIL"}}pill-crit{{else}}pill-warn{{end}}">{{.Rcode}}</span>
{{if .IsConsensus}}<span class="pill pill-info">consensus</span>{{end}}
{{if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty)</span>{{end}}
<div class="resolver-list">{{len .Resolvers}} resolver(s): {{join ", " .Resolvers}}</div>
</div>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Per-region view</h2>
<table>
<thead><tr><th>Region</th><th>Reachable</th><th>Agreeing</th><th>Disagreeing</th><th>Errored</th></tr></thead>
<tbody>
{{range .Regions}}
<tr>
<td>{{.Label}}</td>
<td>{{.Reachable}} / {{.Resolvers}}</td>
<td><span class="ok">{{.Agreeing}}</span></td>
<td>{{if .Disagreeing}}<span class="warn">{{.Disagreeing}}</span>{{else}}0{{end}}</td>
<td>{{if .Errored}}<span class="crit">{{.Errored}}</span>{{else}}0{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Per-resolver details</h2>
<table>
<thead><tr><th>Resolver</th><th>Region</th><th>Transport</th><th>Avg&nbsp;ms</th><th>Answers</th></tr></thead>
<tbody>
{{range .Resolvers}}
<tr class="{{if .Filtered}}filtered{{end}}">
<td>
<strong>{{.Name}}</strong>{{if .Filtered}} <span class="tag">filtered</span>{{end}}<br>
<span class="muted"><code>{{.IP}}</code> · {{.ID}}</span>
</td>
<td>{{.Region}}</td>
<td>{{.Transport}}</td>
<td>{{if .Reachable}}{{.AvgMs}}{{else}}<span class="crit">unreachable</span>{{end}}</td>
<td>
{{range .Probes}}
<details>
<summary>
<code>{{.Key}}</code>
<span class="pill {{if .Error}}pill-crit{{else if eq .Rcode "NOERROR"}}{{if .AgreesWithConsensus}}pill-ok{{else}}pill-warn{{end}}{{else}}pill-crit{{end}}">
{{if .Error}}error{{else}}{{.Rcode}}{{if .AgreesWithConsensus}} · ✓{{else}} · ≠{{end}}{{end}}
</span>
{{if .AD}}<span class="tag">AD</span>{{end}}
<span class="muted">{{.LatencyMs}}ms{{if .MinTTL}} · TTL {{.MinTTL}}{{end}}</span>
</summary>
{{if .Error}}<pre>{{.Error}}</pre>{{else if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty answer)</span>{{end}}
</details>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if .OtherFindings}}
<h2>Other findings</h2>
<table>
<thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Remedy</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.Code}}</code></td>
<td>{{.Message}}</td>
<td class="muted">{{.Remedy}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</body>
</html>
`