package checker import ( "context" "fmt" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // majorityDenialKind picks the denial scheme observed by most servers; ties // fall back to the alphabetically-first kind so the picked value is // deterministic. An empty/None map collapses to DenialNone. func majorityDenialKind(d *DNSSECData) DenialKind { counts := map[DenialKind]int{} for _, v := range d.Servers { if v.DenialKind != "" { counts[v.DenialKind]++ } } if len(counts) == 0 { return DenialNone } type pair struct { k DenialKind n int } var ps []pair for k, n := range counts { ps = append(ps, pair{k, n}) } sort.Slice(ps, func(i, j int) bool { if ps[i].n != ps[j].n { return ps[i].n > ps[j].n } return ps[i].k < ps[j].k }) return ps[0].k } // firstNSEC3Param returns the first NSEC3PARAM observed across servers; it // is checked elsewhere that all servers agree (denial_consistent rule). func firstNSEC3Param(d *DNSSECData) *NSEC3ParamObservation { for _, name := range sortedServers(d) { if v := d.Servers[name]; v.NSEC3PARAM != nil { return v.NSEC3PARAM } } return nil } // denialUsesNSEC3Rule is the central anti-walking rule. It is the most // frequent operator-actionable finding for small zones whose signers default // to NSEC. type denialUsesNSEC3Rule struct{} func (denialUsesNSEC3Rule) Name() string { return "dnssec_denial_uses_nsec3" } func (denialUsesNSEC3Rule) Description() string { return "Warns when the zone uses NSEC for negative answers, which makes the zone walkable (RFC 5155 / RFC 7129)." } func (denialUsesNSEC3Rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } if !hasAnyDNSKEY(data) { return []sdk.CheckState{{ Status: sdk.StatusInfo, Subject: data.Domain, Message: "zone is unsigned: denial-of-existence scheme is not applicable", }} } kind := majorityDenialKind(data) switch kind { case DenialNSEC: return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusWarn, Subject: data.Domain, Message: "zone uses NSEC for negative answers: every name in the zone can be enumerated by walking NSEC chains", }, "Migrate to NSEC3 with iterations=0 and an empty salt (RFC 9276). BIND: `dnssec-policy default;` (named.conf). Knot: `policy.signing-policy { nsec3 = on; nsec3-iterations = 0; nsec3-salt-length = 0 }`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.", "dnssec.nsec_walkable")} case DenialNSEC3, DenialOptOut: return okState(data.Domain, fmt.Sprintf("zone uses %s for negative answers", kind)) case DenialNone: return []sdk.CheckState{{ Status: sdk.StatusInfo, Subject: data.Domain, Message: "could not classify the negative-answer scheme: no NSEC/NSEC3 in the NXDOMAIN probe", }} } return skipped("no denial information available") } // nsec3IterationsRule encodes RFC 9276 §3.1: iterations > 0 buys nothing // against modern attackers but slows down every validating resolver. The // severity is configurable so air-gapped or paranoid setups can downgrade. type nsec3IterationsRule struct{} func (nsec3IterationsRule) Name() string { return "dnssec_nsec3_iterations" } func (nsec3IterationsRule) Description() string { return "Verifies that NSEC3PARAM.Iterations is at most nsec3IterationsMax (default 0, per RFC 9276 §3.1)." } func (nsec3IterationsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } param := firstNSEC3Param(data) if param == nil { return []sdk.CheckState{{ Status: sdk.StatusInfo, Subject: data.Domain, Message: "no NSEC3PARAM observed: rule does not apply (zone uses NSEC or is unsigned)", }} } maxIter := optionUint(opts, "nsec3IterationsMax", defaultNSEC3IterationsMax) severity, _ := sdk.GetOption[string](opts, "nsec3IterationsSeverity") if severity == "" { severity = defaultNSEC3IterationsSeverityWarn } if uint(param.Iterations) <= maxIter { return okState(data.Domain, fmt.Sprintf("NSEC3 iterations = %d (≤ %d)", param.Iterations, maxIter)) } status := sdk.StatusWarn if strings.EqualFold(severity, "crit") { status = sdk.StatusCrit } return []sdk.CheckState{withMeta(sdk.CheckState{ Status: status, Subject: data.Domain, Message: fmt.Sprintf("NSEC3 iterations = %d (recommended ≤ %d, RFC 9276 §3.1); modern resolvers may treat this answer as insecure or bogus", param.Iterations, maxIter), }, "Re-sign the zone with iterations=0. BIND 9.18+: `rndc signing -nsec3param 1 0 0 -` then `rndc reload`. Knot: `nsec3-iterations: 0`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.", "dnssec.nsec3_iterations_too_high")} } // nsec3SaltEmptyRule encodes RFC 9276 §3.1 about salts: a salt offers no // measurable benefit and adds operational cost. Surfaced as WARN (not CRIT) // because it does not break resolution today. type nsec3SaltEmptyRule struct{} func (nsec3SaltEmptyRule) Name() string { return "dnssec_nsec3_salt_empty" } func (nsec3SaltEmptyRule) Description() string { return "Verifies that NSEC3PARAM.SaltLength is 0 (RFC 9276 §3.1: a salt buys no measurable protection)." } func (nsec3SaltEmptyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } param := firstNSEC3Param(data) if param == nil { return []sdk.CheckState{{ Status: sdk.StatusInfo, Subject: data.Domain, Message: "no NSEC3PARAM observed: rule does not apply", }} } if param.SaltLength == 0 { return okState(data.Domain, "NSEC3 salt is empty (RFC 9276 compliant)") } return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusWarn, Subject: data.Domain, Message: fmt.Sprintf("NSEC3 salt length = %d bytes (salt = %q); RFC 9276 §3.1 recommends an empty salt", param.SaltLength, param.Salt), }, "Re-sign the zone with an empty salt. BIND: salt parameter `-` in `dnssec-policy`. Knot: `nsec3-salt-length: 0`. PowerDNS: `pdnsutil set-nsec3 ZONE \"1 0 0 -\"`.", "dnssec.nsec3_salt_present")} } // nsec3OptOutRule reports OPT-OUT misuse. OPT-OUT is appropriate for zones // with many unsigned delegations (TLDs, registries) but defeats authenticated // denial of existence for normal records inside leaf zones. type nsec3OptOutRule struct{} func (nsec3OptOutRule) Name() string { return "dnssec_nsec3_optout_only_when_signed_delegations" } func (nsec3OptOutRule) Description() string { return "Reports informational note when the OPT-OUT flag is set on NSEC3PARAM in a leaf zone." } func (nsec3OptOutRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } param := firstNSEC3Param(data) if param == nil { return skipped("no NSEC3PARAM observed") } if param.Flags&0x01 == 0 { return okState(data.Domain, "OPT-OUT flag not set") } return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusInfo, Subject: data.Domain, Message: "NSEC3 OPT-OUT is set: appropriate only for zones with many unsigned delegations (typically TLDs/registries)", }, "If this is a leaf zone, disable OPT-OUT to keep authenticated denial of existence for every name.", "dnssec.nsec3_optout_inappropriate")} } // denialConsistentRule catches the per-server inconsistency that screams // "your secondaries are not in sync": typically a mid-rollover artefact. type denialConsistentRule struct{} func (denialConsistentRule) Name() string { return "dnssec_denial_consistent" } func (denialConsistentRule) Description() string { return "Verifies that every authoritative server uses the same denial-of-existence scheme." } func (denialConsistentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) if errState != nil { return errState } seen := map[DenialKind][]string{} for _, name := range sortedServers(data) { v := data.Servers[name] if v.DenialKind == "" { continue } seen[v.DenialKind] = append(seen[v.DenialKind], name) } if len(seen) <= 1 { return okState(data.Domain, "all servers agree on the denial-of-existence scheme") } var parts []string for k, servers := range seen { parts = append(parts, fmt.Sprintf("%s: %s", k, strings.Join(servers, ", "))) } sort.Strings(parts) return []sdk.CheckState{withMeta(sdk.CheckState{ Status: sdk.StatusWarn, Subject: data.Domain, Message: "authoritative servers disagree on the denial scheme: " + strings.Join(parts, " / "), }, "Make sure every secondary completed AXFR/IXFR for the latest zone version; a partial NSEC→NSEC3 migration is the typical cause.", "dnssec.denial_kind_drift")} }