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