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

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