Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
85310ad
init subdomain-takeover + NATS skeleton
lcampbell2 Jun 26, 2026
794cc25
finish NATS consumer logic
lcampbell2 Jun 26, 2026
c64e5e4
move main.go to top dir
lcampbell2 Jun 26, 2026
bb6da49
refactor main loop into app/runner.go
lcampbell2 Jun 26, 2026
ba669c4
init worker and publisher objects
lcampbell2 Jun 29, 2026
b4e9b8c
add health check at start of run loop
lcampbell2 Jun 29, 2026
4afcd60
add init logic for cname takeover scanning
lcampbell2 Jul 3, 2026
26f6811
return dict of rcode answers for each dns query type
lcampbell2 Jul 6, 2026
314d207
add function to query and process ns delegations
lcampbell2 Jul 7, 2026
ddefa53
use zone apex for probe qname and recheck for ns hostnames
lcampbell2 Jul 7, 2026
0155dd6
better func names
lcampbell2 Jul 7, 2026
12376bd
Merge branch 'fix/split-dns-query-scanner-results' into feat/subdomai…
lcampbell2 Jul 8, 2026
9101a70
add new input fields
lcampbell2 Jul 8, 2026
c213301
add registrar checks and move new logic to ns_registrar.py
lcampbell2 Jul 8, 2026
c7b77db
Merge branch 'fix/split-dns-query-scanner-results' into feat/subdomai…
lcampbell2 Jul 8, 2026
8442f3a
establish Classify() workflow
lcampbell2 Jul 8, 2026
c4c1582
match cname fingerprints and emit finding
lcampbell2 Jul 10, 2026
61b90a5
revert changes to record gate
lcampbell2 Jul 10, 2026
43e2b6e
Merge branch 'fix/split-dns-query-scanner-results' into feat/subdomai…
lcampbell2 Jul 10, 2026
c09a790
run go mod tidy
lcampbell2 Jul 10, 2026
7e55e88
move logger out of config.go
lcampbell2 Jul 10, 2026
7af7e0f
bootstrap nats and add input interfaces
lcampbell2 Jul 10, 2026
7e4cdd9
add better error handling to main.go during startup and shutdown
lcampbell2 Jul 10, 2026
1d19257
move fingerprint data fixtures to own json files
lcampbell2 Jul 10, 2026
64e789c
remove remediation.go file to serve translated text in api/frontend
lcampbell2 Jul 10, 2026
2c873ab
add RecordType enums
lcampbell2 Jul 10, 2026
f405a61
add confidence and reason code enums
lcampbell2 Jul 10, 2026
d180307
move http interface for NS lookups to own file
lcampbell2 Jul 10, 2026
ad7c272
switch from using QueryAnswers struct field to NoResolve bool for CNA…
lcampbell2 Jul 10, 2026
967a088
add contructor for Classifier
lcampbell2 Jul 10, 2026
92a0579
update README.md
lcampbell2 Jul 10, 2026
9ba9e1f
remove unneeded scan result fixtures
lcampbell2 Jul 10, 2026
16dc260
update model.input to take in published dns_scanner_results payload
lcampbell2 Jul 10, 2026
50d653a
add logic to emit nameserver hijacking exploitability
lcampbell2 Jul 22, 2026
e20f1ea
refactor rules.go into separate cname and ns detection logic files
lcampbell2 Jul 22, 2026
dc360ef
add debug logging
lcampbell2 Jul 22, 2026
92c3356
update README.md
lcampbell2 Jul 22, 2026
ca11886
add detect logic tests
lcampbell2 Jul 22, 2026
2a63ac4
app logic tests
lcampbell2 Jul 22, 2026
6be5870
messaging logic tests
lcampbell2 Jul 22, 2026
65ee95d
add CNAME rtype to record_exists check
lcampbell2 Jul 23, 2026
9957fcb
Merge branch 'fix/split-dns-query-scanner-results' into feat/subdomai…
lcampbell2 Jul 23, 2026
64f8fd9
refactor fingerprints into separate package
lcampbell2 Jul 23, 2026
ce87343
add Makefile for local development
lcampbell2 Jul 23, 2026
6ecde7d
add cloudbuild.yaml
lcampbell2 Jul 23, 2026
763e4f7
add registrar mismatch rules to NS hijack detection logic
lcampbell2 Jul 24, 2026
dce38a0
fix formatting
lcampbell2 Jul 24, 2026
b0ea229
change subject out from upsert to subdomain_takeover
lcampbell2 Jul 29, 2026
433c1d7
fix: gate RDAP scanning behind feature flag and reduce RDAP scan timeout
lcampbell2 Aug 4, 2026
e00430e
Merge branch 'fix/split-dns-query-scanner-results' into feat/subdomai…
lcampbell2 Aug 4, 2026
24ecf66
Merge branch 'master' into feat/subdomain-takeover-scanner
lcampbell2 Aug 4, 2026
d761628
subdomain-takeover: add skeleton, runtime wiring, and ops config
lcampbell2 Aug 4, 2026
acfeda6
subdomain-takeover: add final detection architecture and rules
lcampbell2 Aug 4, 2026
310dece
subdomain-takeover: add fingerprint package and datasets
lcampbell2 Aug 4, 2026
145715e
subdomain-takeover: add unit tests
lcampbell2 Aug 4, 2026
9af65e6
subdomain-takeover: add docs and build/deploy config
lcampbell2 Aug 4, 2026
9490982
update emitted finding payload structure for generic findings processor
lcampbell2 Aug 14, 2026
1d16dbf
improve cname target pattern matching
lcampbell2 Aug 27, 2026
ae20f5d
improve nats bootstrap configs on service restart
lcampbell2 Aug 27, 2026
00edc35
Harden registrar/provider canonical matching to avoid false suppressi…
lcampbell2 Aug 27, 2026
acd8826
consistent SUBJECT_OUT and DURABLE_NAME between code and docs
lcampbell2 Aug 27, 2026
bce0907
add https-first support for body matcher
lcampbell2 Aug 28, 2026
debe0f8
optimize http status regex matching
lcampbell2 Aug 28, 2026
8df8ca7
handle benign iter.Next() errors
lcampbell2 Aug 28, 2026
ee42f66
refactor detect/ tests and remove unneeded interfaces
lcampbell2 Aug 28, 2026
86c78d4
refactor app/ tests and remove unneeded interfaces
lcampbell2 Aug 28, 2026
872bf82
remove wrappers for nak and term functions in worker
lcampbell2 Aug 28, 2026
04c14a7
refactor(subdomain-takeover): remove test-only seams from prod code
lcampbell2 Aug 31, 2026
3e29486
test(subdomain-takeover): rewrite unit tests against lean prod APIs
lcampbell2 Aug 31, 2026
ac7284a
test(subdomain-takeover): add integration tests for NATS server
lcampbell2 Aug 31, 2026
2638c1b
run make tidy
lcampbell2 Aug 31, 2026
f26412d
Merge branch 'stack/01-subtakeover-skeleton' into feat/subdomain-take…
lcampbell2 Sep 14, 2026
2b75fac
Merge branch 'stack/02-subtakeover-detect-core' into feat/subdomain-t…
lcampbell2 Sep 14, 2026
b845e1d
Merge branch 'stack/03-subtakeover-fingerprints' into feat/subdomain-…
lcampbell2 Sep 14, 2026
5340beb
Merge branch 'stack/04-subtakeover-tests' into feat/subdomain-takeove…
lcampbell2 Sep 14, 2026
7ef698c
Merge branch 'stack/05-subtakeover-docs-ops' into feat/subdomain-take…
lcampbell2 Sep 14, 2026
8e81068
Merge branch 'stack/06-subtakeover-fixes' into feat/subdomain-takeove…
lcampbell2 Sep 14, 2026
9d9e375
Merge branch 'stack/07-subtakeover-test-refactor' into feat/subdomain…
lcampbell2 Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scanners/subdomain-takeover/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
NATS_URL=
NATS_STREAM=
SUBJECT_IN=
SUBJECT_OUT=
DURABLE_NAME=
WORKER_COUNT=
LOG_LEVEL=
22 changes: 22 additions & 0 deletions scanners/subdomain-takeover/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
FROM golang:1.25-alpine AS builder

WORKDIR /src
RUN apk add --no-cache git ca-certificates

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/subdomain-takeover ./cmd/service

# Runtime stage
FROM alpine:3.22

RUN addgroup -S scanner && adduser -S scanner -G scanner
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata

COPY --from=builder /out/subdomain-takeover /app/subdomain-takeover

USER scanner
ENTRYPOINT ["/app/subdomain-takeover"]
51 changes: 51 additions & 0 deletions scanners/subdomain-takeover/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.PHONY: help run test test-race test-integration build fmt fmt-check vet lint tidy ci

GO ?= go
SERVICE_BIN ?= subdomain-takeover
BUILD_DIR ?= bin

help:
@printf "Targets:\n"
@printf " make run - Run the service\n"
@printf " make test - Run all unit tests\n"
@printf " make test-race - Run unit tests with race detector\n"
@printf " make test-integration - Run integration tests (boots an in-process NATS server)\n"
@printf " make build - Build service binary\n"
@printf " make fmt - Format Go files\n"
@printf " make fmt-check - Check formatting (no changes)\n"
@printf " make vet - Run go vet\n"
@printf " make lint - Run fmt-check + vet\n"
@printf " make tidy - Tidy modules\n"
@printf " make ci - Lint, test, build\n"

run:
$(GO) run ./cmd/service

test:
$(GO) test ./...

test-race:
$(GO) test -race ./...

test-integration:
$(GO) test -tags integration ./...

build:
mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -o $(BUILD_DIR)/$(SERVICE_BIN) ./cmd/service

fmt:
$(GO) fmt ./...

fmt-check:
@test -z "$$($(GO)fmt -l .)" || (printf "Unformatted files found. Run 'make fmt'.\n" && exit 1)

vet:
$(GO) vet ./...

lint: fmt-check vet

tidy:
$(GO) mod tidy

ci: lint test build
182 changes: 182 additions & 0 deletions scanners/subdomain-takeover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# subdomain-takeover-service

Go microservice that consumes DNS scan results and emits normalized subdomain takeover findings.

## What it does

1. Consumes from `scans.dns_scanner_results` (JetStream stream: `SCANS`)
2. Evaluates passive CNAME and NS takeover risk using provider fingerprints
3. Assigns confidence (`suspected`, `probable`, `confirmed`)
4. Publishes findings to `scans.findings.subdomain-takeover`

Published finding event contract:

```json
{
"source": "subdomain-takeover",
"findingType": "subdomain-takeover-cname",
"domainKey": "12345",
"subject": "old-app.example.gc.ca",
"confidence": "probable",
"reasonCode": "CNAME_DANGLING_NXDOMAIN",
"observedAt": "2026-08-07T12:34:56Z",
"evidence": {
"target": "old-app.azurewebsites.net",
"recordType": "CNAME"
},
"attributes": {
"provider": "azure",
"lameType": ""
}
}
```

`findingType` values emitted by this service:

- `subdomain-takeover-cname`
- `subdomain-takeover-ns`

## Current detection scope

Implemented today:

- CNAME passive detection
- NS passive detection (vulnerable-only emission policy)
- Deterministic NS candidate selection when multiple providers/hosts match
- Nil-safe evidence extraction for CNAME and NS
- Classifier refactor to support injected fingerprint sources (`FingerprintSource`)

Current NS behavior:

- NS provider/host matches are evaluated and ranked
- Findings are emitted only for exploitable NS outcomes:
- `NS_FULL_LAME_PROVIDER_VULNERABLE`
- `NS_PARTIAL_LAME_PROVIDER_VULNERABLE`
- Non-exploitable NS matches (`NS_LAME_PROVIDER_UNKNOWN`, `NS_PROVIDER_MATCH_ONLY`) are classified but not emitted

## Passive-only detection policy

This service uses passive checks only.
It does not attempt account takeover, resource claiming, or any active validation.

As a result, confidence is conservative and evidence-driven:

- `suspected`: weak or incomplete takeover indicators
- `probable`: strong passive indicators with known exploitable conditions
- `confirmed`: reserved for deterministic passive signatures only

## Confidence model (current)

### CNAME

- `suspected`
- provider target matched, but required passive signal is missing
- `probable`
- dangling target with NXDOMAIN where required, or
- provider fingerprint body match for providers that require body verification
- `confirmed`
- reserved; not currently emitted by CNAME rules

### NS

- `confirmed`
- full lame delegation + exploitable provider status
- `probable`
- partial lame delegation + exploitable provider status
- `suspected`
- reason codes exist for weaker NS states, but these are currently not emitted

## Internal package layout

Detection logic was split into focused files to reduce coupling:

- `internal/detect/cname_rules.go`
- `internal/detect/ns_rules.go`
- `internal/detect/ns_reasoning.go`
- `internal/detect/cname_evidence.go`
- `internal/detect/ns_evidence.go`
- `internal/detect/fingerprint_source.go`

`Classifier` now supports dependency injection via `NewClassifierWithSource(...)`, while `NewClassifier(...)` defaults to global loaded fingerprints.

## Fingerprint data

Provider fingerprint data is stored in:

- `internal/fingerprints/data/cname_fingerprints.json`
- `internal/fingerprints/data/ns_fingerprints.json`

The service embeds and validates these files at startup (`fingerprints.Load`).

For CNAME body matching:

- `mode: literal` performs plain substring search
- `mode: regex` performs regular expression matching
- if `mode` is omitted, the loader applies backward-compatible inference

## Prerequisites

- Go 1.25+
- NATS with JetStream enabled
- `nats` CLI (optional, for local testing)

## Quick start

```bash
go mod tidy
go run ./cmd/service
```

## Local commands

This service includes a local `Makefile` for common workflows:

```bash
make help # list targets
make run # run the service
make test # run tests
make lint # fmt-check + vet
make build # build bin/subdomain-takeover
make ci # lint + test + build
```

Environment variables:

- `NATS_URL` (default: `nats://localhost:4222`)
- `NATS_STREAM` (default: `SCANS`)
- `SUBJECT_IN` (default: `scans.dns_scanner_results`)
- `SUBJECT_OUT` (default: `scans.findings.subdomain-takeover`)
- `DURABLE_NAME` (default: `subdomain_takeover`)
- `WORKER_COUNT` (default: `5`)
- `LOG_LEVEL` (default: `info`)

## Message ack behavior

The worker uses explicit JetStream ack semantics:

- decode failures -> `Term()` (drop poison message)
- classify/publish failures -> `Nak()` (retryable)
- successful processing -> `Ack()`

## Local test flow

Publish a test request:

```bash
nats pub scans.dns_scanner_results '{"domain_key":"12345","results":{"domain":"example.gc.ca","resolve_chain":[["old-app.example.gc.ca. 300 IN CNAME old-app.azurewebsites.net."]],"cname_record":"old-app.example.gc.ca. 300 IN CNAME old-app.azurewebsites.net.","ns_delegations":{"ns_hosts":["ns1.example-dns-provider.net"],"ns_checks":[],"ns_delegation":{"total_ns":1,"authoritative_ok":0,"lame_count":1,"lame_type":"full"},"error":""}}}'
```

Watch findings:

```bash
nats sub "scans.findings.subdomain-takeover"
```

## Next steps / nice-to-haves

1. Add table-driven tests for NS reason mapping, ranking, and emission gating.
2. Add classifier integration tests using injected `FingerprintSource`.
3. Document and implement explicit policy for `edge_case` and `registration_closed` NS provider statuses.
4. Add structured debug logs for NS matching decisions (host, provider, status, reason).
5. Add registrar-context mismatch handling (`RegistrarMismatch`) and confidence policy.
6. Add metrics (counts by reason code, emitted vs suppressed) for production observability.
43 changes: 43 additions & 0 deletions scanners/subdomain-takeover/cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
steps:
- name: "golang:1.25"
id: ci-checks
dir: scanners/subdomain-takeover
entrypoint: "bash"
args:
- "-c"
- |
make ci

- name: "gcr.io/cloud-builders/docker"
id: generate-image-name
entrypoint: "bash"
dir: scanners/subdomain-takeover
args:
- "-c"
- |
echo "northamerica-northeast1-docker.pkg.dev/track-compliance/tracker/subdomain-takeover:$(echo $BRANCH_NAME | sed 's/[^a-zA-Z0-9]/-/g')-$SHORT_SHA-$(date +%s)" > /workspace/imagename

- name: "gcr.io/cloud-builders/docker"
id: build-scanner
entrypoint: "bash"
dir: scanners/subdomain-takeover
args:
- "-c"
- |
image=$(cat /workspace/imagename)
docker build -t $image .

- name: "gcr.io/cloud-builders/docker"
id: push-scanner-if-master
entrypoint: "bash"
dir: scanners/subdomain-takeover
args:
- "-c"
- |
if [[ "$BRANCH_NAME" == "master" ]]
then
image=$(cat /workspace/imagename)
docker push $image
else
exit 0
fi
87 changes: 87 additions & 0 deletions scanners/subdomain-takeover/cmd/service/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"context"
"os"
"os/signal"
"syscall"
"time"

"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/app"
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/bootstrap"
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/config"
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/detect"
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/fingerprints"
"github.com/canada-ca/tracker/scanners/subdomain-takeover/internal/messaging"
"github.com/rs/zerolog"
)

func main() {
logger := bootstrap.NewLogger(zerolog.InfoLevel)

cfg, err := config.Load()
if err != nil {
logger.Fatal().Err(err).Msg("failed to load config")
}

logger = bootstrap.NewLogger(cfg.LogLevel)
logger.Info().
Str("nats_url", cfg.NATSURL).
Str("stream", cfg.NATSStream).
Str("subject_in", cfg.SubjectIn).
Str("subject_out", cfg.SubjectOut).
Str("durable", cfg.DurableName).
Int("worker_count", cfg.WorkerCount).
Str("log_level", cfg.LogLevel.String()).
Msg("service configuration loaded")

if err := fingerprints.Load(logger); err != nil {
logger.Fatal().Err(err).Msg("failed to load fingerprints")
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sig)

runtimeDeps, err := bootstrap.NewRuntimeDeps(ctx, cfg, logger)
if err != nil {
logger.Fatal().Err(err).Msg("failed to initialize runtime dependencies")
}

logger.Info().Msgf("Connected to NATS at %s", runtimeDeps.NC.ConnectedUrl())

pub := messaging.NewPublisher(logger, runtimeDeps.JS, cfg.SubjectOut)
matcher := detect.NewHTTPBodyFingerprintMatcher(5 * time.Second)
classifier := detect.NewClassifier(matcher, logger)
worker := app.NewWorker(logger, pub, classifier)

go func() {
<-sig
logger.Info().Msg("Shutdown requested...")
cancel()
runtimeDeps.Iter.Stop()
}()

deps := app.RunnerDeps{
Logger: logger,
WorkerCount: cfg.WorkerCount,
Iter: runtimeDeps.Iter,
Worker: worker,
NC: runtimeDeps.NC,
}

app.Run(ctx, deps)

logger.Info().Msgf("Disconnecting from NATS at %s", runtimeDeps.NC.ConnectedUrl())
if err := runtimeDeps.NC.Flush(); err != nil {
logger.Error().Err(err).Msg("failed to flush nats connection")
}
runtimeDeps.NC.Close()

if err := ctx.Err(); err != nil && err != context.Canceled {
logger.Error().Err(err).Msg("service exited with context error")
}
}
Loading