Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion scripts/verify-claims.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/arsenal-count-honesty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down
134 changes: 134 additions & 0 deletions src/__tests__/subdomain-takeover.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 77 additions & 0 deletions src/arsenal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading