// SPDX-License-Identifier: MIT package checker import ( "encoding/json" "fmt" "sort" "strings" ) // ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed // map of zone analyses. Order lists zones from most-specific to root. func ParseGrokOutput(raw []byte) (map[string]ZoneAnalysis, []string, error) { var top map[string]json.RawMessage if err := json.Unmarshal(raw, &top); err != nil { return nil, nil, err } out := make(map[string]ZoneAnalysis, len(top)) for k, v := range top { // Skip non-zone keys some dnsviz versions emit (e.g. "_meta"). if k == "" || strings.HasPrefix(k, "_") { continue } out[k] = decodeZone(v) } keys := make([]string, 0, len(out)) for k := range out { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return labelDepth(keys[i]) > labelDepth(keys[j]) }) return out, keys, nil } func decodeZone(raw json.RawMessage) ZoneAnalysis { var z ZoneAnalysis var node any if err := json.Unmarshal(raw, &node); err != nil { return z } m, ok := node.(map[string]any) if !ok { if s, ok := node.(string); ok { z.Status = s } return z } if s, ok := m["status"].(string); ok { z.DNSStatus = s } // DNSSEC chain status lives under delegation.status. if del, ok := m["delegation"].(map[string]any); ok { if s, ok := del["status"].(string); ok { 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 } z.Errors, z.Warnings = collectFindings(m, "") return z } func collectFindings(node any, path string) (errs, warns []Finding) { switch v := node.(type) { case map[string]any: for k, val := range v { sub := joinPath(path, k) switch k { case "errors": errs = append(errs, asFindings(val, path)...) continue case "warnings": warns = append(warns, asFindings(val, path)...) continue } e, w := collectFindings(val, sub) errs = append(errs, e...) warns = append(warns, w...) } case []any: for i, item := range v { sub := fmt.Sprintf("%s[%d]", path, i) e, w := collectFindings(item, sub) errs = append(errs, e...) warns = append(warns, w...) } } return } func joinPath(parent, key string) string { if parent == "" { return key } return parent + "/" + key } // asFindings turns a value attached to an "errors"/"warnings" key into a // slice of Finding. DNSViz uses a few shapes here across versions: // - []object{description, code, servers} // - []string (rare, very old grok) // - object keyed by code -> entry (newer grok flattens findings by code) func asFindings(raw any, path string) []Finding { switch v := raw.(type) { case []any: out := make([]Finding, 0, len(v)) for _, item := range v { out = append(out, makeFinding(item, "", path)) } return out case map[string]any: out := make([]Finding, 0, len(v)) keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { out = append(out, makeFinding(v[k], k, path)) } return out case []string: out := make([]Finding, 0, len(v)) for _, s := range v { out = append(out, Finding{Description: s, Path: path}) } return out } return nil } func makeFinding(item any, codeHint, path string) Finding { f := Finding{Path: path, Code: codeHint} switch v := item.(type) { case string: f.Description = v case map[string]any: if s, ok := v["code"].(string); ok && s != "" { f.Code = s } if s, ok := v["description"].(string); ok && s != "" { f.Description = s } else if s, ok := v["message"].(string); ok && s != "" { f.Description = s } if servers, ok := v["servers"].([]any); ok { for _, s := range servers { if str, ok := s.(string); ok { f.Servers = append(f.Servers, str) } } } // If we couldn't extract a human description, keep the raw structure // in Extra rather than synthesising a JSON blob into the description // field (which would then be rendered as ugly text in the report). if f.Description == "" { f.Extra = v } default: // Unknown shape: stash the raw value so the report can still surface // it from a debug section, but don't pollute Description. f.Extra = map[string]any{"value": item} } 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 == "" { return 0 } return strings.Count(z, ".") + 1 }