/** * arsenal-tools.mjs — the t3mp3st execution bridge. * * NORTH STAR: a 1337 hacker group of specialist experts wielding a real Kali Linux+ * arsenal. This module turns catalogued tools (src/arsenal/catalog.ts) into FIRST-CLASS * callable function-tools for the hunter — so the model can invoke `dalfox`/`sqlmap`/ * `ffuf`/… by name instead of only a generic `bash`. * * Each entry is { family, schema, build(args) }: * - schema : the OpenAI/OpenRouter function-tool definition the model sees. * - build : turns the validated args into ONE shell-command string. That string is * handed to the hunter's existing `bashTool`, so every arsenal tool inherits * bashTool's integrity blocklists, host/docker routing, mem-cap and 16KB * truncation FOR FREE. The bridge adds no new execution path. * * SCOPE / SAFETY: this bridge does NOT itself enforce a network-scope gate — it inherits exactly * the egress posture of the hunter's existing `bash` tool (which already lets the model reach any * host it names). In the benchmark hunter the target happens to be loopback (127.0.0.1:PORT) by * construction of the challenge, not by an assertion here. ScopeGuard / host-allowlisting is * enforced ONLY on the production route (server POST /api/tools/execute → guardAction) — use THAT, * not this bridge, off-loopback. All model-supplied values are single-quote escaped via q() before * they reach the shell, and sqlmap's own RCE/file-read flags are denylisted (see sanitizeFlags). * * OPT-IN: the hunter loads this module ONLY when ARSENAL_TOOLS / --arsenal-tools is set, so * default (bash-only) runs are byte-identical and the honest benchmark number stays clean. */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); /** Single-quote escape a value for safe interpolation into a `bash -lc` command. */ export function q(s) { return `'${String(s == null ? '' : s).replace(/'/g, `'\\''`)}'`; } /** Optional flag helper: emit ` ` only when value is provided. */ const opt = (flag, value) => (value == null || value === '' ? '' : ` ${flag} ${q(value)}`); /** * The specialist arsenal. Grouped by expert domain (the swarm operators). Commands are * tuned for non-interactive, machine-readable, time-bounded output suitable for an agent * loop (silent/quiet flags, no colour, batch mode, sane caps). */ export const ARSENAL = { // ── 🔍 recon / osint ────────────────────────────────────────────────────── httpx: { family: 'recon', schema: fn('httpx', 'Probe a URL/host: status code, title, detected tech, server. Fast triage of a web target.', { url: ['string', 'Target URL or host (e.g. http://127.0.0.1:9100)'], }, ['url']), build: a => `httpx -u ${q(a.url)} -sc -title -td -server -silent -no-color`, }, subfinder: { family: 'recon', schema: fn('subfinder', 'Passive subdomain enumeration for a domain.', { domain: ['string', 'Apex domain (e.g. example.com)'], }, ['domain']), build: a => `subfinder -d ${q(a.domain)} -silent`, }, katana: { family: 'recon', schema: fn('katana', 'Crawl a web target and list reachable URLs/endpoints.', { url: ['string', 'Seed URL to crawl'], depth: ['integer', 'Crawl depth (default 2)'], }, ['url']), build: a => `katana -u ${q(a.url)} -d ${intOr(a.depth, 2)} -silent -nc`, }, nmap: { family: 'recon', schema: fn('nmap', 'TCP port/service scan of a host. Use for service discovery on the target.', { target: ['string', 'Host or IP (e.g. 127.0.0.1)'], ports: ['string', 'Port spec, e.g. "1-1000" or "80,443,8080" (optional)'], }, ['target']), build: a => `nmap -Pn -T4 -sV${opt('-p', a.ports)} ${q(a.target)}`, }, // ── 🕸️ web exploitation ─────────────────────────────────────────────────── ffuf: { family: 'web', schema: fn('ffuf', 'Content/parameter fuzzer. Put FUZZ in the URL where the wordlist is substituted.', { url: ['string', 'URL containing the FUZZ keyword, e.g. http://127.0.0.1:9100/FUZZ'], wordlist: ['string', 'Path to a wordlist file'], }, ['url', 'wordlist']), build: a => `ffuf -u ${q(a.url)} -w ${q(a.wordlist)} -mc 200,201,204,301,302,307,401,403,405 -t 30 -s`, }, gobuster: { family: 'web', schema: fn('gobuster', 'Directory/file brute-force against a web root.', { url: ['string', 'Base URL, e.g. http://127.0.0.1:9100'], wordlist: ['string', 'Path to a wordlist file'], }, ['url', 'wordlist']), build: a => `gobuster dir -u ${q(a.url)} -w ${q(a.wordlist)} -q -t 30 -k`, }, feroxbuster: { family: 'web', schema: fn('feroxbuster', 'Recursive content discovery against a web target.', { url: ['string', 'Base URL'], wordlist: ['string', 'Path to a wordlist file (optional)'], }, ['url']), build: a => `feroxbuster -u ${q(a.url)}${opt('-w', a.wordlist)} -q --no-state -k -d 2`, }, dalfox: { family: 'web', schema: fn('dalfox', 'XSS scanner/verifier. Tests a URL (and optional params) for reflected/stored XSS.', { url: ['string', 'Target URL, may include query params'], }, ['url']), build: a => `dalfox url ${q(a.url)} --silence --no-color --skip-bav`, }, sqlmap: { family: 'web', schema: fn('sqlmap', 'SQL injection detection/exploitation. Non-interactive (--batch).', { url: ['string', 'Target URL (include the injectable parameter)'], data: ['string', 'POST body for form-based injection (optional)'], extra: ['string', 'Extra sqlmap flags, e.g. "--dbs" or "--dump -T users" (optional)'], }, ['url']), build: a => `sqlmap -u ${q(a.url)} --batch --level 2 --risk 1 --flush-session${opt('--data', a.data)}${a.extra ? ' ' + sanitizeFlags(a.extra) : ''}`, }, nikto: { family: 'web', schema: fn('nikto', 'Web-server misconfiguration / known-issue scanner.', { url: ['string', 'Target URL'], }, ['url']), build: a => `nikto -h ${q(a.url)} -ask no -nointeractive -maxtime 120s`, }, nuclei: { family: 'web', schema: fn('nuclei', 'Template-driven vulnerability scanner. Optionally filter by tags.', { url: ['string', 'Target URL'], tags: ['string', 'Comma-separated template tags, e.g. "cve,exposure,xss" (optional)'], }, ['url']), build: a => `nuclei -u ${q(a.url)} -silent -nc${opt('-tags', a.tags)}`, }, // ── 🔐 crypto ────────────────────────────────────────────────────────────── john: { family: 'crypto', schema: fn('john', 'John the Ripper: crack a hash file, then show recovered secrets.', { hashfile: ['string', 'Path to the file containing the hash(es)'], wordlist: ['string', 'Path to a wordlist (optional; defaults to incremental)'], format: ['string', 'Hash format, e.g. "raw-md5", "bcrypt" (optional)'], }, ['hashfile']), build: a => `john${opt('--format', a.format)}${opt('--wordlist', a.wordlist)} ${q(a.hashfile)} ; john --show ${q(a.hashfile)}`, }, hashcat: { family: 'crypto', schema: fn('hashcat', 'Hashcat dictionary attack. Requires a hash-mode number (-m).', { hashfile: ['string', 'Path to the hash file'], wordlist: ['string', 'Path to the wordlist'], mode: ['integer', 'Hashcat hash-mode number, e.g. 0=MD5, 100=SHA1, 1800=sha512crypt'], }, ['hashfile', 'wordlist', 'mode']), build: a => `hashcat -m ${intOr(a.mode, 0)} -a 0 ${q(a.hashfile)} ${q(a.wordlist)} --quiet --potfile-disable ; hashcat -m ${intOr(a.mode, 0)} ${q(a.hashfile)} --show --potfile-disable`, }, // ── 🔑 secrets / supply-chain ────────────────────────────────────────────── semgrep: { family: 'secrets', schema: fn('semgrep', 'Static analysis (SAST) over source. Default ruleset is "auto".', { path: ['string', 'Directory or file to scan (default ".")'], config: ['string', 'Ruleset, e.g. "auto", "p/security-audit", or a rule path (optional)'], }, []), build: a => `semgrep --config ${q(a.config || 'auto')} ${q(a.path || '.')} --quiet --no-color --timeout 120`, }, gitleaks: { family: 'secrets', schema: fn('gitleaks', 'Scan a repo/directory for committed secrets.', { path: ['string', 'Directory to scan (default ".")'], }, []), build: a => `gitleaks detect --source ${q(a.path || '.')} --no-banner -v`, }, trufflehog: { family: 'secrets', schema: fn('trufflehog', 'Find verified secrets in a filesystem path.', { path: ['string', 'Directory to scan (default ".")'], }, []), build: a => `trufflehog filesystem ${q(a.path || '.')} --no-update`, }, trivy: { family: 'secrets', schema: fn('trivy', 'Vulnerability / misconfiguration scanner (filesystem, image, or config).', { target: ['string', 'Path, image ref, or config dir to scan'], type: ['string', 'Scan type: "fs", "image", or "config" (default "fs")'], }, ['target']), build: a => `trivy ${trivyType(a.type)} ${q(a.target)} --quiet --no-progress`, }, }; /** Build an OpenAI function-tool schema. props = { name: [jsonType, description] }. */ function fn(name, description, props, required) { const properties = {}; for (const [key, [type, desc]] of Object.entries(props)) { properties[key] = { type, description: desc }; } return { type: 'function', function: { name, description, parameters: { type: 'object', properties, required } }, }; } const intOr = (v, d) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : d); const trivyType = t => (['fs', 'image', 'config', 'repo', 'rootfs'].includes(t) ? t : 'fs'); /** * Allow a constrained set of extra flags (sqlmap.extra): only chars that appear in real * flag strings, NO shell metacharacters. Anything with ; | & ` $ < > etc. is dropped. */ // sqlmap's own capability-expansion flags (OS shell / command exec / arbitrary file r/w / @load). // No shell-injection risk (they're sqlmap args, quoted-safe), but they turn a SQLi probe into RCE // against the target — denylist them so the free-flag slot can't reach those even on loopback. const SQLMAP_DENY = /^(--os-shell|--os-cmd|--os-pwn|--sql-shell|--eval|--file-read|--file-write|--file-dest|--shell|--priv-esc|@)/i; function sanitizeFlags(extra) { return String(extra) .split(/\s+/) .filter(tok => /^[A-Za-z0-9_=,./:@+-]+$/.test(tok)) // drop any token with a shell metachar .filter(tok => !SQLMAP_DENY.test(tok)) // drop sqlmap's RCE/file-read/shell flags .slice(0, 8) .join(' '); } /** Group aliases: a single token expands to its specialist domain's tools. */ export const GROUPS = { all: Object.keys(ARSENAL), recon: keysByFamily('recon'), web: keysByFamily('web'), crypto: keysByFamily('crypto'), secrets: keysByFamily('secrets'), }; function keysByFamily(family) { return Object.keys(ARSENAL).filter(name => ARSENAL[name].family === family); } /** * Resolve a comma-separated spec (tool names and/or group aliases) to a de-duped list of * { name, schema, build } entries. Unknown tokens are ignored. Returns [] for empty input. */ export function selectArsenal(spec) { if (!spec) return []; // own-property guard: a token like 'toString'/'constructor' must NOT resolve an Object.prototype // member (GROUPS['toString'] would be a function → .forEach throws). Only real keys count. const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k); const names = new Set(); for (const raw of String(spec).split(',')) { const tok = raw.trim(); if (!tok) continue; if (has(GROUPS, tok)) GROUPS[tok].forEach(n => names.add(n)); else if (has(ARSENAL, tok)) names.add(tok); } return [...names].map(name => ({ name, schema: ARSENAL[name].schema, build: ARSENAL[name].build })); } /** Return the subset of tool entries whose binary is actually on PATH (async, via `which`). */ export async function filterInstalled(tools) { const checks = await Promise.all(tools.map(async t => { try { await execFileAsync('which', [t.name], { timeout: 1500 }); return true; } catch { return false; } })); return tools.filter((_, i) => checks[i]); }