diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..3c50a1c --- /dev/null +++ b/.drone-manifest.yml @@ -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 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..e5bf907 --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/checker/collect.go b/checker/collect.go index 517c657..a27796c 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -17,21 +17,12 @@ import ( 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. +// resolverTimeout caps each lookup so a blackholed nameserver cannot stall the whole scan. 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. +// resolveHost is a package-level var so tests can stub DNS without hitting 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 @@ -85,7 +76,7 @@ func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) seen[key] = true classifyExternal(&pt, zoneRegistrable) if skipResolution { - pt.Resolution = "skipped" + pt.Resolution = ResolutionSkipped } else { pt.Resolution, pt.ResolutionDetail = resolveHost(ctx, pt.Target) } @@ -97,11 +88,8 @@ func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) 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. +// DiscoverEntries emits in-zone pointers too so future reachability checkers can subscribe, +// even though this checker ignores observations attached to them. func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { d, ok := data.(*DanglingData) if !ok || d == nil { @@ -136,7 +124,7 @@ func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, erro 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) { v, ok := opts["zone"] if !ok || v == nil { @@ -153,11 +141,8 @@ func readZone(opts sdk.CheckerOptions) (*rawZone, error) { 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. +// extractPointers returns pointer records from one service body. +// Unrecognised service shapes return (nil, nil) to avoid polluting CollectErrors for A/AAAA/TXT zones. func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { if len(svc.Service) == 0 { return nil, nil @@ -170,11 +155,11 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { 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) + target := normaliseTarget(b.Record.Target, apex) if target == "" { return nil, nil } - ptOwner := preferRRName(b.Record.Hdr.Name, owner) + ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex) return []Pointer{{ Owner: ptOwner, Subdomain: sub, @@ -190,12 +175,12 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { } out := make([]Pointer, 0, len(b.MXs)) for _, r := range b.MXs { - target := normaliseTarget(r.Mx, owner, apex) + target := normaliseTarget(r.Mx, apex) if target == "" { continue } out = append(out, Pointer{ - Owner: preferRRName(r.Hdr.Name, owner), + Owner: preferRRName(r.Hdr.Name, owner, apex), Subdomain: sub, Rrtype: "MX", Target: target, @@ -211,12 +196,12 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { } out := make([]Pointer, 0, len(b.Records)) for _, r := range b.Records { - target := normaliseTarget(r.Target, owner, apex) + target := normaliseTarget(r.Target, apex) if target == "" { continue } out = append(out, Pointer{ - Owner: preferRRName(r.Hdr.Name, owner), + Owner: preferRRName(r.Hdr.Name, owner, apex), Subdomain: sub, Rrtype: "SRV", Target: target, @@ -230,10 +215,10 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { 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) + ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex) switch b.Record.Hdr.Rrtype { case dns.TypeNS: - target := normaliseTarget(b.Record.Ns, ptOwner, apex) + target := normaliseTarget(b.Record.Ns, apex) if target == "" { return nil, nil } @@ -245,7 +230,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { ServiceType: svc.Type, }}, nil case dns.TypeCNAME: - target := normaliseTarget(b.Record.Target, ptOwner, apex) + target := normaliseTarget(b.Record.Target, apex) if target == "" { return nil, nil } @@ -257,7 +242,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { ServiceType: svc.Type, }}, nil case dns.TypeMX: - target := normaliseTarget(b.Record.Mx, ptOwner, apex) + target := normaliseTarget(b.Record.Mx, apex) if target == "" { return nil, nil } @@ -275,12 +260,9 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { 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. +// classifyExternal marks pt.External/Registrable via eTLD+1. +// For non-PSL names (e.g. ".internal") it falls back to suffix comparison, which treats +// sub-zones of the same registrable as in-zone — acceptable given the edge-case scope. func classifyExternal(pt *Pointer, zoneRegistrable string) { target := strings.TrimSuffix(pt.Target, ".") if target == "" { @@ -288,8 +270,7 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) { } 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"). + // Fall back to suffix comparison for non-PSL names (e.g. ".internal"). suffix := strings.TrimSuffix(zoneRegistrable, ".") if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) { pt.External = true @@ -302,19 +283,11 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) { } } -// 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 +// defaultResolveHost performs an A/AAAA lookup and maps the outcome to a verdict string. func defaultResolveHost(ctx context.Context, target string) (verdict, detail string) { target = strings.TrimSuffix(target, ".") if target == "" { - return "skipped", "empty target" + return ResolutionSkipped, "empty target" } cctx, cancel := context.WithTimeout(ctx, resolverTimeout) defer cancel() @@ -322,71 +295,68 @@ func defaultResolveHost(ctx context.Context, target string) (verdict, detail str ips, err := net.DefaultResolver.LookupHost(cctx, target) if err == nil { if len(ips) == 0 { - return "no_answer", "" + return ResolutionNoAnswer, "" } - return "ok", "" + return ResolutionOK, "" } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { switch { case dnsErr.IsNotFound: - return "nxdomain", dnsErr.Err + return ResolutionNXDomain, dnsErr.Err case dnsErr.IsTimeout: - return "timeout", dnsErr.Err + return ResolutionTimeout, dnsErr.Err case strings.Contains(strings.ToLower(dnsErr.Err), "servfail"): - return "servfail", dnsErr.Err + return ResolutionServFail, dnsErr.Err default: - return "error", dnsErr.Err + return ResolutionError, dnsErr.Err } } - return "error", err.Error() + return ResolutionError, 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. +// ownerFQDN returns the record owner FQDN, preferring the service's _domain field over subdomain+apex. func ownerFQDN(svcDomain, sub, apex string) string { - if svcDomain != "" { - return strings.TrimSuffix(svcDomain, ".") + fallback := func() string { + if apex == "" { + return sub + } + if sub == "" || sub == "@" { + return apex + } + return sub + "." + apex } - if apex == "" { - return sub + if svcDomain == "" { + return fallback() } - if sub == "" || sub == "@" { - return apex - } - return sub + "." + apex + return preferRRName(svcDomain, fallback(), 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 { +// preferRRName returns the RR header Name as an FQDN when present. +// happyDomain encodes service-embedded record owners relative to the zone +// apex, so the rrName must be joined with apex unless it already contains +// the apex suffix (services published with absolute owners). +func preferRRName(rrName, fallback, apex string) string { 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 fallback + return rrName + "." + apex } -// 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 "" +// normaliseTarget converts a target to FQDN form without trailing dot. +// Absolute FQDNs (trailing dot) are stripped; relative names and "@" are +// resolved against apex via sdk.JoinRelative. +func normaliseTarget(target, apex string) string { + if t, ok := strings.CutSuffix(strings.TrimSpace(target), "."); ok { + return t } - 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 + return sdk.JoinRelative(target, apex) } func displaySubdomain(s string) string { diff --git a/checker/definition.go b/checker/definition.go index 0d3cfa0..2cba42d 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -6,14 +6,10 @@ import ( 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. +// Version is overridden at build time via -ldflags. Use SetVersion from entrypoints, not direct assignment. 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. +// SetVersion ignores empty values so a misconfigured ldflags does not erase the default. func SetVersion(v string) { if v != "" { Version = v @@ -21,10 +17,7 @@ func SetVersion(v string) { } // 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. +// Zone-scoped single pass so findings consolidate by owner rather than one observation per service. func Definition() *sdk.CheckerDefinition { def := &sdk.CheckerDefinition{ ID: "dangling", diff --git a/checker/report.go b/checker/report.go index dc11523..da564c0 100644 --- a/checker/report.go +++ b/checker/report.go @@ -9,10 +9,7 @@ import ( 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. +// GetHTMLReport renders the dangling-records observation as HTML. func (p *danglingProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data DanglingData if raw := ctx.Data(); len(raw) > 0 { @@ -76,10 +73,7 @@ func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView { 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. +// cardsFromStates rebuilds per-owner cards from CheckState.Meta so the report and rule never disagree. func cardsFromStates(states []sdk.CheckState) []ownerCard { out := make([]ownerCard, 0, len(states)) for _, st := range states { diff --git a/checker/rule.go b/checker/rule.go index b811a05..296ebfa 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -12,18 +12,11 @@ import ( 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. +// recentRegistrationDays is the window for flagging re-registered domains. +// Attackers re-register a freshly-released target to take over subdomains pointing at it (Ars Technica 2017). const recentRegistrationDays = 90 -// danglingRule is the single rule for v1: 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. +// danglingRule is the single rule for v1. type danglingRule struct{} 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 -// in the per-owner Meta so the report can render a concise list of -// "why this is dangling". +// SignalTrigger captures one reason the rule flagged an owner, stored in CheckState.Meta for the report. type SignalTrigger struct { Rrtype string `json:"rrtype"` Target string `json:"target"` @@ -144,37 +135,25 @@ type ownerFindings struct { 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. +// evaluatePointer returns all signals for a single pointer. +// Multiple triggers are reported individually so the report can explain each reason. func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTrigger { var out []SignalTrigger switch pt.Resolution { - case "nxdomain": + case ResolutionNXDomain: 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": + case ResolutionServFail: 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": + case ResolutionNoAnswer: 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.", @@ -193,10 +172,7 @@ func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTri 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. +// whoisFacts is the minimal subset of a related WHOIS observation used by evaluateWHOIS. type whoisFacts struct { ExpiryDate time.Time `json:"expiryDate"` CreationDate time.Time `json:"creationDate,omitzero"` @@ -244,20 +220,11 @@ func evaluateWHOIS(pt *Pointer, f *whoisFacts) []SignalTrigger { 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. +// ExternalWhoisObservationKey must stay 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). +// loadWHOIS builds a per-Ref index from related WHOIS observations. +// Parse errors are counted but not fatal: WHOIS absence must not turn the rule into Error state. func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoisFacts, int) { out := map[string]*whoisFacts{} related, err := obs.GetRelated(ctx, ExternalWhoisObservationKey) @@ -266,8 +233,7 @@ func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoi } 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). + // Try the per-Ref map shape first (convention from checker-tls). var asMap struct { Facts map[string]whoisFacts `json:"facts"` } @@ -348,9 +314,7 @@ func plural(n int) string { 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. +// sortFindings returns findings sorted by descending severity so the report's top card matches the rule output. func sortFindings(byOwner map[string]*ownerFindings) []*ownerFindings { out := make([]*ownerFindings, 0, len(byOwner)) for _, f := range byOwner { diff --git a/checker/types.go b/checker/types.go index 2c2e1f1..4756211 100644 --- a/checker/types.go +++ b/checker/types.go @@ -1,12 +1,5 @@ -// 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 detects dangling pointer records (CNAME/MX/SRV/NS) whose external targets +// may have expired or been re-registered, enabling subdomain takeover. package checker import ( @@ -15,75 +8,42 @@ import ( 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. +// Resolution verdict constants — the shared contract between Collect and Evaluate. +const ( + ResolutionOK = "ok" + ResolutionNXDomain = "nxdomain" + ResolutionServFail = "servfail" + ResolutionNoAnswer = "no_answer" + ResolutionTimeout = "timeout" + ResolutionError = "error" + ResolutionSkipped = "skipped" +) + +// DanglingData is the raw observation payload; one Pointer per (owner, rrtype, target) triple. 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. + Zone string `json:"zone,omitempty"` + ServicesScanned int `json:"services_scanned"` + Pointers []Pointer `json:"pointers,omitempty"` + // CollectErrors surfaces non-fatal scan problems so silent skips don't 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. +// Pointer is one (owner, rrtype, target) triple from the zone, with its DNS resolution verdict. 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). + Owner string `json:"owner"` 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. + Rrtype string `json:"rrtype"` + Target string `json:"target"` + // External flags takeover risk: Target's registrable domain differs from the zone's. + External bool `json:"external"` 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. + // ServiceType identifies the happyDomain service for linking back to the edit screen. + ServiceType string `json:"service_type,omitempty"` + Resolution string `json:"resolution"` 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. +// rawZone is a partial Zone type redeclared here to avoid importing the happyDomain module. type rawZone struct { DomainName string `json:"domain_name,omitempty"` Services map[string][]rawService `json:"services"` @@ -95,10 +55,6 @@ type rawService struct { 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 { @@ -130,9 +86,7 @@ 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. +// orphanRecord wraps an svcs.Orphan body; Hdr.Rrtype is sniffed to pick the right field. type orphanRecord struct { Record struct { Hdr struct {