Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
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
56 changes: 45 additions & 11 deletions scanners/dns-scanner/dns_scanner/dns_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
from dns.exception import Timeout

from dns_scanner.email_scanners import DKIMScanner, DMARCScanner
from dns_scanner.ns_registrar import check_ns_delegations, get_registrar_context

logger = logging.getLogger(__name__)

TIMEOUT = int(os.getenv("SCAN_TIMEOUT", "20"))
DNSSEC_NAMESERVER_IP = os.getenv("DNSSEC_NAMESERVER_IP")
DNSSEC_NAMESERVER_HOSTNAME= os.getenv("DNSSEC_NAMESERVER_HOSTNAME")
DNSSEC_NAMESERVER_HOSTNAME = os.getenv("DNSSEC_NAMESERVER_HOSTNAME")


@dataclass
Expand All @@ -27,6 +28,8 @@ class DNSScanResult:
zone_apex: str = None
record_exists: bool = None
rcode: str = None
ns_delegations: dict = None
registrar_context: dict = None
resolve_chain: list[list[str]] = None
resolve_ips: [str] = None
cname_record: str = None
Expand All @@ -50,9 +53,9 @@ def find_zone_apex(domain, resolver=None):
while name != dns.name.root:
logger.debug(f"Checking for SOA at {name}")
try:
answers = resolver.resolve(name, 'SOA')
answers = resolver.resolve(name, "SOA")
logger.debug(f"Found SOA for {domain} at {name}: {answers[0]}")
zone_apex = str(name).rstrip('.')
zone_apex = str(name).rstrip(".")
return zone_apex
except NoAnswer:
# Go up one level
Expand Down Expand Up @@ -105,7 +108,13 @@ def minimal_dnssec_check(domain, nameserver_ip, nameserver_hostname, _ttl_marker
"""
try:
q = dns.message.make_query(domain, dns.rdatatype.DNSKEY, want_dnssec=True)
resp = dns.query.tls(q, where=nameserver_ip, timeout=TIMEOUT, server_hostname=nameserver_hostname, verify=True)
resp = dns.query.tls(
q,
where=nameserver_ip,
timeout=TIMEOUT,
server_hostname=nameserver_hostname,
verify=True,
)
if resp.rcode() != dns.rcode.NOERROR:
return None
except Timeout:
Expand All @@ -114,7 +123,9 @@ def minimal_dnssec_check(domain, nameserver_ip, nameserver_hostname, _ttl_marker
)
return None
except Exception as e:
logger.error(f"Error while running minimal DNSSEC check for domain '{domain}': {e}")
logger.error(
f"Error while running minimal DNSSEC check for domain '{domain}': {e}"
)
return None
# Check if AD (Authenticated Data) flag is set, showing that the data is DNSSEC validated
return bool(resp.flags & dns.flags.AD)
Expand All @@ -136,7 +147,7 @@ def get_wildcard_status(domain: str, resolver: Resolver, a_records: Answer):
result = {"wildcard_entry": False, "wildcard_sibling": False}
try:
wildcard_sibling_domain = re.sub(r"^[^.]+", "*", domain)
wildcard_record = dns.resolver.resolve(
wildcard_record = resolver.resolve(
wildcard_sibling_domain,
rdtype=dns.rdatatype.A,
raise_on_no_answer=False,
Expand All @@ -148,7 +159,7 @@ def get_wildcard_status(domain: str, resolver: Resolver, a_records: Answer):
try:
# check for mail-only subdomain (e.g. mail.example.com)
mx_records = resolver.resolve(qname=domain, rdtype=dns.rdatatype.MX)
wildcard_mx = dns.resolver.resolve(
wildcard_mx = resolver.resolve(
wildcard_sibling_domain,
rdtype=dns.rdatatype.MX,
raise_on_no_answer=False,
Expand Down Expand Up @@ -201,7 +212,12 @@ def scan_domain(domain, dkim_selectors=None):

# Check if domain exists
dns_answer_return_types = []
for query_type in [dns.rdatatype.A, dns.rdatatype.SOA, dns.rdatatype.NS]:
for query_type in [
dns.rdatatype.A,
dns.rdatatype.CNAME,
dns.rdatatype.SOA,
dns.rdatatype.NS,
]:
rtype = get_dns_return_type(domain, query_type)
if rtype == "NOERROR":
dns_answer_return_types.append(rtype)
Expand Down Expand Up @@ -284,10 +300,16 @@ def scan_domain(domain, dkim_selectors=None):
logger.debug(f"Skipping DNSSEC check for {domain} - No zone apex found")
zone_dnssec_enabled = None
elif not DNSSEC_NAMESERVER_IP or not DNSSEC_NAMESERVER_HOSTNAME:
logger.debug(f"Skipping DNSSEC check for {domain} - DNSSEC nameserver environment variables not set")
logger.debug(
f"Skipping DNSSEC check for {domain} - DNSSEC nameserver environment variables not set"
)
zone_dnssec_enabled = None
else:
zone_dnssec_enabled = dnssec_check_with_ttl(domain=zone_apex, nameserver_ip=DNSSEC_NAMESERVER_IP, nameserver_hostname=DNSSEC_NAMESERVER_HOSTNAME)
zone_dnssec_enabled = dnssec_check_with_ttl(
domain=zone_apex,
nameserver_ip=DNSSEC_NAMESERVER_IP,
nameserver_hostname=DNSSEC_NAMESERVER_HOSTNAME,
)

scan_result.zone_dnssec_enabled = zone_dnssec_enabled

Expand All @@ -297,12 +319,24 @@ def scan_domain(domain, dkim_selectors=None):
dmarc_scanner = DMARCScanner(domain)
dmarc_scan_result = dmarc_scanner.run()
scan_result.base_domain = dmarc_scan_result.get("base_domain", "")
scan_result.ns_records = dmarc_scan_result.get("ns", {})
scan_result.mx_records = dmarc_scan_result.get("mx", {})
scan_result.spf = dmarc_scan_result.get("spf", {})
scan_result.dmarc = dmarc_scan_result.get("dmarc", {})
logger.debug(f"DMARC scan elapsed time: {time.monotonic() - dmarc_start_time}")

ns_records = dmarc_scan_result.get("ns", {"hostnames": [], "errors": []})
scan_result.ns_records = ns_records
# check nameserver delegations
scan_result.ns_delegations = check_ns_delegations(
domain=domain, zone_apex=zone_apex, ns_records=ns_records
)

registrar_domain = scan_result.base_domain or zone_apex or domain
scan_result.registrar_context = get_registrar_context(
base_domain=registrar_domain,
ns_hosts=scan_result.ns_delegations.get("ns_hosts", []),
)

# If no MX records are found (with warnings), but there are CNAME records, check the CNAME target for MX records
if (
len(scan_result.mx_records.get("hosts", [])) == 0
Expand Down
196 changes: 196 additions & 0 deletions scanners/dns-scanner/dns_scanner/ns_registrar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import os

import dns
import dns.resolver
import requests
from dns.exception import Timeout
from dns.resolver import NXDOMAIN, NoAnswer, NoNameservers

TIMEOUT = int(os.getenv("SCAN_TIMEOUT", "20"))


def probe_nameserver(
where: str, qname: str, qtype: str, recursion_desired: bool, timeout: int
):
query = dns.message.make_query(
qname,
dns.rdatatype.from_text(qtype),
use_edns=True,
)
if not recursion_desired:
query.flags &= ~dns.flags.RD
return dns.query.udp(query, where=where, timeout=timeout)


def get_ns_ip(host: str, resolver):
ns_ip = None
try:
ns_a = resolver.resolve(host, "A")
if ns_a:
ns_ip = ns_a[0].to_text()
except (NoAnswer, NXDOMAIN, NoNameservers, Timeout):
ns_ip = None

if ns_ip is None:
try:
ns_aaaa = resolver.resolve(host, "AAAA")
if ns_aaaa:
ns_ip = ns_aaaa[0].to_text()
except (NoAnswer, NXDOMAIN, NoNameservers, Timeout):
ns_ip = None

return ns_ip


def check_ns_delegations(domain, zone_apex, ns_records, resolver=None, timeout_sec=10):
if resolver is None:
resolver = dns.resolver.get_default_resolver()

qname = zone_apex
if not zone_apex:
qname = domain

ns_hosts = ns_records.get("hostnames", [])
if len(ns_hosts) == 0:
try:
ns_res = resolver.resolve(domain, dns.rdatatype.NS)
ns_hosts = [host.to_text() for host in ns_res]
except (NoAnswer, NXDOMAIN, NoNameservers, Timeout):
ns_hosts = []

output = {
"ns_hosts": ns_hosts,
"ns_checks": [],
"ns_delegation": {
"total_ns": len(ns_hosts),
"authoritative_ok": 0,
"lame_count": 0,
"lame_type": "none",
},
}
if len(ns_hosts) == 0:
output["ns_delegation"]["lame_type"] = "unknown"
return output

for host in ns_hosts:
row = {
"ns_host": host,
"qname": qname,
"qtype": "SOA",
"rcode": None,
"answered_authoritatively": False,
"error": None,
"timeout": False,
}

try:
ns_ip = get_ns_ip(host, resolver)
if ns_ip is None:
row["error"] = "ns_ip_resolution_failed"
output["ns_delegation"]["lame_count"] += 1
output["ns_checks"].append(row)
continue

res = probe_nameserver(ns_ip, qname, "SOA", False, timeout_sec)
row["rcode"] = dns.rcode.to_text(res.rcode())
row["answered_authoritatively"] = bool(res.flags & dns.flags.AA)

if row["answered_authoritatively"] and row["rcode"] in [
"NOERROR",
"NXDOMAIN",
]:
output["ns_delegation"]["authoritative_ok"] += 1
else:
output["ns_delegation"]["lame_count"] += 1
except Timeout:
row["timeout"] = True
row["error"] = "timeout"
output["ns_delegation"]["lame_count"] += 1
except Exception as e:
row["error"] = str(e)
output["ns_delegation"]["lame_count"] += 1

output["ns_checks"].append(row)

ok = output["ns_delegation"]["authoritative_ok"]
total = output["ns_delegation"]["total_ns"]

if ok == total:
output["ns_delegation"]["lame_type"] = "none"
elif ok == 0:
output["ns_delegation"]["lame_type"] = "full"
else:
output["ns_delegation"]["lame_type"] = "partial"

return output


def get_registrar_context(base_domain, ns_hosts=None):
context = {
"base_domain": base_domain,
"lookup_success": False,
"rdap_url": None,
"registrar_name": None,
"registrar_id": None,
"rdap_nameservers": [],
"delegation_matches_rdap": None,
"error": None,
}

if not base_domain:
context["error"] = "missing_base_domain"
return context

rdap_url = f"https://rdap.org/domain/{base_domain}"
context["rdap_url"] = rdap_url

try:
response = requests.get(rdap_url, timeout=TIMEOUT)
response.raise_for_status()
payload = response.json()
except Exception as e:
context["error"] = str(e)
return context

context["lookup_success"] = True

nameservers = payload.get("nameservers", [])
context["rdap_nameservers"] = [
(ns.get("ldhName") or "").rstrip(".").lower()
for ns in nameservers
if ns.get("ldhName")
]

entities = payload.get("entities", [])
for entity in entities:
roles = [r.lower() for r in entity.get("roles", [])]
if "registrar" not in roles:
continue

context["registrar_id"] = entity.get("handle")

vcard = entity.get("vcardArray", [])
if isinstance(vcard, list) and len(vcard) == 2 and isinstance(vcard[1], list):
for entry in vcard[1]:
if not isinstance(entry, list) or len(entry) < 4:
continue
key = entry[0]
value = entry[3]
if key in {"fn", "org"} and value:
context["registrar_name"] = value
break

if context["registrar_name"] is None:
public_ids = entity.get("publicIds", [])
if public_ids:
context["registrar_name"] = public_ids[0].get("identifier")

break

if ns_hosts is not None:
normalized_hosts = {h.rstrip(".").lower() for h in ns_hosts if h}
rdap_hosts = set(context["rdap_nameservers"])
if rdap_hosts:
context["delegation_matches_rdap"] = normalized_hosts == rdap_hosts

return context
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"]
Loading