feat(arsenal): subdomain takeover detection built-in (#134)

* 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

* 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).

* 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
This commit is contained in:
2alf authored and GitHub committed 2026-07-30 23:50:39 -04:00
1 parent 571a7743ae
commit 44e7c210a2
7 files changed
+374 -5

No files matched your search

+2 -2
View File
@@ -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
});
+134
View File
@@ -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: '<Error><Code>NoSuchBucket</Code></Error>' });
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: '<html>welcome to my blog</html>' });
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 () => '<Error><Code>NoSuchBucket</Code></Error>', 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<typeof vi.fn>).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([]);
});
});
+77
View File
@@ -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',
+158
View File
@@ -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');
}
+1 -1
View File
@@ -78,7 +78,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
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'],