package checker import ( "context" "encoding/json" "fmt" "sort" "strings" "time" contract "git.happydns.org/checker-dangling/contract" sdk "git.happydns.org/checker-sdk-go/checker" ) // recentRegistrationDays defines how recently a registrable domain // must have been (re-)registered for the rule to flag it as a likely // takeover candidate. The Ars Technica scenario hinges on attackers // re-registering a freshly-released domain; surfacing recently-changed // registrations is what turns a passing NXDOMAIN-free lookup into an // audit signal. const recentRegistrationDays = 90 // danglingRule is the single rule for v1: it walks the observation's // pointer list, joins it with the related "whois" observations // produced by domain_expiry on the entries we published, and emits one // CheckState per impacted owner. type danglingRule struct{} func (r *danglingRule) Name() string { return "dangling_records" } func (r *danglingRule) Description() string { return "Detects subdomains whose CNAME / MX / SRV / NS targets resolve to NXDOMAIN, or whose external registrable domain is expired or recently re-registered. Combines local DNS resolution with WHOIS observations published by companion checkers." } func (r *danglingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data DanglingData if err := obs.Get(ctx, ObservationKeyDangling, &data); err != nil { return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("failed to load dangling-records observation: %v", err), RuleName: r.Name(), Code: "dangling_observation_error", }} } whoisByRef, whoisLoadErrors := loadWHOIS(ctx, obs) // Group findings by owner so we report once per impacted subdomain // even when multiple pointers under the same owner trigger a rule. byOwner := map[string]*ownerFindings{} for i := range data.Pointers { pt := &data.Pointers[i] triggers := evaluatePointer(pt, whoisByRef) if len(triggers) == 0 { continue } f, ok := byOwner[pt.Owner] if !ok { f = &ownerFindings{Owner: pt.Owner, Subdomain: pt.Subdomain} byOwner[pt.Owner] = f } f.Triggers = append(f.Triggers, triggers...) if sev := scoreSeverity(triggers); sev > f.WorstSeverity { f.WorstSeverity = sev } } out := make([]sdk.CheckState, 0, len(byOwner)+1) if whoisLoadErrors > 0 { out = append(out, sdk.CheckState{ Status: sdk.StatusInfo, Message: fmt.Sprintf("%d related WHOIS observation(s) could not be parsed; takeover signals may be incomplete.", whoisLoadErrors), RuleName: r.Name(), Code: "dangling_whois_load_warning", }) } if len(byOwner) == 0 { out = append(out, sdk.CheckState{ Status: sdk.StatusOK, Message: fmt.Sprintf("No dangling subdomain detected (%d service(s) scanned, %d pointer(s) inspected)", data.ServicesScanned, len(data.Pointers)), RuleName: r.Name(), Code: "dangling_clean", }) return out } for _, f := range sortFindings(byOwner) { out = append(out, sdk.CheckState{ Status: severityToStatus(f.WorstSeverity), Message: buildOwnerMessage(f), RuleName: r.Name(), Code: codeForSeverity(f.WorstSeverity), Subject: displayOwner(f), Meta: map[string]any{ "owner": f.Owner, "subdomain": f.Subdomain, "triggers": f.Triggers, "severity": f.WorstSeverity.String(), }, }) } return out } // Severity is the rule's internal grading. Higher value = more urgent. type Severity int const ( SeverityNone Severity = iota SeverityInfo SeverityWarn SeverityCrit ) func (s Severity) String() string { switch s { case SeverityCrit: return "critical" case SeverityWarn: return "warning" case SeverityInfo: return "info" default: return "none" } } // SignalTrigger captures one reason the rule flagged an owner. Stored // in the per-owner Meta so the report can render a concise list of // "why this is dangling". type SignalTrigger struct { Rrtype string `json:"rrtype"` Target string `json:"target"` Reason string `json:"reason"` Detail string `json:"detail,omitempty"` Severity Severity `json:"severity"` } type ownerFindings struct { Owner string Subdomain string Triggers []SignalTrigger WorstSeverity Severity } // evaluatePointer applies the v1 verdict matrix to a single pointer: // // - Resolution == "nxdomain" → critical (broken pointer). // - Resolution == "servfail" → warning (likely lame upstream, may // also indicate decommissioning). // - Resolution == "no_answer" → info (NOERROR with empty answer // section is rarely the operator's intent for a pointer). // - WHOIS Status contains "pendingDelete"/"redemptionPeriod" → critical. // - WHOIS ExpiryDate already in the past → critical. // - WHOIS shows a registration < recentRegistrationDays old → warning // (possible re-registration; surface for review). // // Multiple triggers on the same pointer are reported individually so // the report can explain "why" without ambiguity. func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTrigger { var out []SignalTrigger switch pt.Resolution { case "nxdomain": out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: "Target does not resolve (NXDOMAIN). The record points at a host that no longer exists.", Detail: pt.ResolutionDetail, Severity: SeverityCrit, }) case "servfail": out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: "Target lookup returned SERVFAIL. The authoritative server may be misconfigured or the delegation broken.", Detail: pt.ResolutionDetail, Severity: SeverityWarn, }) case "no_answer": out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: "Target resolves to no address (NOERROR with empty answer). Rarely the operator's intent for a pointer record.", Severity: SeverityInfo, }) } // WHOIS-driven checks only apply to external targets we successfully // classified into a registrable domain. if pt.External && pt.Registrable != "" { if facts, ok := whoisByRef[contract.Ref(pt.Owner, pt.Rrtype, pt.Target)]; ok && facts != nil { out = append(out, evaluateWHOIS(pt, facts)...) } } return out } // whoisFacts is the minimal shape we need from a related "whois" // observation: ExpiryDate to detect expiration, Status to spot // registry-side states like pendingDelete, and CreationDate (when // reported by the upstream RDAP probe) to flag fresh re-registrations. type whoisFacts struct { ExpiryDate time.Time `json:"expiryDate"` CreationDate time.Time `json:"creationDate,omitzero"` Status []string `json:"status,omitempty"` } func evaluateWHOIS(pt *Pointer, f *whoisFacts) []SignalTrigger { var out []SignalTrigger now := time.Now() var atRiskStatuses []string for _, s := range f.Status { ls := strings.ToLower(s) if strings.Contains(ls, "pendingdelete") || strings.Contains(ls, "redemptionperiod") { atRiskStatuses = append(atRiskStatuses, s) } } if len(atRiskStatuses) > 0 { out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: fmt.Sprintf("Target's registrable domain (%s) is in registry state %s. It may be deleted soon and re-registered by anyone.", pt.Registrable, strings.Join(atRiskStatuses, ", ")), Severity: SeverityCrit, }) } if !f.ExpiryDate.IsZero() && f.ExpiryDate.Before(now) { out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: fmt.Sprintf("Target's registrable domain (%s) expired on %s.", pt.Registrable, f.ExpiryDate.Format("2006-01-02")), Severity: SeverityCrit, }) } if !f.CreationDate.IsZero() { age := now.Sub(f.CreationDate) if age < time.Duration(recentRegistrationDays)*24*time.Hour && age > 0 { out = append(out, SignalTrigger{ Rrtype: pt.Rrtype, Target: pt.Target, Reason: fmt.Sprintf("Target's registrable domain (%s) was registered %d days ago, after the original target was likely decommissioned. Verify the new owner is intentional.", pt.Registrable, int(age.Hours()/24)), Severity: SeverityWarn, }) } } return out } // ExternalWhoisObservationKey names the observation produced by the // companion checker that subscribes to dangling.external-target.v1 // entries and runs RDAP/WHOIS per registrable domain. Kept in sync // with happydomain3/checkers/external_expiry.go. const ExternalWhoisObservationKey = "external_whois" // loadWHOIS resolves related observations of key external_whois into a // per-Ref index. A non-fatal error is silently swallowed: WHOIS data // is best-effort context and its absence must not turn the whole rule // into an Error state. // // The companion checker is expected to return a map[Ref]facts under // each related observation; we also accept a single-fact payload keyed // directly by the entry Ref (host-side flattening case). func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoisFacts, int) { out := map[string]*whoisFacts{} related, err := obs.GetRelated(ctx, ExternalWhoisObservationKey) if err != nil { return out, 0 } parseErrors := 0 for _, ro := range related { // Try the per-Ref map shape first (the convention the host's // external_whois provider uses, mirrored from checker-tls). var asMap struct { Facts map[string]whoisFacts `json:"facts"` } if err := json.Unmarshal(ro.Data, &asMap); err == nil && len(asMap.Facts) > 0 { for ref, f := range asMap.Facts { ff := f out[ref] = &ff } continue } // Fallback: a single-fact payload, keyed by the related Ref. var f whoisFacts if err := json.Unmarshal(ro.Data, &f); err != nil { parseErrors++ continue } out[ro.Ref] = &f } return out, parseErrors } func severityToStatus(s Severity) sdk.Status { switch s { case SeverityCrit: return sdk.StatusCrit case SeverityWarn: return sdk.StatusWarn case SeverityInfo: return sdk.StatusInfo default: return sdk.StatusOK } } func scoreSeverity(triggers []SignalTrigger) Severity { worst := SeverityNone for _, t := range triggers { if t.Severity > worst { worst = t.Severity } } return worst } func codeForSeverity(s Severity) string { switch s { case SeverityCrit: return "dangling_critical" case SeverityWarn: return "dangling_warning" case SeverityInfo: return "dangling_info" default: return "dangling_clean" } } func buildOwnerMessage(f *ownerFindings) string { first := f.Triggers[0] if len(f.Triggers) == 1 { return fmt.Sprintf("%s — %s", displayOwner(f), first.Reason) } return fmt.Sprintf("%s — %s (and %d more signal%s)", displayOwner(f), first.Reason, len(f.Triggers)-1, plural(len(f.Triggers)-1)) } func displayOwner(f *ownerFindings) string { if f.Owner != "" { return f.Owner } return displaySubdomain(f.Subdomain) } func plural(n int) string { if n == 1 { return "" } return "s" } // sortFindings yields a stable, severity-first ordering of the // per-owner findings so the report's "fix this first" card always // matches the rule output. func sortFindings(byOwner map[string]*ownerFindings) []*ownerFindings { out := make([]*ownerFindings, 0, len(byOwner)) for _, f := range byOwner { out = append(out, f) } sort.SliceStable(out, func(i, j int) bool { if out[i].WorstSeverity != out[j].WorstSeverity { return out[i].WorstSeverity > out[j].WorstSeverity } return out[i].Owner < out[j].Owner }) return out }