checker-dnssec/checker/rules_enumeration.go

240 lines
8.8 KiB
Go

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")}
}