checker-legacy-records/checker/rule.go

154 lines
4.5 KiB
Go

package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// legacyRecordsRule emits one CheckState per distinct legacy record type
// found in the zone (not one per occurrence). This matches how operators
// think about remediation ("fix the SPF record" is one task even when
// the zone has six SPF records) and keeps the report's "fix these first"
// section focused.
type legacyRecordsRule struct{}
func (r *legacyRecordsRule) Name() string { return "legacy_records" }
func (r *legacyRecordsRule) Description() string {
return "Detects DNS record types deprecated by the IETF (SPF, A6, KEY/SIG/NXT, WKS, MD/MF, NSAP, …) and reports each occurrence with the relevant RFC reference and a migration suggestion."
}
func (r *legacyRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data LegacyData
if err := obs.Get(ctx, ObservationKeyLegacy, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load legacy-records observation: %v", err),
RuleName: r.Name(),
Code: "legacy_records_error",
}}
}
if len(data.Findings) == 0 {
// Even with zero findings we acknowledge the scan so the report
// does not look empty. CollectErrors are surfaced as Info so a
// silent-skip path doesn't masquerade as a clean pass.
states := []sdk.CheckState{{
Status: sdk.StatusOK,
Message: fmt.Sprintf("No legacy record types detected (%d service(s) scanned)", data.ServicesScanned),
RuleName: r.Name(),
Code: "legacy_records_clean",
}}
for _, e := range data.CollectErrors {
states = append(states, sdk.CheckState{
Status: sdk.StatusInfo,
Message: "Skipped during scan: " + e,
RuleName: r.Name(),
Code: "legacy_records_skip",
})
}
return states
}
groups := groupFindings(data.Findings)
out := make([]sdk.CheckState, 0, len(groups))
for _, g := range groups {
info := deprecatedTypes[g.Rrtype]
out = append(out, sdk.CheckState{
Status: severityToStatus(info.Severity),
Message: buildMessage(g, info),
RuleName: r.Name(),
Code: "legacy_" + strings.ToLower(g.TypeName),
Subject: g.TypeName,
Meta: map[string]any{
"rrtype": g.Rrtype,
"type": g.TypeName,
"reason": info.Reason,
"replacement": info.Replacement,
"how_to_fix": info.HowToFix,
"severity": info.Severity.String(),
"locations": g.Locations,
},
})
}
return out
}
func severityToStatus(s DeprecatedSeverity) sdk.Status {
switch s {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
default:
return sdk.StatusInfo
}
}
func buildMessage(g groupedFinding, info DeprecationInfo) string {
loc := "1 occurrence"
if n := len(g.Locations); n > 1 {
loc = fmt.Sprintf("%d occurrences", n)
}
if info.Replacement != "" {
return fmt.Sprintf("%s record found (%s). %s; use %s instead.",
g.TypeName, loc, info.Reason, info.Replacement)
}
return fmt.Sprintf("%s record found (%s). %s.",
g.TypeName, loc, info.Reason)
}
// groupedFinding aggregates Finding entries by record type so the rule
// emits one CheckState per type, with Locations carrying the per-instance
// detail for the report.
type groupedFinding struct {
Rrtype uint16
TypeName string
Locations []FindingLocation
}
type FindingLocation struct {
Subdomain string `json:"subdomain"`
Name string `json:"name,omitempty"`
ServiceType string `json:"service_type,omitempty"`
}
func groupFindings(fs []Finding) []groupedFinding {
bytype := map[uint16]*groupedFinding{}
for _, f := range fs {
g, ok := bytype[f.Rrtype]
if !ok {
g = &groupedFinding{Rrtype: f.Rrtype, TypeName: f.TypeName}
bytype[f.Rrtype] = g
}
g.Locations = append(g.Locations, FindingLocation{
Subdomain: f.Subdomain,
Name: f.Name,
ServiceType: f.ServiceType,
})
}
out := make([]groupedFinding, 0, len(bytype))
for _, g := range bytype {
sort.SliceStable(g.Locations, func(i, j int) bool {
return g.Locations[i].Subdomain < g.Locations[j].Subdomain
})
out = append(out, *g)
}
// Sort groups by descending severity then by type name so the most
// urgent finding bubbles to the top of the rule output (the report
// preserves this order when ranking the "fix this first" card).
sort.SliceStable(out, func(i, j int) bool {
si := deprecatedTypes[out[i].Rrtype].Severity
sj := deprecatedTypes[out[j].Rrtype].Severity
if si != sj {
return si > sj
}
return out[i].TypeName < out[j].TypeName
})
return out
}