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:
CyberSecurityUPandClaude Opus 5 committed 2026-09-13 19:18:32 -03:00
1 parent 3456c32f4d
commit 481a4eb1b9
8 files changed
+607 -13

No files matched your search

+38
View File
@@ -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) => `