// 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 } } // 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, "") 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 } func labelDepth(zone string) int { z := strings.TrimSuffix(zone, ".") if z == "" { return 0 } return strings.Count(z, ".") + 1 }