Initial commit

This commit is contained in:
nemunaire 2026-04-28 10:38:01 +07:00
commit 30caf67389
18 changed files with 2098 additions and 0 deletions

397
checker/collect.go Normal file
View file

@ -0,0 +1,397 @@
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
contract "git.happydns.org/checker-dangling/contract"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// resolverTimeout caps each individual lookup so a slow / blackholed
// authoritative server cannot stall a zone scan. Set conservatively:
// the host can re-run the check at any time, and a deadline beats a
// hang.
const resolverTimeout = 4 * time.Second
// resolveHost is the function used to classify a target. It is a
// package-level variable so tests can stub it deterministically without
// reaching the network.
var resolveHost = defaultResolveHost
// Collect walks the working zone, extracts every pointer record
// (CNAME / MX / SRV / NS), classifies each target as in-zone or
// external relative to the zone's registrable domain, and resolves
// each target on the live DNS to detect immediate breakage.
func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
zone, err := readZone(opts)
if err != nil {
return nil, err
}
zoneApex := strings.TrimSuffix(zone.DomainName, ".")
if zoneApex == "" {
if name, ok := sdk.GetOption[string](opts, "domain_name"); ok {
zoneApex = strings.TrimSuffix(name, ".")
}
}
zoneRegistrable, _ := publicsuffix.EffectiveTLDPlusOne(zoneApex)
skipResolution, _ := sdk.GetOption[bool](opts, "skip_resolution")
data := &DanglingData{Zone: zoneApex}
// Sort subdomains for deterministic output.
subs := make([]string, 0, len(zone.Services))
for s := range zone.Services {
subs = append(subs, s)
}
sort.Strings(subs)
// Track unique (owner, rrtype, target) so duplicate services do
// not produce duplicate findings.
seen := map[string]bool{}
for _, sub := range subs {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, svc := range zone.Services[sub] {
data.ServicesScanned++
pts, perr := extractPointers(sub, zoneApex, svc)
if perr != nil {
data.CollectErrors = append(data.CollectErrors,
fmt.Sprintf("%s/%s: %v", displaySubdomain(sub), svc.Type, perr))
continue
}
for _, pt := range pts {
key := pt.Owner + "|" + pt.Rrtype + "|" + pt.Target
if seen[key] {
continue
}
seen[key] = true
classifyExternal(&pt, zoneRegistrable)
if skipResolution {
pt.Resolution = "skipped"
} else {
pt.Resolution, pt.ResolutionDetail = resolveHost(ctx, pt.Target)
}
data.Pointers = append(data.Pointers, pt)
}
}
}
return data, nil
}
// DiscoverEntries publishes one DiscoveryEntry per external pointer so
// a subscriber (typically domain_expiry) can RDAP/WHOIS each target's
// registrable domain. In-zone pointers also get an entry so future
// reachability checkers can subscribe; this checker does not currently
// rely on observations attached to those entries.
func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*DanglingData)
if !ok || d == nil {
return nil, nil
}
out := make([]sdk.DiscoveryEntry, 0, len(d.Pointers))
for _, pt := range d.Pointers {
if pt.External && pt.Registrable != "" {
entry, err := contract.NewExternalEntry(contract.ExternalTarget{
Owner: pt.Owner,
Rrtype: pt.Rrtype,
Target: pt.Target,
Registrable: pt.Registrable,
})
if err != nil {
return nil, err
}
out = append(out, entry)
continue
}
entry, err := contract.NewInZoneEntry(contract.InZoneTarget{
Owner: pt.Owner,
Rrtype: pt.Rrtype,
Target: pt.Target,
Registrable: pt.Registrable,
})
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}
// readZone normalises the zone option (native struct or JSON object).
func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
v, ok := opts["zone"]
if !ok || v == nil {
return nil, fmt.Errorf("missing 'zone' option (AutoFillZone): the host did not provide a working zone")
}
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("re-marshal zone option: %w", err)
}
z := &rawZone{}
if err := json.Unmarshal(raw, z); err != nil {
return nil, fmt.Errorf("decode zone option: %w", err)
}
return z, nil
}
// extractPointers walks one service body and returns every
// (owner, rrtype, target) triple it carries. It is best-effort:
// services that do not match any known pointer shape return (nil, nil)
// so the common case of a pure A/AAAA/TXT zone produces no noise in
// CollectErrors.
func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
if len(svc.Service) == 0 {
return nil, nil
}
owner := ownerFQDN(svc.Domain, sub, apex)
switch svc.Type {
case "svcs.CNAME", "svcs.SpecialCNAME":
var b cnameBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode cname body: %w", err)
}
target := normaliseTarget(b.Record.Target, owner, apex)
if target == "" {
return nil, nil
}
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "CNAME",
Target: target,
ServiceType: svc.Type,
}}, nil
case "svcs.MXs":
var b mxsBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode mxs body: %w", err)
}
out := make([]Pointer, 0, len(b.MXs))
for _, r := range b.MXs {
target := normaliseTarget(r.Mx, owner, apex)
if target == "" {
continue
}
out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner),
Subdomain: sub,
Rrtype: "MX",
Target: target,
ServiceType: svc.Type,
})
}
return out, nil
case "svcs.UnknownSRV":
var b srvsBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode srv body: %w", err)
}
out := make([]Pointer, 0, len(b.Records))
for _, r := range b.Records {
target := normaliseTarget(r.Target, owner, apex)
if target == "" {
continue
}
out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner),
Subdomain: sub,
Rrtype: "SRV",
Target: target,
ServiceType: svc.Type,
})
}
return out, nil
case "svcs.Orphan":
var b orphanRecord
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode orphan body: %w", err)
}
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
switch b.Record.Hdr.Rrtype {
case dns.TypeNS:
target := normaliseTarget(b.Record.Ns, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "NS",
Target: target,
ServiceType: svc.Type,
}}, nil
case dns.TypeCNAME:
target := normaliseTarget(b.Record.Target, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "CNAME",
Target: target,
ServiceType: svc.Type,
}}, nil
case dns.TypeMX:
target := normaliseTarget(b.Record.Mx, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "MX",
Target: target,
ServiceType: svc.Type,
}}, nil
}
return nil, nil
}
return nil, nil
}
// classifyExternal sets pt.External and pt.Registrable based on
// publicsuffix-derived eTLD+1. When publicsuffix cannot resolve an
// eTLD+1 (e.g. internal TLD), we fall back to suffix-comparing the
// target against the zone's registrable name. This fallback is
// imprecise for sub-zones (a target under the parent registrable will
// be treated as in-zone), but it is only reached for non-PSL names.
func classifyExternal(pt *Pointer, zoneRegistrable string) {
target := strings.TrimSuffix(pt.Target, ".")
if target == "" {
return
}
reg, err := publicsuffix.EffectiveTLDPlusOne(target)
if err != nil {
// Fall back to suffix comparison when target is not a
// PSL-known name (e.g. ".internal", ".lan").
suffix := strings.TrimSuffix(zoneRegistrable, ".")
if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) {
pt.External = true
}
return
}
pt.Registrable = reg
if zoneRegistrable == "" || !strings.EqualFold(reg, zoneRegistrable) {
pt.External = true
}
}
// defaultResolveHost performs a single A/AAAA lookup on target and
// classifies the outcome into one of:
//
// - "ok" at least one A/AAAA returned
// - "no_answer" NOERROR but the server returned no addresses
// - "nxdomain" authoritative NXDOMAIN
// - "servfail" upstream resolver returned SERVFAIL
// - "timeout" the lookup did not complete in time
// - "error" any other resolution error
func defaultResolveHost(ctx context.Context, target string) (verdict, detail string) {
target = strings.TrimSuffix(target, ".")
if target == "" {
return "skipped", "empty target"
}
cctx, cancel := context.WithTimeout(ctx, resolverTimeout)
defer cancel()
ips, err := net.DefaultResolver.LookupHost(cctx, target)
if err == nil {
if len(ips) == 0 {
return "no_answer", ""
}
return "ok", ""
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
switch {
case dnsErr.IsNotFound:
return "nxdomain", dnsErr.Err
case dnsErr.IsTimeout:
return "timeout", dnsErr.Err
case strings.Contains(strings.ToLower(dnsErr.Err), "servfail"):
return "servfail", dnsErr.Err
default:
return "error", dnsErr.Err
}
}
return "error", err.Error()
}
// ownerFQDN returns the FQDN of the service's owner. We prefer the
// service's _domain field (already an FQDN with trailing dot in
// happyDomain's wire shape) and fall back to subdomain+apex.
func ownerFQDN(svcDomain, sub, apex string) string {
if svcDomain != "" {
return strings.TrimSuffix(svcDomain, ".")
}
if apex == "" {
return sub
}
if sub == "" || sub == "@" {
return apex
}
return sub + "." + apex
}
// preferRRName returns the RR header Name when present (it is the
// authoritative owner for the record), otherwise the service-derived
// owner.
func preferRRName(rrName, fallback string) string {
rrName = strings.TrimSuffix(rrName, ".")
if rrName != "" {
return rrName
}
return fallback
}
// normaliseTarget yields the FQDN form of a record target. happyDomain
// stores within-zone targets relative to the zone, and external targets
// fully-qualified. We accept both shapes.
func normaliseTarget(target, owner, apex string) string {
t := strings.TrimSpace(target)
if t == "" {
return ""
}
if trimmed, ok := strings.CutSuffix(t, "."); ok {
return trimmed
}
// Relative: anchor under the zone apex (or the owner when apex is
// empty, which only happens in tests that omit the domain name).
if apex != "" {
return t + "." + apex
}
return t + "." + owner
}
func displaySubdomain(s string) string {
if s == "" || s == "@" {
return "@"
}
return s
}

76
checker/definition.go Normal file
View file

@ -0,0 +1,76 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at build time via -ldflags by main.go and
// plugin/plugin.go. Use SetVersion from entrypoints rather than
// assigning to it directly.
var Version = "built-in"
// SetVersion updates the package-level Version reported in the
// CheckerDefinition. Empty values are ignored so an entrypoint that
// forgets its own ldflags does not erase the default.
func SetVersion(v string) {
if v != "" {
Version = v
}
}
// Definition exposes the checker to the happyDomain host.
//
// The checker is zone-scoped: it inspects every pointer service in a
// single pass so the report consolidates findings by owner instead of
// fanning one observation out per service.
func Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "dangling",
Name: "Dangling subdomains",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToZone: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyDangling},
Options: sdk.CheckerOptionsDocumentation{
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Type: "string",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
Hide: true,
},
{
Id: "zone",
Type: "string",
Label: "Zone",
AutoFill: sdk.AutoFillZone,
Hide: true,
},
},
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "skip_resolution",
Type: "bool",
Label: "Skip live DNS resolution",
Description: "When set, the checker only reports the static structure of pointer records. Useful for offline analysis; defaults to false.",
Default: false,
},
},
},
Rules: []sdk.CheckRule{
&danglingRule{},
},
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 15 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

16
checker/provider.go Normal file
View file

@ -0,0 +1,16 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns the dangling-records observation provider.
func Provider() sdk.ObservationProvider {
return &danglingProvider{}
}
type danglingProvider struct{}
func (p *danglingProvider) Key() sdk.ObservationKey { return ObservationKeyDangling }
func (p *danglingProvider) Definition() *sdk.CheckerDefinition { return Definition() }

273
checker/report.go Normal file
View file

@ -0,0 +1,273 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders the dangling-records observation as a
// self-contained HTML page. The report shows one card per impacted
// owner, sorted by descending severity, with the failing pointer and
// the human-readable reason behind each trigger.
func (p *danglingProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data DanglingData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse dangling-records data: %w", err)
}
}
view := buildReportView(&data, ctx.States())
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
type reportView struct {
Zone string
ServicesScanned int
Pointers int
OverallText string
OverallClass string
Top *ownerCard
Others []ownerCard
CollectErrors []string
}
type ownerCard struct {
Owner string
Severity string
SeverityCSS string
Triggers []SignalTrigger
}
func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView {
v := &reportView{
Zone: data.Zone,
ServicesScanned: data.ServicesScanned,
Pointers: len(data.Pointers),
CollectErrors: data.CollectErrors,
}
cards := cardsFromStates(states)
if len(cards) == 0 {
// Honour an Error state from the rule so the banner does not
// masquerade as OK when the observation could not be loaded.
if errState, ok := firstErrorState(states); ok {
v.OverallText = errState.Message
v.OverallClass = "status-crit"
return v
}
v.OverallText = fmt.Sprintf("No dangling subdomain detected across %d service(s).", data.ServicesScanned)
v.OverallClass = "status-ok"
return v
}
v.Top = &cards[0]
v.Others = cards[1:]
v.OverallText, v.OverallClass = overallLabel(cards[0].SeverityCSS)
return v
}
// cardsFromStates rebuilds the per-owner cards from the CheckState
// slice the host has already produced. We rely on Meta.triggers (set by
// danglingRule.Evaluate) so the report and the rule never disagree on
// what to show.
func cardsFromStates(states []sdk.CheckState) []ownerCard {
out := make([]ownerCard, 0, len(states))
for _, st := range states {
if st.Code == "dangling_clean" || st.Code == "dangling_observation_error" {
continue
}
card := ownerCard{
Owner: st.Subject,
}
if sev, ok := st.Meta["severity"].(string); ok {
card.Severity = severityLabel(sev)
card.SeverityCSS = sev
}
// Triggers may have been round-tripped through JSON if the host
// crossed an HTTP boundary; handle both shapes.
switch v := st.Meta["triggers"].(type) {
case []SignalTrigger:
card.Triggers = v
case []any:
skipped := 0
for _, item := range v {
b, err := json.Marshal(item)
if err != nil {
skipped++
continue
}
var t SignalTrigger
if err := json.Unmarshal(b, &t); err != nil {
skipped++
continue
}
card.Triggers = append(card.Triggers, t)
}
if skipped > 0 {
card.Triggers = append(card.Triggers, SignalTrigger{
Reason: fmt.Sprintf("%d trigger(s) could not be rendered.", skipped),
})
}
}
out = append(out, card)
}
return out
}
func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
for i := range states {
if states[i].Status == sdk.StatusError {
return states[i], true
}
}
return sdk.CheckState{}, false
}
func severityLabel(css string) string {
switch css {
case "critical":
return "Critical"
case "warning":
return "Warning"
case "info":
return "Informational"
default:
return ""
}
}
func overallLabel(severityCSS string) (text, css string) {
switch severityCSS {
case "critical":
return "Dangling subdomains require urgent attention", "status-crit"
case "warning":
return "Dangling subdomains should be reviewed", "status-warn"
case "info":
return "Informational pointer issues found", "status-info"
default:
return "Dangling subdomains detected", "status-warn"
}
}
var reportTmpl = template.Must(template.New("dangling-records-report").Parse(reportTemplate))
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dangling subdomains {{if .Zone}}{{.Zone}}{{else}}zone report{{end}}</title>
<style>
:root {
--ok: #1e9e5d;
--info: #3b82f6;
--warn: #d97706;
--crit: #dc2626;
--bg: #f7f7f8;
--card: #ffffff;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
}
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
.muted { color: var(--muted); font-size: .85rem; }
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .9rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
.status-ok { background: var(--ok); }
.status-info { background: var(--info); }
.status-warn { background: var(--warn); }
.status-crit { background: var(--crit); }
.status-banner .label { font-weight: 600; font-size: 1rem; }
.top-fix { border-left: 5px solid var(--crit); background: #fef2f2; padding: 1rem 1.1rem; border-radius: 8px; margin-bottom: 1rem; }
.top-fix.severity-warning { border-color: var(--warn); background: #fffbeb; }
.top-fix.severity-info { border-color: var(--info); background: #eff6ff; }
.other-fix { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .55rem; }
.sev { display: inline-block; padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; letter-spacing: .04em; }
.sev-info { background: var(--info); }
.sev-warning { background: var(--warn); }
.sev-critical { background: var(--crit); }
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: .35rem; }
th, td { text-align: left; padding: .4rem .65rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: #f3f4f6; font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
details.errors { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: .55rem .8rem; margin-top: 1rem; }
details.errors summary { cursor: pointer; font-weight: 600; }
details.errors li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; }
</style>
</head>
<body>
<h1>Dangling subdomains</h1>
<div class="muted">{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Pointers}} pointer(s) inspected</div>
<div class="status-banner {{.OverallClass}}" style="margin-top: 1rem;">
<div>
<div class="label">{{.OverallText}}</div>
</div>
</div>
{{if .Top}}
<h2>Fix this first</h2>
<div class="top-fix severity-{{.Top.SeverityCSS}}">
<h3>
<code>{{.Top.Owner}}</code>
<span class="sev sev-{{.Top.SeverityCSS}}">{{.Top.Severity}}</span>
</h3>
{{if .Top.Triggers}}
<table>
<thead><tr><th>Pointer</th><th>Target</th><th>Why</th></tr></thead>
<tbody>
{{range .Top.Triggers}}
<tr>
<td><code>{{.Rrtype}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Reason}}{{if .Detail}} <span class="muted">({{.Detail}})</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{end}}
{{if .Others}}
<h2>Other dangling subdomains</h2>
{{range .Others}}
<div class="other-fix">
<h3>
<code>{{.Owner}}</code>
<span class="sev sev-{{.SeverityCSS}}">{{.Severity}}</span>
</h3>
{{if .Triggers}}
<ul>
{{range .Triggers}}
<li><code>{{.Rrtype}}</code> <code>{{.Target}}</code>: {{.Reason}}</li>
{{end}}
</ul>
{{end}}
</div>
{{end}}
{{end}}
{{if .CollectErrors}}
<details class="errors">
<summary>{{len .CollectErrors}} service(s) skipped during scan</summary>
<ul>
{{range .CollectErrors}}<li>{{.}}</li>{{end}}
</ul>
</details>
{{end}}
</body>
</html>`

366
checker/rule.go Normal file
View 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
}

502
checker/rules_test.go Normal file
View file

@ -0,0 +1,502 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/miekg/dns"
contract "git.happydns.org/checker-dangling/contract"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// --- test helpers ---------------------------------------------------------
// stubResolver lets a single test override the resolution outcome per
// target without touching the real network. The outer test wires it
// in/out via a t.Cleanup so the package-level variable stays clean.
func stubResolver(t *testing.T, table map[string]struct{ verdict, detail string }) {
t.Helper()
prev := resolveHost
resolveHost = func(_ context.Context, target string) (string, string) {
target = strings.TrimSuffix(target, ".")
if v, ok := table[target]; ok {
return v.verdict, v.detail
}
// Default: target resolves cleanly. Tests pin behaviour they
// care about; everything else should be a "boring OK".
return "ok", ""
}
t.Cleanup(func() { resolveHost = prev })
}
func cnameSvc(target string) rawService {
body, _ := json.Marshal(map[string]any{
"cname": map[string]any{
"Hdr": map[string]any{"Name": ""},
"Target": target,
},
})
return rawService{Type: "svcs.CNAME", Domain: "", Service: body}
}
func mxSvc(targets ...string) rawService {
mxs := make([]map[string]any, 0, len(targets))
for _, t := range targets {
mxs = append(mxs, map[string]any{
"Hdr": map[string]any{"Name": ""},
"Mx": t,
"Preference": 10,
})
}
body, _ := json.Marshal(map[string]any{"mx": mxs})
return rawService{Type: "svcs.MXs", Domain: "", Service: body}
}
func srvSvc(target string) rawService {
body, _ := json.Marshal(map[string]any{
"srv": []map[string]any{{
"Hdr": map[string]any{"Name": ""},
"Target": target,
}},
})
return rawService{Type: "svcs.UnknownSRV", Domain: "", Service: body}
}
func nsOrphan(host string) rawService {
body, _ := json.Marshal(map[string]any{
"record": map[string]any{
"Hdr": map[string]any{"Name": "", "Rrtype": dns.TypeNS},
"Ns": host,
},
})
return rawService{Type: "svcs.Orphan", Domain: "", Service: body}
}
// modernNonPointer mimics a service that carries no pointer (e.g. an
// abstract.Server with A/AAAA records). The collector should ignore it
// silently, contributing only to ServicesScanned.
func modernNonPointer() rawService {
body, _ := json.Marshal(map[string]any{"A": map[string]any{}})
return rawService{Type: "abstract.Server", Domain: "", Service: body}
}
func runCollect(t *testing.T, zone *rawZone, opts sdk.CheckerOptions) *DanglingData {
t.Helper()
if opts == nil {
opts = sdk.CheckerOptions{}
}
raw, err := json.Marshal(zone)
if err != nil {
t.Fatalf("marshal zone: %v", err)
}
var jsonZone map[string]any
if err := json.Unmarshal(raw, &jsonZone); err != nil {
t.Fatalf("unmarshal zone: %v", err)
}
opts["zone"] = jsonZone
if _, ok := opts["domain_name"]; !ok && zone.DomainName != "" {
opts["domain_name"] = zone.DomainName
}
out, err := (&danglingProvider{}).Collect(context.Background(), opts)
if err != nil {
t.Fatalf("Collect: %v", err)
}
d, ok := out.(*DanglingData)
if !ok {
t.Fatalf("Collect returned %T, want *DanglingData", out)
}
return d
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// staticObs serves a single observation by key plus a fixed map of
// related observations keyed by ObservationKey. Mirrors the helper
// used by checker-legacy-records, extended to cover GetRelated.
type staticObs struct {
key sdk.ObservationKey
payload []byte
related map[sdk.ObservationKey][]sdk.RelatedObservation
}
func (s staticObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != s.key {
return fmt.Errorf("staticObs: unexpected observation key %q (have %q)", key, s.key)
}
return json.Unmarshal(s.payload, dest)
}
func (s staticObs) GetRelated(_ context.Context, key sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return s.related[key], nil
}
// --- collect tests --------------------------------------------------------
func TestCollect_CleanZone_NoPointers(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"": {modernNonPointer()},
"www": {modernNonPointer()},
},
}
data := runCollect(t, z, nil)
if data.ServicesScanned != 2 {
t.Errorf("ServicesScanned = %d, want 2", data.ServicesScanned)
}
if len(data.Pointers) != 0 {
t.Errorf("Pointers = %+v, want empty", data.Pointers)
}
}
func TestCollect_DetectsCNAMEMXSRV_NS(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"www": {cnameSvc("target.example.net.")},
"": {mxSvc("mail.example.org."), nsOrphan("ns1.someprovider.net.")},
"_sip._tcp": {srvSvc("sipserver.example.io.")},
},
}
data := runCollect(t, z, nil)
if got := len(data.Pointers); got != 4 {
t.Fatalf("Pointers count = %d, want 4: %+v", got, data.Pointers)
}
want := map[string]bool{"CNAME": false, "MX": false, "NS": false, "SRV": false}
for _, p := range data.Pointers {
if !p.External {
t.Errorf("expected pointer to external target to be flagged External: %+v", p)
}
if p.Registrable == "" {
t.Errorf("expected non-empty Registrable for external target: %+v", p)
}
want[p.Rrtype] = true
}
for k, ok := range want {
if !ok {
t.Errorf("missing pointer of type %s", k)
}
}
}
func TestCollect_InZoneTargetIsNotExternal(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"www": {cnameSvc("aliased.example.com.")},
},
}
data := runCollect(t, z, nil)
if len(data.Pointers) != 1 {
t.Fatalf("want 1 pointer, got %d", len(data.Pointers))
}
if data.Pointers[0].External {
t.Errorf("same-registrable target must not be External: %+v", data.Pointers[0])
}
}
func TestCollect_MissingZoneOptionFails(t *testing.T) {
_, err := (&danglingProvider{}).Collect(context.Background(), sdk.CheckerOptions{})
if err == nil {
t.Fatal("expected error when 'zone' option is missing, got nil")
}
}
// --- DiscoverEntries ------------------------------------------------------
func TestDiscoverEntries_PublishesExternalAndInZone(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"alias-ext": {cnameSvc("provider.example.net.")},
"alias-in": {cnameSvc("internal.example.com.")},
},
}
data := runCollect(t, z, nil)
entries, err := (&danglingProvider{}).DiscoverEntries(data)
if err != nil {
t.Fatalf("DiscoverEntries: %v", err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries, got %d: %+v", len(entries), entries)
}
var sawExternal, sawInZone bool
for _, e := range entries {
switch e.Type {
case contract.ExternalTargetType:
sawExternal = true
case contract.InZoneTargetType:
sawInZone = true
default:
t.Errorf("unexpected entry Type %q", e.Type)
}
}
if !sawExternal || !sawInZone {
t.Errorf("entry types missing: external=%v inzone=%v", sawExternal, sawInZone)
}
}
// --- Evaluate matrix ------------------------------------------------------
func TestEvaluate_NXDOMAINIsCritical(t *testing.T) {
stubResolver(t, map[string]struct{ verdict, detail string }{
"gone.example.net": {"nxdomain", "no such host"},
})
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"old": {cnameSvc("gone.example.net.")},
},
}
data := runCollect(t, z, nil)
obs := staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}
states := (&danglingRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
t.Fatalf("want 1 critical state, got %+v", states)
}
if !strings.Contains(states[0].Message, "old.example.com") {
t.Errorf("message should name the impacted owner: %q", states[0].Message)
}
}
func TestEvaluate_ServfailIsWarning(t *testing.T) {
stubResolver(t, map[string]struct{ verdict, detail string }{
"flaky.example.net": {"servfail", "lookup servfail"},
})
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"www": {cnameSvc("flaky.example.net.")},
},
}
data := runCollect(t, z, nil)
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Fatalf("want 1 warning state, got %+v", states)
}
}
func TestEvaluate_WhoisExpiredIsCritical(t *testing.T) {
stubResolver(t, nil) // target resolves OK on DNS — only WHOIS is bad.
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"promo": {cnameSvc("brand.attackertarget.net.")},
},
}
data := runCollect(t, z, nil)
expired := whoisFacts{ExpiryDate: time.Now().Add(-30 * 24 * time.Hour)}
ref := contract.Ref("promo.example.com", "CNAME", "brand.attackertarget.net")
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
ExternalWhoisObservationKey: {{
CheckerID: "domain-expiry",
Key: ExternalWhoisObservationKey,
Data: mustMarshal(t, expired),
Ref: ref,
}},
}
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
t.Fatalf("want 1 critical state, got %+v", states)
}
if !strings.Contains(states[0].Message, "expired") {
t.Errorf("message should mention expired registrable: %q", states[0].Message)
}
}
func TestEvaluate_WhoisPendingDeleteIsCritical(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"shop": {cnameSvc("brand.dropping.net.")},
},
}
data := runCollect(t, z, nil)
facts := whoisFacts{
ExpiryDate: time.Now().Add(30 * 24 * time.Hour),
Status: []string{"clientTransferProhibited", "pendingDelete"},
}
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
ExternalWhoisObservationKey: {{
CheckerID: "domain-expiry",
Key: ExternalWhoisObservationKey,
Data: mustMarshal(t, facts),
Ref: contract.Ref("shop.example.com", "CNAME", "brand.dropping.net"),
}},
}
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
t.Fatalf("want 1 critical state, got %+v", states)
}
}
func TestEvaluate_RecentRegistrationIsWarning(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"legacy": {cnameSvc("brand.recently-grabbed.net.")},
},
}
data := runCollect(t, z, nil)
facts := whoisFacts{
ExpiryDate: time.Now().Add(365 * 24 * time.Hour),
CreationDate: time.Now().Add(-15 * 24 * time.Hour),
}
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
ExternalWhoisObservationKey: {{
CheckerID: "domain-expiry",
Key: ExternalWhoisObservationKey,
Data: mustMarshal(t, facts),
Ref: contract.Ref("legacy.example.com", "CNAME", "brand.recently-grabbed.net"),
}},
}
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Fatalf("want 1 warning state, got %+v", states)
}
}
func TestEvaluate_CleanZoneReturnsOK(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"www": {cnameSvc("aliased.example.com.")}, // in-zone, OK
},
}
data := runCollect(t, z, nil)
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Fatalf("want single OK state, got %+v", states)
}
}
func TestEvaluate_RanksCriticalAboveWarning(t *testing.T) {
stubResolver(t, map[string]struct{ verdict, detail string }{
"flaky.example.net": {"servfail", ""},
"gone.example.net": {"nxdomain", ""},
})
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"a": {cnameSvc("flaky.example.net.")},
"b": {cnameSvc("gone.example.net.")},
},
}
data := runCollect(t, z, nil)
states := (&danglingRule{}).Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
sdk.CheckerOptions{})
if len(states) != 2 {
t.Fatalf("want 2 states, got %d: %+v", len(states), states)
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("first state must be critical (NXDOMAIN), got %v", states[0].Status)
}
if states[1].Status != sdk.StatusWarn {
t.Errorf("second state must be warning (SERVFAIL), got %v", states[1].Status)
}
}
// --- Report ---------------------------------------------------------------
type staticReportCtx struct {
data []byte
states []sdk.CheckState
related map[sdk.ObservationKey][]sdk.RelatedObservation
}
func (s staticReportCtx) Data() json.RawMessage { return s.data }
func (s staticReportCtx) Related(k sdk.ObservationKey) []sdk.RelatedObservation {
return s.related[k]
}
func (s staticReportCtx) States() []sdk.CheckState { return s.states }
func TestReport_OKBannerWhenNoFindings(t *testing.T) {
stubResolver(t, nil)
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"www": {cnameSvc("aliased.example.com.")},
},
}
data := runCollect(t, z, nil)
html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{
data: mustMarshal(t, data),
states: []sdk.CheckState{{Status: sdk.StatusOK, Code: "dangling_clean"}},
})
if err != nil {
t.Fatalf("GetHTMLReport: %v", err)
}
if !strings.Contains(html, "status-ok") {
t.Errorf("report missing OK banner")
}
}
func TestReport_TopCardReflectsCriticalOwner(t *testing.T) {
stubResolver(t, map[string]struct{ verdict, detail string }{
"gone.example.net": {"nxdomain", ""},
})
z := &rawZone{
DomainName: "example.com",
Services: map[string][]rawService{
"old": {cnameSvc("gone.example.net.")},
},
}
data := runCollect(t, z, nil)
rule := &danglingRule{}
states := rule.Evaluate(context.Background(),
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
sdk.CheckerOptions{})
html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{
data: mustMarshal(t, data),
states: states,
})
if err != nil {
t.Fatalf("GetHTMLReport: %v", err)
}
if !strings.Contains(html, "Fix this first") {
t.Errorf("report missing 'Fix this first' card")
}
if !strings.Contains(html, "old.example.com") {
t.Errorf("report does not name the impacted owner")
}
}

147
checker/types.go Normal file
View file

@ -0,0 +1,147 @@
// Package checker implements the happyDomain "dangling records"
// checker: it walks the working zone, identifies every pointer record
// (CNAME / MX / SRV / NS) whose target lives outside the zone, performs
// a light DNS resolution to detect immediate breakage (NXDOMAIN), and
// publishes DiscoveryEntry records so a companion checker (typically
// the host's domain_expiry) can verify each external registrable domain
// via RDAP/WHOIS. The rule layer joins both signals to surface
// subdomains at risk of takeover (the "dangling CNAME" attack class
// publicised by Ars Technica in 2017).
package checker
import (
"encoding/json"
)
const ObservationKeyDangling = "dangling_records"
// DanglingData is the raw observation payload. It carries one Pointer
// entry per (owner, rrtype, target) triple found in the zone, including
// targets resolved to their DNS verdict. Aggregation by owner happens
// in the rule layer.
type DanglingData struct {
// Zone is the zone apex, without trailing dot. Empty when the host
// did not provide a domain_name option.
Zone string `json:"zone,omitempty"`
// ServicesScanned counts every service inspected (matches the same
// field in checker-legacy-records, anchoring the report).
ServicesScanned int `json:"services_scanned"`
// Pointers lists every pointer record encountered. One entry per
// distinct (owner, rrtype, target). External pointers carry a
// non-empty Registrable; in-zone pointers leave it empty so the
// rule does not request RDAP on the user's own apex.
Pointers []Pointer `json:"pointers,omitempty"`
// CollectErrors records non-fatal problems encountered during the
// zone walk, surfaced in the report so silent skips do not
// masquerade as a clean pass.
CollectErrors []string `json:"collect_errors,omitempty"`
}
// Pointer is the unit of observation: one (owner, rrtype, target) seen
// in the zone, plus the result of the local DNS resolution.
type Pointer struct {
// Owner is the FQDN that carries the pointer record (CNAME owner,
// MX/SRV owner, NS apex, …). No trailing dot.
Owner string `json:"owner"`
// Subdomain is Owner relative to the zone apex. "" means apex
// (rendered as "@" in the report).
Subdomain string `json:"subdomain"`
// Rrtype is the textual record type ("CNAME", "MX", "SRV", "NS").
Rrtype string `json:"rrtype"`
// Target is the FQDN the record points at. No trailing dot.
Target string `json:"target"`
// External is true when Target's registrable domain differs from
// the zone's registrable domain (the takeover-risk case).
External bool `json:"external"`
// Registrable is the eTLD+1 of Target. Empty when External is false
// or when public-suffix lookup failed.
Registrable string `json:"registrable,omitempty"`
// ServiceType is the happyDomain service that exposed the record
// ("svcs.CNAME", "svcs.MXs", …). Useful for navigating users back
// to the right edit screen in the report.
ServiceType string `json:"service_type,omitempty"`
// Resolution is the verdict of the local DNS lookup of Target:
// "ok", "nxdomain", "no_answer", "servfail", "timeout", "skipped".
// "skipped" is used when the collector chose not to resolve (for
// example, because lookups are disabled at runtime).
Resolution string `json:"resolution"`
// ResolutionDetail is a free-form sentence describing the
// resolution outcome (e.g. the underlying error string). Optional.
ResolutionDetail string `json:"resolution_detail,omitempty"`
}
// rawZone is the minimal slice of happyDomain's *Zone JSON we consume.
// Like checker-legacy-records, we redeclare just the fields we need so
// this checker compiles without depending on the happyDomain module.
type rawZone struct {
DomainName string `json:"domain_name,omitempty"`
Services map[string][]rawService `json:"services"`
}
type rawService struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// Below: minimal JSON shapes for each service body we extract pointers
// from. We only need fields that point at a host name, so the
// definitions are deliberately partial.
type cnameBody struct {
Record struct {
Hdr struct {
Name string `json:"Name"`
} `json:"Hdr"`
Target string `json:"Target"`
} `json:"cname"`
}
type mxRecord struct {
Hdr struct {
Name string `json:"Name"`
} `json:"Hdr"`
Mx string `json:"Mx"`
}
type mxsBody struct {
MXs []mxRecord `json:"mx"`
}
type srvRecord struct {
Hdr struct {
Name string `json:"Name"`
} `json:"Hdr"`
Target string `json:"Target"`
}
type srvsBody struct {
Records []srvRecord `json:"srv"`
}
// orphanRecord covers the body shape used by svcs.Orphan when the
// embedded RR is a CNAME, NS, MX, or SRV. We sniff Hdr.Rrtype before
// committing to a specific decoder.
type orphanRecord struct {
Record struct {
Hdr struct {
Name string `json:"Name"`
Rrtype uint16 `json:"Rrtype"`
} `json:"Hdr"`
// Optional fields, populated for the relevant rrtype.
Target string `json:"Target,omitempty"`
Mx string `json:"Mx,omitempty"`
Ns string `json:"Ns,omitempty"`
} `json:"record"`
}