mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 12:31:43 +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
@@ -33,6 +33,22 @@ TIP: run inside Kali Linux (or `docker run -it kalilinux/kali-rolling`) so curl/
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
/// 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<String>,
|
||||
/// Extra authorized hosts for an interactive session.
|
||||
#[arg(long = "session-in-scope", global = true)]
|
||||
session_in_scope: Vec<String>,
|
||||
/// Environment for an interactive session: lab · development · staging ·
|
||||
/// production · ot-production.
|
||||
#[arg(long = "session-environment", global = true)]
|
||||
session_environment: Option<String>,
|
||||
/// Policy profile for an interactive session: web · ot.
|
||||
#[arg(long = "session-policy", global = true)]
|
||||
session_policy: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
/// Carve-outs inside that scope.
|
||||
#[arg(long = "exclude")]
|
||||
exclude: Vec<String>,
|
||||
/// 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<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for v in vals {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Authorization boundary + guardrails, enforced by the harness.
|
||||
policy: harness::scope::ScopePolicy,
|
||||
/// Signed capability token for this engagement, when one was issued.
|
||||
capability: Option<String>,
|
||||
/// Risk ceilings, reasoning rules and proof requirements.
|
||||
engagement: harness::policy::EngagementPolicy,
|
||||
attachments: Vec<String>,
|
||||
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<String>,
|
||||
pub in_scope: Vec<String>,
|
||||
pub environment: Option<String>,
|
||||
pub policy: Option<String>,
|
||||
}
|
||||
|
||||
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<Finding>)> = 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 <host|*.domain> — 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 <ns-cap.v1....> · 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<RunRecord>) {
|
||||
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<Finding>, mut fresh: Vec<Finding>) -> Vec<Finding>
|
||||
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<String> {
|
||||
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 <host>", "observe-only: discovery allowed there, interaction blocked");
|
||||
h("/guardrail k v", "soft scope: destructive on|off · accounts <n|off> · rate <req/min>");
|
||||
h("/policy", "show the enforced scope + guardrails");
|
||||
h("/capability <token>","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 <path>", "attach a file/folder to context");
|
||||
h("/context", "list current attachments");
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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