checker-dangling/checker/rule.go

330 lines
9.5 KiB
Go

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 is the window for flagging re-registered domains.
// Attackers re-register a freshly-released target to take over subdomains pointing at it (Ars Technica 2017).
const recentRegistrationDays = 90
// danglingRule is the single rule for v1.
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 CheckState.Meta for the report.
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 returns all signals for a single pointer.
// Multiple triggers are reported individually so the report can explain each reason.
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 subset of a related WHOIS observation used by evaluateWHOIS.
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 must stay in sync with happydomain3/checkers/external_expiry.go.
const ExternalWhoisObservationKey = "external_whois"
// loadWHOIS builds a per-Ref index from related WHOIS observations.
// Parse errors are counted but not fatal: WHOIS absence must not turn the rule into Error state.
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 (convention 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 returns findings sorted by descending severity so the report's top card 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
}