From 481a4eb1b97d450af5cd228660e0dc4a3e47c9c7 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 13 Sep 2026 19:18:32 -0300 Subject: [PATCH] feat: wire capability tokens and the audit trail through CLI, REPL and web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/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) --- neurosploit-rs/app/src/main.rs | 192 +++++++++++++++++- neurosploit-rs/app/src/repl.rs | 166 ++++++++++++++- neurosploit-rs/crates/harness/src/pipeline.rs | 141 +++++++++++++ neurosploit-rs/crates/harness/src/types.rs | 11 + web/public/app.js | 38 ++++ web/public/index.html | 41 ++++ web/public/style.css | 4 + web/server.js | 27 ++- 8 files changed, 607 insertions(+), 13 deletions(-) diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 098fad9..35f8a9e 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -33,6 +33,22 @@ TIP: run inside Kali Linux (or `docker run -it kalilinux/kali-rolling`) so curl/ struct Cli { #[command(subcommand)] cmd: Option, + /// Authorization for an interactive session, set before the first command + /// is typed. The REPL deliberately has no `/`-command that can WIDEN the + /// grant — a session must not be able to authorize itself — so the ceiling + /// arrives here, from whoever launched it. + #[arg(long = "capability-token", global = true)] + capability_token: Option, + /// Extra authorized hosts for an interactive session. + #[arg(long = "session-in-scope", global = true)] + session_in_scope: Vec, + /// Environment for an interactive session: lab · development · staging · + /// production · ot-production. + #[arg(long = "session-environment", global = true)] + session_environment: Option, + /// Policy profile for an interactive session: web · ot. + #[arg(long = "session-policy", global = true)] + session_policy: Option, } #[derive(Subcommand)] @@ -75,6 +91,18 @@ enum Cmd { /// agents must not touch. Repeatable or comma/semicolon-separated. #[arg(long = "out-of-scope")] out_of_scope: Option, + /// Additional authorized hosts: host · *.domain · 10.0.0.0/24 · https://host/path. + /// Without this the engagement is authorized against the target and nothing else. + #[arg(long = "in-scope")] + in_scope: Vec, + /// Environment, which scales every risk score: lab · development · + /// staging · production · ot-production (aliases: ics, scada). + #[arg(long = "environment", default_value = "production")] + environment: String, + /// Engagement policy profile: web · ot (ot = read-only, paced, with the + /// dangerous industrial primitives removed). + #[arg(long = "policy", default_value = "web")] + policy: String, /// Open a Jira card per finding (needs the jira integration enabled). #[arg(long)] jira: bool, @@ -87,6 +115,11 @@ enum Cmd { #[arg(short, long)] verbose: bool, }, + /// Issue or inspect a signed capability token (the engagement's authorization). + Capability { + #[command(subcommand)] + cmd: CapCmd, + }, /// White-box: analyse a repository's source code for vulnerabilities. Whitebox { /// Local path, a GitHub URL (https://github.com/owner/repo[.git]) or an @@ -363,14 +396,22 @@ fn find_base() -> PathBuf { #[tokio::main] async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); + let mut cli = Cli::parse(); let base = find_base(); // No subcommand → launch the Claude-Code-style interactive session. - let cmd = match cli.cmd { + let cmd = match cli.cmd.take() { Some(c) => c, None => { - repl::repl(&base).await?; + // The session's authorization comes from whoever launched it, not + // from inside it. + let auth = repl::SessionAuth { + capability: cli.capability_token.clone().or_else(|| std::env::var("NEUROSPLOIT_CAPABILITY").ok()).filter(|t| !t.trim().is_empty()), + in_scope: cli.session_in_scope.clone(), + environment: cli.session_environment.clone(), + policy: cli.session_policy.clone(), + }; + repl::repl(&base, auth).await?; return Ok(()); } }; @@ -391,7 +432,8 @@ async fn main() -> anyhow::Result<()> { } } } - Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, jira, only, verbose } => { + Cmd::Capability { cmd } => handle_capability(cmd)?, + Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, in_scope, environment, policy, jira, only, verbose } => { let url = if url.starts_with("http") { url } else { format!("https://{url}") }; let mut cfg = RunConfig::new(&url); cfg.max_agents = max_agents; @@ -405,6 +447,7 @@ async fn main() -> anyhow::Result<()> { cfg.objective = objective; cfg.out_of_scope = out_of_scope; cfg.pinned = parse_only(&only); + apply_authorization(&mut cfg, &in_scope, cli.capability_token.clone(), &environment, &policy)?; if !models.is_empty() { cfg.models = models; } @@ -913,6 +956,147 @@ pub(crate) fn print_findings(out: &RunOutput) { /// Parse repeated `--only` values into a clean agent allowlist. Accepts repeats /// and comma/semicolon-separated lists (`--only sqli,xss` == `--only sqli --only xss`). +/// Apply the engagement's authorization to a config: extra scope, the signed +/// grant, the environment (which scales every risk score) and the policy +/// profile. +/// +/// The token is verified HERE, before anything runs, so an invalid grant fails +/// at the command line with a readable message instead of halfway through an +/// engagement. +fn apply_authorization( + cfg: &mut RunConfig, + in_scope: &[String], + token: Option, + environment: &str, + policy: &str, +) -> anyhow::Result<()> { + use harness::policy::{EngagementPolicy, Environment}; + + let env = Environment::parse(environment).ok_or_else(|| { + anyhow::anyhow!("unknown environment '{environment}' — use lab, development, staging, production or ot-production") + })?; + cfg.policy = match policy.trim().to_lowercase().as_str() { + "ot" | "ics" | "scada" => EngagementPolicy::ot(), + "web" | "" => EngagementPolicy::web(env), + other => anyhow::bail!("unknown policy profile '{other}' — use web or ot"), + }; + cfg.policy.safety.environment = env; + + if !in_scope.is_empty() { + // Seed from the target first: adding one host to an empty policy would + // otherwise leave the target itself out of scope. + if cfg.scope.hard.is_empty() { + cfg.scope.allow(&harness::scope::host_of(&cfg.target)); + } + for entry in in_scope { + cfg.scope.allow(entry); + } + } + + cfg.capability = token.or_else(|| std::env::var("NEUROSPLOIT_CAPABILITY").ok()).filter(|t| !t.trim().is_empty()); + match harness::pipeline::verify_capability(cfg) { + Ok(Some(cap)) => { + println!(" \x1b[32m🔏 capability verified\x1b[0m — {}", cap.summary()); + let (_, dropped) = cap.constrain(&cfg.scope); + if !dropped.is_empty() { + println!(" \x1b[33m⚠ outside the grant, removed from scope:\x1b[0m {}", dropped.join(", ")); + } + } + Ok(None) => {} + Err(e) => anyhow::bail!("{e}"), + } + Ok(()) +} + +#[derive(Subcommand)] +enum CapCmd { + /// Mint a token. Requires the signing key (NEUROSPLOIT_CAPABILITY_KEY), + /// which is what makes the grant attributable to whoever authorized it. + Issue { + /// Hosts this grant covers: host · *.domain · 10.0.0.0/24 · https://host/path. + #[arg(long = "scope", required = true)] + scope: Vec, + /// Carve-outs inside that scope. + #[arg(long = "exclude")] + exclude: Vec, + /// Who authorized it (client contact, ticket, system). + #[arg(long)] + issuer: String, + /// Who it is issued to. + #[arg(long)] + subject: String, + /// Hours until it expires. A grant without an end is a standing + /// permission nobody remembers issuing. + #[arg(long, default_value = "72")] + hours: u64, + /// Strongest permitted action: read · enumerate · authenticate · + /// probe-exploit · write · disruptive. + #[arg(long = "max-action", default_value = "probe-exploit")] + max_action: String, + /// Ceiling on effective_risk. + #[arg(long = "max-risk", default_value = "3.5")] + max_risk: f64, + /// lab · development · staging · production · ot-production. + #[arg(long, default_value = "production")] + environment: String, + /// Engagement reference (SOW, ticket). + #[arg(long, default_value = "")] + reference: String, + }, + /// Verify a token and print what it grants. + Verify { + token: String, + }, +} + +fn handle_capability(cmd: CapCmd) -> anyhow::Result<()> { + use harness::capability::{key_from_env, Capability}; + use harness::policy::{ActionKind, Environment}; + + let key = key_from_env().ok_or_else(|| { + anyhow::anyhow!("no signing key — set NEUROSPLOIT_CAPABILITY_KEY or NEUROSPLOIT_CAPABILITY_KEY_FILE") + })?; + match cmd { + CapCmd::Issue { scope, exclude, issuer, subject, hours, max_action, max_risk, environment, reference } => { + let env = Environment::parse(&environment) + .ok_or_else(|| anyhow::anyhow!("unknown environment '{environment}'"))?; + let action = match max_action.trim().to_lowercase().replace('_', "-").as_str() { + "read" => ActionKind::Read, + "enumerate" => ActionKind::Enumerate, + "authenticate" => ActionKind::Authenticate, + "probe-exploit" | "exploit" => ActionKind::ProbeExploit, + "write" => ActionKind::Write, + "disruptive" => ActionKind::Disruptive, + other => anyhow::bail!("unknown action '{other}'"), + }; + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let cap = Capability { + id: format!("cap-{now:x}"), + issuer, + subject, + scope, + exclude, + environment: env, + max_action: action, + max_risk, + expires_at: now + hours * 3600, + not_before: 0, + reference, + }; + println!("{}", cap.issue(&key)); + eprintln!(" \x1b[2m{}\x1b[0m", cap.summary()); + } + CapCmd::Verify { token } => match Capability::verify(&token, &key) { + Ok(c) => { + println!(" \x1b[32m🔏 verified\x1b[0m — {}", c.summary()); + println!("{}", serde_json::to_string_pretty(&c).unwrap_or_default()); + } + Err(e) => anyhow::bail!("{e}"), + }, + } + Ok(()) +} + fn parse_only(vals: &[String]) -> Vec { let mut out: Vec = Vec::new(); for v in vals { diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 5b6d315..0a300f7 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -146,7 +146,7 @@ struct LiveCheckpoint { /// the dispatch would have accepted (`/url`, `/q`, `/log`) into some /// near-neighbour would break working commands. A test keeps the two in sync. pub(crate) const ACCEPTED: &[&str] = &[ - "/?", "/agents", "/attach", "/auth", "/burp", "/chain", "/changed", "/clear", "/config", + "/?", "/agents", "/attach", "/audit", "/auth", "/burp", "/cap", "/capability", "/chain", "/changed", "/clear", "/config", "/context", "/continue", "/creds", "/diff", "/exclude", "/exit", "/expand", "/feed", "/finding", "/findings", "/focus", "/forget", "/full", "/go", "/goal", "/graph", "/guardrail", "/guardrails", "/help", "/history", "/idle", "/inscope", "/instructions", "/integration", "/integrations", "/key", "/log", @@ -165,7 +165,8 @@ const COMMANDS: &[&str] = &[ "/repo", "/auth", "/creds", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline", "/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/only", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", "/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", - "/memory", "/forget", "/graph", "/inscope", "/observe", "/guardrail", "/policy", "/quit", + "/memory", "/forget", "/graph", "/inscope", "/observe", "/guardrail", "/policy", + "/capability", "/audit", "/quit", ]; /// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`, @@ -283,6 +284,10 @@ struct Session { out_of_scope: Option, /// Authorization boundary + guardrails, enforced by the harness. policy: harness::scope::ScopePolicy, + /// Signed capability token for this engagement, when one was issued. + capability: Option, + /// Risk ceilings, reasoning rules and proof requirements. + engagement: harness::policy::EngagementPolicy, attachments: Vec, color: bool, /// Engagement scope from onboarding: web | infra | cloud | ai | skills. @@ -317,6 +322,8 @@ impl Default for Session { objective: None, out_of_scope: None, policy: Default::default(), + capability: None, + engagement: Default::default(), attachments: Vec::new(), color: true, scope: "web", @@ -395,7 +402,20 @@ impl Reader { // MutexGuard across `run().await` on purpose — run() mutates that history for // the whole async operation and no other task contends for it there. #[allow(clippy::await_holding_lock)] -pub async fn repl(base: &Path) -> anyhow::Result<()> { +/// Authorization handed to an interactive session at launch. +/// +/// It is passed in rather than typed because a session that can widen its own +/// grant is not constrained by one. `/capability` inside the REPL can install +/// a token and narrow the scope; it cannot raise the ceiling this sets. +#[derive(Debug, Default, Clone)] +pub struct SessionAuth { + pub capability: Option, + pub in_scope: Vec, + pub environment: Option, + pub policy: Option, +} + +pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> { let lib = agents::load(base); let backends = harness::installed_cli_backends(); println!("\x1b[1m"); @@ -418,6 +438,43 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { if resumed || past > 0 { println!(" ↻ resumed project session from {} — {} past run(s)", proj_dir().display(), past); } + // Authorization from the launcher, applied before anything can run. + if let Some(envname) = auth.environment.as_deref() { + match harness::policy::Environment::parse(envname) { + Some(e) => s.engagement.safety.environment = e, + None => println!(" \x1b[33m⚠ unknown environment '{envname}' — keeping {}\x1b[0m", s.engagement.safety.environment.as_str()), + } + } + if let Some(profile) = auth.policy.as_deref() { + s.engagement = match profile.trim().to_lowercase().as_str() { + "ot" | "ics" | "scada" => harness::policy::EngagementPolicy::ot(), + _ => harness::policy::EngagementPolicy::web(s.engagement.safety.environment), + }; + } + for entry in &auth.in_scope { + s.policy.allow(entry); + } + if let Some(token) = auth.capability.as_deref() { + match harness::capability::key_from_env() { + None => println!(" \x1b[31m⛔ a capability token was supplied but no verification key is configured\x1b[0m — set NEUROSPLOIT_CAPABILITY_KEY. Not applied."), + Some(k) => match harness::capability::Capability::verify(token, &k) { + Ok(c) => { + let (effective, dropped) = c.constrain(&s.policy); + s.policy = effective; + s.capability = Some(token.to_string()); + println!(" \x1b[32m🔏 capability verified\x1b[0m — {}", c.summary()); + if !dropped.is_empty() { + println!(" \x1b[33m⚠ outside the grant, removed from scope:\x1b[0m {}", dropped.join(", ")); + } + } + Err(e) => { + println!(" \x1b[31m⛔ {e}\x1b[0m"); + anyhow::bail!("capability token did not verify — refusing to start an unauthorized session"); + } + }, + } + } + // A recovered interrupted run, carried in memory so `/continue` can relaunch // the engagement on the same target with these findings folded forward. let mut resumable: Option<(String, Vec)> = None; @@ -1048,13 +1105,26 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { // silently make the target itself out of scope. if let Some(t) = s.target.clone() { s.policy.allow(&harness::scope::host_of(&t)); } } - let n = s.policy.allow(arg); - println!(" +{n} in scope · {}", s.policy.summary()); + // Count what actually survived the grant, not what was + // typed — reporting an entry as added when the ceiling + // dropped it is the same lie the ceiling exists to prevent. + let before = s.policy.hard.len(); + s.policy.allow(arg); + let refused = reapply_grant(&mut s); + if !refused.is_empty() { + println!(" \x1b[33m⛔ outside the capability grant, not authorized:\x1b[0m {}", refused.join(", ")); + } + let added = s.policy.hard.len().saturating_sub(before); + println!(" +{added} in scope · {}", s.policy.summary()); } } "/observe" | "/observe-only" => { if arg.trim().is_empty() { println!(" usage: /observe — discovery allowed there, interaction blocked"); } - else { let n = s.policy.observe_only(arg); println!(" +{n} observe-only · {}", s.policy.summary()); } + else { + let n = s.policy.observe_only(arg); + reapply_grant(&mut s); + println!(" +{n} observe-only · {}", s.policy.summary()); + } } "/guardrail" | "/guardrails" => { let (k, v) = arg.split_once(char::is_whitespace).unwrap_or((arg, "")); @@ -1085,6 +1155,68 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { other => println!(" unknown guardrail '{other}' — destructive · accounts · rate"), } } + "/capability" | "/cap" => { + if arg.trim().is_empty() { + match &s.capability { + None => println!(" no capability token — this engagement runs on local configuration alone.\n \x1b[2m/capability · verified with NEUROSPLOIT_CAPABILITY_KEY\x1b[0m"), + Some(t) => match harness::capability::key_from_env() { + None => println!(" \x1b[33m⚠ a token is set but no key is configured\x1b[0m — set NEUROSPLOIT_CAPABILITY_KEY. Claims (UNVERIFIED): {}", + harness::capability::Capability::peek(t).map(|c| c.summary()).unwrap_or_else(|| "unreadable".into())), + Some(k) => match harness::capability::Capability::verify(t, &k) { + Ok(c) => println!(" \x1b[32m🔏 verified\x1b[0m — {}", c.summary()), + Err(e) => println!(" \x1b[31m⛔ {e}\x1b[0m"), + }, + }, + } + } else if arg.trim() == "clear" { + s.capability = None; + println!(" capability token cleared — back to local configuration"); + } else { + let token = arg.trim().to_string(); + match harness::capability::key_from_env() { + None => println!(" \x1b[31m⛔ no verification key\x1b[0m — set NEUROSPLOIT_CAPABILITY_KEY (or _KEY_FILE). An unverifiable token is not authorization; not stored."), + Some(k) => match harness::capability::Capability::verify(&token, &k) { + Ok(c) => { + let (effective, dropped) = c.constrain(&s.policy); + if !dropped.is_empty() { + println!(" \x1b[33m⚠ outside the grant, removed from scope:\x1b[0m {}", dropped.join(", ")); + } + s.policy = effective; + s.capability = Some(token); + println!(" \x1b[32m🔏 verified\x1b[0m — {}", c.summary()); + println!(" scope now: {}", s.policy.summary()); + } + Err(e) => println!(" \x1b[31m⛔ {e}\x1b[0m — token not stored"), + }, + } + } + } + "/audit" => { + // The trail of the most recent run, plus the chain check that + // makes it evidence rather than a log file. + let h = history.lock().unwrap(); + let path = h.last().map(|r| std::path::PathBuf::from(&r.workdir).join("audit.jsonl")) + .unwrap_or_else(|| proj_dir().join("audit.jsonl")); + let log = harness::audit::AuditLog::open(&path); + let records = log.read_all(); + if records.is_empty() { + println!(" no audit records yet ({})", path.display()); + } else { + let n: usize = arg.trim().parse().unwrap_or(15); + println!(" ── audit trail · {} record(s) · {} ──", records.len(), path.display()); + for r in records.iter().rev().take(n).rev() { + let decision = if r.policy_decision.starts_with("deny") { format!("\x1b[31m{}\x1b[0m", r.policy_decision) } + else if r.policy_decision.starts_with("confirm") { format!("\x1b[33m{}\x1b[0m", r.policy_decision) } + else { format!("\x1b[2m{}\x1b[0m", r.policy_decision) }; + println!(" #{:<3} {} {:<18} {:<26} {}", r.seq, r.timestamp, trunc(&r.action, 18), trunc(&r.target, 26), decision); + if !r.result.is_empty() { println!(" \x1b[2m{}\x1b[0m", trunc(&r.result, 100)); } + } + match log.verify() { + Ok(n) => println!(" \x1b[32m✓ hash chain intact\x1b[0m across {n} record(s)"), + Err(e) => println!(" \x1b[31m⛔ chain broken: {e}\x1b[0m"), + } + } + } "/memory" => memory_cmd(&s, arg), "/forget" => { if arg.trim().is_empty() { @@ -1300,6 +1432,8 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { cfg.objective = s.objective.clone(); cfg.out_of_scope = s.out_of_scope.clone(); cfg.scope = s.policy.clone(); + cfg.capability = s.capability.clone(); + cfg.policy = s.engagement.clone(); cfg.auth = s.auth.clone(); cfg.pinned = s.pinned.clone(); // Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive. @@ -1376,6 +1510,8 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, cfg.objective = s.objective.clone(); cfg.out_of_scope = s.out_of_scope.clone(); cfg.scope = s.policy.clone(); + cfg.capability = s.capability.clone(); + cfg.policy = s.engagement.clone(); cfg.auth = s.auth.clone(); cfg.pinned = s.pinned.clone(); if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); } @@ -1526,6 +1662,22 @@ fn merge_findings(prior: Vec, mut fresh: Vec) -> Vec fresh } +/// Re-apply the capability ceiling after the session changed its own scope. +/// +/// Without this, `/inscope` could widen the boundary past the grant — the one +/// thing a capability token exists to prevent. The run itself would still be +/// constrained (the pipeline re-applies the grant), but `/policy` would show a +/// boundary that is not real, and a tool that misreports its own limits is +/// worse than one with none. +fn reapply_grant(s: &mut Session) -> Vec { + let Some(token) = s.capability.clone() else { return Vec::new() }; + let Some(key) = harness::capability::key_from_env() else { return Vec::new() }; + let Ok(cap) = harness::capability::Capability::verify(&token, &key) else { return Vec::new() }; + let (effective, dropped) = cap.constrain(&s.policy); + s.policy = effective; + dropped +} + /// `/memory` — inspect what the harness has learned, or search it. /// /// The four tiers are shown separately because they mean different things: an @@ -1976,6 +2128,8 @@ fn help() { h("/observe ", "observe-only: discovery allowed there, interaction blocked"); h("/guardrail k v", "soft scope: destructive on|off · accounts · rate "); h("/policy", "show the enforced scope + guardrails"); + h("/capability ","signed grant (ns-cap.v1...) — verified, and it CAPS the scope"); + h("/audit [n]", "the run's action trail + hash-chain verification"); h("@path @dir @f:1-20", "attach a file/folder/line-range to context (Tab → menu)"); h("/attach ", "attach a file/folder to context"); h("/context", "list current attachments"); diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 01a9787..7c27d84 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -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, 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) -> 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); diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index b93cd2b..834132b 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -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, + /// 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(), } } } diff --git a/web/public/app.js b/web/public/app.js index 7c25a84..2cc8308 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -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) => ` diff --git a/web/public/index.html b/web/public/index.html index a7862e0..1e6e470 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -306,6 +306,7 @@
+
@@ -372,6 +373,7 @@ +