diff --git a/checker/collect.go b/checker/collect.go index a6713b7..8640947 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -59,18 +59,12 @@ func decodeZone(raw json.RawMessage) ZoneAnalysis { z.Status = s } } - // Root has no parent and therefore no delegation block. dnsviz signals - // trust-anchor validation through the RRSIG covering the apex DNSKEY - // rrset (queries./IN/DNSKEY.answer[*].rrsig[*].status). With - // `dnsviz grok -t …` and a matching anchor, that RRSIG becomes VALID - // and we lift the zone to SECURE; an INVALID/EXPIRED RRSIG drags it - // to BOGUS. Without a trust anchor, this leaves Status empty and we - // fall back to the DNS rcode below. - if z.Status == "" { - z.Status = inferApexDNSKEYStatus(m["queries"]) - } - if z.Status == "" { - z.Status = z.DNSStatus + // Store raw queries so rules can infer the zone status from RRSIG + // validation when no delegation block is present (e.g. root zone). + // Status inference and DNS-rcode fallback are the responsibility of the + // evaluation layer (see effectiveStatus in rule.go). + if q, ok := m["queries"].(map[string]any); ok { + z.Queries = q } z.Errors, z.Warnings = collectFindings(m, "") @@ -181,62 +175,6 @@ func makeFinding(item any, codeHint, path string) Finding { return f } -// inferApexDNSKEYStatus returns "SECURE", "BOGUS", or "" based on the -// status of RRSIGs covering the zone's apex DNSKEY rrset. dnsviz attaches -// a per-RRSIG status whenever a key reaches it (either through DS from -// the parent or through a configured trust anchor at this zone). For -// the root, this is the only place where trust-anchor validation -// surfaces in the grok output. -// -// queries is the value at zone["queries"], a map keyed by -// "/IN/". We pick the DNSKEY query and look at every -// RRSIG inside its answer. -func inferApexDNSKEYStatus(queries any) string { - q, ok := queries.(map[string]any) - if !ok { - return "" - } - var dnskeyQ map[string]any - for k, v := range q { - if !strings.HasSuffix(k, "/IN/DNSKEY") { - continue - } - if m, ok := v.(map[string]any); ok { - dnskeyQ = m - break - } - } - if dnskeyQ == nil { - return "" - } - answers, _ := dnskeyQ["answer"].([]any) - sawValid := false - for _, a := range answers { - am, _ := a.(map[string]any) - if am == nil { - continue - } - rrsigs, _ := am["rrsig"].([]any) - for _, rs := range rrsigs { - rm, _ := rs.(map[string]any) - if rm == nil { - continue - } - s, _ := rm["status"].(string) - switch strings.ToUpper(s) { - case "INVALID", "BOGUS", "EXPIRED", "PREMATURE": - return "BOGUS" - case "VALID", "SECURE": - sawValid = true - } - } - } - if sawValid { - return "SECURE" - } - return "" -} - func labelDepth(zone string) int { z := strings.TrimSuffix(zone, ".") if z == "" { diff --git a/checker/collect_test.go b/checker/collect_test.go index e92ee2d..c356a14 100644 --- a/checker/collect_test.go +++ b/checker/collect_test.go @@ -99,11 +99,19 @@ func TestParseGrokOutput_StringZone(t *testing.T) { } func TestDecodeZone_StatusFallbacks(t *testing.T) { - // Only top-level status; no delegation block. Status must fall back to it. + // Only top-level status; no delegation block. + // The observation layer stores DNSStatus and leaves Status empty. + // effectiveStatus (rule layer) is responsible for the DNS-rcode fallback. raw := []byte(`{"status": "NOERROR"}`) z := decodeZone(raw) - if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" { - t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z) + if z.DNSStatus != "NOERROR" { + t.Errorf("expected DNSStatus = NOERROR, got %q", z.DNSStatus) + } + if z.Status != "" { + t.Errorf("expected Status empty (no delegation block), got %q", z.Status) + } + if got := effectiveStatus(z); got != "NOERROR" { + t.Errorf("effectiveStatus: expected NOERROR fallback, got %q", got) } } diff --git a/checker/report.go b/checker/report.go index f02299b..8752491 100644 --- a/checker/report.go +++ b/checker/report.go @@ -147,14 +147,15 @@ func buildBanner(data *DNSVizData, states []sdk.CheckState) *bannerView { z = data.Zones[leaf] } } - st := statusFromGrok(z.Status) + eff := effectiveStatus(z) + st := statusFromGrok(eff) if w := worstStatus(states); w > st { st = w } return &bannerView{ Status: st.String(), Leaf: strings.TrimSuffix(leaf, "."), - LeafSt: emptyAsUnknown(z.Status), + LeafSt: emptyAsUnknown(eff), } } @@ -381,7 +382,7 @@ func renderChain(data *DNSVizData) string { } func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnalysis, raw map[string]any) { - st := statusFromGrok(z.Status) + st := statusFromGrok(effectiveStatus(z)) level := zoneLevelLabel(idx, total) // Default-open zones with problems so the user sees them without @@ -399,8 +400,9 @@ func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnaly if level != "" { fmt.Fprintf(b, `%s`, html.EscapeString(level)) } - fmt.Fprintf(b, `%s`, st.String(), html.EscapeString(emptyAsUnknown(z.Status))) - if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, z.Status) { + eff := effectiveStatus(z) + fmt.Fprintf(b, `%s`, st.String(), html.EscapeString(emptyAsUnknown(eff))) + if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, eff) { fmt.Fprintf(b, `DNS: %s`, html.EscapeString(z.DNSStatus)) } if n := len(z.Errors); n > 0 { diff --git a/checker/rule.go b/checker/rule.go index ef868ad..1c4cdfa 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -49,6 +49,75 @@ func orderedZones(data *DNSVizData) []string { return keys } +// effectiveStatus returns the DNSSEC status for a zone, applying the +// inference chain that the observation layer deliberately leaves to rules: +// 1. delegation.status (z.Status) — the authoritative answer when present. +// 2. RRSIG-based inference — for zones without a delegation block (root), +// dnsviz surfaces trust-anchor validation only through per-RRSIG statuses. +// 3. DNS rcode fallback (z.DNSStatus) — when the zone is unsigned or grok +// did not classify it at all. +func effectiveStatus(z ZoneAnalysis) string { + if z.Status != "" { + return z.Status + } + if s := inferApexDNSKEYStatus(z.Queries); s != "" { + return s + } + return z.DNSStatus +} + +// inferApexDNSKEYStatus returns "SECURE", "BOGUS", or "" based on the +// status of RRSIGs covering the zone's apex DNSKEY rrset. dnsviz attaches +// a per-RRSIG status whenever a key reaches it (either through DS from +// the parent or through a configured trust anchor at this zone). For +// the root, this is the only place where trust-anchor validation +// surfaces in the grok output. +// +// queries is the value at zone["queries"], a map keyed by +// "/IN/". We pick the DNSKEY query and look at every +// RRSIG inside its answer. +func inferApexDNSKEYStatus(queries map[string]any) string { + var dnskeyQ map[string]any + for k, v := range queries { + if !strings.HasSuffix(k, "/IN/DNSKEY") { + continue + } + if m, ok := v.(map[string]any); ok { + dnskeyQ = m + break + } + } + if dnskeyQ == nil { + return "" + } + answers, _ := dnskeyQ["answer"].([]any) + sawValid := false + for _, a := range answers { + am, _ := a.(map[string]any) + if am == nil { + continue + } + rrsigs, _ := am["rrsig"].([]any) + for _, rs := range rrsigs { + rm, _ := rs.(map[string]any) + if rm == nil { + continue + } + s, _ := rm["status"].(string) + switch strings.ToUpper(s) { + case "INVALID", "BOGUS", "EXPIRED", "PREMATURE": + return "BOGUS" + case "VALID", "SECURE": + sawValid = true + } + } + } + if sawValid { + return "SECURE" + } + return "" +} + func statusFromGrok(s string) sdk.Status { switch strings.ToUpper(strings.TrimSpace(s)) { case "SECURE": diff --git a/checker/rules_status.go b/checker/rules_status.go index bcf79da..1fc48cb 100644 --- a/checker/rules_status.go +++ b/checker/rules_status.go @@ -35,13 +35,14 @@ func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGet leaf = zones[0] z = data.Zones[leaf] } + eff := effectiveStatus(z) st := sdk.CheckState{ Code: "dnsviz_overall_status", Subject: leaf, - Status: statusFromGrok(z.Status), - Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)), + Status: statusFromGrok(eff), + Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(eff)), Meta: map[string]any{ - "status": z.Status, + "status": eff, "errors": len(z.Errors), "warnings": len(z.Warnings), }, @@ -72,11 +73,12 @@ func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGet out := make([]sdk.CheckState, 0, len(zones)) for _, name := range zones { z := data.Zones[name] + eff := effectiveStatus(z) out = append(out, sdk.CheckState{ Code: "dnsviz_per_zone_status", Subject: name, - Status: statusFromGrok(z.Status), - Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)), + Status: statusFromGrok(eff), + Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(eff), len(z.Errors), len(z.Warnings)), }) } return out diff --git a/checker/types.go b/checker/types.go index e0f4669..7950eac 100644 --- a/checker/types.go +++ b/checker/types.go @@ -56,8 +56,11 @@ type DNSVizData struct { // where the problem was found (DS, DNSKEY, RRSIG, NSEC proof, query // response, server, …). We walk the whole zone subtree to collect them. type ZoneAnalysis struct { - // Status is the DNSSEC chain status taken from delegation.status when - // available, falling back to the top-level "status" field otherwise. + // Status is the DNSSEC chain status reported directly under + // delegation.status in the grok output. Empty when no delegation block + // is present (e.g. the root zone). Rules call effectiveStatus(z) rather + // than reading this field directly so that the RRSIG-based inference and + // the DNS-rcode fallback are applied consistently. Status string `json:"status,omitempty"` // DNSStatus is the raw top-level "status" field (DNS rcode such as // "NOERROR"). Kept for the report so we can distinguish "DNS resolved @@ -65,6 +68,11 @@ type ZoneAnalysis struct { DNSStatus string `json:"dns_status,omitempty"` Errors []Finding `json:"errors,omitempty"` Warnings []Finding `json:"warnings,omitempty"` + // Queries holds the per-zone "queries" subtree from the grok output so + // that rules can infer the zone status from RRSIG validation when no + // delegation block is present (e.g. root zone, see inferApexDNSKEYStatus + // in rule.go). + Queries map[string]any `json:"queries,omitempty"` } // Finding mirrors the shape DNSViz uses for entries in errors/warnings.