diff --git a/README.md b/README.md index af00828..8e1d4d5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,33 @@ # DNSResolver -DNSResolver is a Python-based security tool for bulk DNS resolution and cloud infrastructure analysis. Given a list of domains it: +DNSResolver is a passive DNS reconnaissance tool for offensive security. Given a flat list of +candidate domains it **produces targets**, doing two jobs of equal weight: -- Resolves DNS records and matches resolved IPs against known IP ranges for **AWS, GCP, and Azure** -- Detects **dangling CNAME** records pointing to unclaimed cloud resources (potential subdomain takeover) -- Detects **NS takeover** opportunities where nameservers are unresolvable -- Flags **wildcard DNS** zones so catch-all answers are not mistaken for real hosts +**1. Dangling CNAME and NS takeover candidates.** CNAMEs pointing at service names nobody owns +any more, and domains whose nameservers no longer resolve. + +**2. A records resolving into cloud IP space.** The less obvious half, and the reason this tool +exists alongside the many that do job 1. + +A dangling CNAME names a service, so its intent can be read straight from DNS. A bare A record +cannot. If that address is a cloud IP the owner released, whoever allocates it next controls what +is served for that hostname — and **DNS gives no signal that this is the case**. Finding out means +churning allocation in that provider and region until the address comes back to you. + +So DNSResolver answers: *which A records land in cloud ranges worth pursuing, and where?* Each +match carries the provider's own published region, service and network border group, because an +address is only actionable if you know where it is allocated from and what it belongs to. + +It also: + +- Flags **wildcard DNS** zones, so catch-all answers are not mistaken for real hosts - Collects forensic evidence (dig/nslookup output) for flagged domains -All domain processing runs concurrently using `asyncio`, making it practical for large domain lists. +Domain processing runs concurrently using `asyncio`, making it practical for large domain lists. + +**This tool identifies targets — it does not claim them.** Acting on a finding is a separate +tool's job. That is why the output files are machine-readable records rather than prose reports, +and why DNSResolver reports what each provider publishes without judging what is worth pursuing. The tool is intentionally DNS-focused. It does not make active HTTP/HTTPS connections, probe TCP ports, validate TLS certificates, or take screenshots. These were deliberately excluded to keep the tool passive, dependency-light, and scoped to DNS reconnaissance. @@ -165,8 +184,8 @@ it reports what the provider states and leaves that decision to the operator. ## Wildcard DNS detection A zone serving a wildcard record (`*.example.com`) answers for **every** name beneath it. Against an -enumerated subdomain list that means thousands of "resolved" domains that are not real hosts, burying -the findings that matter. +enumerated subdomain list that means thousands of "resolved" domains whose resolution proves +nothing, burying the findings that matter. DNSResolver detects this automatically. For each zone it queries a couple of random labels that are almost certainly not real (`.example.com`). If they resolve, the zone answers for @@ -182,7 +201,7 @@ The end-of-run summary reports the count separately: ``` Resolved : 4 - of which wildcard : 2 (catch-all DNS — not real hosts) + of which wildcard : 2 (catch-all zone — resolution proves nothing) ``` Notes: diff --git a/REVIEW.md b/REVIEW.md index ede4d2e..da2339f 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -210,6 +210,12 @@ merely that a function "runs"; assert the classification/precedence/error-handli ## Phase 7 — Feature-vs-goals gap analysis (the "how good is good enough?" answer) +> ### ⚠️ This section was written against a misread goal — see [Correction](#correction-the-goal-this-review-assessed-was-wrong) at the end +> +> The analysis below treats cloud attribution as a supporting attribute of a dangling-CNAME +> tool. That is not what the tool is for. The verdicts in the table are sound for the goal +> stated, and the goal stated was wrong. Read the correction before relying on this. + **Stated goal (README):** passive, DNS-only bulk resolution + CSP attribution + dangling-CNAME and NS-takeover detection + forensic evidence, practical for large lists. Active probing (HTTP/TLS/ports/ screenshots) is *deliberately* out of scope. @@ -218,7 +224,7 @@ screenshots) is *deliberately* out of scope. |---|---|---| | Bulk async DNS resolution | asyncio + Semaphore + retry loop | ✅ Met | | Multi-IP fidelity (A records) | All A IPs captured, pipe-delimited, all fed to CSP match | ✅ Met | -| CSP attribution (AWS/GCP/Azure) | 3 providers, v4+v6, resilient Azure fetch | ✅ Met (perf caveat RA-C at scale) | +| CSP attribution (AWS/GCP/Azure) | 3 providers, v4+v6, resilient Azure fetch | ⚠️ **Verdict withdrawn** — assessed as an attribute, not as the product. See correction | | Dangling CNAME takeover detection | Depth-limited chain follow, 60 classification patterns, self-ref handling | ✅ Met, strong | | NS takeover detection | Unresolvable-NS check | ✅ Met | | Forensic evidence | Async dig/nslookup capture | ✅ Met (CLI only, by design) | @@ -331,3 +337,73 @@ and what it lacked was a green pipeline, three correctness fixes, and evidence. It is **not** finished — F1 is a genuine capability gap, and scale performance is unproven. Neither is a gap between promise and delivery: the README claims neither wildcard handling nor benchmarked throughput. + + +--- + +## Correction: the goal this review assessed was wrong + +*Added 2026-07-30, after the maintainer corrected the premise.* + +This review assessed DNSResolver as a dangling-CNAME detector with cloud attribution as a +supporting attribute. That is not what the tool is for, and the error runs through the whole +gap analysis in Phase 7. + +### The actual purpose + +Two jobs of **equal** weight: + +1. **Dangling CNAME / subdomain takeover candidates.** Commodity — other tooling covers this. +2. **A records resolving into claimable cloud IP space.** The differentiator. + +Job 2 is the one that is easy to under-value from the code. A dangling CNAME names a service, +so intent can be read straight from DNS. A bare A record cannot: if the address is a cloud IP +the owner released, whoever allocates it next controls what is served for that hostname, and +**DNS gives no signal that this is so**. The only way to find out is to churn allocation in +that provider and region until the address comes back to you. + +The tool identifies and hands off those targets. It never claims anything — that is a separate +tool. Which means the output files are **machine-readable interfaces**, not reports. + +### What the misframing caused this review to miss + +Phase 7 recorded CSP attribution as "✅ Met". At the time, `imports/cloud_ip_ranges.py` kept +only the CIDR string from each published prefix and discarded everything else: + +| Provider | Published | Kept | +|---|---|---| +| AWS | `ip_prefix`, `region`, `service`, `network_border_group` | prefix | +| GCP | `ipv4Prefix`/`ipv6Prefix`, `service`, `scope` | prefix | +| Azure | `addressPrefixes`, `region`, `systemService` | prefixes | + +The output read `example.com resolved to aws IPs: ['1.2.3.4']`. For a downstream consumer that +is close to useless: over half of AWS's ~10,500 prefixes carry the generic `AMAZON` tag, and +nothing distinguished a CloudFront edge from an EC2 address in a named region. The primary +product was, in effect, unfinished — and the review called it met. + +Two further defects followed from the same blind spot, both found only after the reframing: + +- **Wildcard resolutions were fed to the cloud matcher unmarked.** A wildcarded zone in cloud + space emitted one record per enumerated subdomain, each a distinct line, none deduplicated — + the hosting platform's own addresses burying real targets in the primary output. +- **`network_border_group` was discarded**, though it is the boundary an Elastic IP is actually + allocated from, and so the exact scope a consumer operates within. + +### Since fixed + +- Region, service and border group captured from all three providers. +- Handoff records: `[WILDCARD|]domain|ip|provider|region|service|prefix|border_group`. +- Summary groups by provider, region and service, and excludes wildcard records from target counts. +- Wildcard detection documented as best-effort against large rotating pools. + +### The lesson + +This is a sharper version of the verification lesson recorded above. There, tests passed while +the code was wrong. Here, the **review** was internally consistent, evidence-backed and +thorough — while measuring against the wrong thing. Rigour applied to a wrong premise produces +confident, well-supported, wrong conclusions. + +The premise came from the README, which led with dangling-CNAME detection and listed cloud +matching as a bullet. It was a reasonable misreading, available to anyone. That is why +[`AGENTS.md`](AGENTS.md) now states the purpose explicitly at repo root: so the next reader, +human or agent, does not have to infer it. diff --git a/classes/run_summary.py b/classes/run_summary.py index 3bc57e9..dfc261c 100644 --- a/classes/run_summary.py +++ b/classes/run_summary.py @@ -88,11 +88,19 @@ def display(self, total_input, failed_count): if wildcard_count: print( f" of which wildcard : {wildcard_count:>6,}" - " (catch-all DNS — not real hosts)" + " (catch-all zone — resolution proves nothing)" ) print(f" Unresolved errors : {unresolved_count:>6,}") print(f" Failed (all retries) : {failed_count:>6,}") print() + csp_target_count = aws_count + gcp_count + azure_count + if csp_target_count == 0: + print(" No cloud-hosted addresses found.") + else: + print( + f" [>] {csp_target_count} CLOUD-HOSTED ADDRESS(ES) — " + "candidate reclaim targets" + ) print( f" CSP matches — AWS: {aws_count} GCP: {gcp_count} Azure: {azure_count}" ) diff --git a/tests/test_run_summary.py b/tests/test_run_summary.py index bc9efe5..8c69379 100644 --- a/tests/test_run_summary.py +++ b/tests/test_run_summary.py @@ -261,3 +261,26 @@ def test_wildcard_cloud_records_are_excluded_from_targets(tmp_path, capsys): assert "AWS: 1" in out assert "GCP: 0" in out, "wildcard records must not count as GCP targets" assert "excluded (wildcard) : 2" in out + + +def test_cloud_targets_get_headline_billing(tmp_path, capsys): + """ + Cloud reclaim targets are one of the tool's two jobs, so they are announced + rather than reported as a footnote under the takeover banner. + """ + summary = _make_summary( + tmp_path, + {"csp": "a.com|1.1.1.1|aws|eu-west-2|EC2|1.1.0.0/16|eu-west-2\n"}, + ) + summary.display(total_input=1, failed_count=0) + + out = capsys.readouterr().out + assert "1 CLOUD-HOSTED ADDRESS(ES)" in out + + +def test_no_cloud_addresses_says_so_explicitly(tmp_path, capsys): + """Silence would read as 'not checked'; say the check ran and found nothing.""" + summary = _make_summary(tmp_path, {"csp": ""}) + summary.display(total_input=1, failed_count=0) + + assert "No cloud-hosted addresses found." in capsys.readouterr().out