502 lines
15 KiB
Go
502 lines
15 KiB
Go
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")
|
|
}
|
|
}
|