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
22
.drone-manifest.yml
Normal 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
187
.drone.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
Zone string `json:"zone,omitempty"`
|
||||||
// did not provide a domain_name option.
|
ServicesScanned int `json:"services_scanned"`
|
||||||
Zone string `json:"zone,omitempty"`
|
Pointers []Pointer `json:"pointers,omitempty"`
|
||||||
|
// CollectErrors surfaces non-fatal scan problems so silent skips don't masquerade as a clean pass.
|
||||||
// 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"`
|
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,
|
Owner string `json:"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"`
|
Subdomain string `json:"subdomain"`
|
||||||
|
Rrtype string `json:"rrtype"`
|
||||||
// Rrtype is the textual record type ("CNAME", "MX", "SRV", "NS").
|
Target string `json:"target"`
|
||||||
Rrtype string `json:"rrtype"`
|
// External flags takeover risk: Target's registrable domain differs from the zone's.
|
||||||
|
External bool `json:"external"`
|
||||||
// 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"`
|
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
|
ServiceType string `json:"service_type,omitempty"`
|
||||||
// ("svcs.CNAME", "svcs.MXs", …). Useful for navigating users back
|
Resolution string `json:"resolution"`
|
||||||
// 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"`
|
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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue