mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 21:19:49 +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:
1 parent
3456c32f4d
commit
481a4eb1b9
8 files changed
+607
-13
No files matched your search
@@ -99,6 +99,38 @@ pub(crate) fn run_id(cfg: &RunConfig) -> String {
|
||||
/// Built from the target unless the operator configured one explicitly, with
|
||||
/// `out_of_scope` entries that name a host or network promoted into real
|
||||
/// exclusions — until now they were only ever prose in a prompt.
|
||||
/// Verify the engagement's capability token, if one was supplied.
|
||||
///
|
||||
/// Returns the grant and any local scope entries it refused to cover. A token
|
||||
/// that does not verify is an error, not a warning: running anyway would mean
|
||||
/// acting on an authorization nobody can prove was issued.
|
||||
pub fn verify_capability(cfg: &RunConfig) -> Result<Option<crate::capability::Capability>, String> {
|
||||
let Some(token) = cfg.capability.as_deref().filter(|t| !t.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(key) = crate::capability::key_from_env() else {
|
||||
return Err("a capability token was supplied but no verification key is configured — set NEUROSPLOIT_CAPABILITY_KEY or NEUROSPLOIT_CAPABILITY_KEY_FILE. An unverifiable token is not authorization.".into());
|
||||
};
|
||||
crate::capability::Capability::verify(token, &key).map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The audit trail for this run: one per run directory, plus nothing else —
|
||||
/// the chain is per-engagement so a run's trail travels with its evidence.
|
||||
pub fn audit_log(cfg: &RunConfig) -> crate::audit::AuditLog {
|
||||
let path = cfg
|
||||
.workdir
|
||||
.as_deref()
|
||||
.map(|d| Path::new(d).join("audit.jsonl"))
|
||||
.unwrap_or_else(|| proj_store(cfg).join("audit.jsonl"));
|
||||
crate::audit::AuditLog::open(path)
|
||||
}
|
||||
|
||||
/// Id of the grant in force, for the audit records. Empty when the run is
|
||||
/// operating on local configuration alone, which is itself worth recording.
|
||||
fn capability_id(cfg: &RunConfig) -> String {
|
||||
verify_capability(cfg).ok().flatten().map(|c| c.id).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn effective_scope(cfg: &RunConfig) -> crate::scope::ScopePolicy {
|
||||
let mut p = if cfg.scope.hard.is_empty() {
|
||||
crate::scope::ScopePolicy::for_target(&cfg.target)
|
||||
@@ -119,6 +151,14 @@ pub fn effective_scope(cfg: &RunConfig) -> crate::scope::ScopePolicy {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A verified grant is the ceiling. Anything the operator configured that
|
||||
// the grant does not cover is dropped here rather than at request time —
|
||||
// the boundary should be wrong-proof before the first packet, not enforced
|
||||
// after an agent has already decided to go somewhere.
|
||||
if let Ok(Some(cap)) = verify_capability(cfg) {
|
||||
let (constrained, _dropped) = cap.constrain(&p);
|
||||
return constrained;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
@@ -526,6 +566,48 @@ fn write_meta(cfg: &RunConfig, p: &crate::probe::Probe, asset: &str) {
|
||||
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
|
||||
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||
pool.set_progress(tx.clone());
|
||||
|
||||
// Authorization first. A supplied token that does not verify ends the run
|
||||
// here: proceeding would mean acting on a grant nobody can prove was
|
||||
// issued, which is the one failure this whole layer exists to prevent.
|
||||
let grant = match verify_capability(&cfg) {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
let _ = tx.send(format!("notify: ⛔ {e}")).await;
|
||||
audit_log(&cfg).append(
|
||||
crate::audit::AuditRecord::new("harness", "engagement-start", &cfg.target)
|
||||
.decision(&format!("deny: {e}"))
|
||||
.tool("capability")
|
||||
.result("run refused"),
|
||||
);
|
||||
return RunOutput {
|
||||
target: cfg.target.clone(),
|
||||
workdir: cfg.workdir.clone().unwrap_or_default(),
|
||||
findings: vec![],
|
||||
agents_ran: vec![],
|
||||
candidates: 0,
|
||||
recon: String::new(),
|
||||
artifacts: vec![],
|
||||
};
|
||||
}
|
||||
};
|
||||
let audit = audit_log(&cfg);
|
||||
let cap_id = grant.as_ref().map(|c| c.id.clone()).unwrap_or_default();
|
||||
let policy_scope = effective_scope(&cfg);
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new("harness", "engagement-start", &cfg.target)
|
||||
.decision(&format!("allow: scope [{}]", policy_scope.summary()))
|
||||
.tool("neurosploit")
|
||||
.capability(&cap_id)
|
||||
.result(&format!(
|
||||
"{} · {}",
|
||||
grant.as_ref().map(|c| c.summary()).unwrap_or_else(|| "no capability token — local configuration only".into()),
|
||||
cfg.policy.safety.summary()
|
||||
)),
|
||||
);
|
||||
if let Some(c) = &grant {
|
||||
let _ = tx.send(format!("notify: 🔏 capability verified — {}", c.summary())).await;
|
||||
}
|
||||
let _ = tx
|
||||
.send(format!(
|
||||
"Loaded {} agents ({} vuln / {} recon / {} code / {} meta) · models: {} · vote_n={} · concurrency={}{}",
|
||||
@@ -1498,6 +1580,11 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
let _ = tx.send("RL rewards updated".into()).await;
|
||||
}
|
||||
|
||||
// This finalize path is shared by every mode, so it opens its own handle on
|
||||
// the run's trail rather than borrowing one from a particular entry point.
|
||||
let audit = audit_log(&cfg);
|
||||
let cap_id = capability_id(&cfg);
|
||||
|
||||
// Deterministic validation. The votes above are models checking models; this
|
||||
// pass asks whether the recorded artifacts actually demonstrate the class.
|
||||
let vmode = crate::validation::Mode::from_env();
|
||||
@@ -1513,6 +1600,15 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
}
|
||||
Some(crate::validation::Verdict::Rejected(r)) => {
|
||||
let _ = tx.send(format!("validator rejected '{}': {r}", f.title)).await;
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new("validation-engine", "reject-finding", &f.endpoint)
|
||||
.hypothesis(&f.id)
|
||||
.decision(&format!("deny: {r}"))
|
||||
.tool("validator")
|
||||
.capability(&cap_id)
|
||||
.evidence(f.evidence.as_bytes())
|
||||
.result(&format!("rejected '{}'", f.title)),
|
||||
);
|
||||
rejected.push(f);
|
||||
}
|
||||
_ => kept.push(f),
|
||||
@@ -1532,6 +1628,19 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
let (in_scope, out_of_scope) = policy.audit_findings(findings);
|
||||
findings = in_scope;
|
||||
if !out_of_scope.is_empty() {
|
||||
for f in &out_of_scope {
|
||||
// The record of a boundary violation is the point: this is the
|
||||
// line an operator has to be able to show afterwards.
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new(&f.agent, "finding-out-of-scope", &f.endpoint)
|
||||
.hypothesis(&f.id)
|
||||
.decision("deny: proven against a host outside the authorized scope")
|
||||
.tool("scope-guard")
|
||||
.capability(&cap_id)
|
||||
.evidence(f.evidence.as_bytes())
|
||||
.result("withheld from the report"),
|
||||
);
|
||||
}
|
||||
let _ = tx.send(format!(
|
||||
"notify: ⚠ {} finding(s) were proven against hosts OUTSIDE the authorized scope and were withheld: {}",
|
||||
out_of_scope.len(),
|
||||
@@ -1543,6 +1652,38 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
}
|
||||
}
|
||||
|
||||
// Every finding that survives to the report gets a record tying it to the
|
||||
// evidence it was proven with — that hash is what links a claim in a PDF to
|
||||
// the artifact behind it months later.
|
||||
for f in &findings {
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new(&f.agent, "report-finding", &f.endpoint)
|
||||
.hypothesis(&f.id)
|
||||
.decision(&format!("allow: {}", if f.review_status.is_empty() { "reported" } else { &f.review_status }))
|
||||
.tool("pipeline")
|
||||
.capability(&cap_id)
|
||||
.evidence(f.evidence.as_bytes())
|
||||
.result(&format!("[{}] {}", f.severity, f.title)),
|
||||
);
|
||||
}
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new("harness", "engagement-end", &cfg.target)
|
||||
.decision("allow")
|
||||
.tool("neurosploit")
|
||||
.capability(&cap_id)
|
||||
.result(&format!("{} finding(s) reported", findings.len())),
|
||||
);
|
||||
match audit.verify() {
|
||||
Ok(n) => {
|
||||
let _ = tx.send(format!("audit trail: {n} record(s), hash chain intact → audit.jsonl")).await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Worth shouting about: the trail is the artifact that proves what
|
||||
// was done, and a broken chain means it can no longer do that.
|
||||
let _ = tx.send(format!("notify: ⚠ audit chain verification FAILED — {e}")).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Durable knowledge. Everything above this point is about *this* run; these
|
||||
// two stores are what makes the next one start from further along.
|
||||
let notes = absorb(&cfg, &recon, &findings);
|
||||
|
||||
@@ -221,6 +221,15 @@ pub struct RunConfig {
|
||||
/// implicitly authorized against anything but what it was pointed at.
|
||||
#[serde(default)]
|
||||
pub scope: crate::scope::ScopePolicy,
|
||||
/// A signed capability token (`ns-cap.v1.…`). When present it is verified
|
||||
/// before the run starts and becomes the CEILING on scope: local config can
|
||||
/// narrow it, never widen it. A token that fails verification stops the
|
||||
/// run — a broken grant is not a grant.
|
||||
#[serde(default)]
|
||||
pub capability: Option<String>,
|
||||
/// Engagement policy: risk ceilings, reasoning rules, proof requirements.
|
||||
#[serde(default)]
|
||||
pub policy: crate::policy::EngagementPolicy,
|
||||
}
|
||||
|
||||
fn default_vote() -> usize {
|
||||
@@ -264,6 +273,8 @@ impl RunConfig {
|
||||
temp_email: false,
|
||||
vault_dir: None,
|
||||
scope: Default::default(),
|
||||
capability: None,
|
||||
policy: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user