* test(obsidivm): RED — bridge should give an actionable error when the service is down The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not running the bridge throws a bare 'fetch failed' (surfaced as 'FATAL: fetch failed'), naming neither the service, the URL, nor how to start it. Pins that a connection failure becomes an actionable, self-service error. Fails on main: the message is 'fetch failed'. Refs #156 * fix(obsidivm): actionable error when the range service is unreachable The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not running the bridge's shared call() let the raw 'fetch failed' propagate, which surfaced as an opaque 'FATAL: fetch failed' (#156) naming neither the service, the URL, nor how to start it — even in --hunter stub, whose grading step still needs it. Wrap the fetch in call() (the single choke point for every OBSIDIVM request) so a connection failure becomes: 'OBSIDIVM service unreachable at <url> ... Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set OBSIDIVM_URL', flagged err.unreachable to distinguish it from an HTTP-status error; a timeout is reported distinctly. Clarify in the bench --help/usage that every hunter mode (stub included) needs the OBSIDIVM service, live/t3mp3st also a key. Adds scripts/obsidivm-bridge.d.mts so the new test types cleanly. Closes #156 * fix(obsidivm): distinguish invalid-URL from unreachable; test timeout path Adversarial review of the diff found two gaps: - A malformed OBSIDIVM_URL (ERR_INVALID_URL) was folded into the 'service unreachable — is it running?' path, misdirecting the user to start a service when the real fix is the URL. Now branched to a distinct 'Invalid OBSIDIVM base URL … set OBSIDIVM_URL' error, no err.unreachable flag. - The timeout (AbortError) branch had no coverage. Added a test using a never-responding TCP server + short timeoutMs asserting 'timed out after Nms' and NOT unreachable; and a test for the invalid-URL branch. Also completed the .d.mts stub with the cloudgoat* methods it was missing. Refs #156
208 lines
9.0 KiB
JavaScript
208 lines
9.0 KiB
JavaScript
/**
|
|
* T3MP3ST ↔ OBSIDIVM bridge.
|
|
*
|
|
* Thin HTTP client over OBSIDIVM's range API (default http://127.0.0.1:4200).
|
|
* Importable as a module from other scripts:
|
|
*
|
|
* import { obsidivm } from './obsidivm-bridge.mjs';
|
|
* const o = obsidivm();
|
|
* const spec = await o.getSpec();
|
|
* const verdict = await o.scoreText('dvwa', agentOutput);
|
|
* const run = await o.createRun({...});
|
|
* await o.attachSession(run.run_id, {...});
|
|
*
|
|
* Also runnable as a CLI for quick smoke checks:
|
|
*
|
|
* node scripts/obsidivm-bridge.mjs health
|
|
* node scripts/obsidivm-bridge.mjs spec
|
|
* node scripts/obsidivm-bridge.mjs status
|
|
* node scripts/obsidivm-bridge.mjs score <target_id> <text-file>
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
export function obsidivm({ baseUrl, timeoutMs } = {}) {
|
|
const base = (baseUrl || process.env.OBSIDIVM_URL || 'http://127.0.0.1:4200').replace(/\/$/, '');
|
|
const t = timeoutMs || 30000;
|
|
|
|
async function call(method, path, body) {
|
|
const url = base + path;
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), t);
|
|
try {
|
|
let res;
|
|
try {
|
|
res = await fetch(url, {
|
|
method,
|
|
headers: body ? { 'content-type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: ctrl.signal,
|
|
});
|
|
} catch (e) {
|
|
// The fetch itself failed — distinct from an HTTP-status error below. A
|
|
// bare `fetch failed` here is the #156 dead-end; make it self-service.
|
|
// Three sub-cases, kept distinct so the message points at the real fix:
|
|
// AbortError → the request timed out
|
|
// ERR_INVALID_URL → OBSIDIVM_URL is malformed (a config error, NOT a
|
|
// down service — don't send the user to start one)
|
|
// otherwise → nothing is listening on the base URL
|
|
if (e?.name === 'AbortError') {
|
|
throw new Error(`OBSIDIVM ${method} ${path} timed out after ${t}ms (${base}).`);
|
|
}
|
|
if (e?.cause?.code === 'ERR_INVALID_URL') {
|
|
throw new Error(
|
|
`Invalid OBSIDIVM base URL ${JSON.stringify(base)} — set OBSIDIVM_URL (or --obsidivm) ` +
|
|
`to a full URL like http://127.0.0.1:4200.`,
|
|
);
|
|
}
|
|
const err = new Error(
|
|
`OBSIDIVM service unreachable at ${base} — ${method} ${path} failed ` +
|
|
`(${e?.cause?.code || e?.message || 'network error'}). Is it running? ` +
|
|
`Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set OBSIDIVM_URL.`,
|
|
);
|
|
err.unreachable = true;
|
|
err.cause = e;
|
|
throw err;
|
|
}
|
|
const text = await res.text();
|
|
let parsed;
|
|
try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; }
|
|
if (!res.ok) {
|
|
const err = new Error(`OBSIDIVM ${method} ${path} → ${res.status}: ${(parsed.error || text).slice(0, 200)}`);
|
|
err.status = res.status;
|
|
err.body = parsed;
|
|
throw err;
|
|
}
|
|
return parsed;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
return {
|
|
baseUrl: base,
|
|
|
|
// --- discovery --------------------------------------------------------
|
|
async health() { return call('GET', '/api/health'); },
|
|
async getSpec() { return call('GET', '/api/spec'); },
|
|
async status() { return call('GET', '/api/status'); },
|
|
|
|
// --- target lifecycle -------------------------------------------------
|
|
async deploy() { return call('POST', '/api/deploy'); },
|
|
async destroy() { return call('POST', '/api/destroy'); },
|
|
async startTarget(id) { return call('POST', `/api/start/${encodeURIComponent(id)}`); },
|
|
async stopTarget(id) { return call('POST', `/api/stop/${encodeURIComponent(id)}`); },
|
|
|
|
// --- evidence scoring -------------------------------------------------
|
|
/**
|
|
* Submit an agent's free-form output text for evidence-aware scoring
|
|
* against a target's canonical expected findings.
|
|
*
|
|
* @param {string} targetId - one of /api/spec[].id (e.g. 'dvwa')
|
|
* @param {string} text - the agent's transcript / report
|
|
* @returns {Promise<{
|
|
* target_id: string, found: number, total: number,
|
|
* weighted_percent: number, grade: string,
|
|
* results: Array<{id, title, severity, detected, evidence}>
|
|
* }>}
|
|
*/
|
|
async scoreText(targetId, text) {
|
|
return call('POST', '/api/score/text', { target_id: targetId, text });
|
|
},
|
|
|
|
// --- run ledger -------------------------------------------------------
|
|
async createRun(payload = {}) { return call('POST', '/api/runs', payload); },
|
|
async attachSession(runId, payload = {}) {
|
|
return call('POST', `/api/runs/${encodeURIComponent(runId)}/sessions`, payload);
|
|
},
|
|
|
|
// --- evolution + goalposts -------------------------------------------
|
|
async proposeGoalposts(payload = {}) { return call('POST', '/api/goalposts/propose', payload); },
|
|
async evolveStart(payload = {}) { return call('POST', '/api/evolve/start', payload); },
|
|
async evolveStop() { return call('POST', '/api/evolve/stop', {}); },
|
|
|
|
// --- cloudgoat (optional, mostly skip) -------------------------------
|
|
async cloudgoatInstall() { return call('POST', '/api/cloudgoat/install'); },
|
|
async cloudgoatCreate(scenario) { return call('POST', `/api/cloudgoat/create/${encodeURIComponent(scenario)}`); },
|
|
async cloudgoatDestroy(scenario) { return call('POST', `/api/cloudgoat/destroy/${encodeURIComponent(scenario)}`); },
|
|
async cloudgoatDestroyAll() { return call('POST', '/api/cloudgoat/destroy-all'); },
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cli(argv) {
|
|
const [cmd, ...rest] = argv;
|
|
const o = obsidivm();
|
|
switch (cmd) {
|
|
case undefined:
|
|
case '-h':
|
|
case '--help':
|
|
console.log(`obsidivm-bridge CLI
|
|
|
|
health Ping the range
|
|
spec Fetch canonical /api/spec
|
|
status Target / Docker status
|
|
score <target_id> <textfile> POST <textfile> contents to /api/score/text
|
|
start <target_id> docker run a single target
|
|
stop <target_id> docker rm a single target
|
|
deploy deploy all (slow, downloads images)
|
|
destroy tear all down
|
|
evolve-start kick off evolution loop
|
|
evolve-stop stop evolution
|
|
|
|
Env:
|
|
OBSIDIVM_URL default http://127.0.0.1:4200
|
|
`);
|
|
return 0;
|
|
case 'health': console.log(JSON.stringify(await o.health(), null, 2)); return 0;
|
|
case 'spec': {
|
|
const s = await o.getSpec();
|
|
console.log(`version: ${s.version} expected: ${s.expected_count} targets: ${(s.targets || []).length}`);
|
|
console.log(`severity weights: ${JSON.stringify(s.severity_weights)}`);
|
|
console.log(`targets:`);
|
|
for (const t of s.targets || []) {
|
|
console.log(` ${t.id.padEnd(12)} :${t.port} expected=${(t.expected || t.expected_findings || []).length} (${t.name})`);
|
|
}
|
|
return 0;
|
|
}
|
|
case 'status': console.log(JSON.stringify(await o.status(), null, 2)); return 0;
|
|
case 'score': {
|
|
const [tid, file] = rest;
|
|
if (!tid || !file) { console.error('usage: score <target_id> <textfile>'); return 2; }
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
const v = await o.scoreText(tid, text);
|
|
console.log(`${v.target_id} ${v.found}/${v.total} weighted=${v.weighted_percent}% grade=${v.grade}`);
|
|
const hits = (v.results || []).filter(r => r.detected);
|
|
for (const h of hits) {
|
|
const kw = h.evidence && h.evidence.keyword ? ` kw=${h.evidence.keyword}` : '';
|
|
console.log(` ✓ [${h.severity.toUpperCase().padEnd(8)}] ${h.title}${kw}`);
|
|
}
|
|
const miss = (v.results || []).filter(r => !r.detected);
|
|
for (const m of miss) {
|
|
console.log(` · [${m.severity.toUpperCase().padEnd(8)}] ${m.title}`);
|
|
}
|
|
return 0;
|
|
}
|
|
case 'start': console.log(JSON.stringify(await o.startTarget(rest[0]), null, 2)); return 0;
|
|
case 'stop': console.log(JSON.stringify(await o.stopTarget(rest[0]), null, 2)); return 0;
|
|
case 'deploy': console.log(JSON.stringify(await o.deploy(), null, 2)); return 0;
|
|
case 'destroy': console.log(JSON.stringify(await o.destroy(), null, 2)); return 0;
|
|
case 'evolve-start': console.log(JSON.stringify(await o.evolveStart(), null, 2)); return 0;
|
|
case 'evolve-stop': console.log(JSON.stringify(await o.evolveStop(), null, 2)); return 0;
|
|
default:
|
|
console.error(`unknown command: ${cmd}`);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
cli(process.argv.slice(2))
|
|
.then(code => process.exit(code))
|
|
.catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
|
}
|