checker-dnsviz/checker/collect.go
Pierre-Olivier Mercier 4543e9b0cf
All checks were successful
continuous-integration/drone/push Build is passing
Move status inference out of observation layer into rules
The prober (collect.go) was calling inferApexDNSKEYStatus during
zone parsing, effectively making a SECURE/BOGUS judgement inside the
collection phase rather than the evaluation phase.  The DNS-rcode
fallback (z.Status = z.DNSStatus) was also applied at parse time.
2026-05-16 21:51:44 +08:00

184 lines
4.6 KiB
Go

// 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
}