From 166fd23f2a5b56c5a9a134c8f51229f06c8367b1 Mon Sep 17 00:00:00 2001 From: 2alf Date: Wed, 29 Jul 2026 17:09:57 +0200 Subject: [PATCH 1/3] feat(arsenal): add subdomain-takeover classifier + fingerprint table Pure, I/O-free detection logic split into src/arsenal/takeover.ts so it is fully unit-testable without DNS or network: - TAKEOVER_FINGERPRINTS: curated CNAME + unclaimed-resource-body signatures for 15 high-frequency services (S3, GitHub Pages, Heroku, Azure, Fastly, Shopify, Netlify, ...) - classifySubdomainTakeover(): conservative verdict (confirmed / potential / none) driven by a dangling CNAME and/or a service body fingerprint; no cross-service false matches - renderTakeoverReport(): human-readable output block --- src/arsenal/takeover.ts | 158 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/arsenal/takeover.ts diff --git a/src/arsenal/takeover.ts b/src/arsenal/takeover.ts new file mode 100644 index 00000000..bd9faf1a --- /dev/null +++ b/src/arsenal/takeover.ts @@ -0,0 +1,158 @@ +/** + * Subdomain-takeover detection — pure classification logic, separated from the Arsenal tool + * handler so it is fully unit-testable without DNS or network I/O. + * + * A subdomain takeover happens when a DNS record (almost always a CNAME) points at a third-party + * service resource that has been de-provisioned or never claimed. An attacker who registers that + * dangling resource then serves content from the victim's subdomain. Detection has two independent + * signals, either of which is decisive: + * 1. the CNAME target no longer resolves (dangling) — the backing resource is gone; and/or + * 2. the live response body carries a known "unclaimed resource" fingerprint for that service. + * + * Fingerprints are short, factual service error strings (the same public signals the community + * `can-i-take-over-xyz` catalog documents). `nxdomainVuln` marks services where a dangling CNAME + * alone is a strong takeover signal (the platform will happily serve a freshly-claimed name). + */ + +export interface TakeoverFingerprint { + service: string; + /** Matches the CNAME target host that indicates this third-party service. */ + cname: RegExp; + /** Matches the live-response "unclaimed resource" body signature (null = body is not distinctive). */ + fingerprint: RegExp | null; + /** A dangling (non-resolving) CNAME to this service is itself a strong takeover signal. */ + nxdomainVuln: boolean; +} + +export const TAKEOVER_FINGERPRINTS: TakeoverFingerprint[] = [ + { service: 'AWS/S3', cname: /(\.s3[.-]|\.s3\.amazonaws\.com|\.amazonaws\.com)/i, fingerprint: /NoSuchBucket|The specified bucket does not exist/i, nxdomainVuln: true }, + { service: 'GitHub Pages', cname: /\.github\.io$/i, fingerprint: /There isn't a GitHub Pages site here|For root URLs \(like http:\/\/example\.com\/\) you must provide an index/i, nxdomainVuln: false }, + { service: 'Heroku', cname: /(\.herokudns\.com|\.herokuapp\.com|\.herokussl\.com)$/i, fingerprint: /No such app|herokucdn\.com\/error-pages\/no-such-app\.html/i, nxdomainVuln: false }, + { service: 'Fastly', cname: /\.fastly(\.net|lb\.net)$/i, fingerprint: /Fastly error: unknown domain/i, nxdomainVuln: false }, + { service: 'Azure', cname: /(\.azurewebsites\.net|\.cloudapp\.net|\.cloudapp\.azure\.com|\.trafficmanager\.net|\.blob\.core\.windows\.net|\.azureedge\.net|\.azure-api\.net)$/i, fingerprint: null, nxdomainVuln: true }, + { service: 'Shopify', cname: /\.myshopify\.com$/i, fingerprint: /Sorry, this shop is currently unavailable/i, nxdomainVuln: false }, + { service: 'Surge.sh', cname: /\.surge\.sh$/i, fingerprint: /project not found/i, nxdomainVuln: false }, + { service: 'Bitbucket', cname: /\.bitbucket\.io$/i, fingerprint: /Repository not found/i, nxdomainVuln: false }, + { service: 'Ghost', cname: /\.ghost\.io$/i, fingerprint: /The thing you were looking for is no longer here|Domain error/i, nxdomainVuln: false }, + { service: 'Pantheon', cname: /\.pantheonsite\.io$/i, fingerprint: /The gods are wise|404 error unknown site/i, nxdomainVuln: false }, + { service: 'Tumblr', cname: /\.domains\.tumblr\.com$/i, fingerprint: /Whatever you were looking for doesn't currently exist at this address/i, nxdomainVuln: false }, + { service: 'WordPress.com', cname: /\.wordpress\.com$/i, fingerprint: /Do you want to register .*\.wordpress\.com/i, nxdomainVuln: false }, + { service: 'Read the Docs', cname: /\.readthedocs\.io$/i, fingerprint: /unknown to Read the Docs/i, nxdomainVuln: false }, + { service: 'Zendesk', cname: /\.zendesk\.com$/i, fingerprint: /Help Center Closed/i, nxdomainVuln: false }, + { service: 'Netlify', cname: /\.netlify\.(app|com)$/i, fingerprint: /Not Found - Request ID/i, nxdomainVuln: true }, +]; + +export interface TakeoverSignal { + /** The resolved CNAME target host (last hop in the chain), or null when the name has no CNAME. */ + cname: string | null; + /** Whether the CNAME target itself resolves to an address (false = dangling). */ + cnameResolves: boolean; + /** Live-response body, if one was fetched (used to confirm a service fingerprint). */ + body?: string; +} + +export interface TakeoverVerdict { + /** True only for a CONFIRMED takeover (fingerprint match, or dangling CNAME to an nxdomain-prone service). */ + vulnerable: boolean; + confidence: 'confirmed' | 'potential' | 'none'; + service: string | null; + severity: 'high' | 'medium' | 'info'; + reasons: string[]; +} + +/** + * Classify a subdomain-takeover signal into a verdict. Pure — no I/O. Conservative by design: + * a bare "CNAME points at service X" without a dangling target or a body fingerprint is only ever + * `potential`, never a confirmed finding. + */ +export function classifySubdomainTakeover(sig: TakeoverSignal): TakeoverVerdict { + if (!sig.cname) { + return { + vulnerable: false, + confidence: 'none', + service: null, + severity: 'info', + reasons: ['No CNAME record — subdomain takeover is CNAME-based, so this host is not a candidate.'], + }; + } + + const match = TAKEOVER_FINGERPRINTS.find((f) => f.cname.test(sig.cname as string)); + const dangling = !sig.cnameResolves; + const bodyMatch = !!(match && match.fingerprint && sig.body && match.fingerprint.test(sig.body)); + + if (match) { + if (bodyMatch) { + return { + vulnerable: true, + confidence: 'confirmed', + service: match.service, + severity: 'high', + reasons: [ + `CNAME points to ${match.service} (${sig.cname}) and the live response carries that service's "unclaimed resource" fingerprint.`, + 'The backing resource appears unclaimed — a takeover of this subdomain is likely possible.', + ], + }; + } + if (dangling && match.nxdomainVuln) { + return { + vulnerable: true, + confidence: 'confirmed', + service: match.service, + severity: 'high', + reasons: [ + `CNAME points to ${match.service} (${sig.cname}) but that target no longer resolves (dangling).`, + `${match.service} serves any freshly-claimed name at this target, so this subdomain is takeover-prone.`, + ], + }; + } + return { + vulnerable: false, + confidence: 'potential', + service: match.service, + severity: 'medium', + reasons: [ + `CNAME points to ${match.service} (${sig.cname}). No unclaimed-resource fingerprint was confirmed`, + dangling ? 'but the CNAME target does not resolve — verify whether the resource can be claimed.' + : 'and the target still resolves — verify the resource is genuinely owned by the target.', + ], + }; + } + + if (dangling) { + return { + vulnerable: false, + confidence: 'potential', + service: null, + severity: 'medium', + reasons: [ + `CNAME points to ${sig.cname}, which no longer resolves (dangling) — a possible takeover of an unrecognized service.`, + 'Confirm whether the pointed-to resource can be registered by a third party.', + ], + }; + } + + return { + vulnerable: false, + confidence: 'none', + service: null, + severity: 'info', + reasons: [`CNAME points to ${sig.cname}, which resolves normally and matches no known takeover fingerprint.`], + }; +} + +/** Render a verdict as the human-readable tool output block. */ +export function renderTakeoverReport(host: string, chain: string[], verdict: TakeoverVerdict): string { + const badge = verdict.confidence === 'confirmed' ? '🚨 VULNERABLE (confirmed)' + : verdict.confidence === 'potential' ? '⚠️ POTENTIAL — needs manual verification' + : '✅ Not a takeover candidate'; + const lines = [ + `Subdomain takeover check — ${host}`, + `Verdict: ${badge}`, + verdict.service ? `Service: ${verdict.service}` : null, + verdict.confidence !== 'none' ? `Severity: ${verdict.severity}` : null, + chain.length ? `CNAME chain: ${[host, ...chain].join(' → ')}` : `CNAME chain: (none — ${host} has no CNAME)`, + '', + ...verdict.reasons.map((r) => `• ${r}`), + ].filter((l): l is string => l !== null); + return lines.join('\n'); +} From 0d1fffdaa36d1bb7f894a46d8ebfe6a35dde3579 Mon Sep 17 00:00:00 2001 From: 2alf Date: Wed, 29 Jul 2026 17:15:20 +0200 Subject: [PATCH 2/3] feat(arsenal): register subdomain_takeover_check built-in + bump tool count 108->109 - New keyless, self-contained built-in tool 'subdomain_takeover_check' (recon): resolves the CNAME, checks whether the target still resolves (dangling), fetches the live body via the scope-gated targetFetch, and classifies via classifySubdomainTakeover. Emits a real finding. - Added to the Recon operator's default toolkit so the swarm can reach it (operator-toolkits coverage invariant). - Bumped the advertised arsenal size 108 -> 109 in lockstep across the count-honesty test, README, and verify-claims (73 adapters + 36 built-in). --- README.md | 2 +- scripts/verify-claims.mjs | 2 +- src/__tests__/arsenal-count-honesty.test.ts | 4 +- src/arsenal/index.ts | 77 +++++++++++++++++++++ src/operators/index.ts | 2 +- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e583fa26..cac6f04a 100755 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ The framework is an 8-operator kill chain, and this table won't blow smoke about | Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts | | Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output | | Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent | -| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 108 with the opt-in `T3MP3ST_FULL_ARSENAL` (+73 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP | +| Arsenal, MCP server, HTTP API | ✅ Stable | 36 built-in tools by default; 109 with the opt-in `T3MP3ST_FULL_ARSENAL` (+73 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP | | Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner | | Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends | | White-box source analysis | ⚠️ Experimental | Multi-language ingest via web-tree-sitter (Python/JS/TS/Go/Java/C/C++); Python retains its regex parser, while other languages fail open to no extracted blocks; multi-model decomposition costs more tokens, not fewer | diff --git a/scripts/verify-claims.mjs b/scripts/verify-claims.mjs index fd3f9e70..c0814750 100644 --- a/scripts/verify-claims.mjs +++ b/scripts/verify-claims.mjs @@ -165,7 +165,7 @@ check('VERIFY gate implemented', /VERIFY gate|never appeared in tool output/.tes check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot'); // ── CLAIM 4: capability breadth ───────────────────────────────────────────── -console.log('\nCLAIM 4 — capability: 108 tools, 8-operator kill-chain'); +console.log('\nCLAIM 4 — capability: 109 tools, 8-operator kill-chain'); // DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom // built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count // (that double-counts each tool's id+name AND every parameter name). diff --git a/src/__tests__/arsenal-count-honesty.test.ts b/src/__tests__/arsenal-count-honesty.test.ts index 04c95f1d..3165603b 100644 --- a/src/__tests__/arsenal-count-honesty.test.ts +++ b/src/__tests__/arsenal-count-honesty.test.ts @@ -40,10 +40,10 @@ describe('arsenal count honesty (advertised = real registered surface)', () => { // README / verify-claims headline together — that is the point of the lock. expect( total, - `arsenal size drifted from the advertised 108 (adapters=${TOOL_ADAPTERS.length}, ` + + `arsenal size drifted from the advertised 109 (adapters=${TOOL_ADAPTERS.length}, ` + `built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` + 'update the README / verify-claims headline to match', - ).toBe(108); + ).toBe(109); expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate }); diff --git a/src/arsenal/index.ts b/src/arsenal/index.ts index 28b071a0..7a347372 100755 --- a/src/arsenal/index.ts +++ b/src/arsenal/index.ts @@ -15,6 +15,7 @@ import * as net from 'net'; import * as dns from 'dns'; import * as tls from 'tls'; import { ApprovalController, isGatedRisk, type ApprovalRequest } from './approval.js'; +import { classifySubdomainTakeover, renderTakeoverReport } from './takeover.js'; const execFileAsync = promisify(execFile); import type { @@ -34,6 +35,7 @@ const dnsResolveMx = promisify(dns.resolveMx); const dnsResolveTxt = promisify(dns.resolveTxt); const dnsResolveNs = promisify(dns.resolveNs); const dnsReverse = promisify(dns.reverse); +const dnsResolveCname = promisify(dns.resolveCname); import { CVE_DATABASE } from '../stubs/index.js'; import type { CVEEntry } from '../stubs/index.js'; @@ -1886,6 +1888,81 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`, } }, }, + // ── subdomain_takeover_check ──────────────────────────────────────────────────────────────── + // HOW TO VERIFY (backend, no UI needed): + // npm run build + // node -e "import('./dist/arsenal/index.js').then(async m=>{const t=m.BUILTIN_TOOLS.find(x=>x.name==='subdomain_takeover_check');console.log((await t.handler({parameters:{target:process.argv[1]}})).output)})" blog.example.com + // → resolves the live CNAME chain and prints a confirmed/potential/none verdict. + // The CONFIRMED path (dangling CNAME or an unclaimed-resource body fingerprint) is exercised + // end-to-end with DNS + fetch mocked in src/__tests__/subdomain-takeover.test.ts, and the pure + // decision matrix is covered there via classifySubdomainTakeover(). ONLY scan hosts you own or + // are authorized to test. + { + name: 'subdomain_takeover_check', + description: 'Detect a dangling / unclaimed subdomain (CNAME pointing at a de-provisioned third-party service such as S3, GitHub Pages, Heroku, Azure, Fastly). Resolves the CNAME and matches known takeover fingerprints.', + category: 'recon', + parameters: [ + { name: 'target', type: 'string', description: 'Subdomain / hostname to check (e.g. blog.example.com)', required: true }, + { name: 'timeout', type: 'number', description: 'Per-request timeout in ms', required: false, default: 8000 }, + ], + handler: async (context) => { + // Normalize to a bare host: drop scheme, path, port, and a trailing FQDN dot (any of which + // would otherwise break the service fingerprint's host-suffix match). + const target = ((context.parameters.target as string) || context.target?.address || '') + .trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/:\d+$/, '').replace(/\.$/, '').toLowerCase(); + if (!target) return { success: false, error: 'No target specified' }; + const timeout = (context.parameters.timeout as number) || 8000; + + // 1) Resolve the CNAME chain. No CNAME → not a takeover candidate (takeovers are CNAME-based). + const chain: string[] = []; + let cname: string | null = null; + try { + const cnames = await dnsResolveCname(target); + if (cnames.length) { chain.push(...cnames); cname = cnames[cnames.length - 1]; } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + // ENODATA/ENOTFOUND = no CNAME for this name; anything else is a real lookup failure. + if (!/ENODATA|ENOTFOUND|ENOTIMP|SERVFAIL/i.test(msg)) { + return { success: false, error: `CNAME lookup failed for ${target}: ${msg}` }; + } + } + + // 2) Does the CNAME target still resolve? A dangling (non-resolving) CNAME is a strong signal. + let cnameResolves = false; + if (cname) { + try { cnameResolves = (await dnsResolve4(cname)).length > 0; } + catch { try { cnameResolves = (await dnsResolve(cname, 'AAAA') as string[]).length > 0; } catch { cnameResolves = false; } } + } + + // 3) Best-effort fetch of the live response to confirm an "unclaimed resource" fingerprint. + // Never fatal — a takeover is often confirmable from DNS alone. Scope-gated by execute(). + let body: string | undefined; + if (cname) { + for (const scheme of ['https', 'http']) { + try { + const resp = await targetFetch(`${scheme}://${target}`, { method: 'GET', signal: AbortSignal.timeout(timeout), redirect: 'manual' }); + body = (await resp.text()).slice(0, 20000); + break; + } catch { /* try next scheme, then give up */ } + } + } + + const verdict = classifySubdomainTakeover({ cname, cnameResolves, body }); + const output = renderTakeoverReport(target, chain, verdict); + + return { + success: true, + output, + findings: verdict.confidence === 'none' ? [] : [{ + title: verdict.confidence === 'confirmed' + ? `Subdomain takeover${verdict.service ? ` (${verdict.service})` : ''}: ${target}` + : `Possible subdomain takeover${verdict.service ? ` (${verdict.service})` : ''}: ${target}`, + severity: verdict.severity, + details: verdict.reasons.join(' '), + }], + }; + }, + }, { name: 'version_detect', description: 'Detect software versions from response headers, meta tags, and known paths', diff --git a/src/operators/index.ts b/src/operators/index.ts index 305f5b43..32abc634 100755 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -78,7 +78,7 @@ export const ARCHETYPE_PROFILES: Record = { description: 'Specialized in OSINT, network discovery, and asset enumeration', mitreTactics: ['TA0043'], primaryPhases: [KillChainPhase.RECON], - defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'], + defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'subdomain_takeover_check', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'], toolCategories: ['recon', 'web'], capabilities: ['osint', 'dns_enum', 'subdomain_discovery', 'port_scanning', 'service_detection'], techniques: ['T1595', 'T1592', 'T1589', 'T1590', 'T1591'], From 772b45d963efe99a8b14553c887452f96f16df01 Mon Sep 17 00:00:00 2001 From: 2alf Date: Wed, 29 Jul 2026 17:15:20 +0200 Subject: [PATCH 3/3] test(arsenal): cover subdomain_takeover_check (classifier matrix + mocked DNS/fetch e2e) - classifySubdomainTakeover decision matrix: confirmed via body fingerprint, confirmed via dangling nxdomain-prone CNAME, potential (known service, no confirmation), potential (dangling unknown), none (no CNAME / resolves clean), and no cross-service false matching - renderTakeoverReport output shape - end-to-end handler test with DNS + fetch mocked, driving the confirmed S3 path --- src/__tests__/subdomain-takeover.test.ts | 134 +++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/__tests__/subdomain-takeover.test.ts diff --git a/src/__tests__/subdomain-takeover.test.ts b/src/__tests__/subdomain-takeover.test.ts new file mode 100644 index 00000000..5ef5a4e8 --- /dev/null +++ b/src/__tests__/subdomain-takeover.test.ts @@ -0,0 +1,134 @@ +/** + * Coverage for the `subdomain_takeover_check` built-in tool. + * - The pure classifier (classifySubdomainTakeover) holds all decision logic and is tested directly. + * - One integration test drives the real tool handler with DNS + fetch mocked, proving the wiring + * (CNAME chain → dangling check → live-body fingerprint → verdict/finding). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { classifySubdomainTakeover, renderTakeoverReport, TAKEOVER_FINGERPRINTS } from '../arsenal/takeover.js'; + +describe('classifySubdomainTakeover — decision logic', () => { + it('CONFIRMS a takeover when the CNAME service and the live body fingerprint both match', () => { + const v = classifySubdomainTakeover({ cname: 'my-bucket.s3.amazonaws.com', cnameResolves: true, body: 'NoSuchBucket' }); + expect(v.confidence).toBe('confirmed'); + expect(v.vulnerable).toBe(true); + expect(v.service).toBe('AWS/S3'); + expect(v.severity).toBe('high'); + }); + + it('CONFIRMS on a dangling CNAME to an nxdomain-prone service (S3) even without a body', () => { + const v = classifySubdomainTakeover({ cname: 'gone.s3.amazonaws.com', cnameResolves: false }); + expect(v.confidence).toBe('confirmed'); + expect(v.vulnerable).toBe(true); + expect(v.service).toBe('AWS/S3'); + }); + + it('is only POTENTIAL for a service whose dangling CNAME is not decisive and no fingerprint matched', () => { + const v = classifySubdomainTakeover({ cname: 'app.herokudns.com', cnameResolves: false }); + expect(v.confidence).toBe('potential'); + expect(v.vulnerable).toBe(false); // not a confirmed finding + expect(v.service).toBe('Heroku'); + expect(v.severity).toBe('medium'); + }); + + it('does NOT confirm a known service that still resolves and serves a normal page', () => { + const v = classifySubdomainTakeover({ cname: 'user.github.io', cnameResolves: true, body: 'welcome to my blog' }); + expect(v.confidence).toBe('potential'); + expect(v.vulnerable).toBe(false); + }); + + it('flags a dangling CNAME to an UNRECOGNIZED service as potential', () => { + const v = classifySubdomainTakeover({ cname: 'thing.unknown-vendor.example', cnameResolves: false }); + expect(v.confidence).toBe('potential'); + expect(v.service).toBeNull(); + }); + + it('returns "none" when there is no CNAME (takeover is CNAME-based)', () => { + const v = classifySubdomainTakeover({ cname: null, cnameResolves: false }); + expect(v.confidence).toBe('none'); + expect(v.vulnerable).toBe(false); + expect(v.severity).toBe('info'); + }); + + it('returns "none" for a CNAME that resolves and matches no fingerprint', () => { + const v = classifySubdomainTakeover({ cname: 'cdn.some-cdn.example', cnameResolves: true, body: 'ok' }); + expect(v.confidence).toBe('none'); + }); + + it('does not confirm on a fingerprint from a DIFFERENT service (no cross-matching)', () => { + // GitHub Pages body signature but the CNAME points at S3 → not a GitHub confirmation. + const v = classifySubdomainTakeover({ cname: 'x.s3.amazonaws.com', cnameResolves: true, body: "There isn't a GitHub Pages site here" }); + expect(v.confidence).toBe('potential'); // S3 matched by CNAME, but its own fingerprint didn't + expect(v.service).toBe('AWS/S3'); + }); + + it('every fingerprint entry is well-formed (service + cname regex)', () => { + for (const f of TAKEOVER_FINGERPRINTS) { + expect(f.service).toBeTruthy(); + expect(f.cname).toBeInstanceOf(RegExp); + expect(typeof f.nxdomainVuln).toBe('boolean'); + } + }); +}); + +describe('renderTakeoverReport — output shape', () => { + it('renders a confirmed verdict with the CNAME chain and severity', () => { + const v = classifySubdomainTakeover({ cname: 'b.s3.amazonaws.com', cnameResolves: false }); + const out = renderTakeoverReport('blog.example.com', ['b.s3.amazonaws.com'], v); + expect(out).toContain('VULNERABLE (confirmed)'); + expect(out).toContain('blog.example.com → b.s3.amazonaws.com'); + expect(out).toContain('Severity: high'); + }); + + it('renders a non-candidate cleanly when there is no CNAME', () => { + const v = classifySubdomainTakeover({ cname: null, cnameResolves: false }); + const out = renderTakeoverReport('www.example.com', [], v); + expect(out).toContain('Not a takeover candidate'); + expect(out).toContain('(none — www.example.com has no CNAME)'); + }); +}); + +// ── Integration: the real tool handler with DNS + fetch mocked ────────────────────────────────── +vi.mock('dns', () => { + const ok = (result: unknown) => (...args: unknown[]) => (args[args.length - 1] as (e: unknown, r: unknown) => void)(null, result); + const fail = (code: string) => (...args: unknown[]) => { const e = new Error(code) as Error & { code: string }; e.code = code; (args[args.length - 1] as (e: unknown) => void)(e); }; + return { + // resolveCname is overridden per-test via the exported mock below + resolveCname: vi.fn((_n: string, c: (e: unknown, r: unknown) => void) => c(null, ['dead-bucket.s3.amazonaws.com'])), + resolve4: vi.fn(fail('ENOTFOUND')), // CNAME target does not resolve → dangling + resolve: vi.fn(fail('ENOTFOUND')), + resolveMx: vi.fn(ok([])), resolveTxt: vi.fn(ok([])), resolveNs: vi.fn(ok([])), + reverse: vi.fn(ok([])), lookup: vi.fn(ok('127.0.0.1')), + }; +}); + +describe('subdomain_takeover_check handler (integration, mocked DNS + fetch)', () => { + const origFetch = global.fetch; + afterEach(() => { global.fetch = origFetch; vi.clearAllMocks(); }); + beforeEach(() => { + global.fetch = vi.fn(async () => ({ ok: false, status: 404, text: async () => 'NoSuchBucket', headers: new Headers() } as unknown as Response)) as unknown as typeof fetch; + }); + + it('confirms an S3 takeover end-to-end and emits a high-severity finding', async () => { + const { BUILTIN_TOOLS } = await import('../arsenal/index.js'); + const tool = BUILTIN_TOOLS.find((t) => t.name === 'subdomain_takeover_check'); + expect(tool).toBeTruthy(); + const res = await tool!.handler({ parameters: { target: 'blog.example.com' } } as never); + expect(res.success).toBe(true); + expect(res.output).toContain('VULNERABLE (confirmed)'); + expect(res.output).toContain('AWS/S3'); + expect(res.findings?.[0]?.severity).toBe('high'); + expect(res.findings?.[0]?.title).toContain('blog.example.com'); + }); + + it('reports "not a candidate" when the host has no CNAME', async () => { + const dns = await import('dns'); + (dns.resolveCname as unknown as ReturnType).mockImplementationOnce((_n: string, c: (e: unknown) => void) => { const e = new Error('ENODATA') as Error & { code: string }; e.code = 'ENODATA'; c(e); }); + const { BUILTIN_TOOLS } = await import('../arsenal/index.js'); + const tool = BUILTIN_TOOLS.find((t) => t.name === 'subdomain_takeover_check'); + const res = await tool!.handler({ parameters: { target: 'www.example.com' } } as never); + expect(res.success).toBe(true); + expect(res.output).toContain('Not a takeover candidate'); + expect(res.findings).toEqual([]); + }); +});