154 lines
4.5 KiB
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
|
|
}
|