Compare commits

..

No commits in common. "v0.1.0" and "master" have entirely different histories.

7 changed files with 274 additions and 198 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dangling
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dangling
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dangling
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dangling
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -17,21 +17,12 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// resolverTimeout caps each individual lookup so a slow / blackholed // resolverTimeout caps each lookup so a blackholed nameserver cannot stall the whole scan.
// 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 const resolverTimeout = 4 * time.Second
// resolveHost is the function used to classify a target. It is a // resolveHost is a package-level var so tests can stub DNS without hitting the network.
// package-level variable so tests can stub it deterministically without
// reaching the network.
var resolveHost = defaultResolveHost 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) { func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
@ -97,11 +88,8 @@ func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
return data, nil return data, nil
} }
// DiscoverEntries publishes one DiscoveryEntry per external pointer so // DiscoverEntries emits in-zone pointers too so future reachability checkers can subscribe,
// a subscriber (typically domain_expiry) can RDAP/WHOIS each target's // even though this checker ignores observations attached to them.
// 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) { func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*DanglingData) d, ok := data.(*DanglingData)
if !ok || d == nil { if !ok || d == nil {
@ -136,7 +124,7 @@ func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, erro
return out, nil return out, nil
} }
// readZone normalises the zone option (native struct or JSON object). // readZone normalises the zone option (native struct or JSON).
func readZone(opts sdk.CheckerOptions) (*rawZone, error) { func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
v, ok := opts["zone"] v, ok := opts["zone"]
if !ok || v == nil { if !ok || v == nil {
@ -153,11 +141,8 @@ func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
return z, nil return z, nil
} }
// extractPointers walks one service body and returns every // extractPointers returns pointer records from one service body.
// (owner, rrtype, target) triple it carries. It is best-effort: // Unrecognised service shapes return (nil, nil) to avoid polluting CollectErrors for A/AAAA/TXT zones.
// 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) { func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
if len(svc.Service) == 0 { if len(svc.Service) == 0 {
return nil, nil return nil, nil
@ -174,7 +159,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
if target == "" { if target == "" {
return nil, nil return nil, nil
} }
ptOwner := preferRRName(b.Record.Hdr.Name, owner) ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex)
return []Pointer{{ return []Pointer{{
Owner: ptOwner, Owner: ptOwner,
Subdomain: sub, Subdomain: sub,
@ -195,7 +180,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
continue continue
} }
out = append(out, Pointer{ out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner), Owner: preferRRName(r.Hdr.Name, owner, apex),
Subdomain: sub, Subdomain: sub,
Rrtype: "MX", Rrtype: "MX",
Target: target, Target: target,
@ -216,7 +201,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
continue continue
} }
out = append(out, Pointer{ out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner), Owner: preferRRName(r.Hdr.Name, owner, apex),
Subdomain: sub, Subdomain: sub,
Rrtype: "SRV", Rrtype: "SRV",
Target: target, Target: target,
@ -230,7 +215,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
if err := json.Unmarshal(svc.Service, &b); err != nil { if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode orphan body: %w", err) return nil, fmt.Errorf("decode orphan body: %w", err)
} }
ptOwner := preferRRName(b.Record.Hdr.Name, owner) ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex)
switch b.Record.Hdr.Rrtype { switch b.Record.Hdr.Rrtype {
case dns.TypeNS: case dns.TypeNS:
target := normaliseTarget(b.Record.Ns, ptOwner, apex) target := normaliseTarget(b.Record.Ns, ptOwner, apex)
@ -275,12 +260,9 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
return nil, nil return nil, nil
} }
// classifyExternal sets pt.External and pt.Registrable based on // classifyExternal marks pt.External/Registrable via eTLD+1.
// publicsuffix-derived eTLD+1. When publicsuffix cannot resolve an // For non-PSL names (e.g. ".internal") it falls back to suffix comparison, which treats
// eTLD+1 (e.g. internal TLD), we fall back to suffix-comparing the // sub-zones of the same registrable as in-zone — acceptable given the edge-case scope.
// 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) { func classifyExternal(pt *Pointer, zoneRegistrable string) {
target := strings.TrimSuffix(pt.Target, ".") target := strings.TrimSuffix(pt.Target, ".")
if target == "" { if target == "" {
@ -288,8 +270,7 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) {
} }
reg, err := publicsuffix.EffectiveTLDPlusOne(target) reg, err := publicsuffix.EffectiveTLDPlusOne(target)
if err != nil { if err != nil {
// Fall back to suffix comparison when target is not a // Fall back to suffix comparison for non-PSL names (e.g. ".internal").
// PSL-known name (e.g. ".internal", ".lan").
suffix := strings.TrimSuffix(zoneRegistrable, ".") suffix := strings.TrimSuffix(zoneRegistrable, ".")
if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) { if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) {
pt.External = true pt.External = true
@ -302,15 +283,7 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) {
} }
} }
// defaultResolveHost performs a single A/AAAA lookup on target and // defaultResolveHost performs an A/AAAA lookup and maps the outcome to a verdict string.
// 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) { func defaultResolveHost(ctx context.Context, target string) (verdict, detail string) {
target = strings.TrimSuffix(target, ".") target = strings.TrimSuffix(target, ".")
if target == "" { if target == "" {
@ -343,9 +316,7 @@ func defaultResolveHost(ctx context.Context, target string) (verdict, detail str
return "error", err.Error() return "error", err.Error()
} }
// ownerFQDN returns the FQDN of the service's owner. We prefer the // ownerFQDN returns the record owner FQDN, preferring the service's _domain field over subdomain+apex.
// 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 { func ownerFQDN(svcDomain, sub, apex string) string {
if svcDomain != "" { if svcDomain != "" {
return strings.TrimSuffix(svcDomain, ".") return strings.TrimSuffix(svcDomain, ".")
@ -359,20 +330,23 @@ func ownerFQDN(svcDomain, sub, apex string) string {
return sub + "." + apex return sub + "." + apex
} }
// preferRRName returns the RR header Name when present (it is the // preferRRName returns the RR header Name as an FQDN when present.
// authoritative owner for the record), otherwise the service-derived // happyDomain encodes service-embedded record owners relative to the zone
// owner. // apex, so the rrName must be joined with apex unless it already contains
func preferRRName(rrName, fallback string) string { // the apex suffix (services published with absolute owners).
func preferRRName(rrName, fallback, apex string) string {
rrName = strings.TrimSuffix(rrName, ".") rrName = strings.TrimSuffix(rrName, ".")
if rrName != "" { if rrName == "" {
return fallback
}
apex = strings.TrimSuffix(apex, ".")
if apex == "" || rrName == apex || strings.HasSuffix(rrName, "."+apex) {
return rrName return rrName
} }
return fallback return rrName + "." + apex
} }
// normaliseTarget yields the FQDN form of a record target. happyDomain // normaliseTarget converts a target to FQDN form; happyDomain stores in-zone targets relative, external ones absolute.
// stores within-zone targets relative to the zone, and external targets
// fully-qualified. We accept both shapes.
func normaliseTarget(target, owner, apex string) string { func normaliseTarget(target, owner, apex string) string {
t := strings.TrimSpace(target) t := strings.TrimSpace(target)
if t == "" { if t == "" {
@ -381,8 +355,7 @@ func normaliseTarget(target, owner, apex string) string {
if trimmed, ok := strings.CutSuffix(t, "."); ok { if trimmed, ok := strings.CutSuffix(t, "."); ok {
return trimmed return trimmed
} }
// Relative: anchor under the zone apex (or the owner when apex is // Relative target: anchor under apex (empty apex only occurs in tests that omit domain_name).
// empty, which only happens in tests that omit the domain name).
if apex != "" { if apex != "" {
return t + "." + apex return t + "." + apex
} }

View file

@ -6,14 +6,10 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is overridden at build time via -ldflags by main.go and // Version is overridden at build time via -ldflags. Use SetVersion from entrypoints, not direct assignment.
// plugin/plugin.go. Use SetVersion from entrypoints rather than
// assigning to it directly.
var Version = "built-in" var Version = "built-in"
// SetVersion updates the package-level Version reported in the // SetVersion ignores empty values so a misconfigured ldflags does not erase the default.
// CheckerDefinition. Empty values are ignored so an entrypoint that
// forgets its own ldflags does not erase the default.
func SetVersion(v string) { func SetVersion(v string) {
if v != "" { if v != "" {
Version = v Version = v
@ -21,10 +17,7 @@ func SetVersion(v string) {
} }
// Definition exposes the checker to the happyDomain host. // Definition exposes the checker to the happyDomain host.
// // Zone-scoped single pass so findings consolidate by owner rather than one observation per service.
// 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 { func Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{ def := &sdk.CheckerDefinition{
ID: "dangling", ID: "dangling",

View file

@ -9,10 +9,7 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// GetHTMLReport renders the dangling-records observation as a // GetHTMLReport renders the dangling-records observation as HTML.
// 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) { func (p *danglingProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data DanglingData var data DanglingData
if raw := ctx.Data(); len(raw) > 0 { if raw := ctx.Data(); len(raw) > 0 {
@ -76,10 +73,7 @@ func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView {
return v return v
} }
// cardsFromStates rebuilds the per-owner cards from the CheckState // cardsFromStates rebuilds per-owner cards from CheckState.Meta so the report and rule never disagree.
// 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 { func cardsFromStates(states []sdk.CheckState) []ownerCard {
out := make([]ownerCard, 0, len(states)) out := make([]ownerCard, 0, len(states))
for _, st := range states { for _, st := range states {

View file

@ -12,18 +12,11 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// recentRegistrationDays defines how recently a registrable domain // recentRegistrationDays is the window for flagging re-registered domains.
// must have been (re-)registered for the rule to flag it as a likely // Attackers re-register a freshly-released target to take over subdomains pointing at it (Ars Technica 2017).
// 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 const recentRegistrationDays = 90
// danglingRule is the single rule for v1: it walks the observation's // danglingRule is the single rule for v1.
// 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{} type danglingRule struct{}
func (r *danglingRule) Name() string { return "dangling_records" } func (r *danglingRule) Name() string { return "dangling_records" }
@ -126,9 +119,7 @@ func (s Severity) String() string {
} }
} }
// SignalTrigger captures one reason the rule flagged an owner. Stored // SignalTrigger captures one reason the rule flagged an owner, stored in CheckState.Meta for the report.
// in the per-owner Meta so the report can render a concise list of
// "why this is dangling".
type SignalTrigger struct { type SignalTrigger struct {
Rrtype string `json:"rrtype"` Rrtype string `json:"rrtype"`
Target string `json:"target"` Target string `json:"target"`
@ -144,20 +135,8 @@ type ownerFindings struct {
WorstSeverity Severity WorstSeverity Severity
} }
// evaluatePointer applies the v1 verdict matrix to a single pointer: // evaluatePointer returns all signals for a single pointer.
// // Multiple triggers are reported individually so the report can explain each reason.
// - 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 { func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTrigger {
var out []SignalTrigger var out []SignalTrigger
@ -193,10 +172,7 @@ func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTri
return out return out
} }
// whoisFacts is the minimal shape we need from a related "whois" // whoisFacts is the minimal subset of a related WHOIS observation used by evaluateWHOIS.
// 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 { type whoisFacts struct {
ExpiryDate time.Time `json:"expiryDate"` ExpiryDate time.Time `json:"expiryDate"`
CreationDate time.Time `json:"creationDate,omitzero"` CreationDate time.Time `json:"creationDate,omitzero"`
@ -244,20 +220,11 @@ func evaluateWHOIS(pt *Pointer, f *whoisFacts) []SignalTrigger {
return out return out
} }
// ExternalWhoisObservationKey names the observation produced by the // ExternalWhoisObservationKey must stay in sync with happydomain3/checkers/external_expiry.go.
// 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" const ExternalWhoisObservationKey = "external_whois"
// loadWHOIS resolves related observations of key external_whois into a // loadWHOIS builds a per-Ref index from related WHOIS observations.
// per-Ref index. A non-fatal error is silently swallowed: WHOIS data // Parse errors are counted but not fatal: WHOIS absence must not turn the rule into Error state.
// 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) { func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoisFacts, int) {
out := map[string]*whoisFacts{} out := map[string]*whoisFacts{}
related, err := obs.GetRelated(ctx, ExternalWhoisObservationKey) related, err := obs.GetRelated(ctx, ExternalWhoisObservationKey)
@ -266,8 +233,7 @@ func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoi
} }
parseErrors := 0 parseErrors := 0
for _, ro := range related { for _, ro := range related {
// Try the per-Ref map shape first (the convention the host's // Try the per-Ref map shape first (convention from checker-tls).
// external_whois provider uses, mirrored from checker-tls).
var asMap struct { var asMap struct {
Facts map[string]whoisFacts `json:"facts"` Facts map[string]whoisFacts `json:"facts"`
} }
@ -348,9 +314,7 @@ func plural(n int) string {
return "s" return "s"
} }
// sortFindings yields a stable, severity-first ordering of the // sortFindings returns findings sorted by descending severity so the report's top card matches the rule output.
// per-owner findings so the report's "fix this first" card always
// matches the rule output.
func sortFindings(byOwner map[string]*ownerFindings) []*ownerFindings { func sortFindings(byOwner map[string]*ownerFindings) []*ownerFindings {
out := make([]*ownerFindings, 0, len(byOwner)) out := make([]*ownerFindings, 0, len(byOwner))
for _, f := range byOwner { for _, f := range byOwner {

View file

@ -1,12 +1,5 @@
// Package checker implements the happyDomain "dangling records" // Package checker detects dangling pointer records (CNAME/MX/SRV/NS) whose external targets
// checker: it walks the working zone, identifies every pointer record // may have expired or been re-registered, enabling subdomain takeover.
// (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 package checker
import ( import (
@ -15,75 +8,31 @@ import (
const ObservationKeyDangling = "dangling_records" const ObservationKeyDangling = "dangling_records"
// DanglingData is the raw observation payload. It carries one Pointer // DanglingData is the raw observation payload; one Pointer per (owner, rrtype, target) triple.
// 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 { 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"` 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"` 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"` Pointers []Pointer `json:"pointers,omitempty"`
// CollectErrors surfaces non-fatal scan problems so silent skips don't masquerade as a clean pass.
// 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"` CollectErrors []string `json:"collect_errors,omitempty"`
} }
// Pointer is the unit of observation: one (owner, rrtype, target) seen // Pointer is one (owner, rrtype, target) triple from the zone, with its DNS resolution verdict.
// in the zone, plus the result of the local DNS resolution.
type Pointer struct { 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"` Owner string `json:"owner"`
// Subdomain is Owner relative to the zone apex. "" means apex
// (rendered as "@" in the report).
Subdomain string `json:"subdomain"` Subdomain string `json:"subdomain"`
// Rrtype is the textual record type ("CNAME", "MX", "SRV", "NS").
Rrtype string `json:"rrtype"` Rrtype string `json:"rrtype"`
// Target is the FQDN the record points at. No trailing dot.
Target string `json:"target"` Target string `json:"target"`
// External flags takeover risk: Target's registrable domain differs from the zone's.
// External is true when Target's registrable domain differs from
// the zone's registrable domain (the takeover-risk case).
External bool `json:"external"` 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"` Registrable string `json:"registrable,omitempty"`
// ServiceType identifies the happyDomain service for linking back to the edit screen.
// 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"` 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"` 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"` ResolutionDetail string `json:"resolution_detail,omitempty"`
} }
// rawZone is the minimal slice of happyDomain's *Zone JSON we consume. // rawZone is a partial Zone type redeclared here to avoid importing the happyDomain module.
// Like checker-legacy-records, we redeclare just the fields we need so
// this checker compiles without depending on the happyDomain module.
type rawZone struct { type rawZone struct {
DomainName string `json:"domain_name,omitempty"` DomainName string `json:"domain_name,omitempty"`
Services map[string][]rawService `json:"services"` Services map[string][]rawService `json:"services"`
@ -95,10 +44,6 @@ type rawService struct {
Service json.RawMessage `json:"Service"` 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 { type cnameBody struct {
Record struct { Record struct {
Hdr struct { Hdr struct {
@ -130,9 +75,7 @@ type srvsBody struct {
Records []srvRecord `json:"srv"` Records []srvRecord `json:"srv"`
} }
// orphanRecord covers the body shape used by svcs.Orphan when the // orphanRecord wraps an svcs.Orphan body; Hdr.Rrtype is sniffed to pick the right field.
// embedded RR is a CNAME, NS, MX, or SRV. We sniff Hdr.Rrtype before
// committing to a specific decoder.
type orphanRecord struct { type orphanRecord struct {
Record struct { Record struct {
Hdr struct { Hdr struct {