Merge remote-tracking branch 'upstream/main' into pr-47
# Conflicts: # README.md
This commit is contained in:
75 files changed
+7949
-648
No files matched your search
@@ -14,6 +14,8 @@ import {
|
||||
adapterToCustomTool,
|
||||
buildAdapterTools,
|
||||
toolNameFor,
|
||||
isMintable,
|
||||
hasArgTemplate,
|
||||
type AdapterToolDeps,
|
||||
type SubprocessResult,
|
||||
} from '../arsenal/adapter-tools.js';
|
||||
@@ -217,3 +219,151 @@ describe('buildAdapterTools', () => {
|
||||
expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('source / supply-chain scanners run a real invocation (not `<binary> <target>`)', () => {
|
||||
// Each entry: [adapter id, argv for `path: 'src'`]. Without bespoke templates these all fell
|
||||
// through to DEFAULT_TEMPLATE and spawned `['src']` (or `['']` with no path) — a broken scan.
|
||||
const CASES: Array<[string, string[]]> = [
|
||||
['semgrep', ['scan', '--config', 'auto', '--json', 'src']],
|
||||
['gitleaks', ['detect', '--source', 'src', '--report-format', 'json', '--redact', '--no-banner']],
|
||||
['trufflehog', ['filesystem', 'src', '--json', '--no-update']],
|
||||
['trivy', ['fs', '--format', 'json', 'src']],
|
||||
['syft', ['dir:src', '-o', 'cyclonedx-json']],
|
||||
['grype', ['dir:src', '-o', 'json']],
|
||||
['checkov', ['-d', 'src', '-o', 'json']],
|
||||
];
|
||||
|
||||
it.each(CASES)('%s builds its real scanner argv for an explicit path', async (id, expected) => {
|
||||
const deps = makeDeps();
|
||||
await mint(id, deps).handler(ctx({ path: 'src' }));
|
||||
expect(deps.spawns[0]).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(CASES)('%s defaults to the working dir "." when no path is given', async (id) => {
|
||||
const deps = makeDeps();
|
||||
await mint(id, deps).handler(ctx({}));
|
||||
// The path slot resolves to "." — assert "." is present and the argv is more than a bare target.
|
||||
expect(deps.spawns[0].some(a => a === '.' || a === 'dir:.')).toBe(true);
|
||||
expect(deps.spawns[0].length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('never inherits an http(s) mission target as a scan path (falls back to ".")', async () => {
|
||||
const deps = makeDeps();
|
||||
// A networked mission address must not become `semgrep scan --config auto --json https://x` —
|
||||
// that is not a filesystem path. scanPath rejects it and falls back to ".".
|
||||
await mint('semgrep', deps).handler(ctx({ target: 'https://victim.example' }));
|
||||
expect(deps.spawns[0]).toEqual(['scan', '--config', 'auto', '--json', '.']);
|
||||
});
|
||||
|
||||
it('refuses an option-looking path (leading "-" would be a scanner flag) — no spawn', async () => {
|
||||
const deps = makeDeps();
|
||||
// The factory's option-looking-target guard fires before argv is built, so the scanner never runs.
|
||||
const result = await mint('trivy', deps).handler(ctx({ path: '--output=/etc/x' }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(deps.spawns.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse / mobile / smart-contract analysers run a real invocation (not `<binary> <file>`)', () => {
|
||||
// Each entry: [adapter id, params, expected argv]. Without bespoke templates these all fell through
|
||||
// to DEFAULT_TEMPLATE and spawned `['<file>']` — a broken invocation for a subcommand/flag-driven
|
||||
// analyser (and, for r2, one that drops into an interactive shell and hangs the loop).
|
||||
const CASES: Array<[string, Record<string, unknown>, string[]]> = [
|
||||
['objdump', { file: 'a.out' }, ['-d', '-M', 'intel', 'a.out']],
|
||||
['readelf', { file: 'a.out' }, ['-a', 'a.out']],
|
||||
['checksec', { file: 'a.out' }, ['--file=a.out']],
|
||||
['radare2', { file: 'a.out' }, ['-q', '-e', 'scr.color=0', '-c', 'ij', 'a.out']],
|
||||
['exiftool', { file: 'a.out' }, ['-json', 'a.out']],
|
||||
['mythril', { file: 'Vault.sol' }, ['analyze', 'Vault.sol', '-o', 'json']],
|
||||
['apkleaks', { file: 'app.apk' }, ['-f', 'app.apk']],
|
||||
['slither', { path: 'contracts' }, ['contracts', '--json', '-']],
|
||||
['mobsfscan', { path: 'app-src' }, ['--json', 'app-src']],
|
||||
];
|
||||
|
||||
it.each(CASES)('%s builds its real analyser argv', async (id, params, expected) => {
|
||||
const deps = makeDeps();
|
||||
await mint(id, deps).handler(ctx(params));
|
||||
expect(deps.spawns[0]).toEqual(expected);
|
||||
});
|
||||
|
||||
it('radare2 never drops into an interactive shell (batch `-q -c`, never a bare file)', async () => {
|
||||
const deps = makeDeps();
|
||||
await mint('radare2', deps).handler(ctx({ file: 'a.out' }));
|
||||
const argv = deps.spawns[0];
|
||||
expect(argv).toContain('-q'); // quit after the command
|
||||
expect(argv).toContain('-c'); // run one command, non-interactive
|
||||
expect(argv).not.toEqual(['a.out']); // the old broken/hanging positional default
|
||||
});
|
||||
|
||||
it('a file-oriented analyser with no path degrades (clean failure, no spawn)', async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await mint('objdump', deps).handler(ctx({}));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/file\/artifact path/);
|
||||
expect(deps.spawns.length).toBe(0); // never spawned `objdump ''`
|
||||
});
|
||||
|
||||
it('a file-oriented analyser refuses an http(s) URL as a local artifact (no spawn)', async () => {
|
||||
const deps = makeDeps();
|
||||
// A networked mission address inherited via context must not become `objdump https://x`.
|
||||
const res = await mint('readelf', deps).handler(ctx({ file: 'https://victim.example/firmware.bin' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(deps.spawns.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invocation-honesty guard — every mintable adapter is classified, none silently falls through', () => {
|
||||
// Every adapter that CAN be minted either carries a bespoke ARG_TEMPLATE (a real invocation) or is
|
||||
// explicitly filed under exactly one reason for legitimately falling through to `<binary> <target>`.
|
||||
// This is the invocation-correctness sibling of the tool-count honesty test: a newly-catalogued
|
||||
// subcommand tool (e.g. a future `zzuf`/`radare2`-style binary) FAILS this test until someone
|
||||
// decides whether it needs a template — it can never silently ship a broken positional invocation.
|
||||
|
||||
// `<binary> <target>` is genuinely a correct, useful invocation for these.
|
||||
const POSITIONAL_TARGET_OK = new Set([
|
||||
'file', 'strings', 'binwalk', 'radamsa', 'jadx', 'class-dump', 'solhint', 'echidna', 'john',
|
||||
]);
|
||||
// No single safe default exists: the operator supplies the full command (cloud CLIs, model-config
|
||||
// red-team harnesses, device-runtime tools, project-scaffolded RE, or a target-executing debugger).
|
||||
const OPERATOR_DRIVEN = new Set([
|
||||
'prowler', 'scoutsuite', 'cloudfox', 'pmapper', 'aws-cli', 'az-cli', 'gcloud-cli',
|
||||
'garak', 'promptfoo', 'foundry-forge', 'foundry-cast', 'openssl', 'afl-fuzz', 'ghidra',
|
||||
'gdb', 'objection', 'drozer',
|
||||
]);
|
||||
// KNOWN DEBT: the positional default is broken/degraded and these SHOULD get a template later.
|
||||
// Tracked honestly here rather than hidden — a good follow-up PR shrinks this set.
|
||||
const KNOWN_DEBT = new Set([
|
||||
'feroxbuster', 'osv-scanner', 'hashcat', 'apktool', 'yara',
|
||||
]);
|
||||
|
||||
const mintable = TOOL_ADAPTERS.filter(isMintable);
|
||||
const classified = [POSITIONAL_TARGET_OK, OPERATOR_DRIVEN, KNOWN_DEBT];
|
||||
|
||||
it('every mintable adapter is either templated or explicitly filed under one fall-through reason', () => {
|
||||
const unaccounted = mintable
|
||||
.filter(a => !hasArgTemplate(a))
|
||||
.map(a => a.id)
|
||||
.filter(id => !classified.some(s => s.has(id)));
|
||||
expect(unaccounted, 'these mintable adapters silently fall through to `<binary> <target>`').toEqual([]);
|
||||
});
|
||||
|
||||
it('the fall-through allow-lists are disjoint and never overlap a templated adapter', () => {
|
||||
const seen = new Set<string>();
|
||||
for (const set of classified) {
|
||||
for (const id of set) {
|
||||
expect(seen.has(id), `${id} is listed in more than one fall-through set`).toBe(false);
|
||||
seen.add(id);
|
||||
const a = TOOL_ADAPTERS.find(x => x.id === id);
|
||||
expect(a, `allow-listed id '${id}' is not in the catalog`).toBeTruthy();
|
||||
expect(isMintable(a as ToolAdapter), `${id} is not mintable — remove it`).toBe(true);
|
||||
expect(hasArgTemplate(a as ToolAdapter), `${id} IS templated — remove it from the fall-through lists`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the reverse/mobile/smart-contract loadout is actually templated (regression pin)', () => {
|
||||
for (const id of ['objdump', 'readelf', 'checksec', 'radare2', 'exiftool', 'mythril', 'apkleaks', 'slither', 'mobsfscan']) {
|
||||
expect(hasArgTemplate(adapter(id)), `${id} lost its ARG_TEMPLATE`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AnalysisEngine } from '../analysis/index.js';
|
||||
import type { Report } from '../types/index.js';
|
||||
import { KillChainPhase } from '../types/index.js';
|
||||
|
||||
function makeReport(): Report {
|
||||
return {
|
||||
id: 'report-1',
|
||||
missionId: 'mission-1',
|
||||
type: 'full_report',
|
||||
generatedAt: 123,
|
||||
summary: {
|
||||
overview: 'Summary includes Bearer summaryabcdefghijklmnop',
|
||||
riskRating: 'critical',
|
||||
criticalFindings: 1,
|
||||
highFindings: 0,
|
||||
mediumFindings: 0,
|
||||
lowFindings: 0,
|
||||
infoFindings: 0,
|
||||
successfulExploits: 1,
|
||||
credentialsHarvested: 1,
|
||||
systemsCompromised: 1,
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
title: 'Leaked token',
|
||||
description: 'Captured Anthropic key sk-ant-api03-ABCDEFGHIJKLMNOP in config',
|
||||
severity: 'critical',
|
||||
targetId: 'target-1',
|
||||
operatorId: 'operator-1',
|
||||
phase: KillChainPhase.EXPLOIT,
|
||||
evidence: [
|
||||
{
|
||||
type: 'output',
|
||||
content: 'curl -H "Authorization: Bearer evidenceabcdefghijklmnop" https://svc.local',
|
||||
timestamp: 123,
|
||||
},
|
||||
],
|
||||
remediation: 'Rotate token=rawsecretvalue123456 immediately',
|
||||
discoveredAt: 123,
|
||||
},
|
||||
],
|
||||
attackPaths: [
|
||||
{
|
||||
id: 'path-1',
|
||||
name: 'Use https://admin:hunter2@example.test/login',
|
||||
description: 'Pivot with Bearer attackpathabcdefghijklmnop',
|
||||
steps: ['Replay sk-ant-api03-QRSTUVWXABCDEFGHIJKLMNOP against API'],
|
||||
findings: ['finding-1'],
|
||||
impactLevel: 'critical',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
id: 'rec-1',
|
||||
findingId: 'finding-1',
|
||||
priority: 'immediate',
|
||||
title: 'Remove API key',
|
||||
description: 'Delete api_key=anotherrawsecretvalue from docs',
|
||||
effort: 'low',
|
||||
impact: 'high',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AnalysisEngine report redaction', () => {
|
||||
it('redacts secrets before exporting Markdown reports', () => {
|
||||
const engine = Object.create(AnalysisEngine.prototype) as AnalysisEngine;
|
||||
const markdown = engine.exportToMarkdown(makeReport());
|
||||
|
||||
for (const raw of [
|
||||
'sk-ant-api03-ABCDEFGHIJKLMNOP',
|
||||
'Bearer summaryabcdefghijklmnop',
|
||||
'Bearer evidenceabcdefghijklmnop',
|
||||
'rawsecretvalue123456',
|
||||
'hunter2',
|
||||
'attackpathabcdefghijklmnop',
|
||||
'sk-ant-api03-QRSTUVWXABCDEFGHIJKLMNOP',
|
||||
'anotherrawsecretvalue',
|
||||
]) {
|
||||
expect(markdown).not.toContain(raw);
|
||||
}
|
||||
expect(markdown).toContain('[redacted]');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
|
||||
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
|
||||
const setupScript = readFileSync(join(process.cwd(), 'scripts/setup-api.sh'), 'utf8');
|
||||
|
||||
function sourceBlock(startMarker: string, endMarker: string): string {
|
||||
@@ -33,6 +34,11 @@ describe('API key environment handling hardening', () => {
|
||||
expect(block).toContain("join(homedir(), '.t3mp3st', '.env')");
|
||||
});
|
||||
|
||||
it('the API server does not re-enable caller-cwd dotenv loading', () => {
|
||||
expect(serverSource).not.toMatch(/from ['"]dotenv['"]/);
|
||||
expect(serverSource).not.toMatch(/\bdotenv\.config\s*\(/);
|
||||
});
|
||||
|
||||
it('setup-api.sh writes the same T3MP3ST-owned env file that ConfigManager reads', () => {
|
||||
const block = configLoadEnvBlock();
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TOOL_ADAPTERS, type ToolAdapter } from '../arsenal/catalog.js';
|
||||
import { BUILTIN_TOOLS, EXTERNAL_TOOLS } from '../arsenal/index.js';
|
||||
import {
|
||||
isMintable,
|
||||
adapterToCustomTool,
|
||||
toolNameFor,
|
||||
type AdapterToolDeps,
|
||||
} from '../arsenal/adapter-tools.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARSENAL COUNT HONESTY — the "advertised = real, and nothing is silently dead".
|
||||
//
|
||||
// The arsenal advertises "102 tools". verify-claims defends that number by counting
|
||||
// `id:`/`name:` source lines — a SOURCE-LINE count, which would keep passing even if
|
||||
// an entry became uncallable. This test locks the number to the REAL registered arrays
|
||||
// and, more importantly, pins the invariant that makes the count honest:
|
||||
//
|
||||
// an adapter is OFF the generic callable surface (adapterToCustomTool → null) IFF it
|
||||
// is EXPLICITLY gated by its execution mode (catalog_only / import_only).
|
||||
//
|
||||
// i.e. a tool can be counted-but-not-generically-callable ONLY on purpose (metasploit /
|
||||
// hydra reachable solely via the approval-gated post-ex drivers; bloodhound import-only;
|
||||
// pacu/frida catalog-only until narrow approved runners exist),
|
||||
// never by a silent DEFAULT/mint slip. Sibling of no-phantom-tools / stub-honesty.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const deps: AdapterToolDeps = {
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
};
|
||||
|
||||
const isGated = (a: ToolAdapter) =>
|
||||
a.execution === 'catalog_only' || a.execution === 'import_only';
|
||||
|
||||
describe('arsenal count honesty (advertised = real registered surface)', () => {
|
||||
it('the advertised "102 tools" is the real registered surface, not a source-line count', () => {
|
||||
const total = TOOL_ADAPTERS.length + BUILTIN_TOOLS.length + EXTERNAL_TOOLS.length;
|
||||
// Locks the headline to code. If the arsenal grows/shrinks, update this AND the
|
||||
// README / verify-claims headline together — that is the point of the lock.
|
||||
expect(
|
||||
total,
|
||||
`arsenal size drifted from the advertised 102 (adapters=${TOOL_ADAPTERS.length}, ` +
|
||||
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
|
||||
'update the README / verify-claims headline to match',
|
||||
).toBe(102);
|
||||
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
|
||||
});
|
||||
|
||||
it('an adapter is off the generic callable surface IFF it is explicitly gated (no silent-dead entry)', () => {
|
||||
const silentlyDead: string[] = []; // counted, not gated, yet mints null
|
||||
const wronglyGated: string[] = []; // gated, yet somehow mints a callable tool
|
||||
for (const a of TOOL_ADAPTERS) {
|
||||
const callable = adapterToCustomTool(a, deps) !== null;
|
||||
if (!callable && !isGated(a)) silentlyDead.push(`${a.id} (${a.execution})`);
|
||||
if (callable && isGated(a)) wronglyGated.push(`${a.id} (${a.execution})`);
|
||||
// isMintable must agree with actual callability — no divergence.
|
||||
expect(isMintable(a), `isMintable disagrees with callability for ${a.id}`).toBe(callable);
|
||||
}
|
||||
expect(silentlyDead, `counted-but-silently-uncallable adapters: ${silentlyDead.join(', ')}`).toEqual([]);
|
||||
expect(wronglyGated, `gated adapters that are still generically callable: ${wronglyGated.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('the gated set is exactly the documented post-ex / import tools', () => {
|
||||
const gated = TOOL_ADAPTERS.filter(isGated);
|
||||
// metasploit + hydra are catalog_only (callable only via the approval-gated hand-written
|
||||
// post-ex drivers); pacu/frida are catalog_only pending narrow approved runners; bloodhound is
|
||||
// import_only (collector output is imported, never run here).
|
||||
expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'frida', 'hydra', 'metasploit', 'pacu']);
|
||||
expect(TOOL_ADAPTERS.find((a) => a.id === 'bloodhound')?.execution).toBe('import_only');
|
||||
// Every gated adapter is genuinely off the generic surface.
|
||||
for (const a of gated) {
|
||||
expect(adapterToCustomTool(a, deps), `${a.id} must not be generically callable`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('every generically-callable adapter mints a real, uniquely-named tool', () => {
|
||||
const names = new Set<string>();
|
||||
for (const t of [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS]) names.add(t.name);
|
||||
for (const a of TOOL_ADAPTERS) {
|
||||
const tool = adapterToCustomTool(a, deps);
|
||||
if (!tool) continue; // gated — covered above
|
||||
expect(typeof tool.handler, `${a.id} minted without a handler`).toBe('function');
|
||||
expect(tool.name, `${a.id} minted an unexpected name`).toBe(toolNameFor(a));
|
||||
// A minted adapter that shadows a hand-written tool name is deduped by buildAdapterTools;
|
||||
// record it here to prove no two DISTINCT registered surfaces collide unexpectedly.
|
||||
names.add(tool.name);
|
||||
}
|
||||
// Built-ins + externals themselves carry no duplicate names.
|
||||
const builtinNames = [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS].map((t) => t.name);
|
||||
expect(new Set(builtinNames).size).toBe(builtinNames.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
[{"type":"V","inject_type":"inHTML-URL","severity":"high","cwe":"CWE-79","method":"GET","param":"q","data":"https://target.example.com/?q=<script>alert(1)</script>","evidence":"reflected in HTML body","poc":"<script>alert(1)</script>","message_str":"reflected XSS"}]
|
||||
@@ -0,0 +1 @@
|
||||
{"commandline":"ffuf -u https://target.example.com/FUZZ -w common.txt","results":[{"input":{"FUZZ":"admin"},"status":200,"length":1234,"words":56,"url":"https://target.example.com/admin"},{"input":{"FUZZ":"login"},"status":301,"length":0,"words":1,"url":"https://target.example.com/login"}]}
|
||||
@@ -0,0 +1 @@
|
||||
[{"RuleID":"aws-access-token","Description":"AWS Access Key AKIAIOSFODNN7EXAMPLE committed to source","File":"config/prod.env","StartLine":12,"Match":"REDACTED","Secret":"REDACTED","Commit":"abc1234","Author":"dev"}]
|
||||
@@ -0,0 +1 @@
|
||||
{"matches":[{"vulnerability":{"id":"CVE-2022-23529","severity":"High","description":"jsonwebtoken is vulnerable to signature verification bypass."},"artifact":{"name":"jsonwebtoken","version":"8.5.1","type":"npm"}}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"url":"https://target.example.com","status_code":200,"title":"Example Corp","tech":["Nginx","PHP","jQuery"],"webserver":"nginx"}
|
||||
{"url":"https://api.example.com","status_code":403,"title":"Forbidden"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"timestamp":"2026-01-01T00:00:00Z","request":{"method":"GET","endpoint":"https://target.example.com/"}}
|
||||
{"timestamp":"2026-01-01T00:00:01Z","request":{"method":"GET","endpoint":"https://target.example.com/about"}}
|
||||
{"timestamp":"2026-01-01T00:00:02Z","request":{"method":"GET","endpoint":"https://target.example.com/contact"}}
|
||||
@@ -0,0 +1,3 @@
|
||||
[INF] nuclei banner line that is not JSON and must be skipped
|
||||
{"template-id":"CVE-2021-44228","info":{"name":"Apache Log4j RCE","severity":"critical","description":"Log4j JNDI remote code execution via crafted header","classification":{"cve-id":["CVE-2021-44228"],"cwe-id":["CWE-502"],"cvss-score":10.0},"remediation":"Upgrade log4j to 2.17.0 or later"},"host":"https://target.example.com","matched-at":"https://target.example.com/api"}
|
||||
{"template-id":"nginx-version","info":{"name":"Nginx Version Disclosure","severity":"info"},"host":"https://target.example.com","matched-at":"https://target.example.com"}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"check_id":"python.lang.security.dangerous-subprocess-use","path":"app/run.py","start":{"line":42},"end":{"line":42},"extra":{"message":"Detected subprocess call with shell=True — command injection risk","severity":"ERROR","metadata":{"cwe":["CWE-78: OS Command Injection"],"owasp":["A03:2021 - Injection"],"references":["https://owasp.org/Top10/A03_2021-Injection/"]}}}],"errors":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"SchemaVersion":2,"Results":[{"Target":"package-lock.json","Class":"lang-pkgs","Type":"npm","Vulnerabilities":[{"VulnerabilityID":"CVE-2021-3749","PkgName":"axios","InstalledVersion":"0.21.1","FixedVersion":"0.21.2","Severity":"HIGH","Title":"axios: Regular expression denial of service via trim","Description":"axios is vulnerable to Inefficient Regular Expression Complexity (ReDoS).","CweIDs":["CWE-400"]}]}]}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { config, AVAILABLE_MODELS } from '../config/index.js';
|
||||
|
||||
// Regression coverage for #52: native Google Gemini support.
|
||||
// The engine dispatches Gemini through OpenAIAdapter, which posts to
|
||||
// `${baseUrl}/chat/completions`. Gemini's OpenAI-compatible surface lives under
|
||||
// /v1beta/openai, so the base URL MUST carry that path segment — the single most
|
||||
// important thing to get right, and the exact detail an earlier attempt missed.
|
||||
describe('Gemini native provider wiring (#52)', () => {
|
||||
it('routes gemini through the OpenAI-compatible endpoint including the /openai segment', () => {
|
||||
const cfg = config.getLLMConfig('gemini');
|
||||
expect(cfg.provider).toBe('gemini');
|
||||
expect(cfg.baseUrl).toBe('https://generativelanguage.googleapis.com/v1beta/openai');
|
||||
// Guard the regression directly: `${baseUrl}/chat/completions` must resolve under /v1beta/openai.
|
||||
expect(`${cfg.baseUrl}/chat/completions`).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to a bare native Gemini model id (no google/ prefix)', () => {
|
||||
const cfg = config.getLLMConfig('gemini');
|
||||
expect(cfg.model).toBe('gemini-2.5-flash');
|
||||
expect(cfg.model.startsWith('google/')).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes a non-empty native Gemini model catalog with bare ids', () => {
|
||||
expect(AVAILABLE_MODELS.gemini?.length ?? 0).toBeGreaterThan(0);
|
||||
for (const m of AVAILABLE_MODELS.gemini) {
|
||||
expect(m.id.startsWith('google/')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the gemini provider to the GEMINI_API_KEY environment variable', () => {
|
||||
const prev = process.env.GEMINI_API_KEY;
|
||||
process.env.GEMINI_API_KEY = 'test-gemini-key-0123456789';
|
||||
try {
|
||||
expect(config.hasApiKey('gemini')).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GEMINI_API_KEY;
|
||||
else process.env.GEMINI_API_KEY = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => {
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('a wedged mission reaches phase advancement once the per-dispatch backstop fires', async () => {
|
||||
it('a wedged mission stalls instead of advancing after required recon dispatches time out', async () => {
|
||||
// Tiny backstop so the test is fast and deterministic.
|
||||
const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS;
|
||||
process.env.T3MP3ST_TASK_TIMEOUT_MS = '30';
|
||||
@@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => {
|
||||
command.start();
|
||||
|
||||
// Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the
|
||||
// dispatch via the backstop and advance past RECON.
|
||||
// dispatch via the backstop. Failed required recon work should stall instead
|
||||
// of advancing the mission with no successful backend/model work.
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline && !phaseAdvanced) {
|
||||
while (Date.now() < deadline && !command.getStatus().paused) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
const status = command.getStatus();
|
||||
command.stop();
|
||||
|
||||
expect(phaseAdvanced).toBe(true);
|
||||
expect(phaseAdvanced).toBe(false);
|
||||
expect(status.paused).toBe(true);
|
||||
expect(status.stallReason).toContain('stalled in reconnaissance');
|
||||
// The wedged operator was reset back to idle (or re-tasked in a later phase),
|
||||
// never left stuck in 'executing' with no current task.
|
||||
const stuck = command.cell.getAllOperators().some(
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* REGRESSION (issue #78 — macOS/POSIX): the local-agent detector resolved the CLI binary
|
||||
* against the SERVER process PATH. When T3MP3ST is launched from Finder, the desktop app, or
|
||||
* any non-interactive shell, launchd hands it a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) —
|
||||
* missing ~/.local/bin (Claude Code's native install), Homebrew, npm-global, and version-manager
|
||||
* shims (mise/nvm/fnm). `execFile('claude', …)` then throws ENOENT and an installed, working CLI
|
||||
* is reported `installed:false` (the Settings checkboxes go dead — reads like a UI bug).
|
||||
*
|
||||
* The fix is `resolveBin()`: pure-fs resolution over PATH + a curated set of well-known install
|
||||
* dirs under the real agent home, with an executable-file check, fail-open to the bare name.
|
||||
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
|
||||
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
|
||||
*
|
||||
* POSIX-focused, with a small Windows boundary assertion: unresolved Windows shims fail closed
|
||||
* instead of returning a bare name that would bypass the safe .cmd/.bat launch path.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { resolveBin, detectLocalAgents, runLocalAgent, localAgentChat } from '../agent/local-agents.js';
|
||||
|
||||
const FAKE_CLI = '#!/bin/sh\necho "1.2.3"\n';
|
||||
// For the chat path, which writes the prompt to the child's stdin: drain it before replying so the
|
||||
// parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply.
|
||||
const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n';
|
||||
const tmpDirs: string[] = [];
|
||||
const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME };
|
||||
|
||||
/** A throwaway dir; tracked for afterEach cleanup. */
|
||||
function scratch(): string {
|
||||
const d = mkdtempSync(join(tmpdir(), 't3mp3st-pathres-'));
|
||||
tmpDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Drop an executable (0o755) fake binary at `dir/name`; returns its absolute path. */
|
||||
function putExe(dir: string, name: string, content: string = FAKE_CLI): string {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const p = join(dir, name);
|
||||
writeFileSync(p, content, { mode: 0o755 });
|
||||
return p;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = savedEnv.PATH;
|
||||
if (savedEnv.AGENT_HOME === undefined) delete process.env.T3MP3ST_AGENT_HOME; else process.env.T3MP3ST_AGENT_HOME = savedEnv.AGENT_HOME;
|
||||
for (const d of tmpDirs.splice(0)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () => {
|
||||
it('finds a CLI in ~/.local/bin even when PATH omits it (the reported bug)', () => {
|
||||
const home = scratch();
|
||||
const bin = putExe(join(home, '.local', 'bin'), 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home; // real agent home for this scan
|
||||
process.env.PATH = '/usr/bin:/bin'; // minimal launchd-style PATH — omits ~/.local/bin
|
||||
|
||||
expect(resolveBin('faketool')).toBe(bin);
|
||||
});
|
||||
|
||||
// A representative spread of the curated dirs (not just index-0 ~/.local/bin) so a typo or
|
||||
// reorder in any entry is caught. Paths are relative to the agent home.
|
||||
it.each([
|
||||
['.asdf/shims'],
|
||||
['.bun/bin'],
|
||||
['.local/share/pnpm'],
|
||||
])('resolves a CLI installed under the well-known dir %s', (rel) => {
|
||||
const home = scratch();
|
||||
const bin = putExe(join(home, ...rel.split('/')), 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
expect(resolveBin('faketool')).toBe(bin);
|
||||
});
|
||||
|
||||
it('finds a CLI under an nvm per-version bin dir (dynamic enumeration)', () => {
|
||||
const home = scratch();
|
||||
const bin = putExe(join(home, '.nvm', 'versions', 'node', 'v20.11.0', 'bin'), 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
expect(resolveBin('faketool')).toBe(bin);
|
||||
});
|
||||
|
||||
it('finds a CLI under an fnm per-version bin dir (dynamic enumeration)', () => {
|
||||
const home = scratch();
|
||||
const bin = putExe(join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.11.0', 'installation', 'bin'), 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
expect(resolveBin('faketool')).toBe(bin);
|
||||
});
|
||||
|
||||
it('resolves via PATH when the CLI is on it (rich-shell launch still works)', () => {
|
||||
const dir = scratch();
|
||||
const bin = putExe(dir, 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = scratch(); // empty home — force the hit to come from PATH
|
||||
process.env.PATH = `/usr/bin:${dir}`;
|
||||
|
||||
expect(resolveBin('faketool')).toBe(bin);
|
||||
});
|
||||
|
||||
it('prefers a PATH match over a well-known-dir match when both exist (shell precedence)', () => {
|
||||
const home = scratch();
|
||||
const wellKnown = putExe(join(home, '.local', 'bin'), 'faketool');
|
||||
const pathDir = scratch();
|
||||
const onPath = putExe(pathDir, 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = `/usr/bin:${pathDir}`; // PATH scanned before well-known dirs
|
||||
|
||||
expect(resolveBin('faketool')).toBe(onPath);
|
||||
expect(resolveBin('faketool')).not.toBe(wellKnown);
|
||||
});
|
||||
|
||||
it('follows a symlink to an executable (asdf/nvm shims & Claude Code\'s own install are symlinks)', () => {
|
||||
const home = scratch();
|
||||
const real = putExe(join(home, 'tools'), 'realtool');
|
||||
const linkDir = join(home, '.local', 'bin');
|
||||
mkdirSync(linkDir, { recursive: true });
|
||||
const link = join(linkDir, 'faketool');
|
||||
symlinkSync(real, link);
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
|
||||
expect(resolveBin('faketool')).toBe(link); // the resolved candidate path, symlink followed for the file-check
|
||||
});
|
||||
|
||||
it('skips a non-executable match (parity with execFile X_OK) and fails open to the bare name', () => {
|
||||
const home = scratch();
|
||||
const p = join(home, '.local', 'bin', 'faketool');
|
||||
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
|
||||
writeFileSync(p, 'not executable', { mode: 0o644 }); // present but not +x
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
|
||||
expect(resolveBin('faketool')).toBe('faketool'); // not the non-exec path
|
||||
});
|
||||
|
||||
it('does NOT match a directory named like the CLI (dirs carry the search bit; execvp would skip it)', () => {
|
||||
// Regression guard: a bare `accessSync(X_OK)` passes for a directory, so resolveBin would return
|
||||
// the dir → execFile fails EACCES (not ENOENT) → a false installed:true. Require a regular file.
|
||||
const home = scratch();
|
||||
mkdirSync(join(home, '.local', 'bin', 'faketool'), { recursive: true }); // a DIRECTORY, not a binary
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
expect(resolveBin('faketool')).toBe('faketool'); // bare name, not the dir path
|
||||
});
|
||||
|
||||
it('returns an already-qualified path unchanged (the bin.includes("/") guard — no re-resolution)', () => {
|
||||
expect(resolveBin('/opt/custom/claude')).toBe('/opt/custom/claude');
|
||||
expect(resolveBin('./rel/claude')).toBe('./rel/claude');
|
||||
});
|
||||
|
||||
it('fails open to the bare name when nothing matches anywhere', () => {
|
||||
process.env.T3MP3ST_AGENT_HOME = scratch();
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
|
||||
});
|
||||
|
||||
it('does NOT scan POSIX dirs on win32 — unresolved shims fail closed', () => {
|
||||
const home = scratch();
|
||||
putExe(join(home, '.local', 'bin'), 'faketool');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
try {
|
||||
expect(resolveBin('faketool')).toBeUndefined();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', orig);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLocalAgents — wiring: a well-known-dir CLI is reported installed (issue #78)', () => {
|
||||
it('reports Claude Code installed + versioned when `claude` lives in ~/.local/bin and PATH omits it', async () => {
|
||||
const home = scratch();
|
||||
const claudePath = putExe(join(home, '.local', 'bin'), 'claude');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin'; // pre-fix: execFile('claude') → ENOENT → installed:false
|
||||
delete process.env.T3MP3ST_DISABLE_LOCAL_AGENTS; // insurance: a leaked disable flag would short-circuit to []
|
||||
|
||||
const agents = await detectLocalAgents();
|
||||
const claude = agents.find((a) => a.id === 'claude');
|
||||
expect(claude?.installed).toBe(true);
|
||||
expect(claude?.path).toBe(claudePath);
|
||||
expect(claude?.version).toBe('1.2.3'); // parsed from the resolved binary's --version output
|
||||
// codex/hermes are absent from this temp home → still not installed (no false positives).
|
||||
expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => {
|
||||
// Behavioral, not a spawn spy: if the rewiring regressed to the bare `spec.bin`, spawn('claude')
|
||||
// under PATH=/usr/bin:/bin throws ENOENT → ok:false / reject. Success proves the resolved path ran.
|
||||
it('runLocalAgent launches the well-known-dir CLI (not the bare name)', async () => {
|
||||
const home = scratch();
|
||||
putExe(join(home, '.local', 'bin'), 'claude');
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
|
||||
const res = await runLocalAgent('claude', 'ping', { timeoutMs: 4000 });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('1.2.3');
|
||||
});
|
||||
|
||||
it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => {
|
||||
const home = scratch();
|
||||
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN);
|
||||
process.env.T3MP3ST_AGENT_HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
|
||||
await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,15 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => {
|
||||
});
|
||||
|
||||
describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
|
||||
it('describes the current ACTION CONTRACT to the local agent', async () => {
|
||||
cli.mockResolvedValueOnce('Final debrief.');
|
||||
await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
const prompt = String(cli.mock.calls.at(-1)?.[1] || '');
|
||||
expect(prompt).toContain('ACTION CONTRACT');
|
||||
expect(prompt).toContain('nmap_scan');
|
||||
expect(prompt).toContain('port scan a host');
|
||||
});
|
||||
|
||||
it('returns toolCalls + finishReason=tool_calls when the agent requests a tool', async () => {
|
||||
cli.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```');
|
||||
const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
@@ -83,6 +92,19 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
|
||||
expect(res.toolCalls).toBeUndefined();
|
||||
expect(res.finishReason).toBe('stop');
|
||||
});
|
||||
|
||||
it('does not force the old short timeout when no local-agent timeout is configured', async () => {
|
||||
cli.mockResolvedValueOnce('done');
|
||||
await localBackbone().chat([{ role: 'user', content: 'hello' }]);
|
||||
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: undefined });
|
||||
});
|
||||
|
||||
it('passes an explicit local-agent timeout through to the CLI runner', async () => {
|
||||
cli.mockResolvedValueOnce('done');
|
||||
await new LLMBackbone({ provider: 'local-agent', model: 'codex', timeout: 900000 } as never)
|
||||
.chat([{ role: 'user', content: 'hello' }]);
|
||||
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
|
||||
@@ -133,6 +155,29 @@ describe('local-model (HTTP) backbone surfaces toolCalls — the keyless-path fi
|
||||
const sys = body.messages.find((m: { role: string }) => m.role === 'system');
|
||||
expect(sys.content).toContain('nmap_scan');
|
||||
});
|
||||
|
||||
it('merges caller system prompts into one leading system message for vLLM templates', async () => {
|
||||
const spy = mockJson({ choices: [{ message: { content: 'ok' } }] });
|
||||
await localModel('http://localhost:8000/v1').prompt('hello', 'Operator profile system prompt');
|
||||
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.messages.map((m: { role: string }) => m.role)).toEqual(['system', 'user']);
|
||||
expect(body.messages.filter((m: { role: string }) => m.role === 'system')).toHaveLength(1);
|
||||
expect(body.messages[0].content).toContain('planning brain for T3MP3ST');
|
||||
expect(body.messages[0].content).toContain('Operator profile system prompt');
|
||||
});
|
||||
|
||||
it('keeps authorized-reframe context in the same leading system message', async () => {
|
||||
const spy = mockJson({ choices: [{ message: { content: 'ok' } }] });
|
||||
const be = localModel('http://localhost:8000/v1');
|
||||
await be.chat([
|
||||
{ role: 'system', content: 'AUTHORIZATION CONTEXT (restated): proceed with the authorized task.' },
|
||||
{ role: 'user', content: 'scan' },
|
||||
]);
|
||||
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.messages.map((m: { role: string }) => m.role)).toEqual(['system', 'user']);
|
||||
expect(body.messages[0].content).toContain('planning brain for T3MP3ST');
|
||||
expect(body.messages[0].content).toContain('AUTHORIZATION CONTEXT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('xai provider (Grok Build) — routes to xAI OpenAI-compatible API with the key', () => {
|
||||
|
||||
@@ -3,16 +3,49 @@ import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
|
||||
const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8');
|
||||
|
||||
function routeBlock(startMarker: string, endMarker: string): string {
|
||||
function sourceBlock(startMarker: string, endMarker: string): string {
|
||||
const start = serverSource.indexOf(startMarker);
|
||||
expect(start, `missing route marker ${startMarker}`).toBeGreaterThanOrEqual(0);
|
||||
expect(start, `missing source marker ${startMarker}`).toBeGreaterThanOrEqual(0);
|
||||
const end = serverSource.indexOf(endMarker, start);
|
||||
expect(end, `missing end marker ${endMarker}`).toBeGreaterThan(start);
|
||||
return serverSource.slice(start, end);
|
||||
}
|
||||
|
||||
function routeBlock(startMarker: string, endMarker: string): string {
|
||||
return sourceBlock(startMarker, endMarker);
|
||||
}
|
||||
|
||||
describe('local API authorization hardening invariants', () => {
|
||||
it('server mission/general routes default to the configured LLM instead of hardcoded OpenRouter', () => {
|
||||
const resolver = routeBlock('function resolveGeneralLLMConfig(', '/**\n * Bring a planned mission UP for real');
|
||||
const missionRoute = routeBlock("app.post('/api/mission/start'", "app.get('/api/mission/report'");
|
||||
const generalRoutes = routeBlock("app.post('/api/general/plan'", '// =============================================================================\n// THE ADMIRAL');
|
||||
|
||||
expect(resolver).toContain('config.getLLMConfig()');
|
||||
expect(resolver).toContain('provider || defaultConfig.provider');
|
||||
expect(missionRoute).toContain('resolveGeneralLLMConfig(provider, model, apiKey)');
|
||||
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/provider\s*=\s*['"]openrouter['"]/);
|
||||
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/model\s*=\s*['"]anthropic\/claude-sonnet-4['"]/);
|
||||
});
|
||||
|
||||
it('health and LLM status count keyless local backends as configured', () => {
|
||||
const health = routeBlock('function healthPayload()', '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
|
||||
const llmStatus = routeBlock("app.get('/api/llm/status'", '// =============================================================================\n// TOOL EXECUTION ENDPOINTS');
|
||||
|
||||
expect(health).toContain('providerRunsKeyless(llmConfig.provider)');
|
||||
expect(llmStatus).toContain('providerRunsKeyless(llmConfig.provider)');
|
||||
});
|
||||
|
||||
it('War Room no-key/no-agent backend dispatch defers to the server-configured LLM', () => {
|
||||
expect(uiSource).toContain("serverLLM: null");
|
||||
expect(uiSource).toContain('this.serverLLM = data.llm');
|
||||
expect(uiSource).toContain('No browser API key — using the server-configured LLM backend');
|
||||
expect(uiSource).toContain('LLM backend: server default — no browser API key sent');
|
||||
expect(uiSource).not.toMatch(/let provider = ['"]openrouter['"];\s*\n\s*let model = state\.settings\.selectedModel/);
|
||||
});
|
||||
|
||||
it('/api/events does not grant wildcard CORS and rejects foreign browser origins before opening SSE', () => {
|
||||
const route = routeBlock("app.get('/api/events'", '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
|
||||
|
||||
@@ -30,12 +63,65 @@ describe('local API authorization hardening invariants', () => {
|
||||
expect(route).toMatch(/guardAction\(body,\s*['"]command_execution['"],\s*targetResolution\.target/);
|
||||
});
|
||||
|
||||
it('Admiral live launch re-checks every General-produced execution target before mission bring-up', () => {
|
||||
it('/api/tools/execute rejects curl flags that change the effective destination', () => {
|
||||
const parser = sourceBlock('const CURL_TRANSPORT_OVERRIDE_FLAGS', 'function inferCommandTarget');
|
||||
|
||||
for (const flag of [
|
||||
'--resolve',
|
||||
'--connect-to',
|
||||
'--proxy',
|
||||
'--preproxy',
|
||||
'--socks5',
|
||||
'--socks5-hostname',
|
||||
'--unix-socket',
|
||||
'--interface',
|
||||
'--url',
|
||||
'--config',
|
||||
'--next',
|
||||
'-K',
|
||||
]) {
|
||||
expect(parser).toContain(`'${flag}'`);
|
||||
}
|
||||
expect(parser).toMatch(/findCurlTransportOverrideFlag\(args\)/);
|
||||
expect(parser).toMatch(/countCurlUrlOperands\(args\)\s*>\s*1/);
|
||||
expect(parser).toMatch(/multiple URL operands/);
|
||||
expect(parser).toMatch(/changes the effective network destination/);
|
||||
});
|
||||
|
||||
it('Admiral live launch permits expanded targets only with explicit operator approval receipts', () => {
|
||||
const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS');
|
||||
|
||||
expect(route).toMatch(/ensureExecTargetsWithinApprovedTarget\(execConfig\.targets, brief\.target\)/);
|
||||
expect(route).toMatch(/ensureExecTargetsAuthorized\(execConfig\.targets,\s*brief\.target,\s*req\.body/);
|
||||
expect(route).toMatch(/outOfScopeTargets\.length/);
|
||||
expect(route.indexOf('ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target)'))
|
||||
expect(route.indexOf('ensureExecTargetsAuthorized(execConfig.targets, brief.target'))
|
||||
.toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)'));
|
||||
expect(route).toMatch(/approvals/);
|
||||
expect(route).toMatch(/approvalIds/);
|
||||
});
|
||||
|
||||
it('approval lookup requires explicit receipt ids and wildcard hosts require opt-in', () => {
|
||||
const findApproval = routeBlock('function findApproval(', 'function createApprovalRequest');
|
||||
const approvalMatches = sourceBlock('function approvalMatches(', 'function ensureExecTargetsWithinApprovedTarget');
|
||||
|
||||
expect(findApproval).toMatch(/body\.approvalId/);
|
||||
expect(findApproval).toMatch(/body\.approvalIds/);
|
||||
expect(findApproval).not.toMatch(/\[\.\.\.approvalRequests\.values\(\)\]/);
|
||||
expect(serverSource).toMatch(/Wildcard host approval requires explicit opt-in/);
|
||||
expect(serverSource).toMatch(/body\.allowWildcard !== true/);
|
||||
expect(approvalMatches.indexOf("approval.target === '*'"))
|
||||
.toBeLessThan(approvalMatches.indexOf('targetUsesWildcard(approval.target) && !approval.wildcardOptIn'));
|
||||
});
|
||||
|
||||
it('authorize-target only approves existing pending receipts and cannot mint broad active-action approvals', () => {
|
||||
const route = routeBlock("app.post('/api/approvals/authorize-target'", "app.post('/api/approvals/:id/reject'");
|
||||
|
||||
expect(route).toMatch(/approvalId/);
|
||||
expect(route).toMatch(/approvalIds/);
|
||||
expect(route).toMatch(/approvalRequests\.get\(id\)/);
|
||||
expect(route).toMatch(/approval\.status !== 'pending'/);
|
||||
expect(route).toMatch(/Math\.min\(30/);
|
||||
expect(route).not.toMatch(/createApprovalRequest/);
|
||||
expect(route).not.toMatch(/allowedActions/);
|
||||
expect(route).not.toMatch(/command_execution['"],\s*['"]autonomous_execution/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// Static invariants for the `local` provider. Two real OpenAI-compatible servers
|
||||
// broke against the old code: (1) the wire detector only matched a literal /v1, so
|
||||
// versioned paths like Zhipu/z.ai's /api/paas/v4 silently fell through to the
|
||||
// Ollama native format and failed; (2) the provider was treated as always-keyless,
|
||||
// so servers that require a bearer (Zhipu, Together, …) could never authenticate.
|
||||
// These tests pin the fixes in place and document the intent.
|
||||
|
||||
const llmSource = readFileSync(join(process.cwd(), 'src/llm/index.ts'), 'utf8');
|
||||
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
|
||||
|
||||
function block(source: string, startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
expect(start, `missing marker "${startMarker}"`).toBeGreaterThanOrEqual(0);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
expect(end, `missing end marker "${endMarker}"`).toBeGreaterThan(start);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
describe('local OpenAI-compatible provider hardening', () => {
|
||||
it('isOpenAIWire detects ANY versioned path (/v1, /v4, …), not only /v1', () => {
|
||||
const fn = block(
|
||||
llmSource,
|
||||
'private isOpenAIWire(baseUrl: string): boolean {',
|
||||
'\n }',
|
||||
);
|
||||
|
||||
// Must be version-agnostic: a digit class, not a literal `1`.
|
||||
expect(fn).toMatch(/v\\d\+/);
|
||||
// No literal-/v1-only form must remain.
|
||||
expect(fn).not.toMatch(/\/v1\(/);
|
||||
});
|
||||
|
||||
it('getLLMConfig forwards an optional bearer for local servers that require auth', () => {
|
||||
const localCase = block(configSource, "case 'local':", 'break;');
|
||||
|
||||
expect(localCase).toContain('TEMPEST_LOCAL_API_KEY');
|
||||
// Provider-specific aliases are accepted as a convenience.
|
||||
expect(localCase).toMatch(/ZAI_API_KEY/);
|
||||
expect(localCase).toMatch(/ZHIPUAI_API_KEY/);
|
||||
});
|
||||
|
||||
it('getLLMConfig treats UI local placeholders as unset model names', () => {
|
||||
const localCase = block(configSource, "case 'local':", 'break;');
|
||||
|
||||
expect(localCase).toContain('local-model');
|
||||
expect(localCase).toContain('local/ollama');
|
||||
expect(localCase).toContain('TEMPEST_LOCAL_MODEL');
|
||||
});
|
||||
|
||||
it('getApiKey resolves a local key from env without persisting it', () => {
|
||||
// The local key is read in-memory from env, matching the env-key hardening
|
||||
// invariant (env keys are never written to the store).
|
||||
const getApiKey = block(configSource, 'getApiKey(provider:', 'envVarMap');
|
||||
|
||||
expect(getApiKey).toContain("provider === 'local'");
|
||||
expect(getApiKey).toContain('TEMPEST_LOCAL_API_KEY');
|
||||
});
|
||||
|
||||
it('the local key slot is part of the typed apiKeys store', () => {
|
||||
expect(configSource).toMatch(/local\?:\s*string;/);
|
||||
});
|
||||
|
||||
it('the env template documents the local provider options', () => {
|
||||
const template = block(configSource, 'createEnvTemplate(', '`;');
|
||||
|
||||
expect(template).toContain('TEMPEST_LOCAL_BASE_URL');
|
||||
expect(template).toContain('TEMPEST_LOCAL_MODEL');
|
||||
expect(template).toContain('TEMPEST_LOCAL_API_KEY');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// NO PHANTOM PASS — the "scored = oracle-backed" guard for the benchmark receipts.
|
||||
//
|
||||
// `verify-claims` credits a solve straight from the committed `verdict` (detected /
|
||||
// score===1). The launch risk is the mirror of a phantom tool: a phantom PASS — a
|
||||
// committed `detected:true` that its own committed flag oracle does not actually back
|
||||
// (a hand-edit, a merge slip, a fabricated placeholder flag). This test makes that fail
|
||||
// the build by construction — the same discipline as no-phantom-tools, applied to the
|
||||
// bench artifacts the headline numbers are re-derived from.
|
||||
//
|
||||
// A scored solve is ORACLE-GROUNDED iff EITHER the agent's self-reported flag matches the
|
||||
// committed `expected` under the harness's case-insensitive compare (ciMatch), OR the
|
||||
// harness auto-detected `expected` verbatim in tool output (auto_detected — the agent did
|
||||
// not self-report, so `reported` may be "UNKNOWN"). Anything else scored as a solve is a
|
||||
// phantom pass.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Same helpers verify-claims.mjs uses, re-derived here so the gate is independent of it.
|
||||
const ciMatch = (a: unknown, b: unknown) =>
|
||||
!!a && !!b && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
|
||||
|
||||
function looksFabricated(flag: unknown): boolean {
|
||||
if (!flag) return false;
|
||||
const inner = (String(flag).match(/\{([\s\S]*)\}/) || ['', ''])[1]
|
||||
.toLowerCase()
|
||||
.replace(/4/g, 'a').replace(/3/g, 'e').replace(/0/g, 'o')
|
||||
.replace(/1/g, 'i').replace(/5/g, 's').replace(/7/g, 't');
|
||||
return /\b(fake|placeholder|dummy|redacted|todo|tbd)\b|fake_?flag|for_?test|_?test(ing)?_?(flag|local|stub|only)|local_?test|real_[a-z]+_proof|proof_?xyz|sample_?flag|your_?flag_?here|example_?flag|replace_?me|flag_?here/.test(inner);
|
||||
}
|
||||
|
||||
interface Verdict {
|
||||
detected?: boolean;
|
||||
score?: number;
|
||||
reported?: string;
|
||||
expected?: string;
|
||||
auto_detected?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const isScoredSolve = (v: Verdict) => v.detected === true || v.score === 1;
|
||||
const isOracleGrounded = (v: Verdict) =>
|
||||
ciMatch(v.reported, v.expected) ||
|
||||
v.auto_detected === true ||
|
||||
/auto-?detected/i.test(String(v.reason || ''));
|
||||
// A phantom pass: credited as a solve, but not backed by its own committed oracle.
|
||||
const isPhantomPass = (v: Verdict) => isScoredSolve(v) && !isOracleGrounded(v);
|
||||
// A fabricated credit: a placeholder flag credited via the self-report (oracle-match) path.
|
||||
const isFabricatedCredit = (v: Verdict) =>
|
||||
isScoredSolve(v) && ciMatch(v.reported, v.expected) && looksFabricated(v.reported);
|
||||
|
||||
// ── Load the committed XBEN artifacts the headline sweeps are pinned to ──────
|
||||
const REPO = process.cwd();
|
||||
const XBEN = path.join(REPO, 'bench', 'xbow', 'results');
|
||||
const PINNED_DIRS = [
|
||||
'blackbox-golden', 'blackbox-golden-v2', 'blackbox-golden-final',
|
||||
'whitebox-golden', 'whitebox-golden-v2', 'venice-whitebox',
|
||||
];
|
||||
|
||||
function firstVerdict(file: string): Verdict {
|
||||
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return ((d.results || [{}])[0] || {}).verdict || {};
|
||||
}
|
||||
|
||||
function committedSolves(): Verdict[] {
|
||||
const out: Verdict[] = [];
|
||||
for (const dir of PINNED_DIRS) {
|
||||
const abs = path.join(XBEN, dir);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
for (const f of fs.readdirSync(abs).filter((n) => /^xben_\d+_24.*\.json$/.test(n))) {
|
||||
const v = firstVerdict(path.join(abs, f));
|
||||
if (isScoredSolve(v)) out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('no phantom pass (scored = oracle-backed)', () => {
|
||||
it('finds a non-trivial set of committed, scored solves to audit', () => {
|
||||
// Guard against the vacuous-green failure mode: if the artifacts moved and we scan
|
||||
// nothing, the "0 phantom" assertions below would pass while checking nothing.
|
||||
expect(committedSolves().length).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('every committed scored solve is oracle-grounded (self-report ≡ oracle, or auto-detected)', () => {
|
||||
const phantoms = committedSolves().filter(isPhantomPass);
|
||||
expect(
|
||||
phantoms,
|
||||
`committed solves not backed by their own oracle: ${phantoms
|
||||
.map((v) => `reported=${JSON.stringify(v.reported)} expected=${JSON.stringify(v.expected)}`)
|
||||
.join('; ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('no fabricated placeholder flag is credited as a self-reported solve', () => {
|
||||
const fabricated = committedSolves().filter(isFabricatedCredit);
|
||||
expect(fabricated).toEqual([]);
|
||||
});
|
||||
|
||||
// ── The gate must have TEETH: prove it rejects tampered artifacts, not just that
|
||||
// today's clean data passes. Otherwise a future hand-edit sails through. ──
|
||||
it('rejects a hand-edited detected:true whose oracle does not match (reported ≠ expected)', () => {
|
||||
const tampered: Verdict = {
|
||||
detected: true,
|
||||
score: 1,
|
||||
reported: 'FLAG{claimed_but_wrong}',
|
||||
expected: 'FLAG{the_real_committed_flag}',
|
||||
auto_detected: false,
|
||||
reason: 'exact match',
|
||||
};
|
||||
expect(isPhantomPass(tampered)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a fabricated placeholder flag even when reported === expected', () => {
|
||||
const fabricated: Verdict = {
|
||||
detected: true,
|
||||
score: 1,
|
||||
reported: 'FLAG{local_test_flag}',
|
||||
expected: 'FLAG{local_test_flag}',
|
||||
reason: 'exact match',
|
||||
};
|
||||
expect(isPhantomPass(fabricated)).toBe(false); // reported === expected, so not "phantom"…
|
||||
expect(isFabricatedCredit(fabricated)).toBe(true); // …but the fabrication filter catches it.
|
||||
});
|
||||
|
||||
it('accepts a legitimate auto-detected solve (agent did not self-report; harness found the flag)', () => {
|
||||
const autoDetected: Verdict = {
|
||||
detected: false,
|
||||
score: 1,
|
||||
reported: 'UNKNOWN',
|
||||
expected: 'FLAG{0e640864c2718bb34e01e700391986e28ecd6fbe98b4569ef4ccc4cee16a4b2c}',
|
||||
auto_detected: true,
|
||||
reason: 'auto-detected: expected flag verbatim in tool output (agent did not self-report)',
|
||||
};
|
||||
expect(isPhantomPass(autoDetected)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseToolOutput, hasParser, PARSED_TOOL_IDS } from '../arsenal/parsers.js';
|
||||
import { gateLiveFinding } from '../evidence/gate.js';
|
||||
import { adapterToCustomTool, type AdapterToolDeps } from '../arsenal/adapter-tools.js';
|
||||
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
|
||||
import { KillChainPhase } from '../types/index.js';
|
||||
import type { CustomTool, Finding, ToolFinding } from '../types/index.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STRUCTURED PARSERS — turn real scanner stdout into ToolFinding[], honestly.
|
||||
//
|
||||
// Two things are pinned here:
|
||||
// 1) each JSON-emitting scanner's output maps to the right structured fields, and
|
||||
// 2) the honesty contract holds: empty / garbled / unknown input yields [] (never a
|
||||
// fabricated finding), and a parsed finding — carried through the SAME shape the
|
||||
// agent loop + operator build — PASSES the live provenance gate. That last check
|
||||
// is the point of the whole layer: structured output that the honesty spine accepts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fixture = (name: string): string =>
|
||||
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
describe('parseToolOutput — field mapping per scanner', () => {
|
||||
it('nuclei: one finding per JSONL match; skips non-JSON lines; maps cve/cwe/cvss/severity', () => {
|
||||
const f = parseToolOutput('nuclei', fixture('nuclei.jsonl'));
|
||||
expect(f).toHaveLength(2); // banner line skipped
|
||||
const log4j = f[0];
|
||||
expect(log4j.title).toBe('Apache Log4j RCE');
|
||||
expect(log4j.severity).toBe('critical');
|
||||
expect(log4j.cve).toEqual(['CVE-2021-44228']);
|
||||
expect(log4j.cwe).toEqual(['CWE-502']);
|
||||
expect(log4j.cvss).toBe(10.0);
|
||||
expect(log4j.remediation).toMatch(/2\.17\.0/);
|
||||
expect(log4j.details).toContain('target.example.com');
|
||||
expect(f[1].severity).toBe('info'); // nginx version disclosure
|
||||
});
|
||||
|
||||
it('httpx: one info finding per live host with url/status/tech in details', () => {
|
||||
const f = parseToolOutput('httpx', fixture('httpx.jsonl'));
|
||||
expect(f).toHaveLength(2);
|
||||
expect(f[0].severity).toBe('info');
|
||||
expect(f[0].title).toContain('target.example.com');
|
||||
expect(f[0].details).toContain('status: 200');
|
||||
expect(f[0].details).toContain('Nginx');
|
||||
});
|
||||
|
||||
it('dalfox: one finding per XSS probe with severity + CWE', () => {
|
||||
const f = parseToolOutput('dalfox', fixture('dalfox.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f[0].cwe).toEqual(['CWE-79']);
|
||||
expect(f[0].title).toContain('q'); // the param
|
||||
expect(f[0].details).toContain('poc');
|
||||
});
|
||||
|
||||
it('ffuf: a single aggregate finding counting discovered paths (no per-path flood)', () => {
|
||||
const f = parseToolOutput('ffuf', fixture('ffuf.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].title).toBe('ffuf: 2 path(s) discovered');
|
||||
expect(f[0].severity).toBe('info');
|
||||
expect(f[0].details).toContain('admin');
|
||||
expect(f[0].details).toContain('login');
|
||||
});
|
||||
|
||||
it('katana: a single aggregate finding counting discovered endpoints', () => {
|
||||
const f = parseToolOutput('katana', fixture('katana.jsonl'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].title).toBe('katana: 3 endpoint(s) discovered');
|
||||
expect(f[0].details).toContain('/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolOutput — honesty contract (never fabricate, never throw)', () => {
|
||||
it('returns [] for empty, whitespace, and unparseable output', () => {
|
||||
for (const raw of ['', ' ', '\n\n', 'not json at all', '{bad json', '<html>error</html>']) {
|
||||
expect(parseToolOutput('nuclei', raw)).toEqual([]);
|
||||
expect(parseToolOutput('ffuf', raw)).toEqual([]);
|
||||
expect(parseToolOutput('dalfox', raw)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns [] for a tool with no parser wired', () => {
|
||||
expect(parseToolOutput('sqlmap', fixture('nuclei.jsonl'))).toEqual([]);
|
||||
expect(parseToolOutput('metasploit', 'anything')).toEqual([]);
|
||||
expect(hasParser('sqlmap')).toBe(false);
|
||||
});
|
||||
|
||||
it('never throws on adversarial / deeply-nested / wrong-shape input', () => {
|
||||
const evil = [
|
||||
JSON.stringify({ results: 'not-an-array' }),
|
||||
'[1,2,3]',
|
||||
'{"info":null}\n{"info":{"classification":"nope"}}',
|
||||
'['.repeat(500),
|
||||
JSON.stringify({ results: [{ input: null, status: {} }] }),
|
||||
];
|
||||
for (const id of PARSED_TOOL_IDS) {
|
||||
for (const raw of evil) {
|
||||
expect(() => parseToolOutput(id, raw)).not.toThrow();
|
||||
expect(Array.isArray(parseToolOutput(id, raw))).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes exactly the wired parser ids', () => {
|
||||
expect([...PARSED_TOOL_IDS].sort()).toEqual(
|
||||
['dalfox', 'ffuf', 'gitleaks', 'grype', 'httpx', 'katana', 'nuclei', 'semgrep', 'trivy'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolOutput — source / supply-chain scanners', () => {
|
||||
it('semgrep: one finding per rule match with mapped severity + CWE token', () => {
|
||||
const f = parseToolOutput('semgrep', fixture('semgrep.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high'); // ERROR → high
|
||||
expect(f[0].cwe).toEqual(['CWE-78']); // extracted from "CWE-78: OS Command Injection"
|
||||
expect(f[0].details).toContain('app/run.py:42');
|
||||
});
|
||||
|
||||
it('trivy: one finding per package vuln with CVE, CWE, and an upgrade remediation', () => {
|
||||
const f = parseToolOutput('trivy', fixture('trivy.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f[0].cve).toEqual(['CVE-2021-3749']);
|
||||
expect(f[0].cwe).toEqual(['CWE-400']);
|
||||
expect(f[0].remediation).toContain('0.21.2');
|
||||
expect(f[0].details).toContain('axios');
|
||||
});
|
||||
|
||||
it('grype: one finding per match with CVE + package coordinates', () => {
|
||||
const f = parseToolOutput('grype', fixture('grype.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high'); // "High" → high
|
||||
expect(f[0].cve).toEqual(['CVE-2022-23529']);
|
||||
expect(f[0].details).toContain('jsonwebtoken');
|
||||
});
|
||||
|
||||
it('gitleaks: one high finding per secret, and the raw secret is REDACTED from details', () => {
|
||||
const f = parseToolOutput('gitleaks', fixture('gitleaks.json'));
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f[0].details).toContain('config/prod.env:12');
|
||||
// Defense in depth: the AWS key present in the raw report must never survive into the finding.
|
||||
expect(f[0].details).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
||||
expect(f[0].details).toContain('[redacted]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end: a parsed finding passes the live provenance gate', () => {
|
||||
// Mirror how the agent loop stamps provenance (src/agent/index.ts) and how the operator
|
||||
// materialises a ToolFinding into a Finding with output-evidence (src/operators/index.ts),
|
||||
// then run the real gate. This proves parsed output is accepted by the honesty spine.
|
||||
const materialise = (tf: ToolFinding, rawOutput: string, toolName: string): Finding => {
|
||||
const stamped = { ...tf, provenance: 'tool' as const, toolName, toolOutput: rawOutput.slice(0, 4000) };
|
||||
return {
|
||||
id: 'finding-test',
|
||||
title: stamped.title,
|
||||
description: stamped.details,
|
||||
severity: stamped.severity,
|
||||
targetId: 'target-1',
|
||||
operatorId: 'op-1',
|
||||
phase: KillChainPhase.RECON,
|
||||
cvss: stamped.cvss,
|
||||
cve: stamped.cve,
|
||||
cwe: stamped.cwe,
|
||||
evidence: stamped.provenance === 'tool'
|
||||
? [{ type: 'output', content: stamped.toolOutput || stamped.details || '', timestamp: 1, metadata: { tool: stamped.toolName } }]
|
||||
: [],
|
||||
remediation: stamped.remediation,
|
||||
discoveredAt: 1,
|
||||
};
|
||||
};
|
||||
|
||||
it('a parsed nuclei critical finding is gated PASS with provenance=tool', () => {
|
||||
const raw = fixture('nuclei.jsonl');
|
||||
const tf = parseToolOutput('nuclei', raw)[0];
|
||||
const gate = gateLiveFinding(materialise(tf, raw, 'nuclei_tool'));
|
||||
expect(gate.passed).toBe(true);
|
||||
expect(gate.provenance).toBe('tool');
|
||||
});
|
||||
|
||||
it('every parsed finding across all scanners is gate-passable (real tool evidence)', () => {
|
||||
for (const [id, file] of [
|
||||
['nuclei', 'nuclei.jsonl'], ['httpx', 'httpx.jsonl'], ['dalfox', 'dalfox.json'],
|
||||
['ffuf', 'ffuf.json'], ['katana', 'katana.jsonl'],
|
||||
['semgrep', 'semgrep.json'], ['gitleaks', 'gitleaks.json'],
|
||||
['trivy', 'trivy.json'], ['grype', 'grype.json'],
|
||||
] as const) {
|
||||
const raw = fixture(file);
|
||||
for (const tf of parseToolOutput(id, raw)) {
|
||||
const gate = gateLiveFinding(materialise(tf, raw, `${id}_tool`));
|
||||
expect(gate.passed, `${id} finding "${tf.title}" should pass the gate`).toBe(true);
|
||||
expect(gate.provenance).toBe('tool');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('factory wiring: a minted adapter returns structured findings from real stdout', () => {
|
||||
// Proves the actual code path (adapter-tools.ts success return) populates ToolResult.findings —
|
||||
// not just that the pure parser works. Deps are fakes; runSubprocess replays a fixture as stdout.
|
||||
const depsReturning = (stdout: string): AdapterToolDeps => ({
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async () => ({ stdout, stderr: '', exitCode: 0 }),
|
||||
});
|
||||
const mint = (id: string, deps: AdapterToolDeps): CustomTool => {
|
||||
const adapter = TOOL_ADAPTERS.find((a) => a.id === id);
|
||||
if (!adapter) throw new Error(`fixture: adapter '${id}' not found`);
|
||||
const tool = adapterToCustomTool(adapter, deps);
|
||||
if (!tool) throw new Error(`fixture: adapter '${id}' is not mintable`);
|
||||
return tool;
|
||||
};
|
||||
|
||||
it('nuclei_tool surfaces parsed findings (with cwe/cve) AND keeps the raw stdout as evidence', async () => {
|
||||
const raw = fixture('nuclei.jsonl');
|
||||
const res = await mint('nuclei', depsReturning(raw)).handler({ parameters: { url: 'https://target.example.com' } });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.output).toBe(raw); // raw stdout preserved as the evidence of record
|
||||
expect(res.findings).toHaveLength(2);
|
||||
expect(res.findings?.[0].cwe).toEqual(['CWE-502']);
|
||||
});
|
||||
|
||||
it('a tool whose scan yields no parseable output leaves findings unset (no fabrication)', async () => {
|
||||
const res = await mint('nuclei', depsReturning('no findings here\n')).handler({ parameters: { url: 'https://target.example.com' } });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.findings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Arsenal, BUILTIN_TOOLS, createToolContext } from '../arsenal/index.js';
|
||||
|
||||
const httpTool = BUILTIN_TOOLS.find(tool => tool.name === 'http_request');
|
||||
const technologyTool = BUILTIN_TOOLS.find(tool => tool.name === 'technology_detect');
|
||||
if (!httpTool || !technologyTool) throw new Error('Required HTTP tools are not registered');
|
||||
const originalOrigin = process.env.TEMPEST_TARGET_ORIGIN;
|
||||
const originalHeaders = process.env.TEMPEST_TARGET_HEADERS;
|
||||
|
||||
function response(status = 200, headers: Record<string, string> = {}): Response {
|
||||
const normalized = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
|
||||
return {
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Found',
|
||||
headers: {
|
||||
entries: () => normalized.entries(),
|
||||
get: (name: string) => normalized.get(name.toLowerCase()) ?? null,
|
||||
},
|
||||
text: async () => '<html></html>',
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function configure(origin = 'https://target.example') {
|
||||
process.env.TEMPEST_TARGET_ORIGIN = origin;
|
||||
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({
|
||||
Authorization: 'Bearer configured-secret',
|
||||
'X-Tenant': 'acme',
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => configure());
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOrigin === undefined) delete process.env.TEMPEST_TARGET_ORIGIN;
|
||||
else process.env.TEMPEST_TARGET_ORIGIN = originalOrigin;
|
||||
if (originalHeaders === undefined) delete process.env.TEMPEST_TARGET_HEADERS;
|
||||
else process.env.TEMPEST_TARGET_HEADERS = originalHeaders;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('target header binding', () => {
|
||||
it('injects configured headers only into the exact origin', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://other.example/api' }));
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'http://target.example/api' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
|
||||
expect(new Headers(mockFetch.mock.calls[1][1]?.headers).has('authorization')).toBe(false);
|
||||
expect(new Headers(mockFetch.mock.calls[2][1]?.headers).has('authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires both the origin binding and a valid non-empty string header map', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
delete process.env.TEMPEST_TARGET_ORIGIN;
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
|
||||
process.env.TEMPEST_TARGET_ORIGIN = 'https://target.example';
|
||||
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Authorization: 123 });
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
|
||||
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects origins with paths and transport-level headers', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
configure('https://target.example/api');
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
|
||||
configure();
|
||||
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Host: 'other.example' });
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
|
||||
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('host')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets explicit tool headers override configured headers case-insensitively', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await httpTool.handler(createToolContext(undefined, {
|
||||
url: 'https://target.example',
|
||||
headers: { authorization: 'Bearer request-secret' },
|
||||
}));
|
||||
|
||||
const sent = new Headers(mockFetch.mock.calls[0][1].headers);
|
||||
expect(sent.get('authorization')).toBe('Bearer request-secret');
|
||||
expect([...sent.keys()].filter(name => name === 'authorization')).toHaveLength(1);
|
||||
expect(sent.get('x-tenant')).toBe('acme');
|
||||
});
|
||||
|
||||
it('applies configured headers to built-in probes beyond http_request', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await technologyTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('target header redirect safety', () => {
|
||||
it('removes every configured header before a cross-origin redirect', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(response(302, { location: 'https://external.example/landing' }))
|
||||
.mockResolvedValueOnce(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('x-tenant')).toBe('acme');
|
||||
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
|
||||
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('x-tenant')).toBe(false);
|
||||
expect(String(mockFetch.mock.calls[1][0])).toBe('https://external.example/landing');
|
||||
});
|
||||
|
||||
it('keeps configured headers on same-origin redirects', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(response(302, { location: '/next' }))
|
||||
.mockResolvedValueOnce(response());
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
|
||||
|
||||
expect(new Headers(mockFetch.mock.calls[1][1].headers).get('authorization')).toBe('Bearer configured-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('target header secret handling', () => {
|
||||
it('redacts configured values from persisted tool output and findings', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(response(200, {
|
||||
'x-reflected-value': 'Bearer configured-secret',
|
||||
}));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
const arsenal = new Arsenal();
|
||||
arsenal.register(httpTool);
|
||||
|
||||
const result = await arsenal.execute('http_request', createToolContext(undefined, {
|
||||
url: 'https://target.example',
|
||||
}));
|
||||
|
||||
expect(result.output).toContain('[REDACTED]');
|
||||
expect(result.output).not.toContain('configured-secret');
|
||||
expect(arsenal.getExecutions()[0].result?.output).not.toContain('configured-secret');
|
||||
});
|
||||
|
||||
it('keeps configured curl headers out of argv and disables redirect-following flags', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
|
||||
const curl = source.slice(source.indexOf("name: 'curl_request'"));
|
||||
expect(curl).toContain("writeFile(configPath");
|
||||
expect(curl).toContain("mode: 0o600");
|
||||
expect(curl).toContain('withoutCurlRedirectFlags');
|
||||
expect(curl).not.toContain("args.push('-H', `${name}: ${value}`)");
|
||||
});
|
||||
|
||||
it('routes every built-in fetch call through the target-aware helper', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
|
||||
const builtins = source.slice(source.indexOf('export const BUILTIN_TOOLS'), source.indexOf('export const EXTERNAL_TOOLS'));
|
||||
expect(builtins).not.toMatch(/\bfetch\s*\(/);
|
||||
expect(builtins).toContain('targetFetch(');
|
||||
});
|
||||
});
|
||||
+76
-6
@@ -65,12 +65,14 @@ export interface AgentResult {
|
||||
durationMs: number;
|
||||
/** Whether the agent hit the iteration limit */
|
||||
hitLimit: boolean;
|
||||
/** Error from the forced final-summary call after hitting limits, if any */
|
||||
finalSummaryError?: string;
|
||||
}
|
||||
|
||||
export interface AgentEvents {
|
||||
'agent:step': AgentStep;
|
||||
'agent:tool_call': { name: string; args: Record<string, unknown> };
|
||||
'agent:tool_result': { name: string; result: ToolResult };
|
||||
'agent:tool_call': { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:tool_result': { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:thinking': { content: string };
|
||||
'agent:complete': AgentResult;
|
||||
'agent:error': { error: Error; step: number };
|
||||
@@ -131,6 +133,39 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext, sharedContext) },
|
||||
];
|
||||
|
||||
const bootstrapCall = this.getLocalReconBootstrap(task, target, toolDefs);
|
||||
if (bootstrapCall) {
|
||||
const toolStep = await this.executeTool(bootstrapCall, target, -1);
|
||||
steps.push(toolStep);
|
||||
seenCalls.set(
|
||||
`${bootstrapCall.name}:${JSON.stringify(bootstrapCall.arguments || {})}`,
|
||||
String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160)
|
||||
);
|
||||
|
||||
if (toolStep.toolResult?.findings) {
|
||||
const out = String(toolStep.toolResult.output ?? '').slice(0, 4000);
|
||||
for (const tf of toolStep.toolResult.findings) {
|
||||
allFindings.push({ ...tf, provenance: 'tool', toolName: bootstrapCall.name, toolOutput: out || tf.details });
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Running an initial low-noise recon check before planning deeper actions.',
|
||||
toolCalls: [bootstrapCall],
|
||||
});
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: this.formatToolResult(toolStep.toolResult),
|
||||
toolCallId: bootstrapCall.id,
|
||||
name: bootstrapCall.name,
|
||||
});
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Use the bootstrap observation above as your first tool observation. Continue through the normal ACTION CONTRACT: request another listed tool only if it will materially improve the evidence; otherwise provide the final recon debrief.',
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.options.maxIterations; i++) {
|
||||
try {
|
||||
// Ask the LLM what to do next
|
||||
@@ -267,6 +302,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
});
|
||||
|
||||
let summary = 'Agent reached iteration limit without producing a final summary.';
|
||||
let finalSummaryError: string | undefined;
|
||||
try {
|
||||
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
|
||||
summary = finalResponse.content;
|
||||
@@ -275,8 +311,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
for (const f of this.parseFinalFindings(summary)) {
|
||||
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
|
||||
}
|
||||
} catch {
|
||||
// Use what we have
|
||||
} catch (error) {
|
||||
finalSummaryError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const result: AgentResult = {
|
||||
@@ -288,12 +324,45 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
tokensUsed,
|
||||
durationMs: Date.now() - startTime,
|
||||
hitLimit,
|
||||
finalSummaryError,
|
||||
};
|
||||
|
||||
this.emit('agent:complete', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private getLocalReconBootstrap(
|
||||
task: Task,
|
||||
target: Target | undefined,
|
||||
tools: LLMToolDefinition[]
|
||||
): LLMToolCall | undefined {
|
||||
if (this.llm.getProvider() !== 'local-agent') return undefined;
|
||||
if (!target?.address) return undefined;
|
||||
if (task.phase !== 'reconnaissance' && task.operatorType !== 'recon') return undefined;
|
||||
|
||||
const hasTool = (name: string) => tools.some((tool) => tool.name === name);
|
||||
const taskName = task.name.toLowerCase();
|
||||
const id = `local-agent-bootstrap-${Date.now()}`;
|
||||
|
||||
if (taskName.includes('dns') && hasTool('dns_lookup')) {
|
||||
return { id, name: 'dns_lookup', arguments: { domain: target.address, type: 'A' } };
|
||||
}
|
||||
|
||||
if (taskName.includes('port') && hasTool('port_scan')) {
|
||||
return { id, name: 'port_scan', arguments: { target: target.address, ports: '80,443', timeout: 2000 } };
|
||||
}
|
||||
|
||||
if ((taskName.includes('web') || taskName.includes('fingerprint')) && hasTool('header_analysis')) {
|
||||
return { id, name: 'header_analysis', arguments: { url: `https://${target.address}` } };
|
||||
}
|
||||
|
||||
if ((taskName.includes('content') || taskName.includes('discover')) && hasTool('robots_txt_fetch')) {
|
||||
return { id, name: 'robots_txt_fetch', arguments: { url: `https://${target.address}` } };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call via the Arsenal
|
||||
*/
|
||||
@@ -302,7 +371,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
target: Target | undefined,
|
||||
iteration: number
|
||||
): Promise<AgentStep> {
|
||||
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
|
||||
const source = iteration < 0 ? 'backend_seeded' : 'agent';
|
||||
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments, source });
|
||||
|
||||
let toolResult: ToolResult;
|
||||
try {
|
||||
@@ -326,7 +396,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
|
||||
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult, source });
|
||||
|
||||
return {
|
||||
iteration,
|
||||
|
||||
+195
-32
@@ -13,7 +13,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import { execFile, execFileSync, spawn } from 'child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
||||
import { accessSync, constants, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
|
||||
import { homedir, tmpdir, userInfo } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -53,6 +53,16 @@ export function agentHome(): string {
|
||||
}
|
||||
const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p);
|
||||
|
||||
/**
|
||||
* Resolve a path under the OS-native per-user local app-data root, cross-platform:
|
||||
* - Windows: %LOCALAPPDATA% (e.g. C:\Users\<u>\AppData\Local), where the Hermes desktop app
|
||||
* stores its runtime auth under %LOCALAPPDATA%\hermes\ rather than ~/.hermes/.
|
||||
* - POSIX: <agentHome>/AppData/Local fallback (harmless: the artifact simply won't exist there).
|
||||
* PRESENCE-ONLY: callers pass the result to existsSync(); contents are never read.
|
||||
*/
|
||||
const localAppData = (rel: string): string =>
|
||||
join(process.env.LOCALAPPDATA || join(agentHome(), 'AppData', 'Local'), ...rel.split('/'));
|
||||
|
||||
/**
|
||||
* Env for a spawned agent CLI. Two adjustments to our own env:
|
||||
* - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's).
|
||||
@@ -126,7 +136,17 @@ const SPECS: AgentSpec[] = [
|
||||
versionArgs: ['--version'],
|
||||
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||
authArtifacts: ['~/.codex/auth.json', '~/.config/codex/auth.json'],
|
||||
oneShot: (p, m) => ['exec', ...(m ? ['-m', m] : []), p],
|
||||
oneShot: (p, m) => [
|
||||
'exec',
|
||||
'--ephemeral',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'--color',
|
||||
'never',
|
||||
...(m ? ['-m', m] : []),
|
||||
p,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
@@ -137,7 +157,10 @@ const SPECS: AgentSpec[] = [
|
||||
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
|
||||
versionArgs: ['--version'],
|
||||
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||
authArtifacts: ['~/.hermes/.env'],
|
||||
// Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes\ (NOT ~/.hermes/), so
|
||||
// checking only ~/.hermes/ made an authed Windows install read as NOT AUTHED. Presence-only check;
|
||||
// ~/.hermes/auth.json is upstream's POSIX/mac auth artifact, kept alongside the Windows paths.
|
||||
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')],
|
||||
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
|
||||
},
|
||||
];
|
||||
@@ -172,12 +195,103 @@ export interface AgentDetection {
|
||||
ready: boolean; // installed && authed
|
||||
}
|
||||
|
||||
function resolvePath(bin: string): string | undefined {
|
||||
try {
|
||||
return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined;
|
||||
} catch {
|
||||
try { return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
|
||||
const isWin32 = (): boolean => process.platform === 'win32';
|
||||
// Kept as a constructed RegExp so the source never embeds a raw CRLF inside a regex literal.
|
||||
const NEWLINE_RE = new RegExp('\\r?\\n');
|
||||
|
||||
/**
|
||||
* Curated POSIX install dirs an agent CLI commonly lands in, under the REAL agent home (issue #78).
|
||||
*
|
||||
* The bug: a T3MP3ST server launched from Finder / the desktop app / any non-interactive shell
|
||||
* inherits launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). That omits every place a CLI
|
||||
* actually installs — so `execFile('claude', …)` throws ENOENT and a working, authed CLI reads as
|
||||
* `installed:false`. We recover it by ALSO scanning these dirs (they are where Claude Code's native
|
||||
* installer, Homebrew, npm-global, and the version managers put their bins). Anchored to agentHome()
|
||||
* — NOT os.homedir() — so a HOME redirect can't hide them (same rationale as `agentHome`).
|
||||
*/
|
||||
function wellKnownBinDirs(home: string): string[] {
|
||||
const dirs = [
|
||||
join(home, '.local', 'bin'), // Claude Code native installer, pipx, mise
|
||||
join(home, '.local', 'share', 'mise', 'shims'), // mise
|
||||
join(home, '.asdf', 'shims'), // asdf
|
||||
'/opt/homebrew/bin', // Homebrew (Apple Silicon)
|
||||
'/usr/local/bin', // Homebrew (Intel) / manual installs
|
||||
join(home, '.bun', 'bin'), // bun
|
||||
join(home, '.deno', 'bin'), // deno
|
||||
join(home, '.volta', 'bin'), // volta
|
||||
join(home, '.npm-global', 'bin'), // npm prefix override
|
||||
join(home, '.yarn', 'bin'), // yarn global
|
||||
join(home, 'Library', 'pnpm'), // pnpm (macOS default)
|
||||
join(home, '.local', 'share', 'pnpm'), // pnpm (XDG)
|
||||
'/opt/local/bin', // MacPorts
|
||||
];
|
||||
// Version managers keep a per-version bin dir — enumerate the installed versions so an
|
||||
// npm-global CLI (e.g. codex) under the active node still resolves under a minimal PATH.
|
||||
for (const nodeVersions of [join(home, '.nvm', 'versions', 'node')]) {
|
||||
try { for (const v of readdirSync(nodeVersions)) dirs.push(join(nodeVersions, v, 'bin')); } catch { /* not present */ }
|
||||
}
|
||||
for (const fnmDir of [join(home, '.local', 'share', 'fnm', 'node-versions'),
|
||||
join(home, 'Library', 'Application Support', 'fnm', 'node-versions')]) {
|
||||
try { for (const v of readdirSync(fnmDir)) dirs.push(join(fnmDir, v, 'installation', 'bin')); } catch { /* not present */ }
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to `bin`, resolved WITHOUT an operator-controlled shell:
|
||||
* - Windows: where.exe honors PATHEXT, preferring .exe > .cmd > .bat > first hit.
|
||||
* - POSIX: PATH plus well-known install dirs under agentHome() (issue #78).
|
||||
*
|
||||
* POSIX fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw the
|
||||
* same ENOENT (→ installed:false) they did before. Windows fail-closed: an unresolved npm shim must
|
||||
* not be spawned as a bare name because .cmd/.bat launches need explicit shell handling.
|
||||
*/
|
||||
export function resolveBin(bin: string): string | undefined {
|
||||
if (isWin32()) {
|
||||
try {
|
||||
const out = execFileSync('where.exe', [bin], { encoding: 'utf8', timeout: 5000 });
|
||||
const hits = out.split(NEWLINE_RE).map((h) => h.trim()).filter(Boolean);
|
||||
return (
|
||||
hits.find((h) => /\.exe$/i.test(h)) ??
|
||||
hits.find((h) => /\.cmd$/i.test(h)) ??
|
||||
hits.find((h) => /\.bat$/i.test(h)) ??
|
||||
hits[0]
|
||||
);
|
||||
} catch { return undefined; }
|
||||
}
|
||||
if (bin.includes('/')) return bin;
|
||||
const path = process.env.PATH || '';
|
||||
for (const dir of [...path.split(':'), ...wellKnownBinDirs(agentHome())]) {
|
||||
// Absolute dirs only: skips blank PATH segments (whose POSIX "= cwd" meaning is a known
|
||||
// CWD-execution vector) and relative segments (which would leak a non-absolute `path`).
|
||||
if (!dir.startsWith('/')) continue;
|
||||
const cand = join(dir, bin);
|
||||
try {
|
||||
// X_OK alone also passes for a DIRECTORY named `bin` (dirs carry the search bit); the OS's own
|
||||
// execvp skips such a directory and searches on, so require a regular file to match that and
|
||||
// avoid returning a dir that execFile would then reject with EACCES (a false installed:true).
|
||||
accessSync(cand, constants.X_OK);
|
||||
if (statSync(cand).isFile()) return cand;
|
||||
} catch { /* missing, not executable, or not a regular file — keep scanning */ }
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows-safe launcher for a resolved agent binary.
|
||||
*
|
||||
* A `.cmd`/`.bat` shim can't be spawned with shell:false on Windows. It must run through cmd.exe, but
|
||||
* hand-rolling `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])` is unsafe because cmd.exe re-parses
|
||||
* the command tail with its own quoting rules. Letting Node launch the resolved shim with shell:true
|
||||
* uses Node's patched argument escaping path, so adversarial prompt text stays a literal argument.
|
||||
*/
|
||||
function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess {
|
||||
if (needsShell(resolvedBin)) return spawn(resolvedBin, args, { ...options, shell: true });
|
||||
return spawn(resolvedBin, args, { ...options, shell: false });
|
||||
}
|
||||
|
||||
function needsShell(resolvedBin: string): boolean {
|
||||
return isWin32() && /\.(cmd|bat)$/i.test(resolvedBin);
|
||||
}
|
||||
|
||||
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
|
||||
@@ -186,7 +300,10 @@ function authState(spec: AgentSpec): { authed: boolean; method?: string } {
|
||||
}
|
||||
if (spec.keychainService) {
|
||||
try {
|
||||
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore' });
|
||||
// timeout: a headless/SSH/locked-keychain session (the same non-interactive launch context
|
||||
// as #78) can make `security` block on an ACL prompt; bound it so detection can't hang the
|
||||
// event loop. A timeout throws → treated as "not in keychain" (fail-open, same as absence).
|
||||
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore', timeout: 2000 });
|
||||
return { authed: true, method: 'keychain' };
|
||||
} catch { /* not in keychain */ }
|
||||
}
|
||||
@@ -198,25 +315,45 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
|
||||
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
|
||||
blurb: spec.blurb, invokeHint: spec.invokeHint,
|
||||
};
|
||||
// POSIX falls back to the bare name when unresolved; Windows keeps `resolved` authoritative because
|
||||
// bare npm .cmd/.bat shims require explicit shell handling after where.exe resolution.
|
||||
const exe = resolveBin(spec.bin) || (isWin32() ? undefined : spec.bin);
|
||||
return new Promise((resolve) => {
|
||||
execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||
return;
|
||||
}
|
||||
// even a non-zero exit but no ENOENT means the binary exists
|
||||
const version = spec.parseVersion(String(stdout || ''));
|
||||
if (!exe) {
|
||||
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||
return;
|
||||
}
|
||||
const installedNoVersion = () => {
|
||||
const auth = authState(spec);
|
||||
resolve({
|
||||
...base,
|
||||
installed: true,
|
||||
path: resolvePath(spec.bin),
|
||||
version,
|
||||
authed: auth.authed,
|
||||
authMethod: auth.method,
|
||||
ready: auth.authed,
|
||||
...base, installed: true, path: exe !== spec.bin ? exe : undefined, version: '?',
|
||||
authed: auth.authed, authMethod: auth.method, ready: auth.authed,
|
||||
});
|
||||
});
|
||||
};
|
||||
try {
|
||||
execFile(exe, spec.versionArgs, { timeout: 8000, shell: needsShell(exe) }, (err, stdout) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||
return;
|
||||
}
|
||||
// even a non-zero exit but no ENOENT means the binary exists
|
||||
const version = spec.parseVersion(String(stdout || ''));
|
||||
const auth = authState(spec);
|
||||
resolve({
|
||||
...base,
|
||||
installed: true,
|
||||
path: exe !== spec.bin ? exe : undefined,
|
||||
version,
|
||||
authed: auth.authed,
|
||||
authMethod: auth.method,
|
||||
ready: auth.authed,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Synchronous spawn failures (for example restricted child-process creation) should not reject
|
||||
// the whole detection batch; the binary exists, but its version could not be probed.
|
||||
installedNoVersion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,7 +363,15 @@ export async function detectLocalAgents(): Promise<AgentDetection[]> {
|
||||
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
|
||||
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
|
||||
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
|
||||
return Promise.all(SPECS.map(detectOne));
|
||||
const settled = await Promise.allSettled(SPECS.map(detectOne));
|
||||
return settled.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
const s = SPECS[i];
|
||||
return {
|
||||
id: s.id, label: s.label, vendor: s.vendor, bin: s.bin, blurb: s.blurb,
|
||||
invokeHint: s.invokeHint, installed: false, authed: false, ready: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
@@ -236,6 +381,18 @@ export interface AgentRunResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function agentFailureOutput(output: string): string | null {
|
||||
const text = output.trim();
|
||||
if (/^API call failed\b/i.test(text)) return text.slice(0, 300);
|
||||
if (/connection error/i.test(text) && /failed/i.test(text)) return text.slice(0, 300);
|
||||
return null;
|
||||
}
|
||||
|
||||
function envTimeoutMs(name: string, fallback: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota).
|
||||
* Used by /ping (liveness proof) and /dispatch (actually using the operator).
|
||||
@@ -249,13 +406,14 @@ export function runLocalAgent(
|
||||
const t0 = Date.now();
|
||||
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
|
||||
const args = spec.oneShot(prompt, opts.model);
|
||||
const timeoutMs = opts.timeoutMs ?? 120000;
|
||||
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
|
||||
const maxChars = opts.maxChars ?? 4000;
|
||||
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
|
||||
const env = childEnv();
|
||||
const resolvedBin = resolveBin(spec.bin) || spec.bin;
|
||||
return new Promise((resolve) => {
|
||||
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
|
||||
const child = spawn(spec.bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawnAgent(resolvedBin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let errOut = '';
|
||||
let done = false;
|
||||
@@ -270,7 +428,9 @@ export function runLocalAgent(
|
||||
child.on('close', (code) => {
|
||||
const latencyMs = Date.now() - t0;
|
||||
const output = out.trim().slice(0, maxChars);
|
||||
if (code === 0) finish({ ok: true, latencyMs, output });
|
||||
const semanticError = agentFailureOutput(output);
|
||||
if (code === 0 && !semanticError) finish({ ok: true, latencyMs, output });
|
||||
else if (semanticError) finish({ ok: false, latencyMs, output, error: semanticError });
|
||||
else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
|
||||
});
|
||||
});
|
||||
@@ -297,7 +457,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
|
||||
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
|
||||
const env = childEnv();
|
||||
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
|
||||
const timeoutMs = opts.timeoutMs ?? 240000;
|
||||
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
|
||||
|
||||
let args: string[];
|
||||
let viaStdin = true;
|
||||
@@ -308,15 +468,16 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
|
||||
} else if (id === 'codex') {
|
||||
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
|
||||
outFile = join(workDir, 'reply.txt');
|
||||
args = ['exec', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
|
||||
args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
|
||||
} else { // hermes — takes the prompt as an arg
|
||||
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
|
||||
viaStdin = false;
|
||||
}
|
||||
|
||||
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
|
||||
const resolvedBin = resolveBin(spec.bin) || spec.bin;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
||||
const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let errOut = '';
|
||||
let done = false;
|
||||
@@ -329,7 +490,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
|
||||
let content = out.trim();
|
||||
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
|
||||
finish(() => {
|
||||
if (code === 0 && content) resolve(content);
|
||||
const semanticError = agentFailureOutput(content);
|
||||
if (code === 0 && content && !semanticError) resolve(content);
|
||||
else if (semanticError) reject(new Error(semanticError));
|
||||
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
|
||||
});
|
||||
});
|
||||
|
||||
+16
-14
@@ -18,6 +18,7 @@ import type { MissionControl } from '../mission/index.js';
|
||||
import type { OpsecController } from '../opsec/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { SEVERITY_SCORES } from '../evidence/index.js';
|
||||
import { redactString } from '../redact.js';
|
||||
|
||||
// =============================================================================
|
||||
// ANALYSIS ENGINE
|
||||
@@ -251,7 +252,7 @@ export class AnalysisEngine {
|
||||
// Executive Summary
|
||||
lines.push('## Executive Summary');
|
||||
lines.push('');
|
||||
lines.push(report.summary.overview);
|
||||
lines.push(redactString(report.summary.overview));
|
||||
lines.push('');
|
||||
lines.push('### Risk Overview');
|
||||
lines.push('');
|
||||
@@ -282,26 +283,27 @@ export class AnalysisEngine {
|
||||
);
|
||||
|
||||
for (const finding of sortedFindings) {
|
||||
lines.push(`### ${finding.title}`);
|
||||
lines.push(`### ${redactString(finding.title)}`);
|
||||
lines.push('');
|
||||
lines.push(`**Severity:** ${finding.severity.toUpperCase()}`);
|
||||
if (finding.cvss) lines.push(`**CVSS:** ${finding.cvss}`);
|
||||
if (finding.cve?.length) lines.push(`**CVE:** ${finding.cve.join(', ')}`);
|
||||
lines.push('');
|
||||
lines.push('**Description:**');
|
||||
lines.push(finding.description);
|
||||
lines.push(redactString(finding.description));
|
||||
lines.push('');
|
||||
|
||||
if (finding.remediation) {
|
||||
lines.push('**Remediation:**');
|
||||
lines.push(finding.remediation);
|
||||
lines.push(redactString(finding.remediation));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (finding.evidence.length > 0) {
|
||||
lines.push('**Evidence:**');
|
||||
for (const evidence of finding.evidence) {
|
||||
lines.push(`- ${evidence.type}: \`${evidence.content.substring(0, 100)}${evidence.content.length > 100 ? '...' : ''}\``);
|
||||
const content = redactString(evidence.content);
|
||||
lines.push(`- ${evidence.type}: \`${content.substring(0, 100)}${content.length > 100 ? '...' : ''}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -313,15 +315,15 @@ export class AnalysisEngine {
|
||||
lines.push('');
|
||||
|
||||
for (const path of report.attackPaths) {
|
||||
lines.push(`### ${path.name}`);
|
||||
lines.push(`### ${redactString(path.name)}`);
|
||||
lines.push('');
|
||||
lines.push(`**Impact Level:** ${path.impactLevel.toUpperCase()}`);
|
||||
lines.push('');
|
||||
lines.push(path.description);
|
||||
lines.push(redactString(path.description));
|
||||
lines.push('');
|
||||
lines.push('**Steps:**');
|
||||
for (let i = 0; i < path.steps.length; i++) {
|
||||
lines.push(`${i + 1}. ${path.steps[i]}`);
|
||||
lines.push(`${i + 1}. ${redactString(path.steps[i])}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -342,8 +344,8 @@ export class AnalysisEngine {
|
||||
lines.push('### Immediate Priority');
|
||||
lines.push('');
|
||||
for (const rec of byPriority.immediate) {
|
||||
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${rec.description}`);
|
||||
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${redactString(rec.description)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -352,8 +354,8 @@ export class AnalysisEngine {
|
||||
lines.push('### Short-Term Priority');
|
||||
lines.push('');
|
||||
for (const rec of byPriority.short_term) {
|
||||
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${rec.description}`);
|
||||
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${redactString(rec.description)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -362,8 +364,8 @@ export class AnalysisEngine {
|
||||
lines.push('### Long-Term Priority');
|
||||
lines.push('');
|
||||
for (const rec of byPriority.long_term) {
|
||||
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${rec.description}`);
|
||||
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
|
||||
lines.push(` ${redactString(rec.description)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
import type { ToolAdapter } from './catalog.js';
|
||||
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
|
||||
import { parseToolOutput } from './parsers.js';
|
||||
|
||||
// =============================================================================
|
||||
// INJECTED DEPENDENCIES
|
||||
@@ -78,6 +79,39 @@ interface ArgTemplate {
|
||||
const str = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
||||
|
||||
/**
|
||||
* Resolve the filesystem PATH a source/supply-chain scanner should run against (semgrep, gitleaks,
|
||||
* trivy, …). These adapters are non-networked and operate on a directory, defaulting to the working
|
||||
* dir (`.`). The subcommand + output flags in each template are HARDCODED — only this path is
|
||||
* tunable, and it is sanitised so it cannot smuggle a flag or an inherited URL past the gate:
|
||||
* - a leading `-` would be reparsed as a scanner flag (arg injection) → fall back to `.`,
|
||||
* - an http(s) URL (e.g. a networked mission target inherited via context.target.address) is not a
|
||||
* scan path → fall back to `.`.
|
||||
*/
|
||||
const scanPath = (target: string, params: Record<string, unknown>): string => {
|
||||
const p = str(params.path) ?? str(params.target) ?? (target || undefined);
|
||||
if (!p || /^-/.test(p) || /^https?:\/\//i.test(p)) return '.';
|
||||
return p;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the local FILE/artifact a reverse-engineering or mobile analyser runs against (objdump,
|
||||
* readelf, r2, myth, apkleaks, …). Unlike a directory scanner, a binary tool has no sensible cwd
|
||||
* default — a bare `objdump .` is meaningless — so instead of silently degrading this REFUSES an
|
||||
* absent or non-local path by throwing, which the factory handler turns into a clean
|
||||
* `{ success:false }` result (never an unhandled rejection). The two rejected shapes:
|
||||
* - no path at all → the tool would spawn `<binary> ''` / `<binary>` and produce nothing,
|
||||
* - an http(s) URL (e.g. a networked mission target inherited via context.target.address) → not a
|
||||
* local artifact. (A leading `-` is already refused upstream by the factory's option-looking-
|
||||
* target guard, since these templates read the artifact from their `targetParam`.)
|
||||
*/
|
||||
const artifactPath = (target: string, params: Record<string, unknown>): string => {
|
||||
const p = str(params.file) ?? str(params.path) ?? str(params.artifact) ?? (target || undefined);
|
||||
if (!p) throw new Error('requires a file/artifact path (none was provided).');
|
||||
if (/^https?:\/\//i.test(p)) throw new Error(`'${p}' is a URL, not a local artifact path.`);
|
||||
return p;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-binary arg templates for the common command-ready adapters. Keyed by `adapter.binary` (the
|
||||
* process name), with `adapter.id` also accepted as a fallback key so callers can template by either.
|
||||
@@ -217,6 +251,111 @@ const ARG_TEMPLATES: Record<string, ArgTemplate> = {
|
||||
return args;
|
||||
},
|
||||
},
|
||||
|
||||
// ── Source / supply-chain scanners (non-networked, operate on a PATH) ───────────────────────────
|
||||
// Without these, each falls through to DEFAULT_TEMPLATE and spawns `<binary> <target>` — which for
|
||||
// a subcommand-driven scanner is a broken invocation (e.g. `semgrep .` never runs a scan, and with
|
||||
// no path it is `semgrep ''`). Templates mirror the catalog `commandHint` and hardcode the
|
||||
// subcommand + machine-readable output flags; the only tunable is the scan path (see scanPath).
|
||||
semgrep: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => ['scan', '--config', 'auto', '--json', scanPath(target, params)],
|
||||
},
|
||||
gitleaks: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['detect', '--source', scanPath(target, params), '--report-format', 'json', '--redact', '--no-banner'],
|
||||
},
|
||||
trufflehog: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => ['filesystem', scanPath(target, params), '--json', '--no-update'],
|
||||
},
|
||||
trivy: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => ['fs', '--format', 'json', scanPath(target, params)],
|
||||
},
|
||||
syft: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'cyclonedx-json'],
|
||||
},
|
||||
grype: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'json'],
|
||||
},
|
||||
checkov: {
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['-d', scanPath(target, params), '-o', 'json'],
|
||||
},
|
||||
|
||||
// ── Reverse-engineering / mobile / smart-contract static analysis (local_read, operate on a FILE) ─
|
||||
// Without these each falls through to DEFAULT_TEMPLATE and spawns `<binary> <file>` — which for a
|
||||
// subcommand- or flag-driven analyser is a broken (or, for r2, an INTERACTIVE-shell-hanging)
|
||||
// invocation. Templates mirror the catalog `commandHint` and hardcode the READ-ONLY subcommand +
|
||||
// machine-readable output flags; the only tunable is the artifact/scan path (see artifactPath /
|
||||
// scanPath). None add an intrusive or code-executing flag — risk stays where the catalog put it.
|
||||
objdump: {
|
||||
// `objdump <file>` errors ("no options given"); disassembly needs an action flag.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 60_000,
|
||||
build: (target, params) => ['-d', '-M', 'intel', artifactPath(target, params)],
|
||||
},
|
||||
readelf: {
|
||||
// `readelf <file>` errors ("Warning: Nothing to do"); `-a` dumps all ELF headers.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 60_000,
|
||||
build: (target, params) => ['-a', artifactPath(target, params)],
|
||||
},
|
||||
checksec: {
|
||||
// checksec takes its subject as `--file=<path>`, not a bare positional.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target, params) => ['--file=' + artifactPath(target, params)],
|
||||
},
|
||||
r2: {
|
||||
// A bare `r2 <file>` drops into an INTERACTIVE prompt and blocks the agent loop until the
|
||||
// per-adapter timeout burns out. `-q -c ij` runs ONE read-only info command as JSON, then quits;
|
||||
// `scr.color=0` keeps the JSON free of ANSI escapes. No `w`/`!`/analysis-write commands are used.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 60_000,
|
||||
build: (target, params) => ['-q', '-e', 'scr.color=0', '-c', 'ij', artifactPath(target, params)],
|
||||
},
|
||||
exiftool: {
|
||||
// `exiftool <file>` works but prints human text; `-json` gives a structured, parseable channel.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target, params) => ['-json', artifactPath(target, params)],
|
||||
},
|
||||
myth: {
|
||||
// `myth <file>` errors; Mythril needs the `analyze` subcommand. `-o json` emits machine output.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => ['analyze', artifactPath(target, params), '-o', 'json'],
|
||||
},
|
||||
apkleaks: {
|
||||
// `apkleaks <file>` errors; the APK is supplied via `-f`. Results print to stdout.
|
||||
targetParam: 'file',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['-f', artifactPath(target, params)],
|
||||
},
|
||||
slither: {
|
||||
// Slither accepts a positional target, but a bare run prints human text; `--json -` streams the
|
||||
// findings as JSON to stdout. Directory-oriented, so it may default to the working dir.
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => [scanPath(target, params), '--json', '-'],
|
||||
},
|
||||
mobsfscan: {
|
||||
// Directory-oriented static scanner; `--json` gives a structured channel over the default text.
|
||||
targetParam: 'path',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target, params) => ['--json', scanPath(target, params)],
|
||||
},
|
||||
};
|
||||
|
||||
/** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */
|
||||
@@ -233,6 +372,16 @@ function resolveTemplate(adapter: ToolAdapter): ArgTemplate {
|
||||
return ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id] ?? DEFAULT_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the adapter has a bespoke `ARG_TEMPLATE` (a real, hardcoded invocation) rather than
|
||||
* falling through to `DEFAULT_TEMPLATE`'s bare `<binary> <target>`. Exported so the invocation-honesty
|
||||
* guard test can assert that every mintable adapter's invocation correctness is explicitly classified
|
||||
* — and that a newly-catalogued subcommand tool cannot silently ship a broken positional invocation.
|
||||
*/
|
||||
export function hasArgTemplate(adapter: ToolAdapter): boolean {
|
||||
return Boolean(ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target string for an adapter call: the template's preferred param first, then the other
|
||||
* common target keys, then `context.target.address`. Returns undefined if none is present.
|
||||
@@ -322,7 +471,16 @@ export function adapterToCustomTool(adapter: ToolAdapter, deps: AdapterToolDeps)
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: result.stdout };
|
||||
// Populate the structured `findings` channel from the raw stdout when a parser is wired for
|
||||
// this tool (parseToolOutput → [] otherwise). The raw stdout is ALWAYS kept as `output` — it
|
||||
// is the evidence of record the agent loop stamps onto each finding and the live gate checks;
|
||||
// the parser summarises it, never replaces it. A parse yielding nothing leaves findings unset.
|
||||
const findings = parseToolOutput(adapter.id, result.stdout);
|
||||
return {
|
||||
success: true,
|
||||
output: result.stdout,
|
||||
...(findings.length ? { findings } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -179,7 +179,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['jsonl', 'text'],
|
||||
installHint: 'brew install projectdiscovery/tap/httpx',
|
||||
commandHint: 'httpx -u https://example.com -status-code -title -tech-detect -json',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'HTTP probing and technology detection for discovered assets.',
|
||||
},
|
||||
{
|
||||
@@ -211,7 +211,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['jsonl', 'text'],
|
||||
installHint: 'brew install projectdiscovery/tap/katana',
|
||||
commandHint: 'katana -u https://example.com -jsonl -silent',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'Crawler for endpoint discovery and attack-surface mapping.',
|
||||
},
|
||||
{
|
||||
@@ -307,7 +307,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['json', 'text'],
|
||||
installHint: 'brew install dalfox',
|
||||
commandHint: 'dalfox url https://example.com?q=test -o result.json --format json',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'XSS validation adapter for scoped endpoints.',
|
||||
},
|
||||
{
|
||||
@@ -339,7 +339,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['json', 'sarif'],
|
||||
installHint: 'brew install semgrep or pipx install semgrep',
|
||||
commandHint: 'semgrep scan --config auto --json .',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'Static analysis backbone for repository hardening.',
|
||||
},
|
||||
{
|
||||
@@ -355,7 +355,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['json', 'sarif'],
|
||||
installHint: 'brew install gitleaks',
|
||||
commandHint: 'gitleaks detect --source . --report-format json --redact',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'Secret detection must redact values before ledger insertion.',
|
||||
},
|
||||
{
|
||||
@@ -387,7 +387,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['json', 'sarif'],
|
||||
installHint: 'brew install aquasecurity/trivy/trivy',
|
||||
commandHint: 'trivy fs --format json .',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'Vulnerability and IaC scanner for repos and images.',
|
||||
},
|
||||
{
|
||||
@@ -419,7 +419,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
|
||||
outputFormats: ['json'],
|
||||
installHint: 'brew install grype',
|
||||
commandHint: 'grype dir:. -o json',
|
||||
parserStatus: 'planned',
|
||||
parserStatus: 'structured',
|
||||
notes: 'Pairs well with Syft SBOMs for dependency vulnerability triage.',
|
||||
},
|
||||
{
|
||||
|
||||
+186
-37
@@ -7,6 +7,9 @@
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns';
|
||||
@@ -61,6 +64,115 @@ async function checkPort(host: string, port: number, timeout: number = 2000): Pr
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TARGET HEADER INJECTION
|
||||
// =============================================================================
|
||||
|
||||
interface TargetHeaderConfig {
|
||||
origin: string;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
const FORBIDDEN_TARGET_HEADERS = new Set([
|
||||
'connection', 'content-length', 'host', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||||
]);
|
||||
|
||||
/** Parse an exact-origin binding and its default headers. Invalid configuration fails closed. */
|
||||
function parseTargetHeaderConfig(): TargetHeaderConfig | null {
|
||||
const rawOrigin = process.env.TEMPEST_TARGET_ORIGIN?.trim();
|
||||
const rawHeaders = process.env.TEMPEST_TARGET_HEADERS?.trim();
|
||||
if (!rawOrigin || !rawHeaders) return null;
|
||||
|
||||
try {
|
||||
const target = new URL(rawOrigin);
|
||||
if (!['http:', 'https:'].includes(target.protocol)
|
||||
|| target.username || target.password
|
||||
|| target.pathname !== '/' || target.search || target.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(rawHeaders);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== 'string' || FORBIDDEN_TARGET_HEADERS.has(name.toLowerCase())) return null;
|
||||
headers.set(name, value);
|
||||
}
|
||||
return headers.keys().next().done ? null : { origin: target.origin, headers };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function targetHeadersForUrl(url: string | URL, explicit?: RequestInit['headers']): Headers | undefined {
|
||||
const merged = new Headers();
|
||||
const config = parseTargetHeaderConfig();
|
||||
try {
|
||||
if (config && new URL(url).origin === config.origin) {
|
||||
config.headers.forEach((value, name) => merged.set(name, value));
|
||||
}
|
||||
} catch {
|
||||
// Let fetch or curl report malformed URLs; they simply receive no configured secrets.
|
||||
}
|
||||
new Headers(explicit).forEach((value, name) => merged.set(name, value));
|
||||
return merged.keys().next().done ? undefined : merged;
|
||||
}
|
||||
|
||||
function redactConfiguredSecrets(result: ToolResult): ToolResult {
|
||||
const config = parseTargetHeaderConfig();
|
||||
const secrets = config ? [...config.headers.values()].filter(Boolean) : [];
|
||||
if (secrets.length === 0) return result;
|
||||
|
||||
const redact = (value: unknown): unknown => {
|
||||
if (typeof value === 'string') {
|
||||
return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redact(nested)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return redact(result) as ToolResult;
|
||||
}
|
||||
|
||||
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||
const CROSS_ORIGIN_CREDENTIAL_HEADERS = ['authorization', 'cookie', 'cookie2', 'proxy-authorization'];
|
||||
|
||||
/** Apply exact-origin headers to every built-in fetch without leaking them across redirects. */
|
||||
async function targetFetch(url: string | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const config = parseTargetHeaderConfig();
|
||||
let currentUrl = new URL(url);
|
||||
if (!config || currentUrl.origin !== config.origin) return globalThis.fetch(url, init);
|
||||
|
||||
const explicitHeaders = new Headers(init.headers);
|
||||
let method = (init.method || 'GET').toUpperCase();
|
||||
let body = init.body;
|
||||
|
||||
for (let redirects = 0; redirects <= 20; redirects++) {
|
||||
const headers = targetHeadersForUrl(currentUrl, explicitHeaders);
|
||||
const response = await globalThis.fetch(currentUrl, { ...init, method, body, headers, redirect: 'manual' });
|
||||
const location = response.headers.get('location');
|
||||
if (!REDIRECT_STATUS.has(response.status) || !location) return response;
|
||||
if (redirects === 20) throw new Error('Target request exceeded 20 redirects');
|
||||
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (nextUrl.origin !== currentUrl.origin) {
|
||||
config.headers.forEach((_value, name) => explicitHeaders.delete(name));
|
||||
CROSS_ORIGIN_CREDENTIAL_HEADERS.forEach(name => explicitHeaders.delete(name));
|
||||
}
|
||||
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
||||
method = 'GET';
|
||||
body = undefined;
|
||||
['content-encoding', 'content-language', 'content-location', 'content-type'].forEach(name => explicitHeaders.delete(name));
|
||||
}
|
||||
currentUrl = nextUrl;
|
||||
}
|
||||
throw new Error('Target request redirect handling failed');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVENTS
|
||||
// =============================================================================
|
||||
@@ -301,7 +413,7 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await tool.handler(context);
|
||||
const result = redactConfiguredSecrets(await tool.handler(context));
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
execution.completedAt = Date.now();
|
||||
@@ -312,7 +424,10 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
return result;
|
||||
} catch (error) {
|
||||
execution.completedAt = Date.now();
|
||||
execution.error = error instanceof Error ? error.message : String(error);
|
||||
execution.error = redactConfiguredSecrets({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).error;
|
||||
|
||||
this.emit('tool:error', { tool, error: error as Error });
|
||||
|
||||
@@ -748,11 +863,11 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
handler: async (context) => {
|
||||
const url = context.parameters.url as string;
|
||||
const method = (context.parameters.method as string) || 'GET';
|
||||
const headers = (context.parameters.headers as Record<string, string>) || {};
|
||||
const headers = context.parameters.headers as Record<string, string> | undefined;
|
||||
const body = context.parameters.body as string | undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body && method !== 'GET' ? body : undefined,
|
||||
@@ -787,7 +902,10 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
'Permissions-Policy', 'Cross-Origin-Opener-Policy', 'Cross-Origin-Resource-Policy',
|
||||
];
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
|
||||
const response = await targetFetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const analysis = securityHeaders.map(h => {
|
||||
const value = response.headers.get(h);
|
||||
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
|
||||
@@ -834,7 +952,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
batch.map(async (path) => {
|
||||
const fullUrl = `${baseUrl}/${path}`;
|
||||
try {
|
||||
const response = await fetch(fullUrl, {
|
||||
const response = await targetFetch(fullUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
redirect: 'manual',
|
||||
@@ -895,7 +1013,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
const url = context.parameters.url as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
@@ -1022,7 +1140,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
const testUrl = new URL(baseUrl);
|
||||
testUrl.searchParams.set(param, payload);
|
||||
|
||||
const response = await fetch(testUrl.toString(), {
|
||||
const response = await targetFetch(testUrl.toString(), {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = await response.text();
|
||||
@@ -1099,7 +1217,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
try {
|
||||
const baselineUrl = new URL(baseUrl);
|
||||
baselineUrl.searchParams.set(param, 'normalvalue');
|
||||
const baselineResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const baselineResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const baselineBody = await baselineResp.text();
|
||||
baselineLength = baselineBody.length;
|
||||
} catch {
|
||||
@@ -1111,7 +1229,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
const testUrl = new URL(baseUrl);
|
||||
testUrl.searchParams.set(param, payload);
|
||||
|
||||
const response = await fetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const response = await targetFetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const body = await response.text();
|
||||
const bodyLower = body.toLowerCase();
|
||||
|
||||
@@ -1321,7 +1439,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
baselineBody.set(usernameField, username);
|
||||
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
|
||||
|
||||
const baselineResp = await fetch(url, {
|
||||
const baselineResp = await targetFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: baselineBody.toString(),
|
||||
@@ -1345,7 +1463,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
body.set(usernameField, username);
|
||||
body.set(passwordField, password);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
@@ -1605,7 +1723,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
|
||||
try {
|
||||
const robotsUrl = `${baseUrl}/robots.txt`;
|
||||
const response = await fetch(robotsUrl, {
|
||||
const response = await targetFetch(robotsUrl, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
@@ -1766,7 +1884,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
|
||||
try {
|
||||
// Fetch main page
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const headers = Object.fromEntries(response.headers.entries());
|
||||
@@ -1840,7 +1958,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
|
||||
const probePromises = versionPaths.map(async (vp) => {
|
||||
try {
|
||||
const probeResp = await fetch(`${baseUrl}${vp.path}`, {
|
||||
const probeResp = await targetFetch(`${baseUrl}${vp.path}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: 'manual',
|
||||
});
|
||||
@@ -1994,7 +2112,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const url = context.parameters.url as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
@@ -2151,7 +2269,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
batch.map(async (path) => {
|
||||
try {
|
||||
const fullUrl = `${baseUrl}${path}`;
|
||||
const resp = await fetch(fullUrl, {
|
||||
const resp = await targetFetch(fullUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: 'manual',
|
||||
@@ -2231,7 +2349,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
// First try OPTIONS to see if Allow header is returned
|
||||
let optionsAllow: string | null = null;
|
||||
try {
|
||||
const optResp = await fetch(url, {
|
||||
const optResp = await targetFetch(url, {
|
||||
method: 'OPTIONS',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
@@ -2253,7 +2371,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: 'manual',
|
||||
@@ -2332,7 +2450,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
|
||||
for (const test of testOrigins) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Origin': test.origin },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
@@ -2403,7 +2521,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const url = context.parameters.url as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
redirect: 'manual', // Don't follow redirects to capture Set-Cookie
|
||||
});
|
||||
@@ -2521,7 +2639,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const testUrl = new URL(baseUrl);
|
||||
testUrl.searchParams.set(param, payload);
|
||||
|
||||
const response = await fetch(testUrl.toString(), {
|
||||
const response = await targetFetch(testUrl.toString(), {
|
||||
redirect: 'manual', // Don't follow redirects
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
@@ -2613,7 +2731,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
try {
|
||||
const baselineUrl = new URL(baseUrl);
|
||||
baselineUrl.searchParams.set(param, 'nonexistent_file_xyz');
|
||||
const baseResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const baseResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
|
||||
const baseBody = await baseResp.text();
|
||||
baselineLength = baseBody.length;
|
||||
} catch {
|
||||
@@ -2625,7 +2743,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const testUrl = new URL(baseUrl);
|
||||
testUrl.searchParams.set(param, payload);
|
||||
|
||||
const response = await fetch(testUrl.toString(), {
|
||||
const response = await targetFetch(testUrl.toString(), {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = await response.text();
|
||||
@@ -2727,7 +2845,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const testUrl = new URL(baseUrl);
|
||||
testUrl.searchParams.set(param, payload);
|
||||
|
||||
const response = await fetch(testUrl.toString(), {
|
||||
const response = await targetFetch(testUrl.toString(), {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = await response.text();
|
||||
@@ -2803,7 +2921,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
||||
const url = context.parameters.url as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await targetFetch(url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
@@ -3102,6 +3220,10 @@ function sanitizeExternalFlags(tool: 'curl' | 'nmap', raw?: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function withoutCurlRedirectFlags(flags: string[]): string[] {
|
||||
return flags.filter(flag => !/^--location(?:-trusted)?(?:=|$)/i.test(flag) && !/^-[^-]*L/.test(flag));
|
||||
}
|
||||
|
||||
export const EXTERNAL_TOOLS: CustomTool[] = [
|
||||
{
|
||||
name: 'nmap_scan',
|
||||
@@ -3249,6 +3371,8 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
|
||||
const flags = context.parameters.flags as string | undefined;
|
||||
|
||||
const args = ['-s', '-i', '-X', method];
|
||||
let configuredHeaders = targetHeadersForUrl(url);
|
||||
let configDir: string | undefined;
|
||||
if (data) {
|
||||
// A body starting with @ (read local file) or < (read stdin) turns curl into a local-file
|
||||
// disclosure primitive. --data-raw sends it verbatim and disables @/< interpretation.
|
||||
@@ -3256,19 +3380,44 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
|
||||
args.push('--data-raw', data);
|
||||
}
|
||||
if (headers) {
|
||||
for (const h of headers.split(',')) {
|
||||
args.push('-H', h.trim());
|
||||
const headerLines = headers.split(',').map(header => header.trim()).filter(Boolean);
|
||||
if (configuredHeaders) {
|
||||
const explicitHeaders = new Headers();
|
||||
for (const header of headerLines) {
|
||||
const separator = header.indexOf(':');
|
||||
if (separator <= 0) return { success: false, error: `curl_request: invalid header '${header}'` };
|
||||
explicitHeaders.set(header.slice(0, separator).trim(), header.slice(separator + 1).trim());
|
||||
}
|
||||
configuredHeaders = targetHeadersForUrl(url, explicitHeaders);
|
||||
} else {
|
||||
for (const header of headerLines) args.push('-H', header);
|
||||
}
|
||||
}
|
||||
if (flags) args.push(...sanitizeExternalFlags('curl', flags)); // drop -o/-K/-T/-x file-read/write injection
|
||||
args.push(url);
|
||||
|
||||
const result = await runSubprocess('curl', args, { timeout: 30000 });
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
output: result.stdout,
|
||||
error: result.exitCode !== 0 ? result.stderr : undefined,
|
||||
};
|
||||
if (flags) {
|
||||
const sanitized = sanitizeExternalFlags('curl', flags); // drop -o/-K/-T/-x file-read/write injection
|
||||
args.push(...(configuredHeaders ? withoutCurlRedirectFlags(sanitized) : sanitized));
|
||||
}
|
||||
try {
|
||||
if (configuredHeaders) {
|
||||
configDir = await mkdtemp(join(tmpdir(), 't3mp3st-curl-'));
|
||||
const configPath = join(configDir, 'headers.conf');
|
||||
const escapeConfig = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const config = [...configuredHeaders.entries()]
|
||||
.map(([name, value]) => `header = "${escapeConfig(`${name}: ${value}`)}"`)
|
||||
.join('\n');
|
||||
await writeFile(configPath, `${config}\n`, { mode: 0o600 });
|
||||
args.push('--config', configPath);
|
||||
}
|
||||
args.push(url);
|
||||
const result = await runSubprocess('curl', args, { timeout: 30000 });
|
||||
return redactConfiguredSecrets({
|
||||
success: result.exitCode === 0,
|
||||
output: result.stdout,
|
||||
error: result.exitCode !== 0 ? result.stderr : undefined,
|
||||
});
|
||||
} finally {
|
||||
if (configDir) await rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* T3MP3ST Arsenal — tool-output → structured evidence parsers.
|
||||
*
|
||||
* Turns the raw stdout of the JSON-emitting scanners into `ToolFinding[]` so the generic
|
||||
* adapter factory (src/arsenal/adapter-tools.ts) can return STRUCTURED findings, not just
|
||||
* text. Downstream the pipe already exists and is honest-by-construction: the agent loop
|
||||
* stamps `provenance:'tool'` + the raw output onto each finding, the operator materialises
|
||||
* it into a `Finding` with output-evidence, and the live gate (src/evidence/gate.ts) can
|
||||
* then mark it verified. Until now these adapters (parserStatus:'planned') returned raw
|
||||
* stdout only — the structured channel (`ToolResult.findings`) was never populated.
|
||||
*
|
||||
* HONESTY CONTRACT (same discipline as stub-honesty / no-phantom-tools):
|
||||
* - Pure, dependency-free, and NEVER throws. Empty / garbled output → `[]`, never a
|
||||
* fabricated finding. The caller keeps the raw stdout as the evidence of record; a
|
||||
* parser SUMMARISES real output, it does not invent it.
|
||||
* - Parsers do NOT set provenance/toolName/toolOutput — the agent loop stamps those from
|
||||
* the real subprocess result, so a parser can never forge provenance.
|
||||
*/
|
||||
|
||||
import type { Severity, ToolFinding } from '../types/index.js';
|
||||
import { redactString } from '../redact.js';
|
||||
|
||||
// ── small, defensive helpers ────────────────────────────────────────────────
|
||||
const SEVERITIES = new Set<Severity>(['critical', 'high', 'medium', 'low', 'info']);
|
||||
// Map the many scanner severity vocabularies (semgrep ERROR/WARNING/INFO, trivy/grype
|
||||
// CRITICAL…NEGLIGIBLE, npm-audit "moderate", …) onto the five canonical levels.
|
||||
const SEVERITY_ALIAS: Record<string, Severity> = {
|
||||
error: 'high', warning: 'medium', warn: 'medium', inventory: 'info', experiment: 'info',
|
||||
moderate: 'medium', negligible: 'info', unknown: 'info', none: 'info', informational: 'info',
|
||||
};
|
||||
const sev = (v: unknown, fallback: Severity = 'info'): Severity => {
|
||||
const s = String(v ?? '').trim().toLowerCase();
|
||||
if (SEVERITIES.has(s as Severity)) return s as Severity;
|
||||
return SEVERITY_ALIAS[s] ?? fallback;
|
||||
};
|
||||
const asObj = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
const asStrArray = (v: unknown): string[] =>
|
||||
Array.isArray(v)
|
||||
? v.map((x) => String(x)).filter((s) => s.trim().length > 0)
|
||||
: v !== null && v !== undefined && v !== '' ? [String(v)] : [];
|
||||
const num = (v: unknown): number | undefined => {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
const truncate = (s: string, n = 400): string => (s.length > n ? `${s.slice(0, n)}…` : s);
|
||||
|
||||
/** Parse JSONL defensively: one object per non-empty line; any non-JSON line is skipped. */
|
||||
function jsonl(raw: string): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const line of String(raw).split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(t);
|
||||
if (parsed && typeof parsed === 'object') out.push(parsed as Record<string, unknown>);
|
||||
} catch {
|
||||
/* not a JSON line (banner, log, truncated tail) — skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a single JSON document defensively; returns undefined on any error. */
|
||||
function jsonDoc(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(String(raw));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── nuclei -jsonl : one finding per template match ───────────────────────────
|
||||
function parseNuclei(raw: string): ToolFinding[] {
|
||||
const out: ToolFinding[] = [];
|
||||
for (const e of jsonl(raw)) {
|
||||
const info = asObj(e.info);
|
||||
const cls = asObj(info.classification);
|
||||
const templateId = String(e['template-id'] ?? e.templateID ?? '');
|
||||
const title = String(info.name || templateId || 'nuclei match');
|
||||
const bits = [
|
||||
e.host && `host: ${String(e.host)}`,
|
||||
e['matched-at'] && `matched-at: ${String(e['matched-at'])}`,
|
||||
info.description && `desc: ${truncate(String(info.description))}`,
|
||||
].filter(Boolean) as string[];
|
||||
const f: ToolFinding = { title, severity: sev(info.severity), details: bits.join(' | ') || title };
|
||||
const cvss = num(cls['cvss-score']);
|
||||
if (cvss !== undefined) f.cvss = cvss;
|
||||
const cve = asStrArray(cls['cve-id']);
|
||||
if (cve.length) f.cve = cve;
|
||||
const cwe = asStrArray(cls['cwe-id']);
|
||||
if (cwe.length) f.cwe = cwe;
|
||||
if (info.remediation) f.remediation = String(info.remediation);
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── httpx -json : one info finding per live host (technology fingerprint) ─────
|
||||
function parseHttpx(raw: string): ToolFinding[] {
|
||||
const out: ToolFinding[] = [];
|
||||
for (const e of jsonl(raw)) {
|
||||
const url = String(e.url ?? e.input ?? e.host ?? '');
|
||||
if (!url) continue;
|
||||
const status = e.status_code ?? e['status-code'];
|
||||
const title = String(e.title ?? '');
|
||||
const tech = asStrArray(e.tech ?? e.technologies);
|
||||
const server = String(e.webserver ?? e.server ?? '');
|
||||
const bits = [
|
||||
`url: ${url}`,
|
||||
status !== null && status !== undefined && `status: ${String(status)}`,
|
||||
title && `title: ${truncate(title, 120)}`,
|
||||
server && `server: ${server}`,
|
||||
tech.length && `tech: ${tech.join(', ')}`,
|
||||
].filter(Boolean) as string[];
|
||||
out.push({ title: `HTTP service ${url}`, severity: 'info', details: bits.join(' | ') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── dalfox --format json : one finding per XSS probe ─────────────────────────
|
||||
function parseDalfox(raw: string): ToolFinding[] {
|
||||
const doc = jsonDoc(raw);
|
||||
const arr: unknown[] = Array.isArray(doc)
|
||||
? doc
|
||||
: Array.isArray(asObj(doc).pocs) ? (asObj(doc).pocs as unknown[]) : [];
|
||||
const out: ToolFinding[] = [];
|
||||
for (const item of arr) {
|
||||
const e = asObj(item);
|
||||
const param = String(e.param ?? '');
|
||||
const kind = String(e.type ?? e.inject_type ?? 'XSS');
|
||||
const bits = [
|
||||
e.method && `method: ${String(e.method)}`,
|
||||
param && `param: ${param}`,
|
||||
e.data && `data: ${truncate(String(e.data), 200)}`,
|
||||
e.evidence && `evidence: ${truncate(String(e.evidence), 200)}`,
|
||||
e.poc && `poc: ${truncate(String(e.poc), 300)}`,
|
||||
].filter(Boolean) as string[];
|
||||
const f: ToolFinding = {
|
||||
title: `dalfox ${kind}${param ? ` @ ${param}` : ''}`,
|
||||
severity: sev(e.severity, 'medium'),
|
||||
details: bits.join(' | ') || String(e.message_str ?? e.message ?? 'XSS probe'),
|
||||
};
|
||||
const cwe = asStrArray(e.cwe).map((c) => (/^CWE-/i.test(c) ? c.toUpperCase() : `CWE-${c}`));
|
||||
if (cwe.length) f.cwe = cwe;
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── ffuf -of json : one aggregate info finding (N paths) ─────────────────────
|
||||
function parseFfuf(raw: string): ToolFinding[] {
|
||||
const results = ((): unknown[] => {
|
||||
const r = asObj(jsonDoc(raw)).results;
|
||||
return Array.isArray(r) ? r : [];
|
||||
})();
|
||||
if (!results.length) return [];
|
||||
const lines = results.slice(0, 50).map((r) => {
|
||||
const e = asObj(r);
|
||||
const path = String(asObj(e.input).FUZZ ?? e.input ?? e.url ?? '');
|
||||
return `${path} (status ${String(e.status ?? '?')}, len ${String(e.length ?? '?')})`;
|
||||
});
|
||||
const more = results.length > 50 ? `\n… +${results.length - 50} more` : '';
|
||||
return [{
|
||||
title: `ffuf: ${results.length} path(s) discovered`,
|
||||
severity: 'info',
|
||||
details: lines.join('\n') + more,
|
||||
}];
|
||||
}
|
||||
|
||||
// ── katana -jsonl : one aggregate info finding (N endpoints) ──────────────────
|
||||
function parseKatana(raw: string): ToolFinding[] {
|
||||
const endpoints: string[] = [];
|
||||
for (const e of jsonl(raw)) {
|
||||
const ep = String(asObj(e.request).endpoint ?? e.endpoint ?? e.url ?? '');
|
||||
if (ep) endpoints.push(ep);
|
||||
}
|
||||
if (!endpoints.length) return [];
|
||||
const more = endpoints.length > 50 ? `\n… +${endpoints.length - 50} more` : '';
|
||||
return [{
|
||||
title: `katana: ${endpoints.length} endpoint(s) discovered`,
|
||||
severity: 'info',
|
||||
details: endpoints.slice(0, 50).join('\n') + more,
|
||||
}];
|
||||
}
|
||||
|
||||
// ── semgrep scan --json : one finding per rule match (SAST) ──────────────────
|
||||
function parseSemgrep(raw: string): ToolFinding[] {
|
||||
const results = ((): unknown[] => {
|
||||
const r = asObj(jsonDoc(raw)).results;
|
||||
return Array.isArray(r) ? r : [];
|
||||
})();
|
||||
const out: ToolFinding[] = [];
|
||||
for (const item of results) {
|
||||
const e = asObj(item);
|
||||
const extra = asObj(e.extra);
|
||||
const meta = asObj(extra.metadata);
|
||||
const checkId = String(e.check_id ?? e.checkId ?? 'semgrep rule');
|
||||
const path = String(e.path ?? '');
|
||||
const line = num(asObj(e.start).line);
|
||||
const where = path ? `${path}${line !== undefined ? `:${line}` : ''}` : '';
|
||||
const f: ToolFinding = {
|
||||
title: checkId,
|
||||
severity: sev(extra.severity),
|
||||
details: [where && `at ${where}`, extra.message && truncate(String(extra.message))].filter(Boolean).join(' | ') || checkId,
|
||||
};
|
||||
// semgrep CWE metadata is like ["CWE-89: SQL Injection"] — keep the CWE-NNN token.
|
||||
const cwe = asStrArray(meta.cwe).map((c) => c.match(/CWE-\d+/i)?.[0] ?? '').filter(Boolean);
|
||||
if (cwe.length) f.cwe = cwe.map((c) => c.toUpperCase());
|
||||
const refs = asStrArray(meta.references);
|
||||
if (refs.length) f.remediation = `refs: ${refs.slice(0, 3).join(', ')}`;
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── gitleaks detect --report-format json --redact : one finding per secret ────
|
||||
function parseGitleaks(raw: string): ToolFinding[] {
|
||||
const arr: unknown[] = Array.isArray(jsonDoc(raw)) ? (jsonDoc(raw) as unknown[]) : [];
|
||||
const out: ToolFinding[] = [];
|
||||
for (const item of arr) {
|
||||
const e = asObj(item);
|
||||
const rule = String(e.RuleID ?? e.Description ?? 'secret');
|
||||
const file = String(e.File ?? '');
|
||||
const line = num(e.StartLine);
|
||||
const where = file ? `${file}${line !== undefined ? `:${line}` : ''}` : '';
|
||||
// Defense in depth: the adapter already runs gitleaks with --redact, but scrub the details
|
||||
// through redactString anyway so a raw secret can NEVER reach a finding or the evidence vault.
|
||||
const details = redactString([where && `at ${where}`, String(e.Description ?? '')].filter(Boolean).join(' | '));
|
||||
out.push({ title: `secret: ${rule}`, severity: 'high', details: details || `secret: ${rule}` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── trivy fs --format json : one finding per package vulnerability ────────────
|
||||
function parseTrivy(raw: string): ToolFinding[] {
|
||||
const results = ((): unknown[] => {
|
||||
const r = asObj(jsonDoc(raw)).Results;
|
||||
return Array.isArray(r) ? r : [];
|
||||
})();
|
||||
const out: ToolFinding[] = [];
|
||||
for (const res of results) {
|
||||
const target = String(asObj(res).Target ?? '');
|
||||
const vulns = asObj(res).Vulnerabilities;
|
||||
if (!Array.isArray(vulns)) continue;
|
||||
for (const item of vulns) {
|
||||
const v = asObj(item);
|
||||
const id = String(v.VulnerabilityID ?? '');
|
||||
const pkg = String(v.PkgName ?? '');
|
||||
const installed = String(v.InstalledVersion ?? '');
|
||||
const fixed = String(v.FixedVersion ?? '');
|
||||
const f: ToolFinding = {
|
||||
title: String(v.Title || id || 'vulnerability'),
|
||||
severity: sev(v.Severity),
|
||||
details: [target && `target: ${target}`, pkg && `pkg: ${pkg}${installed ? ` ${installed}` : ''}`,
|
||||
v.Description && truncate(String(v.Description), 200)].filter(Boolean).join(' | ') || id,
|
||||
};
|
||||
if (/^CVE-/i.test(id)) f.cve = [id];
|
||||
const cwe = asStrArray(v.CweIDs);
|
||||
if (cwe.length) f.cwe = cwe;
|
||||
if (fixed) f.remediation = `upgrade ${pkg || 'package'} to ${fixed}`;
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── grype dir:. -o json : one finding per matched vulnerability ───────────────
|
||||
function parseGrype(raw: string): ToolFinding[] {
|
||||
const matches = ((): unknown[] => {
|
||||
const m = asObj(jsonDoc(raw)).matches;
|
||||
return Array.isArray(m) ? m : [];
|
||||
})();
|
||||
const out: ToolFinding[] = [];
|
||||
for (const item of matches) {
|
||||
const m = asObj(item);
|
||||
const vuln = asObj(m.vulnerability);
|
||||
const art = asObj(m.artifact);
|
||||
const id = String(vuln.id ?? '');
|
||||
const name = String(art.name ?? '');
|
||||
const version = String(art.version ?? '');
|
||||
const f: ToolFinding = {
|
||||
title: id || 'vulnerability',
|
||||
severity: sev(vuln.severity),
|
||||
details: [name && `pkg: ${name}${version ? ` ${version}` : ''}`,
|
||||
vuln.description && truncate(String(vuln.description), 200)].filter(Boolean).join(' | ') || id,
|
||||
};
|
||||
if (/^CVE-/i.test(id)) f.cve = [id];
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PARSERS: Record<string, (raw: string) => ToolFinding[]> = {
|
||||
nuclei: parseNuclei,
|
||||
httpx: parseHttpx,
|
||||
dalfox: parseDalfox,
|
||||
ffuf: parseFfuf,
|
||||
katana: parseKatana,
|
||||
semgrep: parseSemgrep,
|
||||
gitleaks: parseGitleaks,
|
||||
trivy: parseTrivy,
|
||||
grype: parseGrype,
|
||||
};
|
||||
|
||||
/** Adapter ids that have a structured output parser wired here. */
|
||||
export const PARSED_TOOL_IDS: readonly string[] = Object.keys(PARSERS);
|
||||
|
||||
/** True iff a structured parser is wired for this adapter id. */
|
||||
export function hasParser(toolId: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(PARSERS, toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tool's raw stdout into structured `ToolFinding[]`.
|
||||
*
|
||||
* Returns `[]` for any tool with no parser, for empty output, and for output a parser cannot
|
||||
* make sense of. NEVER throws — an unexpected parser failure degrades to `[]` so a malformed
|
||||
* scan can never crash the agent loop or fabricate a finding.
|
||||
*/
|
||||
export function parseToolOutput(toolId: string, rawOutput: string): ToolFinding[] {
|
||||
const parser = PARSERS[toolId];
|
||||
if (!parser || !rawOutput || !String(rawOutput).trim()) return [];
|
||||
try {
|
||||
return parser(rawOutput);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+120
-15
@@ -23,6 +23,8 @@ export interface TempestSettings {
|
||||
anthropic?: string;
|
||||
openai?: string;
|
||||
xai?: string;
|
||||
gemini?: string;
|
||||
local?: string;
|
||||
};
|
||||
|
||||
// Default LLM settings
|
||||
@@ -61,6 +63,12 @@ export interface TempestSettings {
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
// Google Gemini — native Gemini API via its OpenAI-compatible endpoint
|
||||
gemini: {
|
||||
baseUrl: string;
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
// Codex CLI/account subscription backend
|
||||
codex: {
|
||||
command: string;
|
||||
@@ -124,6 +132,14 @@ const DEFAULT_SETTINGS: TempestSettings = {
|
||||
defaultModel: 'grok-build-0.1',
|
||||
},
|
||||
|
||||
// Gemini's OpenAI-compatible surface lives under /v1beta/openai. The OpenAIAdapter posts to
|
||||
// `${baseUrl}/chat/completions`, so the /openai path segment is REQUIRED here — without it,
|
||||
// requests hit /v1beta/chat/completions, which is not a valid Gemini endpoint.
|
||||
gemini: {
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
},
|
||||
|
||||
codex: {
|
||||
command: 'codex',
|
||||
defaultModel: 'codex-default',
|
||||
@@ -422,6 +438,34 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
|
||||
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'tools'],
|
||||
},
|
||||
],
|
||||
// Native Gemini model ids (bare, no `google/` prefix) for Google's OpenAI-compatible endpoint.
|
||||
// Distinct from the `google/gemini-*` ids under `openrouter`, which route through OpenRouter.
|
||||
gemini: [
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
name: 'Gemini 2.5 Flash (native)',
|
||||
provider: 'Google',
|
||||
contextWindow: 1000000,
|
||||
maxOutput: 8192,
|
||||
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
name: 'Gemini 2.5 Pro (native)',
|
||||
provider: 'Google',
|
||||
contextWindow: 1000000,
|
||||
maxOutput: 8192,
|
||||
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'multimodal', 'tools'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
name: 'Gemini 2.0 Flash (native)',
|
||||
provider: 'Google',
|
||||
contextWindow: 1000000,
|
||||
maxOutput: 8192,
|
||||
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'],
|
||||
},
|
||||
],
|
||||
local: [
|
||||
{
|
||||
id: 'local-model',
|
||||
@@ -493,10 +537,14 @@ class ConfigManager {
|
||||
join(homedir(), '.env'),
|
||||
];
|
||||
|
||||
let envProvider: string | undefined;
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = readFileSync(envPath, 'utf-8');
|
||||
const lines = envContent.split('\n');
|
||||
// Validation variables added
|
||||
const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'local'];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
@@ -509,9 +557,18 @@ class ConfigManager {
|
||||
// empty OPENROUTER_API_KEY= — the .env file no longer clobbers it.
|
||||
if (key && value && process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
|
||||
// Track LLM_PROVIDER for default provider
|
||||
if (key === 'LLM_PROVIDER' && VALID_PROVIDERS.includes(value.toLowerCase())) {
|
||||
envProvider = value.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set TEMPEST_DEFAULT_PROVIDER if LLM_PROVIDER found
|
||||
if (envProvider && !process.env.TEMPEST_DEFAULT_PROVIDER) {
|
||||
process.env.TEMPEST_DEFAULT_PROVIDER = envProvider;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -547,7 +604,7 @@ class ConfigManager {
|
||||
/**
|
||||
* Set an API key for a provider
|
||||
*/
|
||||
setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai', key: string): void {
|
||||
setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local', key: string): void {
|
||||
const apiKeys = this.config.get('apiKeys');
|
||||
apiKeys[provider] = key;
|
||||
this.config.set('apiKeys', apiKeys);
|
||||
@@ -556,14 +613,22 @@ class ConfigManager {
|
||||
/**
|
||||
* Get an API key for a provider
|
||||
*/
|
||||
getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): string | undefined {
|
||||
getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): string | undefined {
|
||||
// First check environment variables (highest priority)
|
||||
// local provider: a self-hosted/OpenAI-compatible server MAY require a bearer
|
||||
// (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars.
|
||||
if (provider === 'local') {
|
||||
const localKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
|
||||
if (localKey) return localKey;
|
||||
return this.config.get('apiKeys')[provider];
|
||||
}
|
||||
const envVarMap = {
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
venice: 'VENICE_API_KEY',
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
gemini: 'GEMINI_API_KEY',
|
||||
};
|
||||
|
||||
// Force a fully UNCONFIGURED server (no key from env OR the saved store) — used by
|
||||
@@ -582,7 +647,7 @@ class ConfigManager {
|
||||
/**
|
||||
* Check if a provider has a valid API key configured
|
||||
*/
|
||||
hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): boolean {
|
||||
hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): boolean {
|
||||
const key = this.getApiKey(provider);
|
||||
return !!key && key.length > 10;
|
||||
}
|
||||
@@ -590,7 +655,7 @@ class ConfigManager {
|
||||
/**
|
||||
* Remove an API key
|
||||
*/
|
||||
removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai'): void {
|
||||
removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): void {
|
||||
const apiKeys = this.config.get('apiKeys');
|
||||
delete apiKeys[provider];
|
||||
this.config.set('apiKeys', apiKeys);
|
||||
@@ -607,6 +672,7 @@ class ConfigManager {
|
||||
if (this.hasApiKey('anthropic')) providers.push('anthropic');
|
||||
if (this.hasApiKey('openai')) providers.push('openai');
|
||||
if (this.hasApiKey('xai')) providers.push('xai');
|
||||
if (this.hasApiKey('gemini')) providers.push('gemini');
|
||||
|
||||
// Codex uses the local Codex CLI/account auth instead of API-key storage.
|
||||
providers.push('codex');
|
||||
@@ -621,7 +687,16 @@ class ConfigManager {
|
||||
* Get the LLM configuration for the default or specified provider
|
||||
*/
|
||||
getLLMConfig(provider?: LLMProvider, model?: string): LLMConfig {
|
||||
const actualProvider = provider || this.config.get('defaultProvider');
|
||||
let actualProvider = provider;
|
||||
|
||||
if (!actualProvider) {
|
||||
const envProvider = process.env.TEMPEST_DEFAULT_PROVIDER?.trim();
|
||||
if (envProvider) {
|
||||
actualProvider = envProvider as LLMProvider;
|
||||
} else {
|
||||
actualProvider = this.config.get('defaultProvider');
|
||||
}
|
||||
}
|
||||
|
||||
let apiKey: string | undefined;
|
||||
let baseUrl: string | undefined;
|
||||
@@ -654,6 +729,12 @@ class ConfigManager {
|
||||
baseUrl = this.config.get('xai').baseUrl;
|
||||
actualModel = model || this.config.get('xai').defaultModel;
|
||||
break;
|
||||
case 'gemini':
|
||||
// Google Gemini via its OpenAI-compatible endpoint (native tool-calling).
|
||||
apiKey = this.getApiKey('gemini');
|
||||
baseUrl = this.config.get('gemini').baseUrl;
|
||||
actualModel = model || this.config.get('gemini').defaultModel;
|
||||
break;
|
||||
case 'codex':
|
||||
actualModel = model || this.config.get('codex').defaultModel;
|
||||
break;
|
||||
@@ -661,13 +742,17 @@ class ConfigManager {
|
||||
actualModel = 'mock-model';
|
||||
break;
|
||||
case 'local':
|
||||
// Self-hosted, keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
|
||||
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp)
|
||||
// and TEMPEST_LOCAL_MODEL at the model tag you're serving.
|
||||
// Self-hosted, usually keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
|
||||
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp, Zhipu/z.ai
|
||||
// /api/paas/v4) and TEMPEST_LOCAL_MODEL at the model tag you're serving.
|
||||
// Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) —
|
||||
// TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it.
|
||||
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
|
||||
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
|
||||
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
|
||||
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||
// Placeholder ids from AVAILABLE_MODELS / the static UI are not real served model tags.
|
||||
// Treat them as unset so TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag
|
||||
// passed in still takes priority.
|
||||
actualModel = (model && !['local-model', 'local/ollama'].includes(model) ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||
apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${actualProvider}`);
|
||||
@@ -699,7 +784,7 @@ class ConfigManager {
|
||||
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
|
||||
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
|
||||
const chain: FallbackEntry[] = [];
|
||||
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => {
|
||||
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => {
|
||||
if (p === primary || !this.hasApiKey(p)) return;
|
||||
chain.push({
|
||||
provider: p,
|
||||
@@ -713,6 +798,7 @@ class ConfigManager {
|
||||
add('anthropic');
|
||||
add('openai');
|
||||
add('xai');
|
||||
add('gemini');
|
||||
return chain;
|
||||
}
|
||||
|
||||
@@ -739,6 +825,9 @@ class ConfigManager {
|
||||
case 'codex':
|
||||
this.config.set('defaultModel', this.config.get('codex').defaultModel);
|
||||
break;
|
||||
case 'local':
|
||||
this.config.set('defaultModel', process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +886,7 @@ class ConfigManager {
|
||||
anthropic: settings.apiKeys.anthropic ? '***REDACTED***' : undefined,
|
||||
openai: settings.apiKeys.openai ? '***REDACTED***' : undefined,
|
||||
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
|
||||
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
|
||||
},
|
||||
};
|
||||
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
|
||||
@@ -829,6 +919,21 @@ OPENAI_API_KEY=
|
||||
# xAI API Key
|
||||
# Get your key at: https://console.x.ai/
|
||||
XAI_API_KEY=
|
||||
|
||||
# Google Gemini API Key (direct Gemini API via its OpenAI-compatible endpoint)
|
||||
# Get your key at: https://aistudio.google.com/apikey
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server)
|
||||
# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below).
|
||||
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
|
||||
# vLLM :8000/v1, or e.g. Zhipu/z.ai /api/paas/v4 — any /vN path speaks the
|
||||
# OpenAI wire (/chat/completions, choices[]); otherwise the Ollama native format is used.
|
||||
# Most local servers are keyless; if yours requires a bearer (Zhipu, Together, …)
|
||||
# set TEMPEST_LOCAL_API_KEY (ZAI_API_KEY / ZHIPUAI_API_KEY are accepted as aliases).
|
||||
TEMPEST_LOCAL_BASE_URL=http://localhost:11434/api
|
||||
TEMPEST_LOCAL_MODEL=llama3
|
||||
TEMPEST_LOCAL_API_KEY=
|
||||
`;
|
||||
writeFileSync(filePath, template);
|
||||
}
|
||||
@@ -841,8 +946,8 @@ XAI_API_KEY=
|
||||
export const config = new ConfigManager();
|
||||
|
||||
// Helper functions for quick access
|
||||
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => config.getApiKey(provider);
|
||||
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai', key: string) => config.setApiKey(provider, key);
|
||||
export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => config.hasApiKey(provider);
|
||||
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.getApiKey(provider);
|
||||
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini', key: string) => config.setApiKey(provider, key);
|
||||
export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.hasApiKey(provider);
|
||||
export const getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model);
|
||||
export const getConfiguredProviders = () => config.getConfiguredProviders();
|
||||
+148
-14
@@ -208,9 +208,12 @@ import type {
|
||||
TempestConfig,
|
||||
LLMConfig,
|
||||
RuntimeHooks,
|
||||
LLMProvider,
|
||||
OperatorArchetype,
|
||||
CommandEvents,
|
||||
Finding,
|
||||
ScanProgressEvent,
|
||||
Task,
|
||||
} from './types/index.js';
|
||||
|
||||
// Re-export commonly used types
|
||||
@@ -268,6 +271,10 @@ import {
|
||||
// TEMPEST COMMAND
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_AGENT_MAX_ITERATIONS = 15;
|
||||
const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30);
|
||||
const MAX_PROGRESS_EVENTS = 300;
|
||||
|
||||
/**
|
||||
* TEMPEST Command - Main orchestration controller
|
||||
*/
|
||||
@@ -322,6 +329,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
private tickInterval: NodeJS.Timeout | null = null;
|
||||
private tickCount: number = 0;
|
||||
private hooks: RuntimeHooks;
|
||||
private readonly taskTimeoutMs: number;
|
||||
|
||||
/**
|
||||
* White-box source context (security-prioritized code excerpt), set by the
|
||||
@@ -356,6 +364,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
super();
|
||||
this.name = config.name;
|
||||
this.hooks = config.hooks || {};
|
||||
this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider);
|
||||
|
||||
// Initialize LLM backbone
|
||||
this.llm = new LLMBackbone(config.llm);
|
||||
@@ -538,6 +547,40 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* Setup event forwarding for an operator
|
||||
*/
|
||||
private setupOperatorEvents(operator: OperatorAgent): void {
|
||||
operator.on('task:started', ({ task }) => {
|
||||
this.recordProgress('task_started', operator, task, `Started ${task.name}`);
|
||||
});
|
||||
|
||||
operator.on('task:completed', ({ task, result }) => {
|
||||
const findings = result.findings?.length ? ` Findings: ${result.findings.join(', ')}` : '';
|
||||
this.recordProgress(
|
||||
'task_completed',
|
||||
operator,
|
||||
task,
|
||||
`${result.success === false ? 'Finished unsuccessfully' : 'Completed'} ${task.name}.${findings}`,
|
||||
{ success: result.success !== false }
|
||||
);
|
||||
});
|
||||
|
||||
operator.on('task:failed', ({ task, error }) => {
|
||||
this.recordProgress('task_failed', operator, task, error, { success: false });
|
||||
});
|
||||
|
||||
operator.on('agent:thinking', ({ task, content }) => {
|
||||
this.recordProgress('thinking', operator, task, content);
|
||||
});
|
||||
|
||||
operator.on('agent:tool_call', ({ task, name, args, source }) => {
|
||||
this.recordProgress('tool_call', operator, task, `${name} ${JSON.stringify(args || {})}`, { toolName: name, source });
|
||||
});
|
||||
|
||||
operator.on('agent:tool_result', ({ task, name, result, source }) => {
|
||||
const detail = result.success
|
||||
? (result.output || 'Tool completed')
|
||||
: (result.error || result.output || 'Tool failed');
|
||||
this.recordProgress('tool_result', operator, task, detail, { toolName: name, success: result.success, source });
|
||||
});
|
||||
|
||||
operator.on('finding:discovered', ({ finding }) => {
|
||||
this.vault.addFinding(finding);
|
||||
this.emit('finding:discovered', { finding, operatorId: operator.id });
|
||||
@@ -622,6 +665,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
private recordProgress(
|
||||
kind: ScanProgressEvent['kind'],
|
||||
operator: OperatorAgent,
|
||||
task: Task | undefined,
|
||||
detail: string,
|
||||
extra: Partial<Pick<ScanProgressEvent, 'toolName' | 'success' | 'source'>> = {}
|
||||
): void {
|
||||
const compact = String(detail || '').replace(/\s+/g, ' ').trim().slice(0, 2000);
|
||||
const event: ScanProgressEvent = {
|
||||
id: `progress-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
timestamp: Date.now(),
|
||||
kind,
|
||||
operatorId: operator.id,
|
||||
callsign: operator.callsign,
|
||||
archetype: operator.archetype,
|
||||
taskId: task?.id,
|
||||
taskName: task?.name,
|
||||
detail: compact,
|
||||
...extra,
|
||||
};
|
||||
|
||||
this.progressEvents.push(event);
|
||||
if (this.progressEvents.length > MAX_PROGRESS_EVENTS) {
|
||||
this.progressEvents.splice(0, this.progressEvents.length - MAX_PROGRESS_EVENTS);
|
||||
}
|
||||
this.emit('scan:progress', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a finding and update the target's services/vulnerabilities.
|
||||
* This is the intelligence pipeline that feeds data between phases.
|
||||
@@ -786,6 +857,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
public resume(): void {
|
||||
if (!this.running || !this.paused) return;
|
||||
this.paused = false;
|
||||
this.stallReason = null;
|
||||
this.emit('command:resumed');
|
||||
}
|
||||
|
||||
@@ -814,29 +886,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays
|
||||
* in-flight longer than this, the tick loop force-resolves it as a timeout so
|
||||
* pendingOrActive can reach 0 and the mission can advance/complete even when an
|
||||
* operator promise wedges. Deliberately large (default 5 min) so it NEVER kills a
|
||||
* legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires
|
||||
* on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS.
|
||||
* operator promise wedges. Deliberately large (default 5 min for API models,
|
||||
* 30 min for local-agent backends) so it does not kill legitimately slow local
|
||||
* CLI work; it only fires on truly-hung dispatches. Override via
|
||||
* T3MP3ST_TASK_TIMEOUT_MS.
|
||||
*/
|
||||
private readonly taskTimeoutMs: number = TempestCommand.resolveTaskTimeoutMs();
|
||||
private progressEvents: ScanProgressEvent[] = [];
|
||||
|
||||
/**
|
||||
* Resolve the dispatch timeout from the environment, falling back to the 5-minute
|
||||
* default. Guards against a non-numeric / non-positive override.
|
||||
* Resolve the dispatch timeout from the environment, falling back to the
|
||||
* provider-specific default. Guards against a non-numeric / non-positive override.
|
||||
*/
|
||||
private static resolveTaskTimeoutMs(): number {
|
||||
private static resolveTaskTimeoutMs(provider?: LLMProvider): number {
|
||||
const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline
|
||||
const LOCAL_AGENT_TASK_TIMEOUT_MS = 1800000; // local CLI agents can need multiple slow turns
|
||||
const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS;
|
||||
if (raw != null && raw.trim() !== '') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return DEFAULT_TASK_TIMEOUT_MS;
|
||||
return provider === 'local-agent' ? LOCAL_AGENT_TASK_TIMEOUT_MS : DEFAULT_TASK_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Track whether we've seeded initial tasks for the current mission */
|
||||
private taskSeeded: boolean = false;
|
||||
|
||||
/** Human-readable reason a mission was paused because required work failed. */
|
||||
private stallReason: string | null = null;
|
||||
|
||||
/**
|
||||
* Main tick loop — seeds tasks, dispatches to operators, advances phases
|
||||
*/
|
||||
@@ -889,13 +966,28 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
);
|
||||
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id));
|
||||
|
||||
// If we have tasks, all are done, and nothing is in-flight → advance phase
|
||||
// If we have tasks, all are done, and nothing is in-flight → advance phase.
|
||||
// Failed required tasks are terminal, but they are not successful progress.
|
||||
// Stall instead of walking the phase bar forward with no backend/model work.
|
||||
if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) {
|
||||
const failedCurrentPhase = allMissionTasks.filter(
|
||||
t => t.phase === mission.currentPhase && t.status === 'failed'
|
||||
);
|
||||
if (failedCurrentPhase.length > 0) {
|
||||
const firstError = failedCurrentPhase[0].result?.error;
|
||||
this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` +
|
||||
(firstError ? ` — ${firstError}` : '');
|
||||
this.paused = true;
|
||||
this.emit('command:paused');
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseIndex = mission.phases.indexOf(mission.currentPhase);
|
||||
if (phaseIndex === -1) return; // Guard: phase not found (race condition)
|
||||
if (phaseIndex < mission.phases.length - 1) {
|
||||
// Advance to next phase and generate tasks
|
||||
this.mission.advancePhase(mission.id);
|
||||
this.stallReason = null;
|
||||
const targets = this.targetEnv.getAllTargets();
|
||||
for (const target of targets) {
|
||||
this.mission.generateNextPhaseTasks(target.address);
|
||||
@@ -926,6 +1018,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
|
||||
// Auto-spawn an operator if none exists for this archetype
|
||||
if (!operator) {
|
||||
if (this.llm.getProvider() === 'local-agent') continue;
|
||||
const allOps = this.cell.getAllOperators();
|
||||
const archetypeCount = allOps.filter(op => op.archetype === task.operatorType).length;
|
||||
// Spawn up to 3 operators per archetype for parallelism
|
||||
@@ -972,8 +1065,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
// double-fire the completion hook.
|
||||
if (!this.activeDispatches.has(task.id)) return;
|
||||
this.clearDispatch(task.id);
|
||||
taskQueue.complete(task.id, result);
|
||||
this.hooks.onTaskCompleted?.(task);
|
||||
if (result.success === false) {
|
||||
taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
|
||||
} else {
|
||||
taskQueue.complete(task.id, result);
|
||||
this.hooks.onTaskCompleted?.(task);
|
||||
}
|
||||
}).catch((_error) => {
|
||||
if (!this.activeDispatches.has(task.id)) return;
|
||||
this.clearDispatch(task.id);
|
||||
@@ -1007,8 +1104,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* never advances, and completeMission() is never called: the mission HANGS.
|
||||
*
|
||||
* On every tick we scan the in-flight dispatches and force-resolve any that have
|
||||
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs, default 5 min), or
|
||||
* that exhibit the exact wedge symptom (owning operator is `executing`/`tasked`
|
||||
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit
|
||||
* the exact wedge symptom (owning operator is `executing`/`tasked`
|
||||
* but its currentTask is null — i.e. it has silently dropped the task). For each
|
||||
* we: (1) mark the task failed/timed-out in the queue, (2) clear its dispatch
|
||||
* bookkeeping, and (3) reset the owning operator back to idle so it can take new
|
||||
@@ -1070,6 +1167,9 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* Auto-spawn operators needed for a given kill chain phase
|
||||
*/
|
||||
private autoSpawnForPhase(phase: KillChainPhase): void {
|
||||
if (this.llm.getProvider() === 'local-agent') {
|
||||
return;
|
||||
}
|
||||
const phaseOperators: Record<string, OperatorArchetype[]> = {
|
||||
[KillChainPhase.RECON]: ['recon'],
|
||||
[KillChainPhase.WEAPONIZE]: ['scanner'],
|
||||
@@ -1110,6 +1210,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
this.on('detection:triggered', (data) => broadcast('detection', data));
|
||||
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
|
||||
this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
|
||||
this.on('scan:progress', (data) => broadcast('scan:progress', data));
|
||||
this.on('tick', (count) => {
|
||||
// Broadcast status every 5 ticks to avoid flooding
|
||||
if (typeof count === 'number' && count % 5 === 0) {
|
||||
@@ -1135,8 +1236,11 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
|
||||
// the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
|
||||
const profile = ARCHETYPE_PROFILES[archetype];
|
||||
const maxIterations = this.llm.getProvider() === 'local-agent'
|
||||
? LOCAL_AGENT_MAX_ITERATIONS
|
||||
: DEFAULT_AGENT_MAX_ITERATIONS;
|
||||
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
|
||||
maxIterations: 15,
|
||||
maxIterations,
|
||||
maxTokens: 50000,
|
||||
toolCategories: profile.toolCategories,
|
||||
tools: profile.defaultTools,
|
||||
@@ -1199,8 +1303,20 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
vault: ReturnType<EvidenceVault['getStats']>;
|
||||
opsec: ReturnType<OpsecController['getStats']>;
|
||||
activeMission: string | null;
|
||||
stallReason: string | null;
|
||||
progress: ScanProgressEvent[];
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
phase: string;
|
||||
status: string;
|
||||
operatorType: string;
|
||||
assignedTo?: string;
|
||||
result?: { success: boolean; output?: string; error?: string; findings?: string[] };
|
||||
}>;
|
||||
} {
|
||||
const activeMission = this.mission.getActiveMission();
|
||||
const taskQueue = this.mission.getTaskQueue();
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
@@ -1212,6 +1328,24 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
vault: this.vault.getStats(),
|
||||
opsec: this.opsec.getStats(),
|
||||
activeMission: activeMission?.id || null,
|
||||
stallReason: this.stallReason,
|
||||
progress: [...this.progressEvents],
|
||||
tasks: activeMission
|
||||
? taskQueue.getForMission(activeMission.id).map(task => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
phase: task.phase,
|
||||
status: task.status,
|
||||
operatorType: task.operatorType,
|
||||
assignedTo: task.assignedTo,
|
||||
result: task.result ? {
|
||||
success: task.result.success,
|
||||
output: task.result.output,
|
||||
error: task.result.error,
|
||||
findings: task.result.findings,
|
||||
} : undefined,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -725,9 +725,12 @@ class LocalAdapter implements LLMProviderAdapter {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// A /v1 base URL means an OpenAI-compatible server (/chat/completions, choices[]).
|
||||
// A versioned base URL (/v1, /v2, /v4, …) means an OpenAI-compatible server
|
||||
// (/chat/completions, choices[]). Many OpenAI-compatible providers version their
|
||||
// API at paths other than /v1 (e.g. Zhipu/z.ai exposes /api/paas/v4), so match any
|
||||
// /vN rather than literally /v1.
|
||||
private isOpenAIWire(baseUrl: string): boolean {
|
||||
return /\/v1(\/|$)/.test(baseUrl);
|
||||
return /\/v\d+(\/|$)/.test(baseUrl);
|
||||
}
|
||||
|
||||
// Inject the Arsenal contract as a system turn when tools are offered, and
|
||||
@@ -741,9 +744,12 @@ class LocalAdapter implements LLMProviderAdapter {
|
||||
+ (contract
|
||||
? ' You drive a ReAct loop: REQUEST tools, the harness runs them and returns results, you reason until the surface is exhausted.' + contract
|
||||
: ' Operate in planning and analysis mode; when JSON is requested, return ONLY the requested block — no preamble.');
|
||||
const out: { role: string; content: string }[] = [{ role: 'system', content: preamble }];
|
||||
const systemParts = [preamble];
|
||||
const out: { role: string; content: string }[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||
if (m.role === 'system') {
|
||||
if (m.content.trim()) systemParts.push(m.content);
|
||||
} else if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||
out.push({ role: 'assistant', content: `[requested tools: ${m.toolCalls.map(t => t.name).join(', ')}]` });
|
||||
} else if (m.role === 'tool') {
|
||||
out.push({ role: 'user', content: `TOOL RESULT (${m.name || 'tool'}):\n${m.content}` });
|
||||
@@ -751,7 +757,7 @@ class LocalAdapter implements LLMProviderAdapter {
|
||||
out.push({ role: m.role, content: m.content });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return [{ role: 'system', content: systemParts.join('\n\n') }, ...out];
|
||||
}
|
||||
|
||||
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
||||
@@ -1067,7 +1073,8 @@ class LocalAgentAdapter implements LLMProviderAdapter {
|
||||
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
||||
const agentId = this.config.model || 'codex';
|
||||
const prompt = this.formatPrompt(messages, options);
|
||||
const content = (await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 })).trim();
|
||||
const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined;
|
||||
const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim();
|
||||
// Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
|
||||
// ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
|
||||
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
|
||||
@@ -1203,6 +1210,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
|
||||
return new OpenAIAdapter(config);
|
||||
case 'xai':
|
||||
return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible
|
||||
case 'gemini':
|
||||
return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai)
|
||||
case 'codex':
|
||||
return new CodexAdapter(config);
|
||||
case 'mock':
|
||||
|
||||
+40
-5
@@ -22,6 +22,7 @@ import {
|
||||
import type { LLMBackbone } from '../llm/index.js';
|
||||
import type { Arsenal } from '../arsenal/index.js';
|
||||
import type { AgentLoop } from '../agent/index.js';
|
||||
import type { ToolResult } from '../types/index.js';
|
||||
import type { Target } from '../types/index.js';
|
||||
import { OPERATOR_SYSTEM_PROMPTS } from '../prompts/index.js';
|
||||
import { gateLiveFinding } from '../evidence/gate.js';
|
||||
@@ -36,6 +37,9 @@ export interface OperatorEvents {
|
||||
'task:completed': { task: Task; result: TaskResult };
|
||||
'task:failed': { task: Task; error: string };
|
||||
'task:decomposed': { parent: Task; subtasks: Task[]; reason: string };
|
||||
'agent:thinking': { task: Task; content: string };
|
||||
'agent:tool_call': { task: Task; name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:tool_result': { task: Task; name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
|
||||
'finding:discovered': { finding: Finding };
|
||||
'finding:gate-blocked': { finding: Finding; reasons: string[] };
|
||||
'credential:harvested': { credential: Credential };
|
||||
@@ -422,11 +426,38 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
|
||||
// Pass the white-box source excerpt (if any) so the model sees the target's
|
||||
// real source alongside the task; empty string keeps black-box behavior.
|
||||
if (this.agentLoop && this.llm) {
|
||||
// [Phase-2] Register as live on the board and pull the shared situation report so this
|
||||
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
|
||||
this.board?.heartbeat(this.id, 'hunting', task.name);
|
||||
const sharedContext = this.board?.situationReport(this.id);
|
||||
const result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
|
||||
const onThinking = ({ content }: { content: string }): void => {
|
||||
this.emit('agent:thinking', { task, content });
|
||||
};
|
||||
const onToolCall = ({ name, args, source }: { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' }): void => {
|
||||
this.emit('agent:tool_call', { task, name, args, source });
|
||||
};
|
||||
const onToolResult = ({ name, result, source }: { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' }): void => {
|
||||
this.emit('agent:tool_result', { task, name, result, source });
|
||||
};
|
||||
const canForwardAgentEvents =
|
||||
typeof this.agentLoop.on === 'function' &&
|
||||
typeof this.agentLoop.off === 'function';
|
||||
if (canForwardAgentEvents) {
|
||||
this.agentLoop.on('agent:thinking', onThinking);
|
||||
this.agentLoop.on('agent:tool_call', onToolCall);
|
||||
this.agentLoop.on('agent:tool_result', onToolResult);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
// [Phase-2] Register as live on the board and pull the shared situation report so this
|
||||
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
|
||||
this.board?.heartbeat(this.id, 'hunting', task.name);
|
||||
const sharedContext = this.board?.situationReport(this.id);
|
||||
result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
|
||||
} finally {
|
||||
if (canForwardAgentEvents) {
|
||||
this.agentLoop.off('agent:thinking', onThinking);
|
||||
this.agentLoop.off('agent:tool_call', onToolCall);
|
||||
this.agentLoop.off('agent:tool_result', onToolResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert agent findings to operator findings.
|
||||
// PROVENANCE-HONEST: only a tool-backed finding gets tool-output evidence (the raw
|
||||
@@ -445,6 +476,7 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
|
||||
phase: task.phase as KillChainPhase,
|
||||
cvss: finding.cvss,
|
||||
cve: finding.cve,
|
||||
cwe: finding.cwe,
|
||||
evidence: toolBacked
|
||||
? [{
|
||||
type: 'output',
|
||||
@@ -461,6 +493,9 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
|
||||
return {
|
||||
success: result.success,
|
||||
output: result.summary,
|
||||
error: result.finalSummaryError
|
||||
? `${result.summary} Final summary failed: ${result.finalSummaryError}`
|
||||
: undefined,
|
||||
findings: result.findings.map(f => f.title),
|
||||
nextTasks: undefined,
|
||||
};
|
||||
|
||||
+355
-44
@@ -32,9 +32,6 @@ import type { OperatorArchetype } from './types/index.js';
|
||||
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
|
||||
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveContainedRepoPath, RepoPathError } from './recon/whitebox.js';
|
||||
import { redactCredential } from './evidence/index.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -185,6 +182,25 @@ function isLoopbackOrigin(originHeader: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// #84: a SAME-ORIGIN request — the caller's Origin (or Referer) host:port equals THIS server's
|
||||
// own Host header — is by definition not a foreign-website drive-by. It is trusted ONLY when the
|
||||
// operator has bound to a non-loopback address (see HOST_IS_LOOPBACK), i.e. they have explicitly
|
||||
// opted into network access — the same decision that stands the anti-rebinding Host guard down
|
||||
// below. That lets the operator's own LAN/custom-host UI (e.g. http://192.168.x.x:3333/ui) talk to
|
||||
// its backend instead of being rejected as cross-origin, while a foreign origin (evil.com) still
|
||||
// mismatches the Host and is refused. On a loopback bind this is never consulted: the strict
|
||||
// loopback-only origin gate plus the Host guard remain fully in force, so no CSRF/rebinding gap.
|
||||
// URL.host is "hostname:port" with the default port (80/443) omitted — exactly how browsers set
|
||||
// BOTH the Origin and the Host header, so a genuine same-origin request compares equal.
|
||||
function isSameOriginAsHost(source: string | undefined, hostHeader: string | undefined): boolean {
|
||||
if (!source || !hostHeader) return false;
|
||||
try {
|
||||
return new URL(source).host.toLowerCase() === hostHeader.trim().toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// CORS locked to the localhost UI origins ONLY. A same-origin fetch from the UI
|
||||
// (or a tool with no Origin like curl/CLI) is allowed; any other website's
|
||||
// Origin is rejected so the browser blocks it from reading our responses.
|
||||
@@ -219,6 +235,8 @@ app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
// No Origin AND no Referer → trusted local caller (curl/CLI/MCP). Allow.
|
||||
if (!source) return next();
|
||||
if (isLoopbackOrigin(source)) return next();
|
||||
// #84: on a non-loopback (opted-in network) bind, also trust the operator's own same-origin UI.
|
||||
if (!HOST_IS_LOOPBACK && isSameOriginAsHost(source, req.headers.host)) return next();
|
||||
res.status(403).json({
|
||||
error: 'Cross-origin request rejected',
|
||||
detail: 'This local API only accepts requests from the localhost UI, curl, or the CLI. A cross-origin (foreign-website) Origin/Referer was detected and blocked to prevent CSRF/drive-by command execution.',
|
||||
@@ -345,6 +363,79 @@ interface ParsedCommand {
|
||||
|
||||
const SHELL_META = /[|&;$<>`\\]/;
|
||||
const COMMAND_CONTROL = /[\x00-\x1F\x7F-\x9F\u2028\u2029]/;
|
||||
const CURL_TRANSPORT_OVERRIDE_FLAGS = new Set([
|
||||
'--resolve',
|
||||
'--connect-to',
|
||||
'--proxy',
|
||||
'--preproxy',
|
||||
'--socks4',
|
||||
'--socks4a',
|
||||
'--socks5',
|
||||
'--socks5-hostname',
|
||||
'--unix-socket',
|
||||
'--abstract-unix-socket',
|
||||
'--interface',
|
||||
'--url',
|
||||
'--config',
|
||||
'--next',
|
||||
'-x',
|
||||
'-K',
|
||||
]);
|
||||
const CURL_VALUE_FLAGS = new Set([
|
||||
'-A', '--user-agent',
|
||||
'-b', '--cookie',
|
||||
'-c', '--cookie-jar',
|
||||
'-d', '--data', '--data-ascii', '--data-binary', '--data-raw', '--data-urlencode',
|
||||
'-F', '--form',
|
||||
'-H', '--header',
|
||||
'-m', '--max-time',
|
||||
'-o', '--output',
|
||||
'-T', '--upload-file',
|
||||
'-u', '--user',
|
||||
'-X', '--request',
|
||||
'--cacert', '--cert', '--connect-timeout', '--key', '--request-target', '--retry',
|
||||
]);
|
||||
|
||||
function findCurlTransportOverrideFlag(args: string[]): string | undefined {
|
||||
for (const arg of args) {
|
||||
if (!arg) continue;
|
||||
const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
||||
if (CURL_TRANSPORT_OVERRIDE_FLAGS.has(flag)) return flag;
|
||||
if (arg.startsWith('-x') && arg !== '-X' && arg.length > 2) return '-x';
|
||||
if (arg.startsWith('-K') && arg.length > 2) return '-K';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeCurlUrlOperand(arg: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\/\S+/i.test(arg)
|
||||
|| /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|
||||
|| /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|
||||
|| /^\[[0-9a-f:]+\](?::\d+)?(?:[/?#].*)?$/i.test(arg);
|
||||
}
|
||||
|
||||
function countCurlUrlOperands(args: string[]): number {
|
||||
let count = 0;
|
||||
let skipNext = false;
|
||||
let endOfOptions = false;
|
||||
for (const arg of args) {
|
||||
if (!arg) continue;
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
if (!endOfOptions && arg === '--') { endOfOptions = true; continue; }
|
||||
if (!endOfOptions && arg.startsWith('--')) {
|
||||
const hasInlineValue = arg.includes('=');
|
||||
const flag = hasInlineValue ? arg.slice(0, arg.indexOf('=')) : arg;
|
||||
if (!hasInlineValue && CURL_VALUE_FLAGS.has(flag)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
if (!endOfOptions && arg.startsWith('-')) {
|
||||
if (arg.length === 2 && CURL_VALUE_FLAGS.has(arg)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
if (looksLikeCurlUrlOperand(arg)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseCommand(command: string): ParsedCommand | { error: string } {
|
||||
if (SHELL_META.test(command) || COMMAND_CONTROL.test(command)) return { error: 'Shell control characters are not allowed; use direct argv-style commands only.' };
|
||||
@@ -356,6 +447,15 @@ function parseCommand(command: string): ParsedCommand | { error: string } {
|
||||
if (adapter?.execution === 'catalog_only' || adapter?.execution === 'import_only') {
|
||||
return { error: `Tool is catalog-only and cannot be executed directly: ${bin}` };
|
||||
}
|
||||
if (bin === 'curl') {
|
||||
const overrideFlag = findCurlTransportOverrideFlag(args);
|
||||
if (overrideFlag) {
|
||||
return { error: `curl flag ${overrideFlag} changes the effective network destination and is not allowed through /api/tools/execute.` };
|
||||
}
|
||||
if (countCurlUrlOperands(args) > 1) {
|
||||
return { error: 'curl commands with multiple URL operands are not allowed through /api/tools/execute; submit one transfer per approved target.' };
|
||||
}
|
||||
}
|
||||
return { bin, args };
|
||||
}
|
||||
|
||||
@@ -508,6 +608,7 @@ interface ApprovalRequest {
|
||||
target: string;
|
||||
reason: string;
|
||||
status: ApprovalStatus;
|
||||
wildcardOptIn?: boolean;
|
||||
operationId?: string;
|
||||
requestedBy: DraftSource | 'system';
|
||||
createdAt: string;
|
||||
@@ -1092,10 +1193,29 @@ function approvalIsFresh(approval: ApprovalRequest): boolean {
|
||||
return Date.parse(approval.expiresAt) > Date.now();
|
||||
}
|
||||
|
||||
function targetUsesWildcard(target: string): boolean {
|
||||
const raw = normalizeTargetValue(target).toLowerCase();
|
||||
if (raw === '*') return true;
|
||||
const withoutScheme = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
||||
return withoutScheme.startsWith('*.') || hostFromTarget(raw).startsWith('*.');
|
||||
}
|
||||
|
||||
function wildcardHostFromTarget(target: string): string {
|
||||
const raw = normalizeTargetValue(target).toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
||||
return raw.replace(/\/.*$/, '').replace(/:\d+$/, '');
|
||||
}
|
||||
|
||||
function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: string): boolean {
|
||||
if (approval.action !== action) return false;
|
||||
if (approval.target === '*') return action === 'model_call' || action === 'autonomous_execution';
|
||||
return hostFromTarget(approval.target) === hostFromTarget(target);
|
||||
if (targetUsesWildcard(approval.target) && !approval.wildcardOptIn) return false;
|
||||
const approvalHost = targetUsesWildcard(approval.target) ? wildcardHostFromTarget(approval.target) : hostFromTarget(approval.target);
|
||||
const targetHost = hostFromTarget(target);
|
||||
if (approvalHost.startsWith('*.')) {
|
||||
const suffix = approvalHost.slice(1);
|
||||
return targetHost.endsWith(suffix) && targetHost !== approvalHost.slice(2);
|
||||
}
|
||||
return approvalHost === targetHost;
|
||||
}
|
||||
|
||||
function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] {
|
||||
@@ -1105,6 +1225,15 @@ function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget
|
||||
.filter(target => hostFromTarget(target) !== approvedHost);
|
||||
}
|
||||
|
||||
function ensureExecTargetsAuthorized(
|
||||
targets: string[],
|
||||
approvedTarget: string,
|
||||
body: Record<string, unknown>,
|
||||
): string[] {
|
||||
return ensureExecTargetsWithinApprovedTarget(targets, approvedTarget)
|
||||
.filter(target => !findApproval(body, 'autonomous_execution', target));
|
||||
}
|
||||
|
||||
function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
|
||||
if (!approvalMatches(approval, action, target)) return false;
|
||||
if (approval.operationId && operationId && approval.operationId !== operationId) return false;
|
||||
@@ -1112,10 +1241,17 @@ function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction
|
||||
}
|
||||
|
||||
function findApproval(body: Record<string, unknown>, action: GuardAction, target: string): ApprovalRequest | null {
|
||||
const id = typeof body.approvalId === 'string' ? body.approvalId : '';
|
||||
const approval = id ? approvalRequests.get(id) : undefined;
|
||||
if (!approval || !approvalIsFresh(approval) || !approvalMatches(approval, action, target)) return null;
|
||||
return approval;
|
||||
const ids = [
|
||||
typeof body.approvalId === 'string' ? body.approvalId : '',
|
||||
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const id of ids) {
|
||||
const approval = approvalRequests.get(id);
|
||||
if (approval && approvalIsFresh(approval) && approvalMatches(approval, action, target)) return approval;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createApprovalRequest(action: GuardAction, target: string, reason: string, body: Record<string, unknown>): ApprovalRequest {
|
||||
@@ -1126,6 +1262,7 @@ function createApprovalRequest(action: GuardAction, target: string, reason: stri
|
||||
target,
|
||||
reason,
|
||||
status: 'pending',
|
||||
wildcardOptIn: body.allowWildcard === true && targetUsesWildcard(target),
|
||||
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
|
||||
requestedBy: ['human', 'agent', 't3mp3st'].includes(String(body.source)) ? body.source as DraftSource : 'system',
|
||||
createdAt: nowIso(),
|
||||
@@ -4241,7 +4378,7 @@ function healthPayload(): Record<string, unknown> {
|
||||
version: '0.2.1',
|
||||
apiVersion: 'v1',
|
||||
llm: {
|
||||
configured: Boolean(llmConfig.apiKey) || llmConfig.provider === 'codex',
|
||||
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
|
||||
connected: Boolean(llm) || llmConfig.provider === 'codex',
|
||||
provider: llmConfig.provider,
|
||||
model: llmConfig.model,
|
||||
@@ -4640,7 +4777,10 @@ export function broadcastEvent(event: string, data: Record<string, unknown>): vo
|
||||
const MAX_SSE_CLIENTS = 64;
|
||||
app.get('/api/events', (_req: Request, res: Response) => {
|
||||
const origin = _req.get('origin');
|
||||
if (origin && !isLoopbackOrigin(origin)) {
|
||||
// #84: allow the operator's own same-origin UI on a non-loopback (opted-in) bind; foreign
|
||||
// origins still fail the loopback check AND the Host match, so they remain rejected.
|
||||
const sameOriginNetworkBind = !HOST_IS_LOOPBACK && isSameOriginAsHost(origin, _req.headers.host);
|
||||
if (origin && !isLoopbackOrigin(origin) && !sameOriginNetworkBind) {
|
||||
res.status(403).json({
|
||||
error: 'Cross-origin event stream rejected',
|
||||
detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.',
|
||||
@@ -4803,6 +4943,15 @@ app.post('/api/approvals/request', (req: Request, res: Response) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
|
||||
res.status(400).json({
|
||||
error: 'Wildcard host approval requires explicit opt-in',
|
||||
action,
|
||||
target,
|
||||
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reason = typeof body.reason === 'string' && body.reason.trim()
|
||||
? body.reason.trim()
|
||||
: `Approval requested for ${action} against ${target}`;
|
||||
@@ -4826,6 +4975,68 @@ app.post('/api/approvals/:id/approve', (req: Request, res: Response) => {
|
||||
res.json(approval);
|
||||
});
|
||||
|
||||
app.post('/api/approvals/authorize-target', (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const target = normalizeTargetValue(body.target);
|
||||
const approvalIds = [
|
||||
typeof body.approvalId === 'string' ? body.approvalId : '',
|
||||
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
|
||||
].filter(Boolean);
|
||||
if (!approvalIds.length) {
|
||||
res.status(400).json({
|
||||
error: 'approvalId or approvalIds required',
|
||||
next: 'Request a pending receipt first, then approve that exact receipt id.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (target === '*') {
|
||||
res.status(400).json({
|
||||
error: 'Wildcard target authorization is not allowed for active actions',
|
||||
next: 'Authorize one concrete host or URL so receipts remain target-scoped.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
|
||||
res.status(400).json({
|
||||
error: 'Wildcard host authorization requires explicit opt-in',
|
||||
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ttlMinutes = Math.max(1, Math.min(30, Number(body.ttlMinutes || 30)));
|
||||
const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
|
||||
const approvals: ApprovalRequest[] = [];
|
||||
for (const id of [...new Set(approvalIds)]) {
|
||||
const approval = approvalRequests.get(id);
|
||||
if (!approval) {
|
||||
res.status(404).json({ error: 'Approval request not found', approvalId: id });
|
||||
return;
|
||||
}
|
||||
if (approval.status !== 'pending') {
|
||||
res.status(409).json({ error: 'Approval request is not pending', approvalId: id, status: approval.status });
|
||||
return;
|
||||
}
|
||||
if (target && !approvalMatches(approval, approval.action, target)) {
|
||||
res.status(400).json({
|
||||
error: 'Approval target mismatch',
|
||||
approvalId: id,
|
||||
approvalTarget: approval.target,
|
||||
target,
|
||||
});
|
||||
return;
|
||||
}
|
||||
approval.status = 'approved';
|
||||
approval.approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'local-operator';
|
||||
approval.expiresAt = expiresAt;
|
||||
approval.updatedAt = nowIso();
|
||||
emitContractEvent('approval.approved', { approvalId: approval.id, action: approval.action, target: approval.target });
|
||||
approvals.push(approval);
|
||||
}
|
||||
|
||||
res.json({ target: target || null, expiresAt, approvals });
|
||||
});
|
||||
|
||||
app.post('/api/approvals/:id/reject', (req: Request, res: Response) => {
|
||||
const approval = approvalRequests.get(req.params.id);
|
||||
if (!approval) {
|
||||
@@ -5872,7 +6083,8 @@ app.get('/api/llm/status', (_req: Request, res: Response) => {
|
||||
connected: !!llm,
|
||||
provider: llmConfig.provider,
|
||||
model: llmConfig.model,
|
||||
hasApiKey: !!llmConfig.apiKey
|
||||
hasApiKey: !!llmConfig.apiKey,
|
||||
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5964,8 +6176,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
targets = [],
|
||||
operators = [],
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
// OPTIONAL white-box source: an absolute path to a LOCAL repo you own. When
|
||||
// present, we ingest + security-rank it and hand the packed source to the
|
||||
// command via setWhiteboxSource BEFORE start(), so operators reason over the
|
||||
@@ -5973,16 +6185,24 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
repoPath,
|
||||
} = req.body;
|
||||
|
||||
// Use provided apiKey or fall back to server-configured one. Local-agent backends
|
||||
// (Claude Code / Codex / Hermes) need NO key — the agent uses its own login.
|
||||
// Use the request-selected backend, or fall back to the server's configured default.
|
||||
// Local/local-agent backends need no hosted API key.
|
||||
// SECURITY NOTE: apiKey is read from the request body (Authorization header is
|
||||
// preferred). Kept body-accepted for the same-origin UI; only reachable from
|
||||
// the local operator (loopback bind + origin guard). Header move is out of scope.
|
||||
const effectiveKey = apiKey || config.getLLMConfig().apiKey;
|
||||
if (providerNeedsApiKey(provider) && !effectiveKey) {
|
||||
const missionLLMConfig = resolveGeneralLLMConfig(provider, model, apiKey);
|
||||
const effectiveKey = missionLLMConfig.apiKey;
|
||||
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
|
||||
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
|
||||
return;
|
||||
}
|
||||
if (missionLLMConfig.provider === 'local-agent') {
|
||||
const localAgent = await requireLiveLocalAgent(missionLLMConfig.model);
|
||||
if (!localAgent.ok) {
|
||||
res.status(503).json({ error: localAgent.error });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
res.status(400).json({ error: 'At least one target required' });
|
||||
@@ -5995,6 +6215,14 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
if (!guard.allowed) { blockForApproval(res, guard); return; }
|
||||
}
|
||||
|
||||
if (provider === 'local-agent') {
|
||||
const localAgent = await requireLiveLocalAgent(model);
|
||||
if (!localAgent.ok) {
|
||||
res.status(503).json({ error: localAgent.error });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE
|
||||
// (canonicalize + confine to the allowed root) before we build/start the command,
|
||||
// so a bad/escaping path 400s cleanly instead of half-starting a run.
|
||||
@@ -6013,7 +6241,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
}
|
||||
|
||||
try {
|
||||
const cmd = createTempestCommandInstance(name, effectiveKey, provider, model);
|
||||
const cmd = createTempestCommandInstance(name, effectiveKey, missionLLMConfig.provider, missionLLMConfig.model);
|
||||
|
||||
// Add targets
|
||||
for (const t of targets) {
|
||||
@@ -6206,7 +6434,7 @@ app.post('/api/mission/resume', (_req: Request, res: Response) => {
|
||||
app.get('/api/mission/status', (_req: Request, res: Response) => {
|
||||
const cmd = getTempestCommand();
|
||||
if (!cmd) {
|
||||
res.json({ active: false });
|
||||
res.json({ active: false, progress: [], tasks: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6218,6 +6446,7 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
active: status.running,
|
||||
paused: status.paused,
|
||||
stallReason: status.stallReason,
|
||||
name: status.name,
|
||||
tickCount: status.tickCount,
|
||||
mission: mission ? {
|
||||
@@ -6235,6 +6464,8 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
|
||||
targets: status.targets,
|
||||
vault: status.vault,
|
||||
opsec: status.opsec,
|
||||
tasks: status.tasks,
|
||||
progress: status.progress,
|
||||
findings: findings.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
@@ -6471,13 +6702,29 @@ function providerNeedsApiKey(provider: string): boolean {
|
||||
return !['codex', 'mock', 'local', 'local-agent'].includes(provider);
|
||||
}
|
||||
|
||||
function providerRunsKeyless(provider: string): boolean {
|
||||
return !providerNeedsApiKey(provider);
|
||||
}
|
||||
|
||||
function readPositiveTimeoutEnv(name: string): number | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === '') return undefined;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readGeneralTimeoutEnv(): number | undefined {
|
||||
return readPositiveTimeoutEnv('TEMPEST_GENERAL_TIMEOUT_MS')
|
||||
?? readPositiveTimeoutEnv('T3MP3ST_GENERAL_TIMEOUT_MS');
|
||||
}
|
||||
|
||||
// SECURITY NOTE: `apiKey` is accepted from the request BODY here (and in the
|
||||
// mission/general routes that call this). Sending secrets in the body is not
|
||||
// ideal — an Authorization header is preferred — but the same-origin UI posts
|
||||
// the key in the body, so we accept it to avoid breaking it. Moving to a header
|
||||
// needs a coordinated UI change and is out of scope. The body key is only ever
|
||||
// reachable from the local operator (loopback bind + origin guard).
|
||||
function resolveGeneralLLMConfig(provider: string, model: string | undefined, apiKey: string | undefined): {
|
||||
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined): {
|
||||
provider: any;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
@@ -6485,7 +6732,8 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
|
||||
temperature: number;
|
||||
timeout: number;
|
||||
} {
|
||||
const selectedProvider = provider || 'openrouter';
|
||||
const defaultConfig = config.getLLMConfig();
|
||||
const selectedProvider = provider || defaultConfig.provider;
|
||||
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the
|
||||
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
|
||||
if (selectedProvider === 'local-agent') {
|
||||
@@ -6494,7 +6742,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
|
||||
model: model || 'codex',
|
||||
maxTokens: 8192,
|
||||
temperature: 0.4,
|
||||
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000,
|
||||
timeout: readGeneralTimeoutEnv()
|
||||
?? readPositiveTimeoutEnv('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS')
|
||||
?? 600000,
|
||||
};
|
||||
}
|
||||
const baseConfig = config.getLLMConfig(selectedProvider as any, model);
|
||||
@@ -6508,7 +6758,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
|
||||
apiKey: effectiveKey,
|
||||
maxTokens: 8192,
|
||||
temperature: 0.4,
|
||||
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, // General planning needs room (was a hardcoded 60s); override via env
|
||||
timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6544,8 +6794,6 @@ function bringUpMissionFromPlan(
|
||||
async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: string; stderr: string }> {
|
||||
const marker = 'T3MP3ST_CODEX_READY';
|
||||
const args = [
|
||||
'--ask-for-approval',
|
||||
'never',
|
||||
'exec',
|
||||
'-c',
|
||||
'model_reasoning_effort="low"',
|
||||
@@ -6571,7 +6819,7 @@ async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: st
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error('Codex exec readiness probe timed out'));
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
// Bounded accumulation so a runaway/verbose child can't grow these strings without limit
|
||||
// before the 30s timer fires (matches the local-agent caps). A normal probe emits a tiny
|
||||
@@ -6623,7 +6871,7 @@ app.get('/api/codex/status', async (_req: Request, res: Response): Promise<void>
|
||||
tokenHandling: 'no token is accepted by or returned from T3MP3ST',
|
||||
command,
|
||||
version: stdout.trim(),
|
||||
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
|
||||
executionMode: 'codex exec --ephemeral --sandbox read-only',
|
||||
execProbe: 'POST /api/codex/probe', // exec self-test moved off GET (B-04)
|
||||
});
|
||||
} catch (error: any) {
|
||||
@@ -6643,7 +6891,7 @@ app.post('/api/codex/probe', async (_req: Request, res: Response): Promise<void>
|
||||
provider: 'codex',
|
||||
command,
|
||||
version: stdout.trim(),
|
||||
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
|
||||
executionMode: 'codex exec --ephemeral --sandbox read-only',
|
||||
};
|
||||
try {
|
||||
const probe = await runCodexExecReadinessProbe(command);
|
||||
@@ -6676,8 +6924,8 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
|
||||
urgency,
|
||||
opsecPreference,
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
if (!objective) {
|
||||
@@ -6750,8 +6998,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
let generalConfig;
|
||||
@@ -6863,8 +7111,8 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
|
||||
urgency,
|
||||
opsecPreference,
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
if (!objective) {
|
||||
@@ -7122,7 +7370,7 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad
|
||||
*/
|
||||
app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { messages, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { messages, provider, model, apiKey } = req.body as {
|
||||
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
@@ -7152,7 +7400,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
|
||||
*/
|
||||
app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as {
|
||||
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : '';
|
||||
@@ -7187,7 +7435,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
|
||||
*/
|
||||
app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { brief, confirmed, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { brief, confirmed, provider, model, apiKey } = req.body as {
|
||||
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
if (!brief || !brief.objective || !brief.target) {
|
||||
@@ -7242,12 +7490,20 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
|
||||
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
|
||||
return;
|
||||
}
|
||||
const outOfScopeTargets = ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target);
|
||||
const outOfScopeTargets = ensureExecTargetsAuthorized(execConfig.targets, brief.target, req.body as Record<string, unknown>);
|
||||
if (outOfScopeTargets.length) {
|
||||
const approvals = outOfScopeTargets.map(target => createApprovalRequest(
|
||||
'autonomous_execution',
|
||||
target,
|
||||
`Admiral plan expanded execution scope from ${brief.target} to ${target}`,
|
||||
req.body as Record<string, unknown>
|
||||
));
|
||||
res.status(403).json({
|
||||
error: 'Admiral LIVE plan contains targets outside the approved brief target',
|
||||
error: 'Admiral LIVE plan needs approval for expanded target scope',
|
||||
approvedTarget: brief.target,
|
||||
outOfScopeTargets,
|
||||
approvals,
|
||||
next: 'Approve the expanded target receipts, then retry /api/admiral/launch with approvalIds.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -7351,8 +7607,51 @@ app.get('/api/bounty/credentials', (_req: Request, res: Response) => {
|
||||
// Detect agent CLIs that are already installed + logged-in on this machine and enlist them as
|
||||
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see
|
||||
// src/agent/local-agents.ts). In-memory registry of agents connected this session:
|
||||
const connectedLocalAgents = new Map<string, { id: string; label: string; version?: string; connectedAt: number; lastPing?: AgentRunSummary | null }>();
|
||||
type AgentRunSummary = { ok: boolean; latencyMs: number; output: string; error?: string };
|
||||
type ConnectedLocalAgent = {
|
||||
id: string;
|
||||
label: string;
|
||||
version?: string;
|
||||
connectedAt: number;
|
||||
lastPing?: AgentRunSummary | null;
|
||||
lastHealthCheckAt?: number;
|
||||
};
|
||||
const connectedLocalAgents = new Map<string, ConnectedLocalAgent>();
|
||||
const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
|
||||
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 45_000;
|
||||
|
||||
async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
const due = Array.from(connectedLocalAgents.values()).filter((agent) => (
|
||||
force ||
|
||||
!agent.lastHealthCheckAt ||
|
||||
now - agent.lastHealthCheckAt > LOCAL_AGENT_HEALTH_TTL_MS
|
||||
));
|
||||
|
||||
await Promise.all(due.map(async (agent) => {
|
||||
const result = await pingLocalAgent(agent.id, undefined, LOCAL_AGENT_HEALTH_TIMEOUT_MS);
|
||||
const current = connectedLocalAgents.get(agent.id);
|
||||
if (!current) return;
|
||||
current.lastPing = result;
|
||||
current.lastHealthCheckAt = Date.now();
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
|
||||
const id = String(model || 'codex').trim();
|
||||
const entry = connectedLocalAgents.get(id);
|
||||
if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` };
|
||||
|
||||
await refreshConnectedLocalAgentHealth(true);
|
||||
const refreshed = connectedLocalAgents.get(id);
|
||||
if (refreshed?.lastPing?.ok) return { ok: true, id };
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `local agent "${id}" is connected but not live` +
|
||||
(refreshed?.lastPing?.error ? ` — ${refreshed.lastPing.error}` : ''),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent)
|
||||
app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise<void> => {
|
||||
@@ -7379,7 +7678,14 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi
|
||||
if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; }
|
||||
let ping: AgentRunSummary | null = null;
|
||||
if (doPing) ping = await pingLocalAgent(id);
|
||||
connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping });
|
||||
connectedLocalAgents.set(id, {
|
||||
id,
|
||||
label: d.label,
|
||||
version: d.version,
|
||||
connectedAt: Date.now(),
|
||||
lastPing: ping,
|
||||
lastHealthCheckAt: ping ? Date.now() : undefined,
|
||||
});
|
||||
results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping });
|
||||
}
|
||||
syncLocalAgentSelection(connectedLocalAgents, ids, replace);
|
||||
@@ -7392,7 +7698,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise<
|
||||
if (!body.id) { res.status(400).json({ error: 'id required' }); return; }
|
||||
const r = await pingLocalAgent(body.id, body.prompt);
|
||||
const entry = connectedLocalAgents.get(body.id);
|
||||
if (entry) entry.lastPing = r;
|
||||
if (entry) {
|
||||
entry.lastPing = r;
|
||||
entry.lastHealthCheckAt = Date.now();
|
||||
}
|
||||
res.json({ id: body.id, ...r });
|
||||
});
|
||||
|
||||
@@ -7412,8 +7721,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void =>
|
||||
res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) });
|
||||
});
|
||||
|
||||
// GET /api/agents/local/status — currently connected agents
|
||||
app.get('/api/agents/local/status', (_req: Request, res: Response): void => {
|
||||
// GET /api/agents/local/status — connected agents, optionally with a bounded live health check.
|
||||
app.get('/api/agents/local/status', async (req: Request, res: Response): Promise<void> => {
|
||||
const check = /^(1|true|yes|on)$/i.test(String(req.query.check || ''));
|
||||
if (check) await refreshConnectedLocalAgentHealth(false);
|
||||
res.json({ connected: Array.from(connectedLocalAgents.values()) });
|
||||
});
|
||||
|
||||
|
||||
+5
-15
@@ -281,13 +281,14 @@ async function runSetup(): Promise<void> {
|
||||
'Welcome to T3MP3ST Setup',
|
||||
`This wizard will help you configure T3MP3ST for first use.
|
||||
|
||||
You'll need an API key from one of these providers:
|
||||
You can use a local model with no API key, or add a key from one of these providers:
|
||||
• ${chalk.cyan('Local')} - Ollama / LM Studio / vLLM via TEMPEST_LOCAL_BASE_URL
|
||||
• ${chalk.cyan('OpenRouter')} (recommended) - Access multiple models
|
||||
• ${chalk.cyan('Anthropic')} - Direct Claude access
|
||||
• ${chalk.cyan('OpenAI')} - GPT models
|
||||
|
||||
The setup will guide you through:
|
||||
1. Adding your API key(s)
|
||||
1. Adding API key(s), if you use a hosted provider
|
||||
2. Selecting your default provider and model
|
||||
3. Configuring basic settings`,
|
||||
'cyan'
|
||||
@@ -365,12 +366,11 @@ async function setupApiKeys(): Promise<void> {
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'providers',
|
||||
message: 'Which API keys would you like to configure?',
|
||||
message: 'Which hosted API keys would you like to configure? Leave empty to use a local model only.',
|
||||
choices: [
|
||||
{
|
||||
name: `OpenRouter ${hasApiKey('openrouter') ? chalk.green('(configured)') : chalk.yellow('(recommended)')}`,
|
||||
value: 'openrouter',
|
||||
checked: !hasApiKey('openrouter'),
|
||||
},
|
||||
{
|
||||
name: `Venice ${hasApiKey('venice') ? chalk.green('(configured)') : ''}`,
|
||||
@@ -409,22 +409,12 @@ async function setupApiKeys(): Promise<void> {
|
||||
async function setupProvider(): Promise<void> {
|
||||
const configuredProviders = [];
|
||||
|
||||
configuredProviders.push({ name: 'Local model (Ollama / LM Studio / vLLM, no API key)', value: 'local' });
|
||||
if (hasApiKey('openrouter')) configuredProviders.push({ name: 'OpenRouter', value: 'openrouter' });
|
||||
if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' });
|
||||
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
|
||||
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
|
||||
|
||||
if (configuredProviders.length === 0) {
|
||||
showWarning('No API keys configured. Please add at least one API key first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (configuredProviders.length === 1) {
|
||||
config.setDefaultProvider(configuredProviders[0].value as any);
|
||||
showSuccess(`Default provider set to: ${configuredProviders[0].name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
|
||||
+7
-1
@@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter<TargetEvents> {
|
||||
|
||||
export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target {
|
||||
const parsed = new URL(url);
|
||||
const normalizedUrl = parsed.toString();
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'):
|
||||
type: 'web_application',
|
||||
zone,
|
||||
status: 'discovered',
|
||||
address: url,
|
||||
address: parsed.hostname,
|
||||
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
|
||||
protocol: parsed.protocol.replace(':', ''),
|
||||
services: [],
|
||||
vulnerabilities: [],
|
||||
credentials: [],
|
||||
metadata: {
|
||||
url: normalizedUrl,
|
||||
origin: parsed.origin,
|
||||
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
|
||||
},
|
||||
discoveredAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
+18
-1
@@ -6,7 +6,7 @@
|
||||
// LLM CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'codex' | 'mock' | 'local' | 'local-agent';
|
||||
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'codex' | 'mock' | 'local' | 'local-agent';
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: LLMProvider;
|
||||
@@ -426,6 +426,7 @@ export interface ToolFinding {
|
||||
details: string;
|
||||
cvss?: number;
|
||||
cve?: string[];
|
||||
cwe?: string[];
|
||||
remediation?: string;
|
||||
/**
|
||||
* How this finding was produced — the provenance flag the honesty gate keys on:
|
||||
@@ -534,6 +535,21 @@ export interface Appendix {
|
||||
// EVENT TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ScanProgressEvent {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
kind: 'task_started' | 'thinking' | 'tool_call' | 'tool_result' | 'task_completed' | 'task_failed';
|
||||
operatorId: string;
|
||||
callsign: string;
|
||||
archetype: OperatorArchetype;
|
||||
taskId?: string;
|
||||
taskName?: string;
|
||||
toolName?: string;
|
||||
source?: 'agent' | 'backend_seeded';
|
||||
detail: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandEvents {
|
||||
'command:started': void;
|
||||
'command:stopped': void;
|
||||
@@ -547,6 +563,7 @@ export interface CommandEvents {
|
||||
'target:owned': { target: Target; operatorId: string };
|
||||
'detection:triggered': DetectionEvent;
|
||||
'mission:phase_changed': { missionId: string; phase: KillChainPhase };
|
||||
'scan:progress': ScanProgressEvent;
|
||||
'abort:recommended': string;
|
||||
/** A capability-approval gate decision (allowed/denied) on an intrusive/dangerous tool — bridged to
|
||||
* the dashboard's live approval/audit feed. Structural match for arsenal/approval.ts ApprovalRecord. */
|
||||
|
||||
Reference in new issue
Block a user