Initial commit
This commit is contained in:
commit
30caf67389
18 changed files with 2098 additions and 0 deletions
366
checker/rule.go
Normal file
366
checker/rule.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue