mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +02:00
feat: wire capability tokens and the audit trail through CLI, REPL and web
The risk model, grants and hash-chained trail existed as modules nothing called. Now every engagement runs under them. Capability - `neurosploit capability issue|verify` mints and inspects grants. - `--capability-token` (global, so the REPL takes it too), `--in-scope`, `--environment`, `--policy` on `run`; verification happens at the command line, so an invalid grant fails with a readable message instead of halfway through an engagement. - The pipeline verifies before anything else and REFUSES to run on a token that does not verify — proceeding would mean acting on an authorization nobody can prove was issued. `effective_scope` then applies the grant as a ceiling. - Web: an Authorization tab carrying the token, extra hosts, environment and policy profile. The browser decodes the claims for display and says plainly that it is not verifying them — a "valid" badge from a party without the key would be the UI vouching for something it cannot check. A hole the smoke test found: `/inscope evil.test` inside a session under a grant WIDENED the scope past it — the one thing a capability token exists to prevent. The run itself would still have been constrained (the pipeline re-applies the grant), but `/policy` reported a boundary that was not real, and a tool that misreports its own limits is worse than one with none. Scope mutations now re-apply the ceiling and name what it refused. Session authorization also arrives from argv rather than a `/`-command, because a session that can widen its own grant is not constrained by one. Audit - One hash-chained record per action in `<run>/audit.jsonl`, in the specified shape, covering engagement start/end, validator rejections, findings that reach the report (with the hash of the evidence behind them) and findings withheld for being out of scope. - The run verifies its own chain at the end and says loudly if it is broken. - `/audit [n]` tails the trail and verifies it; the web offers it as a download next to the report, so "show me what the tool did" is a link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3456c32f4d
commit
481a4eb1b9
@@ -27,6 +27,8 @@ const state = {
|
||||
providers: [],
|
||||
auth: { header: '', roles: [] },
|
||||
credsPath: '',
|
||||
// Engagement authorization: the grant, plus settings that may only narrow it.
|
||||
authz: { capability: '', inScope: '', environment: 'production', policyProfile: 'web' },
|
||||
keys: [],
|
||||
runs: [],
|
||||
currentJob: null,
|
||||
@@ -496,6 +498,10 @@ async function startExploitation() {
|
||||
auth: state.auth.header || undefined,
|
||||
roles: state.auth.roles.length ? state.auth.roles : undefined,
|
||||
creds: state.credsPath || undefined,
|
||||
capability: state.authz.capability || undefined,
|
||||
inScope: state.authz.inScope.split(/[,;\s]+/).filter(Boolean),
|
||||
environment: state.authz.environment,
|
||||
policyProfile: state.authz.policyProfile,
|
||||
};
|
||||
|
||||
$('#btnLaunch').disabled = true;
|
||||
@@ -1566,6 +1572,13 @@ async function loadDetail(id) {
|
||||
// The PDF is produced by the harness (Typst) when that binary is present, so
|
||||
// it is offered only when it actually exists — a dead download button is
|
||||
// worse than none.
|
||||
// The audit trail travels with the run's evidence; offering it here is what
|
||||
// makes "show me what the tool did" a link rather than a support request.
|
||||
const auditLink = $('#detailOpenAudit');
|
||||
if (detail.assets.includes('audit.jsonl')) {
|
||||
auditLink.href = `/api/runs/${encodeURIComponent(id)}/asset/audit.jsonl`;
|
||||
show(auditLink, true);
|
||||
} else show(auditLink, false);
|
||||
const pdfLink = $('#detailOpenPdf');
|
||||
if (detail.assets.includes('report.pdf')) {
|
||||
pdfLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.pdf`;
|
||||
@@ -1599,6 +1612,31 @@ $('#authHeader').addEventListener('input', (e) => { state.auth.header = e.target
|
||||
$('#authHeader').value = state.auth.header;
|
||||
$('#credsPath').addEventListener('input', (e) => { state.credsPath = e.target.value.trim(); });
|
||||
|
||||
$('#capToken').addEventListener('input', (e) => {
|
||||
state.authz.capability = e.target.value.trim();
|
||||
// Decode the claims for display only. This is NOT verification — the
|
||||
// signature is checked by the harness, which holds the key; showing a
|
||||
// "valid" badge here would be the browser vouching for something it cannot
|
||||
// check.
|
||||
const el = $('#capStatus');
|
||||
const t = state.authz.capability;
|
||||
if (!t) { el.textContent = ''; el.className = 'field-status'; return; }
|
||||
try {
|
||||
const body = t.replace(/^ns-cap\.v1\./, '').split('.')[0];
|
||||
const claims = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
const left = claims.expires_at ? Math.round((claims.expires_at - Date.now() / 1000) / 3600) : null;
|
||||
el.className = 'field-status ' + (left !== null && left <= 0 ? 'bad' : 'ok');
|
||||
el.textContent = `claims (unverified here — the harness checks the signature): ${claims.issuer} → ${claims.subject} · ${(claims.scope || []).join(', ')} · ${claims.environment} · max ${claims.max_action}` +
|
||||
(left === null ? '' : left <= 0 ? ' · EXPIRED' : ` · ${left}h left`);
|
||||
} catch {
|
||||
el.className = 'field-status bad';
|
||||
el.textContent = 'not a readable ns-cap.v1 token';
|
||||
}
|
||||
});
|
||||
$('#inScope').addEventListener('input', (e) => { state.authz.inScope = e.target.value; });
|
||||
$('#envSelect').addEventListener('change', (e) => { state.authz.environment = e.target.value; });
|
||||
$('#policySelect').addEventListener('change', (e) => { state.authz.policyProfile = e.target.value; });
|
||||
|
||||
function renderRoleList() {
|
||||
const root = $('#roleList');
|
||||
root.innerHTML = state.auth.roles.map((r, i) => `
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
<div class="run-actions">
|
||||
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
|
||||
<a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a>
|
||||
<button class="btn" id="btnDetailBack">← New engagement</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -372,6 +373,7 @@
|
||||
<button class="modal-tab active" data-mtab="target">Target auth</button>
|
||||
<button class="modal-tab" data-mtab="keys">API keys</button>
|
||||
<button class="modal-tab" data-mtab="creds">Creds file</button>
|
||||
<button class="modal-tab" data-mtab="authz">Authorization</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-panel" data-mpanel="target">
|
||||
@@ -391,6 +393,45 @@
|
||||
<div class="field-help" style="margin-bottom:12px;">Keys are kept in this server process's memory only — never written to disk. Cleared on restart.</div>
|
||||
<div id="providerKeyList"></div>
|
||||
</div>
|
||||
<div class="modal-panel" data-mpanel="authz" hidden>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="capToken">Capability token</label>
|
||||
<input id="capToken" type="text" placeholder="ns-cap.v1.…" spellcheck="false" />
|
||||
<div class="field-status" id="capStatus"></div>
|
||||
<div class="field-help">
|
||||
The signed grant for this engagement: who authorized it, which hosts, which environment, until when.
|
||||
It is verified by the harness (<code>NEUROSPLOIT_CAPABILITY_KEY</code>) and acts as a <strong>ceiling</strong> —
|
||||
the settings below can narrow the scope, never widen it. Mint one with
|
||||
<code>neurosploit capability issue</code>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="inScope">Additional authorized hosts</label>
|
||||
<input id="inScope" type="text" placeholder="app.example.com, *.api.example.com, 10.0.0.0/24" />
|
||||
<div class="field-help">Without this the engagement is authorized against the target and nothing else — discovering a host is not permission to test it.</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="envSelect">Environment</label>
|
||||
<select id="envSelect">
|
||||
<option value="production" selected>production</option>
|
||||
<option value="staging">staging</option>
|
||||
<option value="development">development</option>
|
||||
<option value="lab">lab</option>
|
||||
<option value="ot-production">ot-production (ICS/SCADA)</option>
|
||||
</select>
|
||||
<div class="field-help">Scales every risk score — the same action is a different act on a lab bench and on a live substation.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="policySelect">Policy profile</label>
|
||||
<select id="policySelect">
|
||||
<option value="web" selected>web — standard</option>
|
||||
<option value="ot">ot — read-only, paced, industrial writes blocked</option>
|
||||
</select>
|
||||
<div class="field-help">OT blocks writes, disruptive actions, fuzzing and exploit payloads over industrial protocols, and caps the rate at ~1 req/s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-panel" data-mpanel="creds" hidden>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="credsPath">creds.yaml path (overrides target auth above)</label>
|
||||
|
||||
@@ -564,6 +564,10 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.modal-body { padding: var(--sp-5); overflow-y: auto; flex: 1; }
|
||||
.modal-panel[hidden] { display: none; }
|
||||
|
||||
.field-status { font-size: 11.5px; font-family: var(--mono); color: var(--text-faint); overflow-wrap: anywhere; }
|
||||
.field-status.ok { color: var(--sev-low-fg); }
|
||||
.field-status.bad { color: var(--sev-critical-fg); }
|
||||
|
||||
.provider-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); }
|
||||
.provider-row:last-child { border-bottom: none; }
|
||||
.provider-row .p-name { flex: none; width: 150px; font-size: 12.5px; font-weight: 500; }
|
||||
|
||||
+24
-3
@@ -454,7 +454,7 @@ async function runDetail(id) {
|
||||
readJsonSafe(path.join(dir, 'status.json'), {}),
|
||||
readJsonSafe(path.join(dir, 'findings.json'), []),
|
||||
]);
|
||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md', 'audit.jsonl', 'graph.json']
|
||||
.filter((f) => fs.existsSync(path.join(dir, f)));
|
||||
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
|
||||
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs };
|
||||
@@ -588,6 +588,12 @@ function buildArgs(body) {
|
||||
if (body.focus) args.push('--focus', body.focus);
|
||||
if (body.objective) args.push('--objective', body.objective);
|
||||
if (body.outOfScope) args.push('--out-of-scope', body.outOfScope);
|
||||
// Authorization: the signed grant caps the scope, the extra in-scope entries
|
||||
// can only narrow within it, and the environment scales every risk score.
|
||||
for (const entry of body.inScope || []) args.push('--in-scope', entry);
|
||||
if (body.capability) args.push('--capability-token', body.capability);
|
||||
if (body.environment) args.push('--environment', body.environment);
|
||||
if (body.policyProfile) args.push('--policy', body.policyProfile);
|
||||
for (const a of body.agents || []) args.push('--only', a);
|
||||
args.push('--verbose');
|
||||
return args;
|
||||
@@ -635,6 +641,18 @@ async function startJob(body) {
|
||||
/// engagement (`/target`/`/repo` → `/model` → toggles → `/only` → `/run`).
|
||||
/// `/only` is what makes this equivalent to the CLI's `--only` — REPL had no
|
||||
/// such command before this feature (added to app/src/repl.rs alongside it).
|
||||
/// Flags that apply to every mode, including the REPL-backed one. The REPL
|
||||
/// takes them as argv because a `/`-command for an authorization ceiling would
|
||||
/// let the session widen its own grant mid-run.
|
||||
function authArgs(body) {
|
||||
const args = [];
|
||||
for (const entry of body.inScope || []) args.push('--in-scope', entry);
|
||||
if (body.capability) args.push('--capability-token', body.capability);
|
||||
if (body.environment) args.push('--environment', body.environment);
|
||||
if (body.policyProfile) args.push('--policy', body.policyProfile);
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildReplScript(body) {
|
||||
const lines = [];
|
||||
if (body.mode === 'whitebox') lines.push(`/repo ${body.repo || body.target}`);
|
||||
@@ -670,12 +688,15 @@ async function startJobViaRepl(body) {
|
||||
const id = crypto.randomUUID();
|
||||
const credsPath = await materializeCreds(body, id);
|
||||
const script = buildReplScript({ ...body, creds: credsPath });
|
||||
const job = new Job(id, BIN, [], body.repo || body.target || '', body.name || '');
|
||||
const auth = authArgs(body);
|
||||
const job = new Job(id, BIN, auth, body.repo || body.target || '', body.name || '');
|
||||
job.pinnedAgents = body.agents || [];
|
||||
job.repl = true;
|
||||
jobs.set(id, job);
|
||||
|
||||
const child = spawn(BIN, [], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
// The REPL session inherits the engagement's authorization from argv, so the
|
||||
// ceiling is set before the first command is scripted into it.
|
||||
const child = spawn(BIN, auth, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
job.child = child;
|
||||
let buf = '';
|
||||
const onData = (chunk) => {
|
||||
|
||||
Reference in New Issue
Block a user