260 lines
12 KiB
JavaScript
260 lines
12 KiB
JavaScript
/**
|
|
* 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 `<flag> <q(value)>` 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]);
|
|
}
|