feat(scope): --scope-file YAML loader + web Scoping/Guardrails UI

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 <yaml>. 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) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-09-18 19:20:29 -03:00
co-authored by Claude Opus 5
parent f1fb6b8bc7
commit 8894649ccb
8 changed files with 413 additions and 3 deletions
+1
View File
@@ -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 <yaml>` | 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
+67
View File
@@ -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."
+16 -1
View File
@@ -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<String>,
/// 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<String>,
/// 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)?;
+197
View File
@@ -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<ScopePolicy> {
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: [ - <pattern> ... ]
/// exclude: [ - <pattern> ... ]
/// soft:
/// observe_only: [ - <pattern> ... ]
/// allow_destructive_methods: <bool>
/// allow_account_creation: <bool>
/// max_accounts: <int>
/// max_requests_per_minute: <int>
/// forbidden_payloads: [ - <string> ... ]
/// notes: [ - <string> ... ]
/// ```
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<String> {
.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());
}
}
+23
View File
@@ -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;
+56
View File
@@ -476,6 +476,62 @@
<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="section-title" style="margin-top:20px;display:flex;align-items:center;gap:8px;">
Scoping &amp; Guardrails
<span class="pill" style="font-size:10px;">enforced in code</span>
</div>
<div class="field-help" style="margin:-6px 0 10px;">
The <b>hard scope</b> is the boundary: a request whose host is not listed is <b>refused before it is sent</b> — not warned about. Exclusions always win. A capability token still caps all of this. Leave the hard list empty to keep the plain target + <em>Additional authorized hosts</em> behaviour.
</div>
<div class="field-group">
<label class="field-label" for="scopeHard">Hard scope <span class="req">— the allowlist</span></label>
<textarea id="scopeHard" rows="3" placeholder="one per line, or comma-separated&#10;app.example.com&#10;*.staging.example.com&#10;https://example.com/api/v2&#10;10.20.30.0/24"></textarea>
<div class="field-help">Exact host · <code>*.wildcard</code> (apex + subdomains) · <code>CIDR</code> · <code>https://host/path</code> prefix. Empty = nothing extra is enforced here.</div>
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label" for="scopeExclude">Exclusions <span class="req">— always win</span></label>
<textarea id="scopeExclude" rows="3" placeholder="payments.example.com&#10;admin.example.com&#10;https://app.example.com/billing"></textarea>
<div class="field-help">Refused even if the allowlist would cover them.</div>
</div>
<div class="field-group">
<label class="field-label" for="scopeObserve">Observe-only</label>
<textarea id="scopeObserve" rows="3" placeholder="cdn.example.com&#10;*.thirdparty.example.com"></textarea>
<div class="field-help">May be looked at (recon) but never attacked.</div>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">State-changing methods</label>
<div class="check-row"><input type="checkbox" id="scopeDestructive" /> <label for="scopeDestructive">Allow DELETE / PUT / PATCH</label></div>
<div class="field-help">Off by default — a scan should not change the target's state to prove a bug.</div>
</div>
<div class="field-group">
<label class="field-label">Test accounts</label>
<div class="check-row"><input type="checkbox" id="scopeAccounts" checked /> <label for="scopeAccounts">Allow account creation</label></div>
</div>
<div class="field-group">
<label class="field-label" for="scopeMaxAccounts">Max accounts</label>
<input class="narrow" id="scopeMaxAccounts" type="number" min="0" value="3" />
<div class="field-help">0 = unlimited.</div>
</div>
<div class="field-group">
<label class="field-label" for="scopeRate">Requests / min</label>
<input class="narrow" id="scopeRate" type="number" min="0" value="240" />
<div class="field-help">Whole engagement. 0 = unlimited. Keep low on production.</div>
</div>
</div>
<div class="field-group">
<label class="field-label" for="scopeForbidden">Forbidden payloads</label>
<textarea id="scopeForbidden" rows="2" placeholder="delete from&#10;drop table&#10;rm -rf /"></textarea>
<div class="field-help">Substrings NEVER acceptable, whatever the finding — the classes that damage a target instead of demonstrating a bug. Extends the built-in defaults.</div>
</div>
<div class="field-group">
<label class="field-label" for="scopeNotes">Notes <span class="req">— guidance, NOT enforced</span></label>
<textarea id="scopeNotes" rows="2" placeholder="SOW-2026-0142; test window 02:00–06:00 UTC; prove PII with a canary row only"></textarea>
<div class="field-help">Context passed to the agents. Kept separate from the rules on purpose — prose is not a control.</div>
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label" for="envSelect">Environment</label>
+4
View File
@@ -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; }
+49 -2
View File
@@ -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;