240 lines
8.8 KiB
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")}
|
|
}
|