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:
CyberSecurityUPandClaude Opus 5 committed 2026-09-18 19:20:29 -03:00
1 parent f1fb6b8bc7
commit 8894649ccb
8 files changed
+413 -3

No files matched your search

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