Initial commit
This commit is contained in:
commit
292cc4147d
18 changed files with 1958 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-dangling
|
||||
checker-dangling.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-dangling .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-dangling /checker-dangling
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-dangling", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-dangling"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-dangling
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go) $(wildcard contract/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker test clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
checker-legacy-records
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
65
README.md
Normal file
65
README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# checker-dangling
|
||||
|
||||
A happyDomain checker that scans a working zone for **dangling subdomains**:
|
||||
records (`CNAME` / `MX` / `SRV` / `NS`) whose targets resolve to NXDOMAIN,
|
||||
or whose external registrable domain is expired, in `pendingDelete`, or
|
||||
recently re-registered. This is the attack class popularised by Ars
|
||||
Technica in 2017, where universities ended up serving porn from CNAMEs
|
||||
that pointed at decommissioned third-party services after malicious
|
||||
actors re-registered the lapsed targets.
|
||||
|
||||
It runs in three deployment modes (standalone HTTP binary, Go plugin,
|
||||
Docker image), like every other checker in the happyDomain ecosystem.
|
||||
|
||||
## How it works
|
||||
|
||||
The checker walks every service in the working zone (`AutoFillZone`) and
|
||||
extracts pointer records from `svcs.CNAME`, `svcs.SpecialCNAME`,
|
||||
`svcs.MXs`, `svcs.UnknownSRV`, and `svcs.Orphan` bodies (the latter
|
||||
covering bare `NS`/`CNAME`/`MX` records when no dedicated service is
|
||||
attached). For each (owner, rrtype, target) triple it:
|
||||
|
||||
1. Classifies the target as in-zone or external relative to the zone's
|
||||
eTLD+1 (via `golang.org/x/net/publicsuffix`).
|
||||
2. Performs a single, time-bounded DNS resolution to detect immediate
|
||||
breakage (`nxdomain`, `servfail`, `no_answer`, `timeout`).
|
||||
3. Publishes a `DiscoveryEntry` per pointer:
|
||||
- `dangling.external-target.v1` for external pointers — companion
|
||||
checkers (notably the host's `domain_expiry`) subscribe to this
|
||||
type and run RDAP/WHOIS on the registrable domain.
|
||||
- `dangling.in-zone-target.v1` for same-registrable pointers — used
|
||||
as a join key for future reachability checkers (alias / ping /
|
||||
http) that may consume it.
|
||||
|
||||
## Verdict matrix
|
||||
|
||||
| Signal | Severity | Source |
|
||||
|--------------------------------------------------------------|----------|-------------------------|
|
||||
| Target NXDOMAIN | Critical | local DNS resolution |
|
||||
| Target SERVFAIL | Warning | local DNS resolution |
|
||||
| Target NOERROR with empty answer | Info | local DNS resolution |
|
||||
| Registrable domain expired | Critical | `whois` related obs. |
|
||||
| Registrable status `pendingDelete` / `redemptionPeriod` | Critical | `whois` related obs. |
|
||||
| Registrable registered within the last 90 days | Warning | `whois` related obs. |
|
||||
|
||||
The rule emits one `CheckState` per impacted owner and ranks them by
|
||||
descending severity so the report's "Fix this first" card always
|
||||
matches the rule output.
|
||||
|
||||
## Companion: `domain_expiry`
|
||||
|
||||
For the WHOIS-driven signals to fire, the host's existing
|
||||
`domain_expiry` checker must be extended to subscribe to
|
||||
`dangling.external-target.v1` entries via `AutoFillDiscoveryEntries`,
|
||||
run RDAP per registrable domain, and publish a per-Ref `whois`
|
||||
observation. Without that subscription the checker still works as a
|
||||
DNS-only dangling detector.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make # standalone binary
|
||||
make plugin # .so plugin for happyDomain
|
||||
make docker # Docker image
|
||||
make test # run the unit tests
|
||||
```
|
||||
363
checker/collect.go
Normal file
363
checker/collect.go
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
|
||||
contract "git.happydns.org/checker-dangling/contract"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// resolverTimeout caps each lookup so a blackholed nameserver cannot stall the whole scan.
|
||||
const resolverTimeout = 4 * time.Second
|
||||
|
||||
// resolveHost is a package-level var so tests can stub DNS without hitting the network.
|
||||
var resolveHost = defaultResolveHost
|
||||
|
||||
func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zone, err := readZone(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zoneApex := strings.TrimSuffix(zone.DomainName, ".")
|
||||
if zoneApex == "" {
|
||||
if name, ok := sdk.GetOption[string](opts, "domain_name"); ok {
|
||||
zoneApex = strings.TrimSuffix(name, ".")
|
||||
}
|
||||
}
|
||||
zoneRegistrable, _ := publicsuffix.EffectiveTLDPlusOne(zoneApex)
|
||||
|
||||
skipResolution, _ := sdk.GetOption[bool](opts, "skip_resolution")
|
||||
|
||||
data := &DanglingData{Zone: zoneApex}
|
||||
|
||||
// Sort subdomains for deterministic output.
|
||||
subs := make([]string, 0, len(zone.Services))
|
||||
for s := range zone.Services {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
sort.Strings(subs)
|
||||
|
||||
// Track unique (owner, rrtype, target) so duplicate services do
|
||||
// not produce duplicate findings.
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, sub := range subs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, svc := range zone.Services[sub] {
|
||||
data.ServicesScanned++
|
||||
pts, perr := extractPointers(sub, zoneApex, svc)
|
||||
if perr != nil {
|
||||
data.CollectErrors = append(data.CollectErrors,
|
||||
fmt.Sprintf("%s/%s: %v", displaySubdomain(sub), svc.Type, perr))
|
||||
continue
|
||||
}
|
||||
for _, pt := range pts {
|
||||
key := pt.Owner + "|" + pt.Rrtype + "|" + pt.Target
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
classifyExternal(&pt, zoneRegistrable)
|
||||
if skipResolution {
|
||||
pt.Resolution = "skipped"
|
||||
} else {
|
||||
pt.Resolution, pt.ResolutionDetail = resolveHost(ctx, pt.Target)
|
||||
}
|
||||
data.Pointers = append(data.Pointers, pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DiscoverEntries 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 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]sdk.DiscoveryEntry, 0, len(d.Pointers))
|
||||
for _, pt := range d.Pointers {
|
||||
if pt.External && pt.Registrable != "" {
|
||||
entry, err := contract.NewExternalEntry(contract.ExternalTarget{
|
||||
Owner: pt.Owner,
|
||||
Rrtype: pt.Rrtype,
|
||||
Target: pt.Target,
|
||||
Registrable: pt.Registrable,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
continue
|
||||
}
|
||||
entry, err := contract.NewInZoneEntry(contract.InZoneTarget{
|
||||
Owner: pt.Owner,
|
||||
Rrtype: pt.Rrtype,
|
||||
Target: pt.Target,
|
||||
Registrable: pt.Registrable,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readZone normalises the zone option (native struct or JSON).
|
||||
func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
|
||||
v, ok := opts["zone"]
|
||||
if !ok || v == nil {
|
||||
return nil, fmt.Errorf("missing 'zone' option (AutoFillZone): the host did not provide a working zone")
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("re-marshal zone option: %w", err)
|
||||
}
|
||||
z := &rawZone{}
|
||||
if err := json.Unmarshal(raw, z); err != nil {
|
||||
return nil, fmt.Errorf("decode zone option: %w", err)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// extractPointers 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
|
||||
}
|
||||
owner := ownerFQDN(svc.Domain, sub, apex)
|
||||
|
||||
switch svc.Type {
|
||||
case "svcs.CNAME", "svcs.SpecialCNAME":
|
||||
var b cnameBody
|
||||
if err := json.Unmarshal(svc.Service, &b); err != nil {
|
||||
return nil, fmt.Errorf("decode cname body: %w", err)
|
||||
}
|
||||
target := normaliseTarget(b.Record.Target, owner, apex)
|
||||
if target == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
|
||||
return []Pointer{{
|
||||
Owner: ptOwner,
|
||||
Subdomain: sub,
|
||||
Rrtype: "CNAME",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
}}, nil
|
||||
|
||||
case "svcs.MXs":
|
||||
var b mxsBody
|
||||
if err := json.Unmarshal(svc.Service, &b); err != nil {
|
||||
return nil, fmt.Errorf("decode mxs body: %w", err)
|
||||
}
|
||||
out := make([]Pointer, 0, len(b.MXs))
|
||||
for _, r := range b.MXs {
|
||||
target := normaliseTarget(r.Mx, owner, apex)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Pointer{
|
||||
Owner: preferRRName(r.Hdr.Name, owner),
|
||||
Subdomain: sub,
|
||||
Rrtype: "MX",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
case "svcs.UnknownSRV":
|
||||
var b srvsBody
|
||||
if err := json.Unmarshal(svc.Service, &b); err != nil {
|
||||
return nil, fmt.Errorf("decode srv body: %w", err)
|
||||
}
|
||||
out := make([]Pointer, 0, len(b.Records))
|
||||
for _, r := range b.Records {
|
||||
target := normaliseTarget(r.Target, owner, apex)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Pointer{
|
||||
Owner: preferRRName(r.Hdr.Name, owner),
|
||||
Subdomain: sub,
|
||||
Rrtype: "SRV",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
case "svcs.Orphan":
|
||||
var b orphanRecord
|
||||
if err := json.Unmarshal(svc.Service, &b); err != nil {
|
||||
return nil, fmt.Errorf("decode orphan body: %w", err)
|
||||
}
|
||||
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
|
||||
switch b.Record.Hdr.Rrtype {
|
||||
case dns.TypeNS:
|
||||
target := normaliseTarget(b.Record.Ns, ptOwner, apex)
|
||||
if target == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Pointer{{
|
||||
Owner: ptOwner,
|
||||
Subdomain: sub,
|
||||
Rrtype: "NS",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
}}, nil
|
||||
case dns.TypeCNAME:
|
||||
target := normaliseTarget(b.Record.Target, ptOwner, apex)
|
||||
if target == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Pointer{{
|
||||
Owner: ptOwner,
|
||||
Subdomain: sub,
|
||||
Rrtype: "CNAME",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
}}, nil
|
||||
case dns.TypeMX:
|
||||
target := normaliseTarget(b.Record.Mx, ptOwner, apex)
|
||||
if target == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Pointer{{
|
||||
Owner: ptOwner,
|
||||
Subdomain: sub,
|
||||
Rrtype: "MX",
|
||||
Target: target,
|
||||
ServiceType: svc.Type,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// classifyExternal 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 == "" {
|
||||
return
|
||||
}
|
||||
reg, err := publicsuffix.EffectiveTLDPlusOne(target)
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
return
|
||||
}
|
||||
pt.Registrable = reg
|
||||
if zoneRegistrable == "" || !strings.EqualFold(reg, zoneRegistrable) {
|
||||
pt.External = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, resolverTimeout)
|
||||
defer cancel()
|
||||
|
||||
ips, err := net.DefaultResolver.LookupHost(cctx, target)
|
||||
if err == nil {
|
||||
if len(ips) == 0 {
|
||||
return "no_answer", ""
|
||||
}
|
||||
return "ok", ""
|
||||
}
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
switch {
|
||||
case dnsErr.IsNotFound:
|
||||
return "nxdomain", dnsErr.Err
|
||||
case dnsErr.IsTimeout:
|
||||
return "timeout", dnsErr.Err
|
||||
case strings.Contains(strings.ToLower(dnsErr.Err), "servfail"):
|
||||
return "servfail", dnsErr.Err
|
||||
default:
|
||||
return "error", dnsErr.Err
|
||||
}
|
||||
}
|
||||
return "error", err.Error()
|
||||
}
|
||||
|
||||
// ownerFQDN returns the 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, ".")
|
||||
}
|
||||
if apex == "" {
|
||||
return sub
|
||||
}
|
||||
if sub == "" || sub == "@" {
|
||||
return apex
|
||||
}
|
||||
return sub + "." + apex
|
||||
}
|
||||
|
||||
// preferRRName returns the RR header Name when present, as it is authoritative over the service-derived owner.
|
||||
func preferRRName(rrName, fallback string) string {
|
||||
rrName = strings.TrimSuffix(rrName, ".")
|
||||
if rrName != "" {
|
||||
return rrName
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// normaliseTarget converts a target to FQDN form; happyDomain stores in-zone targets relative, external ones absolute.
|
||||
func normaliseTarget(target, owner, apex string) string {
|
||||
t := strings.TrimSpace(target)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed, ok := strings.CutSuffix(t, "."); ok {
|
||||
return trimmed
|
||||
}
|
||||
// Relative target: anchor under apex (empty apex only occurs in tests that omit domain_name).
|
||||
if apex != "" {
|
||||
return t + "." + apex
|
||||
}
|
||||
return t + "." + owner
|
||||
}
|
||||
|
||||
func displaySubdomain(s string) string {
|
||||
if s == "" || s == "@" {
|
||||
return "@"
|
||||
}
|
||||
return s
|
||||
}
|
||||
69
checker/definition.go
Normal file
69
checker/definition.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at build time via -ldflags. Use SetVersion from entrypoints, not direct assignment.
|
||||
var Version = "built-in"
|
||||
|
||||
// SetVersion ignores empty values so a misconfigured ldflags does not erase the default.
|
||||
func SetVersion(v string) {
|
||||
if v != "" {
|
||||
Version = v
|
||||
}
|
||||
}
|
||||
|
||||
// Definition exposes the checker to the happyDomain host.
|
||||
// Zone-scoped single pass so findings consolidate by owner rather than one observation per service.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "dangling",
|
||||
Name: "Dangling subdomains",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToZone: true,
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyDangling},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "zone",
|
||||
Type: "string",
|
||||
Label: "Zone",
|
||||
AutoFill: sdk.AutoFillZone,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "skip_resolution",
|
||||
Type: "bool",
|
||||
Label: "Skip live DNS resolution",
|
||||
Description: "When set, the checker only reports the static structure of pointer records. Useful for offline analysis; defaults to false.",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{
|
||||
&danglingRule{},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 15 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
def.BuildRulesInfo()
|
||||
return def
|
||||
}
|
||||
16
checker/provider.go
Normal file
16
checker/provider.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns the dangling-records observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &danglingProvider{}
|
||||
}
|
||||
|
||||
type danglingProvider struct{}
|
||||
|
||||
func (p *danglingProvider) Key() sdk.ObservationKey { return ObservationKeyDangling }
|
||||
|
||||
func (p *danglingProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
267
checker/report.go
Normal file
267
checker/report.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("parse dangling-records data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
view := buildReportView(&data, ctx.States())
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := reportTmpl.Execute(buf, view); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type reportView struct {
|
||||
Zone string
|
||||
ServicesScanned int
|
||||
Pointers int
|
||||
OverallText string
|
||||
OverallClass string
|
||||
Top *ownerCard
|
||||
Others []ownerCard
|
||||
CollectErrors []string
|
||||
}
|
||||
|
||||
type ownerCard struct {
|
||||
Owner string
|
||||
Severity string
|
||||
SeverityCSS string
|
||||
Triggers []SignalTrigger
|
||||
}
|
||||
|
||||
func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView {
|
||||
v := &reportView{
|
||||
Zone: data.Zone,
|
||||
ServicesScanned: data.ServicesScanned,
|
||||
Pointers: len(data.Pointers),
|
||||
CollectErrors: data.CollectErrors,
|
||||
}
|
||||
|
||||
cards := cardsFromStates(states)
|
||||
if len(cards) == 0 {
|
||||
// Honour an Error state from the rule so the banner does not
|
||||
// masquerade as OK when the observation could not be loaded.
|
||||
if errState, ok := firstErrorState(states); ok {
|
||||
v.OverallText = errState.Message
|
||||
v.OverallClass = "status-crit"
|
||||
return v
|
||||
}
|
||||
v.OverallText = fmt.Sprintf("No dangling subdomain detected across %d service(s).", data.ServicesScanned)
|
||||
v.OverallClass = "status-ok"
|
||||
return v
|
||||
}
|
||||
|
||||
v.Top = &cards[0]
|
||||
v.Others = cards[1:]
|
||||
v.OverallText, v.OverallClass = overallLabel(cards[0].SeverityCSS)
|
||||
return v
|
||||
}
|
||||
|
||||
// cardsFromStates rebuilds 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 {
|
||||
if st.Code == "dangling_clean" || st.Code == "dangling_observation_error" {
|
||||
continue
|
||||
}
|
||||
card := ownerCard{
|
||||
Owner: st.Subject,
|
||||
}
|
||||
if sev, ok := st.Meta["severity"].(string); ok {
|
||||
card.Severity = severityLabel(sev)
|
||||
card.SeverityCSS = sev
|
||||
}
|
||||
// Triggers may have been round-tripped through JSON if the host
|
||||
// crossed an HTTP boundary; handle both shapes.
|
||||
switch v := st.Meta["triggers"].(type) {
|
||||
case []SignalTrigger:
|
||||
card.Triggers = v
|
||||
case []any:
|
||||
skipped := 0
|
||||
for _, item := range v {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
var t SignalTrigger
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
card.Triggers = append(card.Triggers, t)
|
||||
}
|
||||
if skipped > 0 {
|
||||
card.Triggers = append(card.Triggers, SignalTrigger{
|
||||
Reason: fmt.Sprintf("%d trigger(s) could not be rendered.", skipped),
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, card)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
|
||||
for i := range states {
|
||||
if states[i].Status == sdk.StatusError {
|
||||
return states[i], true
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{}, false
|
||||
}
|
||||
|
||||
func severityLabel(css string) string {
|
||||
switch css {
|
||||
case "critical":
|
||||
return "Critical"
|
||||
case "warning":
|
||||
return "Warning"
|
||||
case "info":
|
||||
return "Informational"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func overallLabel(severityCSS string) (text, css string) {
|
||||
switch severityCSS {
|
||||
case "critical":
|
||||
return "Dangling subdomains require urgent attention", "status-crit"
|
||||
case "warning":
|
||||
return "Dangling subdomains should be reviewed", "status-warn"
|
||||
case "info":
|
||||
return "Informational pointer issues found", "status-info"
|
||||
default:
|
||||
return "Dangling subdomains detected", "status-warn"
|
||||
}
|
||||
}
|
||||
|
||||
var reportTmpl = template.Must(template.New("dangling-records-report").Parse(reportTemplate))
|
||||
|
||||
const reportTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dangling subdomains — {{if .Zone}}{{.Zone}}{{else}}zone report{{end}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ok: #1e9e5d;
|
||||
--info: #3b82f6;
|
||||
--warn: #d97706;
|
||||
--crit: #dc2626;
|
||||
--bg: #f7f7f8;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
}
|
||||
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
|
||||
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
|
||||
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
|
||||
.muted { color: var(--muted); font-size: .85rem; }
|
||||
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .9rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
|
||||
.status-ok { background: var(--ok); }
|
||||
.status-info { background: var(--info); }
|
||||
.status-warn { background: var(--warn); }
|
||||
.status-crit { background: var(--crit); }
|
||||
.status-banner .label { font-weight: 600; font-size: 1rem; }
|
||||
.top-fix { border-left: 5px solid var(--crit); background: #fef2f2; padding: 1rem 1.1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||
.top-fix.severity-warning { border-color: var(--warn); background: #fffbeb; }
|
||||
.top-fix.severity-info { border-color: var(--info); background: #eff6ff; }
|
||||
.other-fix { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .55rem; }
|
||||
.sev { display: inline-block; padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sev-info { background: var(--info); }
|
||||
.sev-warning { background: var(--warn); }
|
||||
.sev-critical { background: var(--crit); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: .35rem; }
|
||||
th, td { text-align: left; padding: .4rem .65rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { background: #f3f4f6; font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
details.errors { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: .55rem .8rem; margin-top: 1rem; }
|
||||
details.errors summary { cursor: pointer; font-weight: 600; }
|
||||
details.errors li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dangling subdomains</h1>
|
||||
<div class="muted">{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Pointers}} pointer(s) inspected</div>
|
||||
|
||||
<div class="status-banner {{.OverallClass}}" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<div class="label">{{.OverallText}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Top}}
|
||||
<h2>Fix this first</h2>
|
||||
<div class="top-fix severity-{{.Top.SeverityCSS}}">
|
||||
<h3>
|
||||
<code>{{.Top.Owner}}</code>
|
||||
<span class="sev sev-{{.Top.SeverityCSS}}">{{.Top.Severity}}</span>
|
||||
</h3>
|
||||
{{if .Top.Triggers}}
|
||||
<table>
|
||||
<thead><tr><th>Pointer</th><th>Target</th><th>Why</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Top.Triggers}}
|
||||
<tr>
|
||||
<td><code>{{.Rrtype}}</code></td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Reason}}{{if .Detail}} <span class="muted">({{.Detail}})</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Others}}
|
||||
<h2>Other dangling subdomains</h2>
|
||||
{{range .Others}}
|
||||
<div class="other-fix">
|
||||
<h3>
|
||||
<code>{{.Owner}}</code>
|
||||
<span class="sev sev-{{.SeverityCSS}}">{{.Severity}}</span>
|
||||
</h3>
|
||||
{{if .Triggers}}
|
||||
<ul>
|
||||
{{range .Triggers}}
|
||||
<li><code>{{.Rrtype}}</code> → <code>{{.Target}}</code>: {{.Reason}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .CollectErrors}}
|
||||
<details class="errors">
|
||||
<summary>{{len .CollectErrors}} service(s) skipped during scan</summary>
|
||||
<ul>
|
||||
{{range .CollectErrors}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
330
checker/rule.go
Normal file
330
checker/rule.go
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
contract "git.happydns.org/checker-dangling/contract"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// recentRegistrationDays 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.
|
||||
type danglingRule struct{}
|
||||
|
||||
func (r *danglingRule) Name() string { return "dangling_records" }
|
||||
|
||||
func (r *danglingRule) Description() string {
|
||||
return "Detects subdomains whose CNAME / MX / SRV / NS targets resolve to NXDOMAIN, or whose external registrable domain is expired or recently re-registered. Combines local DNS resolution with WHOIS observations published by companion checkers."
|
||||
}
|
||||
|
||||
func (r *danglingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data DanglingData
|
||||
if err := obs.Get(ctx, ObservationKeyDangling, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load dangling-records observation: %v", err),
|
||||
RuleName: r.Name(),
|
||||
Code: "dangling_observation_error",
|
||||
}}
|
||||
}
|
||||
|
||||
whoisByRef, whoisLoadErrors := loadWHOIS(ctx, obs)
|
||||
|
||||
// Group findings by owner so we report once per impacted subdomain
|
||||
// even when multiple pointers under the same owner trigger a rule.
|
||||
byOwner := map[string]*ownerFindings{}
|
||||
for i := range data.Pointers {
|
||||
pt := &data.Pointers[i]
|
||||
triggers := evaluatePointer(pt, whoisByRef)
|
||||
if len(triggers) == 0 {
|
||||
continue
|
||||
}
|
||||
f, ok := byOwner[pt.Owner]
|
||||
if !ok {
|
||||
f = &ownerFindings{Owner: pt.Owner, Subdomain: pt.Subdomain}
|
||||
byOwner[pt.Owner] = f
|
||||
}
|
||||
f.Triggers = append(f.Triggers, triggers...)
|
||||
if sev := scoreSeverity(triggers); sev > f.WorstSeverity {
|
||||
f.WorstSeverity = sev
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]sdk.CheckState, 0, len(byOwner)+1)
|
||||
if whoisLoadErrors > 0 {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: fmt.Sprintf("%d related WHOIS observation(s) could not be parsed; takeover signals may be incomplete.", whoisLoadErrors),
|
||||
RuleName: r.Name(),
|
||||
Code: "dangling_whois_load_warning",
|
||||
})
|
||||
}
|
||||
|
||||
if len(byOwner) == 0 {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("No dangling subdomain detected (%d service(s) scanned, %d pointer(s) inspected)", data.ServicesScanned, len(data.Pointers)),
|
||||
RuleName: r.Name(),
|
||||
Code: "dangling_clean",
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
for _, f := range sortFindings(byOwner) {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: severityToStatus(f.WorstSeverity),
|
||||
Message: buildOwnerMessage(f),
|
||||
RuleName: r.Name(),
|
||||
Code: codeForSeverity(f.WorstSeverity),
|
||||
Subject: displayOwner(f),
|
||||
Meta: map[string]any{
|
||||
"owner": f.Owner,
|
||||
"subdomain": f.Subdomain,
|
||||
"triggers": f.Triggers,
|
||||
"severity": f.WorstSeverity.String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Severity is the rule's internal grading. Higher value = more urgent.
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SeverityNone Severity = iota
|
||||
SeverityInfo
|
||||
SeverityWarn
|
||||
SeverityCrit
|
||||
)
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "critical"
|
||||
case SeverityWarn:
|
||||
return "warning"
|
||||
case SeverityInfo:
|
||||
return "info"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// SignalTrigger captures one reason the rule flagged an owner, stored in CheckState.Meta for the report.
|
||||
type SignalTrigger struct {
|
||||
Rrtype string `json:"rrtype"`
|
||||
Target string `json:"target"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Severity Severity `json:"severity"`
|
||||
}
|
||||
|
||||
type ownerFindings struct {
|
||||
Owner string
|
||||
Subdomain string
|
||||
Triggers []SignalTrigger
|
||||
WorstSeverity Severity
|
||||
}
|
||||
|
||||
// evaluatePointer 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":
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: "Target does not resolve (NXDOMAIN). The record points at a host that no longer exists.",
|
||||
Detail: pt.ResolutionDetail, Severity: SeverityCrit,
|
||||
})
|
||||
case "servfail":
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: "Target lookup returned SERVFAIL. The authoritative server may be misconfigured or the delegation broken.",
|
||||
Detail: pt.ResolutionDetail, Severity: SeverityWarn,
|
||||
})
|
||||
case "no_answer":
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: "Target resolves to no address (NOERROR with empty answer). Rarely the operator's intent for a pointer record.",
|
||||
Severity: SeverityInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// WHOIS-driven checks only apply to external targets we successfully
|
||||
// classified into a registrable domain.
|
||||
if pt.External && pt.Registrable != "" {
|
||||
if facts, ok := whoisByRef[contract.Ref(pt.Owner, pt.Rrtype, pt.Target)]; ok && facts != nil {
|
||||
out = append(out, evaluateWHOIS(pt, facts)...)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// whoisFacts is the minimal subset of a related WHOIS observation used by evaluateWHOIS.
|
||||
type whoisFacts struct {
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
CreationDate time.Time `json:"creationDate,omitzero"`
|
||||
Status []string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func evaluateWHOIS(pt *Pointer, f *whoisFacts) []SignalTrigger {
|
||||
var out []SignalTrigger
|
||||
now := time.Now()
|
||||
|
||||
var atRiskStatuses []string
|
||||
for _, s := range f.Status {
|
||||
ls := strings.ToLower(s)
|
||||
if strings.Contains(ls, "pendingdelete") || strings.Contains(ls, "redemptionperiod") {
|
||||
atRiskStatuses = append(atRiskStatuses, s)
|
||||
}
|
||||
}
|
||||
if len(atRiskStatuses) > 0 {
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: fmt.Sprintf("Target's registrable domain (%s) is in registry state %s. It may be deleted soon and re-registered by anyone.", pt.Registrable, strings.Join(atRiskStatuses, ", ")),
|
||||
Severity: SeverityCrit,
|
||||
})
|
||||
}
|
||||
|
||||
if !f.ExpiryDate.IsZero() && f.ExpiryDate.Before(now) {
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: fmt.Sprintf("Target's registrable domain (%s) expired on %s.", pt.Registrable, f.ExpiryDate.Format("2006-01-02")),
|
||||
Severity: SeverityCrit,
|
||||
})
|
||||
}
|
||||
|
||||
if !f.CreationDate.IsZero() {
|
||||
age := now.Sub(f.CreationDate)
|
||||
if age < time.Duration(recentRegistrationDays)*24*time.Hour && age > 0 {
|
||||
out = append(out, SignalTrigger{
|
||||
Rrtype: pt.Rrtype, Target: pt.Target,
|
||||
Reason: fmt.Sprintf("Target's registrable domain (%s) was registered %d days ago, after the original target was likely decommissioned. Verify the new owner is intentional.", pt.Registrable, int(age.Hours()/24)),
|
||||
Severity: SeverityWarn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ExternalWhoisObservationKey must stay in sync with happydomain3/checkers/external_expiry.go.
|
||||
const ExternalWhoisObservationKey = "external_whois"
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return out, 0
|
||||
}
|
||||
parseErrors := 0
|
||||
for _, ro := range related {
|
||||
// Try the per-Ref map shape first (convention from checker-tls).
|
||||
var asMap struct {
|
||||
Facts map[string]whoisFacts `json:"facts"`
|
||||
}
|
||||
if err := json.Unmarshal(ro.Data, &asMap); err == nil && len(asMap.Facts) > 0 {
|
||||
for ref, f := range asMap.Facts {
|
||||
ff := f
|
||||
out[ref] = &ff
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Fallback: a single-fact payload, keyed by the related Ref.
|
||||
var f whoisFacts
|
||||
if err := json.Unmarshal(ro.Data, &f); err != nil {
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
out[ro.Ref] = &f
|
||||
}
|
||||
return out, parseErrors
|
||||
}
|
||||
|
||||
func severityToStatus(s Severity) sdk.Status {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return sdk.StatusCrit
|
||||
case SeverityWarn:
|
||||
return sdk.StatusWarn
|
||||
case SeverityInfo:
|
||||
return sdk.StatusInfo
|
||||
default:
|
||||
return sdk.StatusOK
|
||||
}
|
||||
}
|
||||
|
||||
func scoreSeverity(triggers []SignalTrigger) Severity {
|
||||
worst := SeverityNone
|
||||
for _, t := range triggers {
|
||||
if t.Severity > worst {
|
||||
worst = t.Severity
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func codeForSeverity(s Severity) string {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "dangling_critical"
|
||||
case SeverityWarn:
|
||||
return "dangling_warning"
|
||||
case SeverityInfo:
|
||||
return "dangling_info"
|
||||
default:
|
||||
return "dangling_clean"
|
||||
}
|
||||
}
|
||||
|
||||
func buildOwnerMessage(f *ownerFindings) string {
|
||||
first := f.Triggers[0]
|
||||
if len(f.Triggers) == 1 {
|
||||
return fmt.Sprintf("%s — %s", displayOwner(f), first.Reason)
|
||||
}
|
||||
return fmt.Sprintf("%s — %s (and %d more signal%s)", displayOwner(f), first.Reason,
|
||||
len(f.Triggers)-1, plural(len(f.Triggers)-1))
|
||||
}
|
||||
|
||||
func displayOwner(f *ownerFindings) string {
|
||||
if f.Owner != "" {
|
||||
return f.Owner
|
||||
}
|
||||
return displaySubdomain(f.Subdomain)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
// sortFindings 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 {
|
||||
out = append(out, f)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].WorstSeverity != out[j].WorstSeverity {
|
||||
return out[i].WorstSeverity > out[j].WorstSeverity
|
||||
}
|
||||
return out[i].Owner < out[j].Owner
|
||||
})
|
||||
return out
|
||||
}
|
||||
502
checker/rules_test.go
Normal file
502
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
contract "git.happydns.org/checker-dangling/contract"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// --- test helpers ---------------------------------------------------------
|
||||
|
||||
// stubResolver lets a single test override the resolution outcome per
|
||||
// target without touching the real network. The outer test wires it
|
||||
// in/out via a t.Cleanup so the package-level variable stays clean.
|
||||
func stubResolver(t *testing.T, table map[string]struct{ verdict, detail string }) {
|
||||
t.Helper()
|
||||
prev := resolveHost
|
||||
resolveHost = func(_ context.Context, target string) (string, string) {
|
||||
target = strings.TrimSuffix(target, ".")
|
||||
if v, ok := table[target]; ok {
|
||||
return v.verdict, v.detail
|
||||
}
|
||||
// Default: target resolves cleanly. Tests pin behaviour they
|
||||
// care about; everything else should be a "boring OK".
|
||||
return "ok", ""
|
||||
}
|
||||
t.Cleanup(func() { resolveHost = prev })
|
||||
}
|
||||
|
||||
func cnameSvc(target string) rawService {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"cname": map[string]any{
|
||||
"Hdr": map[string]any{"Name": ""},
|
||||
"Target": target,
|
||||
},
|
||||
})
|
||||
return rawService{Type: "svcs.CNAME", Domain: "", Service: body}
|
||||
}
|
||||
|
||||
func mxSvc(targets ...string) rawService {
|
||||
mxs := make([]map[string]any, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
mxs = append(mxs, map[string]any{
|
||||
"Hdr": map[string]any{"Name": ""},
|
||||
"Mx": t,
|
||||
"Preference": 10,
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"mx": mxs})
|
||||
return rawService{Type: "svcs.MXs", Domain: "", Service: body}
|
||||
}
|
||||
|
||||
func srvSvc(target string) rawService {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"srv": []map[string]any{{
|
||||
"Hdr": map[string]any{"Name": ""},
|
||||
"Target": target,
|
||||
}},
|
||||
})
|
||||
return rawService{Type: "svcs.UnknownSRV", Domain: "", Service: body}
|
||||
}
|
||||
|
||||
func nsOrphan(host string) rawService {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"record": map[string]any{
|
||||
"Hdr": map[string]any{"Name": "", "Rrtype": dns.TypeNS},
|
||||
"Ns": host,
|
||||
},
|
||||
})
|
||||
return rawService{Type: "svcs.Orphan", Domain: "", Service: body}
|
||||
}
|
||||
|
||||
// modernNonPointer mimics a service that carries no pointer (e.g. an
|
||||
// abstract.Server with A/AAAA records). The collector should ignore it
|
||||
// silently, contributing only to ServicesScanned.
|
||||
func modernNonPointer() rawService {
|
||||
body, _ := json.Marshal(map[string]any{"A": map[string]any{}})
|
||||
return rawService{Type: "abstract.Server", Domain: "", Service: body}
|
||||
}
|
||||
|
||||
func runCollect(t *testing.T, zone *rawZone, opts sdk.CheckerOptions) *DanglingData {
|
||||
t.Helper()
|
||||
if opts == nil {
|
||||
opts = sdk.CheckerOptions{}
|
||||
}
|
||||
raw, err := json.Marshal(zone)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal zone: %v", err)
|
||||
}
|
||||
var jsonZone map[string]any
|
||||
if err := json.Unmarshal(raw, &jsonZone); err != nil {
|
||||
t.Fatalf("unmarshal zone: %v", err)
|
||||
}
|
||||
opts["zone"] = jsonZone
|
||||
if _, ok := opts["domain_name"]; !ok && zone.DomainName != "" {
|
||||
opts["domain_name"] = zone.DomainName
|
||||
}
|
||||
|
||||
out, err := (&danglingProvider{}).Collect(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
}
|
||||
d, ok := out.(*DanglingData)
|
||||
if !ok {
|
||||
t.Fatalf("Collect returned %T, want *DanglingData", out)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// staticObs serves a single observation by key plus a fixed map of
|
||||
// related observations keyed by ObservationKey. Mirrors the helper
|
||||
// used by checker-legacy-records, extended to cover GetRelated.
|
||||
type staticObs struct {
|
||||
key sdk.ObservationKey
|
||||
payload []byte
|
||||
related map[sdk.ObservationKey][]sdk.RelatedObservation
|
||||
}
|
||||
|
||||
func (s staticObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||
if key != s.key {
|
||||
return fmt.Errorf("staticObs: unexpected observation key %q (have %q)", key, s.key)
|
||||
}
|
||||
return json.Unmarshal(s.payload, dest)
|
||||
}
|
||||
|
||||
func (s staticObs) GetRelated(_ context.Context, key sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return s.related[key], nil
|
||||
}
|
||||
|
||||
// --- collect tests --------------------------------------------------------
|
||||
|
||||
func TestCollect_CleanZone_NoPointers(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"": {modernNonPointer()},
|
||||
"www": {modernNonPointer()},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
if data.ServicesScanned != 2 {
|
||||
t.Errorf("ServicesScanned = %d, want 2", data.ServicesScanned)
|
||||
}
|
||||
if len(data.Pointers) != 0 {
|
||||
t.Errorf("Pointers = %+v, want empty", data.Pointers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollect_DetectsCNAMEMXSRV_NS(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"www": {cnameSvc("target.example.net.")},
|
||||
"": {mxSvc("mail.example.org."), nsOrphan("ns1.someprovider.net.")},
|
||||
"_sip._tcp": {srvSvc("sipserver.example.io.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
if got := len(data.Pointers); got != 4 {
|
||||
t.Fatalf("Pointers count = %d, want 4: %+v", got, data.Pointers)
|
||||
}
|
||||
want := map[string]bool{"CNAME": false, "MX": false, "NS": false, "SRV": false}
|
||||
for _, p := range data.Pointers {
|
||||
if !p.External {
|
||||
t.Errorf("expected pointer to external target to be flagged External: %+v", p)
|
||||
}
|
||||
if p.Registrable == "" {
|
||||
t.Errorf("expected non-empty Registrable for external target: %+v", p)
|
||||
}
|
||||
want[p.Rrtype] = true
|
||||
}
|
||||
for k, ok := range want {
|
||||
if !ok {
|
||||
t.Errorf("missing pointer of type %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollect_InZoneTargetIsNotExternal(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"www": {cnameSvc("aliased.example.com.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
if len(data.Pointers) != 1 {
|
||||
t.Fatalf("want 1 pointer, got %d", len(data.Pointers))
|
||||
}
|
||||
if data.Pointers[0].External {
|
||||
t.Errorf("same-registrable target must not be External: %+v", data.Pointers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollect_MissingZoneOptionFails(t *testing.T) {
|
||||
_, err := (&danglingProvider{}).Collect(context.Background(), sdk.CheckerOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when 'zone' option is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- DiscoverEntries ------------------------------------------------------
|
||||
|
||||
func TestDiscoverEntries_PublishesExternalAndInZone(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"alias-ext": {cnameSvc("provider.example.net.")},
|
||||
"alias-in": {cnameSvc("internal.example.com.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
|
||||
entries, err := (&danglingProvider{}).DiscoverEntries(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverEntries: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("want 2 entries, got %d: %+v", len(entries), entries)
|
||||
}
|
||||
var sawExternal, sawInZone bool
|
||||
for _, e := range entries {
|
||||
switch e.Type {
|
||||
case contract.ExternalTargetType:
|
||||
sawExternal = true
|
||||
case contract.InZoneTargetType:
|
||||
sawInZone = true
|
||||
default:
|
||||
t.Errorf("unexpected entry Type %q", e.Type)
|
||||
}
|
||||
}
|
||||
if !sawExternal || !sawInZone {
|
||||
t.Errorf("entry types missing: external=%v inzone=%v", sawExternal, sawInZone)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Evaluate matrix ------------------------------------------------------
|
||||
|
||||
func TestEvaluate_NXDOMAINIsCritical(t *testing.T) {
|
||||
stubResolver(t, map[string]struct{ verdict, detail string }{
|
||||
"gone.example.net": {"nxdomain", "no such host"},
|
||||
})
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"old": {cnameSvc("gone.example.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
obs := staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)}
|
||||
states := (&danglingRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
||||
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want 1 critical state, got %+v", states)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "old.example.com") {
|
||||
t.Errorf("message should name the impacted owner: %q", states[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_ServfailIsWarning(t *testing.T) {
|
||||
stubResolver(t, map[string]struct{ verdict, detail string }{
|
||||
"flaky.example.net": {"servfail", "lookup servfail"},
|
||||
})
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"www": {cnameSvc("flaky.example.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
|
||||
sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want 1 warning state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_WhoisExpiredIsCritical(t *testing.T) {
|
||||
stubResolver(t, nil) // target resolves OK on DNS — only WHOIS is bad.
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"promo": {cnameSvc("brand.attackertarget.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
|
||||
expired := whoisFacts{ExpiryDate: time.Now().Add(-30 * 24 * time.Hour)}
|
||||
ref := contract.Ref("promo.example.com", "CNAME", "brand.attackertarget.net")
|
||||
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
|
||||
ExternalWhoisObservationKey: {{
|
||||
CheckerID: "domain-expiry",
|
||||
Key: ExternalWhoisObservationKey,
|
||||
Data: mustMarshal(t, expired),
|
||||
Ref: ref,
|
||||
}},
|
||||
}
|
||||
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
|
||||
sdk.CheckerOptions{})
|
||||
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want 1 critical state, got %+v", states)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "expired") {
|
||||
t.Errorf("message should mention expired registrable: %q", states[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_WhoisPendingDeleteIsCritical(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"shop": {cnameSvc("brand.dropping.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
|
||||
facts := whoisFacts{
|
||||
ExpiryDate: time.Now().Add(30 * 24 * time.Hour),
|
||||
Status: []string{"clientTransferProhibited", "pendingDelete"},
|
||||
}
|
||||
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
|
||||
ExternalWhoisObservationKey: {{
|
||||
CheckerID: "domain-expiry",
|
||||
Key: ExternalWhoisObservationKey,
|
||||
Data: mustMarshal(t, facts),
|
||||
Ref: contract.Ref("shop.example.com", "CNAME", "brand.dropping.net"),
|
||||
}},
|
||||
}
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
|
||||
sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want 1 critical state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_RecentRegistrationIsWarning(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"legacy": {cnameSvc("brand.recently-grabbed.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
|
||||
facts := whoisFacts{
|
||||
ExpiryDate: time.Now().Add(365 * 24 * time.Hour),
|
||||
CreationDate: time.Now().Add(-15 * 24 * time.Hour),
|
||||
}
|
||||
related := map[sdk.ObservationKey][]sdk.RelatedObservation{
|
||||
ExternalWhoisObservationKey: {{
|
||||
CheckerID: "domain-expiry",
|
||||
Key: ExternalWhoisObservationKey,
|
||||
Data: mustMarshal(t, facts),
|
||||
Ref: contract.Ref("legacy.example.com", "CNAME", "brand.recently-grabbed.net"),
|
||||
}},
|
||||
}
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data), related: related},
|
||||
sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want 1 warning state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CleanZoneReturnsOK(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"www": {cnameSvc("aliased.example.com.")}, // in-zone, OK
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
|
||||
sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Fatalf("want single OK state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_RanksCriticalAboveWarning(t *testing.T) {
|
||||
stubResolver(t, map[string]struct{ verdict, detail string }{
|
||||
"flaky.example.net": {"servfail", ""},
|
||||
"gone.example.net": {"nxdomain", ""},
|
||||
})
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"a": {cnameSvc("flaky.example.net.")},
|
||||
"b": {cnameSvc("gone.example.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
states := (&danglingRule{}).Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
|
||||
sdk.CheckerOptions{})
|
||||
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("want 2 states, got %d: %+v", len(states), states)
|
||||
}
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("first state must be critical (NXDOMAIN), got %v", states[0].Status)
|
||||
}
|
||||
if states[1].Status != sdk.StatusWarn {
|
||||
t.Errorf("second state must be warning (SERVFAIL), got %v", states[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Report ---------------------------------------------------------------
|
||||
|
||||
type staticReportCtx struct {
|
||||
data []byte
|
||||
states []sdk.CheckState
|
||||
related map[sdk.ObservationKey][]sdk.RelatedObservation
|
||||
}
|
||||
|
||||
func (s staticReportCtx) Data() json.RawMessage { return s.data }
|
||||
func (s staticReportCtx) Related(k sdk.ObservationKey) []sdk.RelatedObservation {
|
||||
return s.related[k]
|
||||
}
|
||||
func (s staticReportCtx) States() []sdk.CheckState { return s.states }
|
||||
|
||||
func TestReport_OKBannerWhenNoFindings(t *testing.T) {
|
||||
stubResolver(t, nil)
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"www": {cnameSvc("aliased.example.com.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{
|
||||
data: mustMarshal(t, data),
|
||||
states: []sdk.CheckState{{Status: sdk.StatusOK, Code: "dangling_clean"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "status-ok") {
|
||||
t.Errorf("report missing OK banner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport_TopCardReflectsCriticalOwner(t *testing.T) {
|
||||
stubResolver(t, map[string]struct{ verdict, detail string }{
|
||||
"gone.example.net": {"nxdomain", ""},
|
||||
})
|
||||
z := &rawZone{
|
||||
DomainName: "example.com",
|
||||
Services: map[string][]rawService{
|
||||
"old": {cnameSvc("gone.example.net.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z, nil)
|
||||
rule := &danglingRule{}
|
||||
states := rule.Evaluate(context.Background(),
|
||||
staticObs{key: ObservationKeyDangling, payload: mustMarshal(t, data)},
|
||||
sdk.CheckerOptions{})
|
||||
|
||||
html, err := (&danglingProvider{}).GetHTMLReport(staticReportCtx{
|
||||
data: mustMarshal(t, data),
|
||||
states: states,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "Fix this first") {
|
||||
t.Errorf("report missing 'Fix this first' card")
|
||||
}
|
||||
if !strings.Contains(html, "old.example.com") {
|
||||
t.Errorf("report does not name the impacted owner")
|
||||
}
|
||||
}
|
||||
90
checker/types.go
Normal file
90
checker/types.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const ObservationKeyDangling = "dangling_records"
|
||||
|
||||
// DanglingData is the raw observation payload; one Pointer per (owner, rrtype, target) triple.
|
||||
type DanglingData struct {
|
||||
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 one (owner, rrtype, target) triple from the zone, with its DNS resolution verdict.
|
||||
type Pointer struct {
|
||||
Owner string `json:"owner"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
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 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 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"`
|
||||
}
|
||||
|
||||
type rawService struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
|
||||
type cnameBody struct {
|
||||
Record struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Hdr"`
|
||||
Target string `json:"Target"`
|
||||
} `json:"cname"`
|
||||
}
|
||||
|
||||
type mxRecord struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Hdr"`
|
||||
Mx string `json:"Mx"`
|
||||
}
|
||||
|
||||
type mxsBody struct {
|
||||
MXs []mxRecord `json:"mx"`
|
||||
}
|
||||
|
||||
type srvRecord struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Hdr"`
|
||||
Target string `json:"Target"`
|
||||
}
|
||||
|
||||
type srvsBody struct {
|
||||
Records []srvRecord `json:"srv"`
|
||||
}
|
||||
|
||||
// orphanRecord wraps an svcs.Orphan body; Hdr.Rrtype is sniffed to pick the right field.
|
||||
type orphanRecord struct {
|
||||
Record struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
Rrtype uint16 `json:"Rrtype"`
|
||||
} `json:"Hdr"`
|
||||
// Optional fields, populated for the relevant rrtype.
|
||||
Target string `json:"Target,omitempty"`
|
||||
Mx string `json:"Mx,omitempty"`
|
||||
Ns string `json:"Ns,omitempty"`
|
||||
} `json:"record"`
|
||||
}
|
||||
86
contract/entry.go
Normal file
86
contract/entry.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Package contract defines the DiscoveryEntry types published by
|
||||
// checker-dangling and consumed by companion checkers (notably
|
||||
// domain-expiry, which subscribes to ExternalTargetType to perform RDAP
|
||||
// on the target's registrable domain).
|
||||
//
|
||||
// This package is deliberately tiny and dependency-light so that any
|
||||
// consumer can import it without dragging the whole checker in.
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// ExternalTargetType is the DiscoveryEntry.Type for an out-of-zone
|
||||
// pointer target (CNAME/MX/SRV/NS host) whose registrable domain is
|
||||
// distinct from the zone apex. Consumers subscribed to this type are
|
||||
// expected to look up the registrable domain (RDAP/WHOIS) and publish a
|
||||
// "whois" observation per entry Ref.
|
||||
const ExternalTargetType = "dangling.external-target.v1"
|
||||
|
||||
// InZoneTargetType is the DiscoveryEntry.Type for a pointer target that
|
||||
// resolves within the same zone or the same registrable domain. It is
|
||||
// declared so future probing checkers (ping/http/alias) can subscribe to
|
||||
// it for in-zone reachability checks. v1 of checker-dangling does not
|
||||
// itself rely on observations attached to in-zone entries.
|
||||
const InZoneTargetType = "dangling.in-zone-target.v1"
|
||||
|
||||
// ExternalTarget is the payload of an ExternalTargetType entry.
|
||||
//
|
||||
// Owner is the FQDN whose pointer is at risk (e.g. "old-promo.example.com.").
|
||||
// Pointer captures the type+target verbatim so a consumer can refer to
|
||||
// the precise record when reporting findings. Registrable is the eTLD+1
|
||||
// (or PSL-derived equivalent) that an RDAP probe should query.
|
||||
type ExternalTarget struct {
|
||||
Owner string `json:"owner"`
|
||||
Rrtype string `json:"rrtype"` // "CNAME", "MX", "SRV", "NS"
|
||||
Target string `json:"target"` // FQDN, no trailing dot
|
||||
Registrable string `json:"registrable"`
|
||||
}
|
||||
|
||||
// InZoneTarget mirrors ExternalTarget for in-zone or same-registrable
|
||||
// pointers. Registrable is set when known so a subscriber can decide to
|
||||
// skip records that point at the user's own domain.
|
||||
type InZoneTarget struct {
|
||||
Owner string `json:"owner"`
|
||||
Rrtype string `json:"rrtype"`
|
||||
Target string `json:"target"`
|
||||
Registrable string `json:"registrable,omitempty"`
|
||||
}
|
||||
|
||||
// Ref builds the canonical, stable Ref for a (owner, rrtype, target)
|
||||
// triple. Callers must use this on both the producer and consumer side
|
||||
// so RelatedObservation.Ref correlates with the right entry.
|
||||
func Ref(owner, rrtype, target string) string {
|
||||
return fmt.Sprintf("%s|%s|%s", owner, rrtype, target)
|
||||
}
|
||||
|
||||
// NewExternalEntry builds a DiscoveryEntry of type ExternalTargetType
|
||||
// with the canonical Ref.
|
||||
func NewExternalEntry(t ExternalTarget) (sdk.DiscoveryEntry, error) {
|
||||
payload, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return sdk.DiscoveryEntry{}, fmt.Errorf("marshal external target: %w", err)
|
||||
}
|
||||
return sdk.DiscoveryEntry{
|
||||
Type: ExternalTargetType,
|
||||
Ref: Ref(t.Owner, t.Rrtype, t.Target),
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewInZoneEntry builds a DiscoveryEntry of type InZoneTargetType.
|
||||
func NewInZoneEntry(t InZoneTarget) (sdk.DiscoveryEntry, error) {
|
||||
payload, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return sdk.DiscoveryEntry{}, fmt.Errorf("marshal in-zone target: %w", err)
|
||||
}
|
||||
return sdk.DiscoveryEntry{
|
||||
Type: InZoneTargetType,
|
||||
Ref: Ref(t.Owner, t.Rrtype, t.Target),
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module git.happydns.org/checker-dangling
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.5.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
golang.org/x/net v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
29
main.go
Normal file
29
main.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Command checker-dangling is the standalone HTTP server entrypoint
|
||||
// for the dangling/orphan-target checker.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
dangling "git.happydns.org/checker-dangling/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// Version is overridden at build time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
dangling.SetVersion(Version)
|
||||
|
||||
srv := server.New(dangling.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
15
plugin/plugin.go
Normal file
15
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the dangling
|
||||
// records checker. It is built as a Go plugin and loaded at runtime.
|
||||
package main
|
||||
|
||||
import (
|
||||
dangling "git.happydns.org/checker-dangling/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
dangling.SetVersion(Version)
|
||||
return dangling.Definition(), dangling.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue