Initial commit
This commit is contained in:
commit
53626dd36a
29 changed files with 3940 additions and 0 deletions
246
checker/collect.go
Normal file
246
checker/collect.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// 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.<zone>/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
|
||||
// "<zone>/IN/<RRTYPE>". 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue