From 8894649ccb42a04c4763085b3b663e49fd98ddf5 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Fri, 18 Sep 2026 19:20:29 -0300 Subject: [PATCH] feat(scope): --scope-file YAML loader + web Scoping/Guardrails UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard scoping was already enforced in code (every request passes ScopePolicy::check_request; exclude beats allowlist; capability token caps it; out-of-scope findings withheld + audited). What was missing was a way to author that boundary from a file or the web form instead of only CLI flags. - scope.rs: ScopePolicy::from_yaml / from_file — a dependency-free parser for the friendly string format (app.example.com, *.wildcard, CIDR, url-prefix), the same strings Pattern::parse already takes, NOT the raw serde {kind,value} shape. Strict in one direction: an unreadable file errors, an empty hard list authorizes nothing (a safe failure, but the operator's choice, not a typo). - CLI: --scope-file . Loaded before authorization so --in-scope adds to it and the capability grant still caps it. - Web: a full Scoping & Guardrails section in the Authorization tab — hard scope, exclusions, observe-only, destructive-method + account-creation toggles, max accounts, rate limit, forbidden payloads, notes. The server materializes a scope YAML and passes --scope-file; notes stay labelled "guidance, NOT enforced" so prose is never mistaken for a control. - examples/scope.example.yaml documents the format. End-to-end verified: web form -> YAML -> Rust loader -> enforced boundary. 332 tests (+4). Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + examples/scope.example.yaml | 67 +++++++ neurosploit-rs/app/src/main.rs | 17 +- neurosploit-rs/crates/harness/src/scope.rs | 197 +++++++++++++++++++++ web/public/app.js | 23 +++ web/public/index.html | 56 ++++++ web/public/style.css | 4 + web/server.js | 51 +++++- 8 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 examples/scope.example.yaml diff --git a/README.md b/README.md index 4071bd9..23850e6 100755 --- a/README.md +++ b/README.md @@ -717,6 +717,7 @@ git clone https://github.com/digininja/DVWA /tmp/DVWA | `--sandbox [image]` | Run agent commands in a Kali container (docker/podman) instead of on the host. | | `--revalidate-poc` | Re-run every PoC after validation; demote any that no longer reproduces. | | `--compliance pci-dss,hipaa,soc2` | Map findings onto compliance controls in the report. | +| `--scope-file ` | Load the hard scope + guardrails from a YAML file (see `examples/scope.example.yaml`). Enforced in code; a capability token still caps it. | | `-v, --verbose` | Log each agent as it launches, recon, and votes. | ### Authentication — run via API key *or* subscription diff --git a/examples/scope.example.yaml b/examples/scope.example.yaml new file mode 100644 index 0000000..b79afdf --- /dev/null +++ b/examples/scope.example.yaml @@ -0,0 +1,67 @@ +# NeuroSploit — engagement scope & guardrails +# --------------------------------------------------------------------------- +# HARD scope is enforced in code: a request whose host is not covered by `hard` +# (or is hit by `exclude`) is REFUSED before it leaves — not warned about, +# refused. SOFT scope is the guardrails inside that boundary. +# +# Every pattern below is a plain string, parsed the same way the --in-scope flag +# and the web form parse it (crate::scope::Pattern::parse): +# +# app.example.com exact host +# *.example.com the apex AND every sub-domain +# 10.0.0.0/24 an IPv4 network (CIDR) +# https://example.com/api/v2 a URL prefix — narrower than a whole host +# +# An empty `hard` list means NOTHING is authorized. Scope is never implicit. +# =========================================================================== + +# --- HARD: the allowlist. Only these are testable. ------------------------ +hard: + - app.example.com + - "*.staging.example.com" # apex + subdomains of the staging tier + - https://example.com/api/v2 # only this path prefix on the apex host + - 10.20.30.0/24 # an internal range reached via --transport + +# --- EXCLUDE: carve-outs. These always beat the allowlist. ---------------- +# A host here is refused even if `hard` would otherwise cover it. +exclude: + - admin.example.com # never touch the admin console + - https://app.example.com/billing # PCI surface — out of this engagement + - payments.example.com + +# --- SOFT: guardrails inside the boundary --------------------------------- +soft: + # Hosts you may LOOK at but never attack (recon only — no payloads). + observe_only: + - cdn.example.com + - "*.thirdparty.example.com" + + # State-mutating verbs (DELETE/PUT/PATCH). Off by default: a scan should not + # change the target's state to "prove" a bug. + allow_destructive_methods: false + + # Registering test accounts, and how many. 0 = unlimited (not recommended). + allow_account_creation: true + max_accounts: 3 + + # Requests per minute across the WHOLE engagement. 0 = unlimited. + # Keep this low on production; the OT profile caps far lower still. + max_requests_per_minute: 240 + + # Payload substrings that are NEVER acceptable, whatever the finding — the + # classes that damage a production target instead of demonstrating a bug. + # These extend the built-in defaults (drop table, rm -rf /, fork bombs, …). + forbidden_payloads: + - "drop table" + - "truncate table" + - "delete from" + - "rm -rf /" + - "shutdown" + - "while(true)" + + # Free-text context for the agents. NOT enforceable — kept separate from the + # rules on purpose, so nobody mistakes prose for a control. + notes: + - "Authorized per SOW-2026-0142; contact security@example.com on any outage." + - "Test window 02:00–06:00 UTC only." + - "Data-exfil PoCs: prove read access with a canary row, do not pull real PII." diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index fb855b7..81005a2 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -123,6 +123,11 @@ enum Cmd { /// Without this the engagement is authorized against the target and nothing else. #[arg(long = "in-scope")] in_scope: Vec, + /// Load the hard scope + guardrails from a YAML file (see + /// examples/scope.example.yaml). Its `hard` list is the boundary; + /// --in-scope adds to it and a capability token still caps it. + #[arg(long = "scope-file")] + scope_file: Option, /// Environment, which scales every risk score: lab · development · /// staging · production · ot-production (aliases: ics, scada). #[arg(long = "environment", default_value = "production")] @@ -577,7 +582,7 @@ async fn main() -> anyhow::Result<()> { Cmd::Internal { graph, scaffold, from, expand, mermaid, save } => { handle_internal(graph.as_deref(), scaffold.as_deref(), &from, expand, mermaid, save.as_deref())? } - Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, in_scope, environment, policy, budget, token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route, revalidate_poc, compliance, jira, only, verbose } => { + Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, in_scope, scope_file, environment, policy, budget, token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route, revalidate_poc, compliance, 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; @@ -591,6 +596,16 @@ async fn main() -> anyhow::Result<()> { cfg.objective = objective; cfg.out_of_scope = out_of_scope; cfg.pinned = parse_only(&only); + if let Some(path) = scope_file.as_deref() { + let sp = harness::scope::ScopePolicy::from_file(std::path::Path::new(path)) + .map_err(|e| anyhow::anyhow!("scope-file {path}: {e}"))?; + if sp.hard.is_empty() { + println!(" \x1b[33m⚠ {path} sets no hard scope — nothing would be authorized; ignoring it\x1b[0m"); + } else { + println!(" \x1b[2mscope-file: {} host rule(s), {} exclusion(s), rate {}rpm\x1b[0m", sp.hard.len(), sp.exclude.len(), sp.soft.max_requests_per_minute); + cfg.scope = sp; + } + } apply_authorization(&mut cfg, &in_scope, cli.capability_token.clone(), &environment, &policy)?; apply_budget(&mut cfg, budget.as_deref(), token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route)?; apply_network(&mut cfg, &cli)?; diff --git a/neurosploit-rs/crates/harness/src/scope.rs b/neurosploit-rs/crates/harness/src/scope.rs index 4b676fb..5fe1b1b 100644 --- a/neurosploit-rs/crates/harness/src/scope.rs +++ b/neurosploit-rs/crates/harness/src/scope.rs @@ -345,6 +345,114 @@ impl ScopePolicy { self.soft.observe_only.len() - before } + /// Load a scope from a YAML file (the operator-facing format). + /// + /// The friendly string form — `app.example.com`, `*.example.com`, + /// `10.0.0.0/24`, `https://example.com/api` — the SAME strings the CLI flag + /// and the web form take, parsed through [`Pattern::parse`]. NOT the raw + /// serde shape (`{kind, value}`), which is faithful but unwritable by hand. + /// + /// This is a hard boundary, so parsing is strict in one direction: an + /// unreadable file is an error, never a silently-empty policy. An empty + /// policy authorizes nothing, and "nothing" is a safe failure — but it must + /// be the operator's choice, not a typo swallowed here. + pub fn from_file(path: &std::path::Path) -> std::io::Result { + let text = std::fs::read_to_string(path)?; + Ok(ScopePolicy::from_yaml(&text)) + } + + /// Parse the friendly scope YAML subset. Dependency-free, matching the + /// house style of `creds.rs` — the schema is small and known: + /// + /// ```yaml + /// hard: [ - ... ] + /// exclude: [ - ... ] + /// soft: + /// observe_only: [ - ... ] + /// allow_destructive_methods: + /// allow_account_creation: + /// max_accounts: + /// max_requests_per_minute: + /// forbidden_payloads: [ - ... ] + /// notes: [ - ... ] + /// ``` + pub fn from_yaml(text: &str) -> ScopePolicy { + let mut p = ScopePolicy::default(); + // Section state: which list a `- item` currently belongs to. + #[derive(PartialEq)] + enum Sect { None, Hard, Exclude, Observe, Forbidden, Notes } + let mut sect = Sect::None; + // Track whether we are inside the `soft:` block (deeper indent), so a + // top-level `notes:` (there is none today, but be robust) is not + // confused with `soft.notes`. + for raw in text.lines() { + let line = strip_comment(raw); + if line.trim().is_empty() { + continue; + } + let indent = line.len() - line.trim_start().len(); + let t = line.trim(); + + if let Some(item) = t.strip_prefix("- ") { + let val = unquote(item.trim()); + if val.is_empty() { + continue; + } + match sect { + Sect::Hard => { p.allow(&val); } + Sect::Exclude => { p.deny(&val); } + Sect::Observe => { p.observe_only(&val); } + Sect::Forbidden => p.soft.forbidden_payloads.push(val.to_lowercase()), + Sect::Notes => p.soft.notes.push(val), + Sect::None => {} + } + continue; + } + + // A `key:` or `key: value` line. Indent 0 = top level; deeper = + // inside `soft:`. + let (key, value) = match t.split_once(':') { + Some((k, v)) => (k.trim(), unquote(v.trim())), + None => continue, + }; + let top = indent == 0; + // An inline list on the key line (`hard: [a, b]`) is routed by key + // regardless of depth, before the section-header handling. + if value.starts_with('[') { + let inner = value.trim_start_matches('[').trim_end_matches(']'); + for tok in inner.split(',') { + let v = unquote(tok.trim()); + if v.is_empty() { continue; } + match key { + "hard" => { p.allow(&v); } + "exclude" => { p.deny(&v); } + "observe_only" => { p.observe_only(&v); } + "forbidden_payloads" => p.soft.forbidden_payloads.push(v.to_lowercase()), + "notes" => p.soft.notes.push(v), + _ => {} + } + } + sect = Sect::None; + continue; + } + match (top, key) { + (true, "hard") => sect = Sect::Hard, + (true, "exclude") => sect = Sect::Exclude, + (true, "soft") => sect = Sect::None, + // soft.* children + (false, "observe_only") => sect = Sect::Observe, + (false, "forbidden_payloads") => sect = Sect::Forbidden, + (false, "notes") => sect = Sect::Notes, + (false, "allow_destructive_methods") => { p.soft.allow_destructive_methods = truthy(&value); sect = Sect::None; } + (false, "allow_account_creation") => { p.soft.allow_account_creation = truthy(&value); sect = Sect::None; } + (false, "max_accounts") => { if let Ok(n) = value.parse() { p.soft.max_accounts = n; } sect = Sect::None; } + (false, "max_requests_per_minute") => { if let Ok(n) = value.parse() { p.soft.max_requests_per_minute = n; } sect = Sect::None; } + _ => { sect = Sect::None; } + } + } + p + } + pub fn in_hard_scope(&self, url: &str) -> bool { if self.exclude.iter().any(|p| p.matches(url)) { return false; @@ -549,6 +657,37 @@ fn split_list(raw: &str) -> Vec { .collect() } +/// Drop a trailing `# comment`. A scope pattern never contains `#`, and we only +/// strip when the `#` follows whitespace or opens the line. +fn strip_comment(line: &str) -> String { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'#' && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') { + return line[..i].to_string(); + } + i += 1; + } + line.to_string() +} + +/// Strip matching surrounding quotes. +fn unquote(s: &str) -> String { + let t = s.trim(); + if (t.starts_with('"') && t.ends_with('"') && t.len() >= 2) + || (t.starts_with('\'') && t.ends_with('\'') && t.len() >= 2) + { + t[1..t.len() - 1].to_string() + } else { + t.to_string() + } +} + +/// YAML-ish truthiness. +fn truthy(s: &str) -> bool { + matches!(s.trim().to_lowercase().as_str(), "true" | "yes" | "on" | "1") +} + #[cfg(test)] mod tests { use super::*; @@ -699,4 +838,62 @@ mod tests { d => panic!("an unconfigured policy must be closed, not open: {d:?}"), } } + + #[test] + fn from_yaml_parses_the_friendly_format_and_enforces_it() { + let yaml = r#" +hard: + - app.example.com + - "*.staging.example.com" + - https://example.com/api/v2 +exclude: + - payments.example.com +soft: + observe_only: + - cdn.example.com + allow_destructive_methods: false + max_accounts: 5 + max_requests_per_minute: 120 + forbidden_payloads: + - "delete from" + notes: + - "SOW-2026-0142" +"#; + let p = ScopePolicy::from_yaml(yaml); + assert!(p.check_request("https://app.example.com/x", "GET", "").allowed()); + assert!(p.check_request("https://sub.staging.example.com/x", "GET", "").allowed()); + assert!(!p.check_request("https://payments.example.com/x", "GET", "").allowed()); + assert!(!p.check_request("https://evil.test/x", "GET", "").allowed()); + assert!(p.check_request("https://example.com/api/v2/users", "GET", "").allowed()); + assert!(!p.check_request("https://example.com/admin", "GET", "").allowed()); + assert!(!p.check_request("https://cdn.example.com/x", "POST", "").allowed()); + assert_eq!(p.soft.max_accounts, 5); + assert_eq!(p.soft.max_requests_per_minute, 120); + assert!(!p.soft.allow_destructive_methods); + assert!(p.soft.forbidden_payloads.iter().any(|f| f == "delete from")); + assert!(p.soft.notes.iter().any(|n| n.contains("SOW"))); + } + + #[test] + fn from_yaml_strips_comments_and_quotes() { + let yaml = "hard:\n - app.example.com # the app\n - \"*.api.example.com\"\n"; + let p = ScopePolicy::from_yaml(yaml); + assert!(p.check_request("https://app.example.com/x", "GET", "").allowed()); + assert!(p.check_request("https://v2.api.example.com/x", "GET", "").allowed()); + } + + #[test] + fn an_empty_scope_yaml_authorizes_nothing() { + let p = ScopePolicy::from_yaml("soft:\n max_accounts: 2\n"); + assert!(p.hard.is_empty()); + assert!(!p.check_request("https://anything.test/x", "GET", "").allowed()); + } + + #[test] + fn inline_list_form_also_parses() { + let p = ScopePolicy::from_yaml("hard: [app.example.com, api.example.com]\n"); + assert!(p.check_request("https://api.example.com/x", "GET", "").allowed()); + assert!(p.check_request("https://app.example.com/x", "GET", "").allowed()); + } + } diff --git a/web/public/app.js b/web/public/app.js index 4f018ee..6b83850 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -455,6 +455,27 @@ function budgetSummary() { return parts.join(' · '); } +/// Gather the Scoping/Guardrails form into the object the server turns into a +/// scope YAML. A hard list is what makes it a boundary; without one the server +/// sends nothing and the run keeps its target+flags behaviour. +function collectScope() { + const lines = (id) => ($(`#${id}`)?.value || '').split(/[\n,;]+/).map((x) => x.trim()).filter(Boolean); + const hard = lines('scopeHard'); + const scope = { + hard, + exclude: lines('scopeExclude'), + observeOnly: lines('scopeObserve'), + allowDestructive: $('#scopeDestructive')?.checked || false, + allowAccountCreation: $('#scopeAccounts') ? $('#scopeAccounts').checked : true, + maxAccounts: $('#scopeMaxAccounts')?.value ?? '', + rateLimit: $('#scopeRate')?.value ?? '', + forbidden: lines('scopeForbidden'), + notes: lines('scopeNotes'), + }; + // Only meaningful when a boundary was actually drawn. + return hard.length ? scope : undefined; +} + function renderReview() { const target = $('#fieldTarget').value.trim(); const repo = $('#fieldRepo').value.trim(); @@ -473,6 +494,7 @@ function renderReview() { { k: 'Budget', v: budgetSummary() }, { k: 'Egress', v: state.authz.transport || 'direct' }, { k: 'Out-of-band', v: state.authz.oobDomain ? `*.${state.authz.oobDomain}` : 'none — blind classes stay leads' }, + { k: 'Hard scope', v: (() => { const sc = collectScope(); return sc ? `${sc.hard.length} rule(s), ${sc.exclude.length} excluded, ${sc.rateLimit || '∞'}rpm${sc.allowDestructive ? ', destructive ON' : ''}` : 'target + authorized hosts only'; })() }, { k: 'Intercept', v: $('#fieldIntercept').value === 'off' ? 'direct' : $('#fieldIntercept').value }, { k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' }, { k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' }, @@ -537,6 +559,7 @@ async function startExploitation() { oobHttp: state.authz.oobHttp || undefined, oobDns: state.authz.oobDns || undefined, sms: state.authz.sms || undefined, + scope: collectScope(), }; $('#btnLaunch').disabled = true; diff --git a/web/public/index.html b/web/public/index.html index e36f55d..33d06b7 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -476,6 +476,62 @@
Without this the engagement is authorized against the target and nothing else — discovering a host is not permission to test it.
+ +
+ Scoping & Guardrails + enforced in code +
+
+ The hard scope is the boundary: a request whose host is not listed is refused before it is sent — not warned about. Exclusions always win. A capability token still caps all of this. Leave the hard list empty to keep the plain target + Additional authorized hosts behaviour. +
+
+ + +
Exact host · *.wildcard (apex + subdomains) · CIDR · https://host/path prefix. Empty = nothing extra is enforced here.
+
+
+
+ + +
Refused even if the allowlist would cover them.
+
+
+ + +
May be looked at (recon) but never attacked.
+
+
+
+
+ +
+
Off by default — a scan should not change the target's state to prove a bug.
+
+
+ +
+
+
+ + +
0 = unlimited.
+
+
+ + +
Whole engagement. 0 = unlimited. Keep low on production.
+
+
+
+ + +
Substrings NEVER acceptable, whatever the finding — the classes that damage a target instead of demonstrating a bug. Extends the built-in defaults.
+
+
+ + +
Context passed to the agents. Kept separate from the rules on purpose — prose is not a control.
+
diff --git a/web/public/style.css b/web/public/style.css index 369fd91..d39de4f 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -748,3 +748,7 @@ body.resizing-ns { user-select: none; cursor: ns-resize; } @media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } + +/* Scoping/Guardrails UI accents */ +.pill { display:inline-block; padding:1px 7px; border-radius:999px; background:var(--sev-medium-bg); color:var(--sev-medium-fg); font-weight:600; letter-spacing:.02em; } +.req { color:var(--muted, #888); font-weight:400; font-size:.9em; } diff --git a/web/server.js b/web/server.js index 0eb6430..c092226 100644 --- a/web/server.js +++ b/web/server.js @@ -141,6 +141,49 @@ function buildCredsYaml({ auth, roles }) { return lines.join('\n') + '\n'; } +// Turn the web form's Scoping/Guardrails object into the scope YAML the CLI +// loads with --scope-file. The hard list is the boundary; everything else is a +// guardrail inside it. Written to a temp file per job. +function buildScopeYaml(scope) { + const lines = []; + const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\n,;]+/)).map((x) => String(x).trim()).filter(Boolean); + const block = (key, items) => { + if (!items.length) return; + lines.push(`${key}:`); + for (const it of items) lines.push(` - ${JSON.stringify(it)}`); + }; + block('hard', list(scope.hard)); + block('exclude', list(scope.exclude)); + const soft = []; + const obs = list(scope.observeOnly); + if (obs.length) { soft.push(' observe_only:'); for (const o of obs) soft.push(` - ${JSON.stringify(o)}`); } + soft.push(` allow_destructive_methods: ${scope.allowDestructive ? 'true' : 'false'}`); + soft.push(` allow_account_creation: ${scope.allowAccountCreation === false ? 'false' : 'true'}`); + if (scope.maxAccounts !== undefined && scope.maxAccounts !== '') soft.push(` max_accounts: ${Number(scope.maxAccounts) || 0}`); + if (scope.rateLimit !== undefined && scope.rateLimit !== '') soft.push(` max_requests_per_minute: ${Number(scope.rateLimit) || 0}`); + const forb = list(scope.forbidden); + if (forb.length) { soft.push(' forbidden_payloads:'); for (const fp of forb) soft.push(` - ${JSON.stringify(fp)}`); } + const notes = list(scope.notes); + if (notes.length) { soft.push(' notes:'); for (const n of notes) soft.push(` - ${JSON.stringify(n)}`); } + lines.push('soft:'); + lines.push(...soft); + return lines.join('\n') + '\n'; +} + +// Only materialize a scope file when the operator actually set a hard boundary +// through the form — otherwise the run keeps its normal target+flags behaviour. +async function materializeScope(body, jobId) { + const scope = body.scope; + if (!scope) return undefined; + const hard = (Array.isArray(scope.hard) ? scope.hard : String(scope.hard || '').split(/[\n,;]+/)).map((x) => String(x).trim()).filter(Boolean); + if (!hard.length) return undefined; // no boundary set — nothing to enforce beyond flags + const dir = path.join(os.tmpdir(), 'neurosploit-web'); + await fsp.mkdir(dir, { recursive: true }); + const file = path.join(dir, `${jobId}.scope.yaml`); + await fsp.writeFile(file, buildScopeYaml(scope)); + return file; +} + async function materializeCreds(body, jobId) { if (body.creds) return body.creds; // explicit file path on disk wins if (!body.auth && !(body.roles || []).length) return undefined; @@ -610,6 +653,7 @@ function buildArgs(body) { // 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.scopePath) args.push('--scope-file', body.scopePath); 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); @@ -622,7 +666,8 @@ async function startJob(body) { if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/'); const id = crypto.randomUUID(); const credsPath = await materializeCreds(body, id); - const args = buildArgs({ ...body, creds: credsPath }); + const scopePath = await materializeScope(body, id); + const args = buildArgs({ ...body, creds: credsPath, scopePath }); const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || ''); job.pinnedAgents = body.agents || []; jobs.set(id, job); @@ -666,6 +711,7 @@ async function startJob(body) { function authArgs(body) { const args = []; for (const entry of body.inScope || []) args.push('--in-scope', entry); + if (body.scopePath) args.push('--scope-file', body.scopePath); 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); @@ -724,7 +770,8 @@ async function startJobViaRepl(body) { const id = crypto.randomUUID(); const credsPath = await materializeCreds(body, id); const script = buildReplScript({ ...body, creds: credsPath }); - const auth = authArgs(body); + const scopePath = await materializeScope(body, id); + const auth = authArgs({ ...body, scopePath }); const job = new Job(id, BIN, auth, body.repo || body.target || '', body.name || ''); job.pinnedAgents = body.agents || []; job.repl = true;