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 }