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
+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;