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/__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([]);
+ });
+});
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/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');
+}
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'],