mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 13:09:36 +02:00
feat: PoC validator, Kali sandbox, intercept proxy, compliance, +8 validators
Closes the three benchmark gaps and adds the two the user asked for. poc.rs — re-runs each finding's recorded proof and sorts it into reproduced / changed / gone / unverifiable. The last two are kept apart deliberately: a PoC that could not be tested (out of scope now, state-changing, nothing recorded) is never reported as one that failed. Never re-runs a mutating request to "confirm" it. Can only lower a finding's standing, never raise it. Wired as a run pass (--revalidate-poc) and a subcommand (neurosploit poc <run> --apply). proxy.rs — own recording forward proxy (HTTP in full; HTTPS tunnelled with honest metadata, no fake CA) that chains upstream to Burp / Caido / ZAP / mitmproxy. A bare tool routes straight through it; own+tool records here and forwards for full TLS interception. Flows -> flows.jsonl, distinct hosts become passive-discovery leads. Harness and agent child commands share one route. sandbox.rs — Kali docker/podman container: no host network, no mounted socket, no-new-privileges, workdir mounted, proxy/transport env inherited. A missing runtime is an explicit error, never a silent fallback to host execution — the whole point being to keep attack payloads off the operator's host. Subcommands sandbox up|exec|install|down. compliance.rs — maps confirmed findings onto PCI-DSS v4.0, HIPAA Security Rule and SOC 2 controls. Phrased as "bears on control X", never "compliant/non- compliant"; the disclaimer is rendered on top and absence of a finding is never presented as compliance. Report section + `neurosploit compliance <run>`. validation.rs — 8 new deterministic validators (19 -> 27 classes): verbose errors/stack traces (CWE-209), cleartext/HSTS (319), CRLF response splitting (113), dangerous HTTP methods (650), GraphQL introspection, exposed backup files (530), Host header injection (644), cacheable private responses (525). Each names exactly what it saw and rejects the classic false positives (a block page echoing a payload, the SPA served under a bogus path, a copyright year mistaken for a code). All wired through RunConfig, the CLI (global --intercept/--sandbox; run-level --revalidate-poc/--compliance) and the web console's Tooling & assurance block. 328 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
64d6efa8c3
commit
f1fb6b8bc7
16 files changed
+2528
-12
No files matched your search
+16
-10
@@ -24,8 +24,8 @@ The tools compared: [Strix](https://github.com/usestrix/strix) (Apache 2.0),
|
||||
| Black-box | ✅ | ⚠️ needs source | ✅ | ✅ |
|
||||
| White-box | ✅ SAST+DAST | ✅ core design | ⚠️ | ✅ + grey-box |
|
||||
| Browser validation | ✅ built-in | ✅ | ✅ | ✅ Playwright, XSS proven by execution |
|
||||
| Intercepting proxy | ✅ Caido | — | ✅ Burp | ⚠️ upstream proxy only |
|
||||
| Container isolation | ✅ | ✅ ephemeral Docker | ✅ | ❌ **runs on the host** |
|
||||
| Intercepting proxy | ✅ Caido | — | ✅ Burp | ✅ own interceptor + Burp/Caido/ZAP/mitmproxy |
|
||||
| Container isolation | ✅ | ✅ ephemeral Docker | ✅ | ✅ Kali docker/podman (no host net, no socket) |
|
||||
| Exploit-only reporting | ✅ "working PoCs" | ✅ "no exploit, no report" | ✅ | ⚠️ **different rule — see below** |
|
||||
| CVSS | tag on the finding | not scored | ✅ | ✅ **evidence-graded, computed not guessed** |
|
||||
| Multi-model adversarial vote | — | — | — | ✅ |
|
||||
@@ -36,6 +36,9 @@ The tools compared: [Strix](https://github.com/usestrix/strix) (Apache 2.0),
|
||||
| Self-hosted OOB channel (blind SSRF/XXE/RCE) | via tools | — | ✅ Burp | ✅ own DNS+HTTP listeners |
|
||||
| Fail-closed egress (VPN/bastion/tunnel) | — | — | — | ✅ |
|
||||
| WAF-aware inference (block ≠ "not vulnerable") | — | — | — | ✅ |
|
||||
| PoC re-validation (re-run, demote what's gone) | — | — | — | ✅ |
|
||||
| Compliance mapping (PCI-DSS/HIPAA/SOC 2) | SOC2/ISO/PCI report shapes | — | ✅ | ✅ control-level, disclaimer enforced |
|
||||
| Deterministic per-CWE validators | — | — | — | ✅ 27 classes |
|
||||
| FAIR loss quantification | — | — | — | ✅ |
|
||||
| Provenance / watermarking | — | — | — | ✅ |
|
||||
| Published benchmark results | dir exists, empty | — | marketing | ❌ **none, including this one** |
|
||||
@@ -109,15 +112,18 @@ stopped". Long engagements die of token exhaustion more often than of bugs.
|
||||
|
||||
## Where NeuroSploit is behind — honestly
|
||||
|
||||
**1. No container isolation.** Strix and Shannon run each scan in an ephemeral
|
||||
container. NeuroSploit runs on the operator's host. For a tool that executes
|
||||
attacker-supplied-shaped payloads this is the largest single gap in the
|
||||
comparison, and the next thing worth building.
|
||||
**1. Container isolation is new and shallow.** NeuroSploit now runs commands in
|
||||
a Kali docker/podman container (no host network, no mounted socket,
|
||||
`no-new-privileges`), which closes the headline gap — but Strix and Shannon
|
||||
have run this way from day one and have found the sharp edges. Ours is young.
|
||||
And wiring *every* agent-authored command through the container (versus the
|
||||
harness's own tool commands) is still partial.
|
||||
|
||||
**2. No real intercepting proxy.** Strix ships Caido integration; Penligent
|
||||
drives Burp. NeuroSploit can route through an upstream proxy — and now through
|
||||
a VPN, bastion, or Cloudflare tunnel, fail-closed — but it does not own the
|
||||
request/response stream, which limits replay fidelity and passive discovery.
|
||||
**2. TLS interception delegates to the tools.** The own interceptor records
|
||||
plaintext HTTP fully and tunnels HTTPS honestly (host, timing, byte counts) —
|
||||
for decrypted HTTPS it chains to Burp/Caido/ZAP/mitmproxy, which own the CA
|
||||
machinery. That is a deliberate honesty split, not a full re-implementation of
|
||||
what those tools do.
|
||||
|
||||
**3. Nobody has run it against a benchmark.** Strix has an empty `benchmarks/`
|
||||
directory, Shannon publishes none, and neither does this project. Until
|
||||
|
||||
@@ -563,6 +563,59 @@ messages. A rate-limit claim then counts *delivered messages carrying distinct
|
||||
codes* — not HTTP 200s, which is what makes the finding survive a vendor's
|
||||
review.
|
||||
|
||||
### Intercepting proxy — own it, or plug into the tool
|
||||
|
||||
The engagement flows through one point the operator can watch and replay:
|
||||
|
||||
```bash
|
||||
--intercept burp # route straight through Burp / Caido / ZAP / mitmproxy
|
||||
--intercept own # the harness's own recording interceptor (passive discovery)
|
||||
--intercept own+burp # record here, forward to Burp for full HTTPS interception
|
||||
```
|
||||
|
||||
The own interceptor records plaintext HTTP in full and tunnels HTTPS honestly
|
||||
(host, timing, bytes — no fake CA). Flows land in `flows.jsonl`; distinct hosts
|
||||
become passive-discovery leads. Both the harness and the agents' child commands
|
||||
route through it.
|
||||
|
||||
### Sandbox — run the dangerous half off the host
|
||||
|
||||
```bash
|
||||
--sandbox # Kali container (kalilinux/kali-rolling)
|
||||
--sandbox my/custom-image # or your own
|
||||
neurosploit sandbox up|exec|install|down
|
||||
```
|
||||
|
||||
No host network, no mounted docker socket, `no-new-privileges`. The workdir is
|
||||
mounted so evidence comes back; the proxy/transport route is inherited. A
|
||||
missing runtime is an **explicit** error — never a silent fallback to running
|
||||
attack payloads on the host.
|
||||
|
||||
### PoC re-validation — the harness checking its own work
|
||||
|
||||
```bash
|
||||
--revalidate-poc # during a run
|
||||
neurosploit poc <run> --repeats 3 --apply # on a finished run
|
||||
```
|
||||
|
||||
Re-runs each finding's recorded proof and sorts the result into **reproduced ·
|
||||
changed · gone · unverifiable**. The last two are kept apart on purpose: a PoC
|
||||
that *could not be tested* (out of scope now, state-changing, nothing recorded)
|
||||
is never reported as one that *failed*. State-changing requests are never
|
||||
re-run to "confirm" them.
|
||||
|
||||
### Compliance mapping — PCI-DSS · HIPAA · SOC 2
|
||||
|
||||
```bash
|
||||
neurosploit run <t> --compliance pci-dss,hipaa,soc2 # section in the report
|
||||
neurosploit compliance <run> --framework soc2 # on a finished run
|
||||
```
|
||||
|
||||
Maps confirmed findings onto control requirements (PCI-DSS 6.2.4, HIPAA
|
||||
§164.312(e), SOC 2 CC7.1, …). It **indicates gaps for an assessor** — never a
|
||||
compliance verdict, and the disclaimer that says so is rendered on top,
|
||||
non-negotiably. Absence of a finding is never presented as compliance.
|
||||
|
||||
### Internal network & Active Directory — the engagement as a graph
|
||||
|
||||
An internal result is a path, not a list. `Asset → Exposure → Weakness →
|
||||
@@ -660,6 +713,10 @@ git clone https://github.com/digininja/DVWA /tmp/DVWA
|
||||
| `--deep-test-limit N` | Cap on findings that get deep reasoning. |
|
||||
| `--coverage-first` / `--depth-first` | Map everything first, or chase a lead as it appears. |
|
||||
| `--sample-per-route N` | Requests per endpoint family — `/api/users/{id}` is sampled, not enumerated. |
|
||||
| `--intercept <spec>` | Route through Burp/Caido/ZAP/mitmproxy, an own recording interceptor, or both (`own+burp`). |
|
||||
| `--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. |
|
||||
| `-v, --verbose` | Log each agent as it launches, recon, and votes. |
|
||||
|
||||
### Authentication — run via API key *or* subscription
|
||||
|
||||
@@ -69,6 +69,14 @@ struct Cli {
|
||||
/// Inbound SMS: twilio:<sid>:<token>:<number> or webhook:<url>:<number>.
|
||||
#[arg(long = "sms", global = true)]
|
||||
sms: Option<String>,
|
||||
/// Intercepting proxy: burp · caido · zap · mitmproxy · own · own+burp ·
|
||||
/// http://host:port. Routes the harness AND agent commands through it.
|
||||
#[arg(long = "intercept", global = true)]
|
||||
intercept: Option<String>,
|
||||
/// Run agent commands inside a container instead of on the host. Bare flag
|
||||
/// uses the Kali image; give a value to override (e.g. --sandbox my/img).
|
||||
#[arg(long = "sandbox", global = true, num_args = 0..=1, default_missing_value = "")]
|
||||
sandbox: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -143,6 +151,14 @@ enum Cmd {
|
||||
/// Requests per endpoint family (/api/users/{id} is sampled, not enumerated).
|
||||
#[arg(long = "sample-per-route", default_value_t = 3)]
|
||||
sample_per_route: usize,
|
||||
/// Re-run each finding's PoC after validation; demote any that no
|
||||
/// longer reproduces.
|
||||
#[arg(long = "revalidate-poc")]
|
||||
revalidate_poc: bool,
|
||||
/// Map findings onto compliance controls in the report: pci-dss, hipaa,
|
||||
/// soc2 (repeatable or comma-separated).
|
||||
#[arg(long = "compliance")]
|
||||
compliance: Vec<String>,
|
||||
/// Open a Jira card per finding (needs the jira integration enabled).
|
||||
#[arg(long)]
|
||||
jira: bool,
|
||||
@@ -161,6 +177,35 @@ enum Cmd {
|
||||
/// Run id (`ns-…`) or a path to the run directory.
|
||||
run: String,
|
||||
},
|
||||
/// Compliance mapping: re-frame a finished run's findings against PCI-DSS,
|
||||
/// HIPAA or SOC 2 controls.
|
||||
Compliance {
|
||||
/// Run id (`ns-…`) or path to the run directory.
|
||||
run: String,
|
||||
/// Frameworks: pci-dss · hipaa · soc2 (repeatable/comma-separated; all if omitted).
|
||||
#[arg(long = "framework")]
|
||||
framework: Vec<String>,
|
||||
/// Include unconfirmed findings (leads) too. Off by default.
|
||||
#[arg(long = "include-leads")]
|
||||
include_leads: bool,
|
||||
},
|
||||
/// Re-validate a finished run's PoCs: re-run each finding's recorded proof
|
||||
/// and report which still reproduce.
|
||||
Poc {
|
||||
/// Run id (`ns-…`) or path to the run directory.
|
||||
run: String,
|
||||
/// Re-runs per finding (a single send can be a fluke).
|
||||
#[arg(long = "repeats", default_value_t = 2)]
|
||||
repeats: usize,
|
||||
/// Write the demoted findings back to findings.json.
|
||||
#[arg(long = "apply")]
|
||||
apply: bool,
|
||||
},
|
||||
/// Manage the Kali sandbox container (up · exec · down).
|
||||
Sandbox {
|
||||
#[command(subcommand)]
|
||||
cmd: SandboxCmd,
|
||||
},
|
||||
/// Issue or inspect a signed capability token (the engagement's authorization).
|
||||
Capability {
|
||||
#[command(subcommand)]
|
||||
@@ -524,12 +569,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
Err(e) => anyhow::bail!("rebuild failed: {e}"),
|
||||
}
|
||||
}
|
||||
Cmd::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?,
|
||||
Cmd::Poc { run, repeats, apply } => handle_poc(&base, &run, repeats, apply).await?,
|
||||
Cmd::Sandbox { cmd } => handle_sandbox(cmd).await?,
|
||||
Cmd::Capability { cmd } => handle_capability(cmd)?,
|
||||
Cmd::Provenance { cmd } => handle_provenance(cmd)?,
|
||||
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, 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, 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;
|
||||
@@ -546,6 +594,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
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)?;
|
||||
cfg.intercept = cli.intercept.clone();
|
||||
cfg.sandbox = cli.sandbox.clone();
|
||||
cfg.revalidate_poc = revalidate_poc;
|
||||
cfg.compliance = revalidate_split(&compliance);
|
||||
if !models.is_empty() {
|
||||
cfg.models = models;
|
||||
}
|
||||
@@ -1106,6 +1158,32 @@ fn apply_authorization(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SandboxCmd {
|
||||
/// Pull the image and start the container.
|
||||
Up {
|
||||
/// Image (default kalilinux/kali-rolling).
|
||||
#[arg(long)]
|
||||
image: Option<String>,
|
||||
/// Host path mounted at /work.
|
||||
#[arg(long)]
|
||||
mount: Option<String>,
|
||||
},
|
||||
/// Run a command inside the container.
|
||||
Exec {
|
||||
/// The command line to run.
|
||||
command: Vec<String>,
|
||||
#[arg(long)]
|
||||
image: Option<String>,
|
||||
},
|
||||
/// Install packages inside the container (e.g. sqlmap ffuf nuclei).
|
||||
Install {
|
||||
packages: Vec<String>,
|
||||
},
|
||||
/// Stop and remove the container.
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProvCmd {
|
||||
/// Print this build's fingerprint and the markers it mints.
|
||||
@@ -1239,6 +1317,121 @@ fn handle_internal(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_run(base: &std::path::Path, run: &str) -> anyhow::Result<std::path::PathBuf> {
|
||||
let dir = std::path::PathBuf::from(run);
|
||||
let dir = if dir.is_dir() { dir } else { base.join("runs").join(run) };
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!("no such run directory: {}", dir.display());
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn load_findings(dir: &std::path::Path) -> anyhow::Result<Vec<harness::types::Finding>> {
|
||||
let text = std::fs::read_to_string(dir.join("findings.json"))
|
||||
.map_err(|e| anyhow::anyhow!("no findings.json in {}: {e}", dir.display()))?;
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
|
||||
fn handle_compliance(base: &std::path::Path, run: &str, frameworks: &[String], include_leads: bool) -> anyhow::Result<()> {
|
||||
use harness::compliance::{map_findings, Framework};
|
||||
let dir = resolve_run(base, run)?;
|
||||
let findings = load_findings(&dir)?;
|
||||
let wanted: Vec<Framework> = if frameworks.is_empty() {
|
||||
Framework::all().to_vec()
|
||||
} else {
|
||||
frameworks.iter().flat_map(|v| v.split([',', ';'])).filter_map(|f| Framework::parse(f.trim())).collect()
|
||||
};
|
||||
if wanted.is_empty() {
|
||||
anyhow::bail!("no recognised framework — use pci-dss, hipaa or soc2");
|
||||
}
|
||||
for fw in wanted {
|
||||
let report = map_findings(&findings, fw, !include_leads);
|
||||
let md = report.to_markdown();
|
||||
let out = dir.join(format!("compliance-{}.md", fw.as_str()));
|
||||
std::fs::write(&out, &md)?;
|
||||
println!("\n{}", md);
|
||||
println!(" \x1b[2msaved → {}\x1b[0m", out.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_poc(base: &std::path::Path, run: &str, repeats: usize, apply: bool) -> anyhow::Result<()> {
|
||||
let dir = resolve_run(base, run)?;
|
||||
let mut findings = load_findings(&dir)?;
|
||||
// Scope the replay to the hosts the findings are on — a re-validation run
|
||||
// must not reach past where the engagement was authorized.
|
||||
let target = findings.iter().map(|f| harness::scope::host_of(&f.endpoint)).find(|h| !h.is_empty()).unwrap_or_default();
|
||||
let policy = harness::scope::ScopePolicy::for_target(&format!("https://{target}"));
|
||||
let validator = harness::poc::PocValidator::new(policy).with_repeats(repeats);
|
||||
println!(" re-validating {} finding(s)…", findings.len());
|
||||
let results = validator.validate_all(&findings).await;
|
||||
for r in &results {
|
||||
let color = match r.reproduction {
|
||||
harness::poc::Reproduction::Reproduced => "1;32",
|
||||
harness::poc::Reproduction::Gone => "1;31",
|
||||
harness::poc::Reproduction::Changed => "1;33",
|
||||
harness::poc::Reproduction::Unverifiable => "2",
|
||||
};
|
||||
println!(" \x1b[{color}m{:<13}\x1b[0m {}", r.reproduction.as_str(), r.detail);
|
||||
}
|
||||
println!("\n {}", harness::poc::summary(&results));
|
||||
if apply {
|
||||
let by_id: std::collections::HashMap<&str, &harness::poc::PocResult> = results.iter().map(|r| (r.finding_id.as_str(), r)).collect();
|
||||
for f in findings.iter_mut() {
|
||||
if let Some(r) = by_id.get(f.id.as_str()) {
|
||||
harness::poc::apply(f, r);
|
||||
}
|
||||
}
|
||||
std::fs::write(dir.join("findings.json"), serde_json::to_string_pretty(&findings)?)?;
|
||||
println!(" \x1b[2mwrote demotions back to findings.json — run `neurosploit rebuild {run}` to refresh the report\x1b[0m");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_sandbox(cmd: SandboxCmd) -> anyhow::Result<()> {
|
||||
use harness::sandbox::{Sandbox, SandboxConfig};
|
||||
let mk = |image: Option<String>, mount: Option<String>| -> anyhow::Result<Sandbox> {
|
||||
let mut sc = SandboxConfig::default();
|
||||
if let Some(i) = image { sc = sc.with_image(&i); }
|
||||
if let Some(m) = mount { sc = sc.mounting(&m); }
|
||||
Sandbox::new(sc).map_err(|e| anyhow::anyhow!(e))
|
||||
};
|
||||
match cmd {
|
||||
SandboxCmd::Up { image, mount } => {
|
||||
let sb = mk(image, mount)?;
|
||||
println!(" {} runtime", sb.runtime().bin());
|
||||
match sb.ensure().await {
|
||||
Ok(msg) => println!(" \x1b[1;32m✓\x1b[0m {msg}"),
|
||||
Err(e) => anyhow::bail!(e),
|
||||
}
|
||||
}
|
||||
SandboxCmd::Exec { command, image } => {
|
||||
let sb = mk(image, None)?;
|
||||
let line = command.join(" ");
|
||||
if line.trim().is_empty() { anyhow::bail!("nothing to run"); }
|
||||
let r = sb.exec(&line).await.map_err(|e| anyhow::anyhow!(e))?;
|
||||
print!("{}", r.stdout);
|
||||
eprint!("{}", r.stderr);
|
||||
if r.timed_out { anyhow::bail!("command timed out"); }
|
||||
std::process::exit(r.code);
|
||||
}
|
||||
SandboxCmd::Install { packages } => {
|
||||
if packages.is_empty() { anyhow::bail!("name at least one package"); }
|
||||
let sb = mk(None, None)?;
|
||||
let refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
|
||||
println!(" installing {} in the sandbox…", refs.join(", "));
|
||||
let r = sb.install(&refs).await.map_err(|e| anyhow::anyhow!(e))?;
|
||||
if r.ok() { println!(" \x1b[1;32m✓ installed\x1b[0m"); } else { eprintln!("{}", r.stderr); anyhow::bail!("install failed"); }
|
||||
}
|
||||
SandboxCmd::Down => {
|
||||
let sb = mk(None, None)?;
|
||||
sb.teardown().await;
|
||||
println!(" container removed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_provenance(cmd: ProvCmd) -> anyhow::Result<()> {
|
||||
use harness::provenance::{Manifest, Provenance, SIGIL};
|
||||
match cmd {
|
||||
@@ -1432,6 +1625,15 @@ fn apply_network(cfg: &mut RunConfig, cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split comma/semicolon-joined framework names into a flat, lowercased list.
|
||||
fn revalidate_split(vals: &[String]) -> Vec<String> {
|
||||
vals.iter()
|
||||
.flat_map(|v| v.split([',', ';']))
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_only(vals: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for v in vals {
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Compliance framing — mapping findings to the controls a client is audited on.
|
||||
//!
|
||||
//! A client who has to answer to PCI-DSS, HIPAA or SOC 2 does not read a
|
||||
//! pentest report as a list of CWEs. They read it as "which of my controls does
|
||||
//! this put at risk, and what do I tell the assessor". So this module takes the
|
||||
//! findings the engagement already proved and re-frames them against the
|
||||
//! control catalogue of each framework: a reflected-XSS finding is *also* a gap
|
||||
//! against PCI-DSS 6.2.4 and SOC 2 CC7.1, and saying so is most of the work of
|
||||
//! turning a technical report into one a compliance team can act on.
|
||||
//!
|
||||
//! ## The honesty line, drawn hard
|
||||
//!
|
||||
//! A scanner cannot declare compliance. Compliance is an auditor's conclusion
|
||||
//! over an entire environment — policies, evidence, scope, compensating
|
||||
//! controls — almost none of which a pentest sees. What a finding *can* do is
|
||||
//! **indicate a gap** against a specific control: evidence the assessor will
|
||||
//! want to look at. So every output here is phrased as "this finding bears on
|
||||
//! control X", never "you are non-compliant with X". A tool that prints a green
|
||||
//! "PCI COMPLIANT" badge off a scan is lying, and the client's QSA knows it.
|
||||
//!
|
||||
//! Absence of findings is treated the same way: it is *not* evidence of
|
||||
//! compliance, only absence of the specific gaps this engagement tested for —
|
||||
//! and the summary says so rather than leaving a reader to assume the opposite.
|
||||
|
||||
use crate::types::Finding;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A compliance framework we can map findings onto.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Framework {
|
||||
/// PCI-DSS v4.0 — payment card data.
|
||||
PciDss,
|
||||
/// HIPAA Security Rule (45 CFR §164.3xx) — protected health information.
|
||||
Hipaa,
|
||||
/// SOC 2 — Trust Services Criteria (security, availability, confidentiality).
|
||||
Soc2,
|
||||
}
|
||||
|
||||
impl Framework {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Framework::PciDss => "pci-dss",
|
||||
Framework::Hipaa => "hipaa",
|
||||
Framework::Soc2 => "soc2",
|
||||
}
|
||||
}
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Framework::PciDss => "PCI-DSS v4.0",
|
||||
Framework::Hipaa => "HIPAA Security Rule",
|
||||
Framework::Soc2 => "SOC 2 (Trust Services Criteria)",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Framework> {
|
||||
Some(match s.trim().to_lowercase().replace([' ', '_'], "-").as_str() {
|
||||
"pci" | "pci-dss" | "pcidss" => Framework::PciDss,
|
||||
"hipaa" => Framework::Hipaa,
|
||||
"soc2" | "soc-2" | "soc" => Framework::Soc2,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn all() -> [Framework; 3] {
|
||||
[Framework::PciDss, Framework::Hipaa, Framework::Soc2]
|
||||
}
|
||||
}
|
||||
|
||||
/// One control a finding bears on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Control {
|
||||
/// The control's own identifier in the framework ("6.2.4", "CC6.7",
|
||||
/// "§164.312(e)(1)").
|
||||
pub id: String,
|
||||
/// What the control requires, in a sentence.
|
||||
pub requirement: String,
|
||||
}
|
||||
|
||||
impl Control {
|
||||
fn new(id: &str, requirement: &str) -> Control {
|
||||
Control { id: id.into(), requirement: requirement.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls a class of weakness bears on, per framework.
|
||||
///
|
||||
/// Keyed on the security *category* a CWE belongs to, not on the raw CWE
|
||||
/// number, because the mapping is the same for a whole family: every injection
|
||||
/// class points at the same secure-coding controls. [`categorize`] does the
|
||||
/// CWE→category step so this table stays legible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Category {
|
||||
Injection,
|
||||
BrokenAccessControl,
|
||||
BrokenAuth,
|
||||
CryptoTransport,
|
||||
SensitiveDataExposure,
|
||||
SecurityMisconfig,
|
||||
Ssrf,
|
||||
VulnerableComponent,
|
||||
InsufficientLogging,
|
||||
Csrf,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Place a finding in a control-relevant category.
|
||||
fn categorize(f: &Finding) -> Category {
|
||||
let n = f.cwe.chars().filter(|c| c.is_ascii_digit()).collect::<String>();
|
||||
let t = format!("{} {}", f.title, f.cwe).to_lowercase();
|
||||
let is = |cwe: &[&str]| cwe.contains(&n.as_str());
|
||||
if is(&["89", "79", "78", "77", "94", "917", "611", "1336", "943", "564", "113", "90"]) || t.contains("injection") || t.contains("xss") {
|
||||
Category::Injection
|
||||
} else if is(&["639", "863", "862", "285", "22", "23", "918"]) && !t.contains("ssrf") || t.contains("idor") || t.contains("access control") || t.contains("bola") || t.contains("path traversal") {
|
||||
Category::BrokenAccessControl
|
||||
} else if is(&["287", "306", "307", "384", "620", "521", "347", "1004"]) || t.contains("auth") || t.contains("jwt") || t.contains("session") || t.contains("rate limit") || t.contains("brute") {
|
||||
Category::BrokenAuth
|
||||
} else if is(&["319", "311", "326", "327", "523"]) || t.contains("cleartext") || t.contains("tls") || t.contains("hsts") || t.contains("weak cipher") {
|
||||
Category::CryptoTransport
|
||||
} else if is(&["200", "209", "532", "538", "540", "312", "530", "525", "524"]) || t.contains("disclosure") || t.contains("exposed") || t.contains("leak") {
|
||||
Category::SensitiveDataExposure
|
||||
} else if is(&["16", "1021", "614", "942", "650", "548", "1230", "693", "644"]) || t.contains("misconfig") || t.contains("cors") || t.contains("clickjack") || t.contains("header") || t.contains("directory listing") {
|
||||
Category::SecurityMisconfig
|
||||
} else if is(&["918"]) || t.contains("ssrf") || t.contains("server-side request") {
|
||||
Category::Ssrf
|
||||
} else if is(&["1035", "1104", "937"]) || t.contains("outdated") || t.contains("vulnerable component") || t.contains("known cve") {
|
||||
Category::VulnerableComponent
|
||||
} else if is(&["778", "223"]) || t.contains("logging") || t.contains("audit trail") {
|
||||
Category::InsufficientLogging
|
||||
} else if is(&["352"]) || t.contains("csrf") || t.contains("cross-site request") {
|
||||
Category::Csrf
|
||||
} else {
|
||||
Category::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn controls_for(cat: Category, fw: Framework) -> Vec<Control> {
|
||||
use Category::*;
|
||||
use Framework::*;
|
||||
match (fw, cat) {
|
||||
// ---- PCI-DSS v4.0 ------------------------------------------------
|
||||
(PciDss, Injection) => vec![
|
||||
Control::new("6.2.4", "Software engineering techniques prevent or mitigate common software attacks (injection included) in bespoke and custom software"),
|
||||
Control::new("6.3.1", "Security vulnerabilities are identified and managed"),
|
||||
],
|
||||
(PciDss, BrokenAccessControl) => vec![
|
||||
Control::new("7.2.1", "An access control model restricts access based on need-to-know and least privilege"),
|
||||
Control::new("6.2.4", "Custom software resists attacks on access-control logic"),
|
||||
],
|
||||
(PciDss, BrokenAuth) => vec![
|
||||
Control::new("8.3.1", "Strong authentication for users and administrators is enforced"),
|
||||
Control::new("8.3.6", "Passwords/passphrases meet minimum strength and lockout requirements"),
|
||||
],
|
||||
(PciDss, CryptoTransport) => vec![
|
||||
Control::new("4.2.1", "Strong cryptography protects PAN during transmission over open, public networks"),
|
||||
],
|
||||
(PciDss, SensitiveDataExposure) => vec![
|
||||
Control::new("3.5.1", "PAN is rendered unreadable wherever stored"),
|
||||
Control::new("6.2.4", "Custom software does not leak sensitive data through errors or debug output"),
|
||||
],
|
||||
(PciDss, SecurityMisconfig) => vec![
|
||||
Control::new("2.2.1", "System components are configured securely and hardened"),
|
||||
Control::new("6.4.1", "Public-facing web applications are protected against attacks"),
|
||||
],
|
||||
(PciDss, Ssrf) => vec![Control::new("1.3.1", "Inbound/outbound traffic to the CDE is restricted to what is necessary"), Control::new("6.2.4", "Custom software mitigates SSRF")],
|
||||
(PciDss, VulnerableComponent) => vec![Control::new("6.3.3", "Security patches are installed within a defined window"), Control::new("11.3.1", "Internal vulnerability scans are performed and findings resolved")],
|
||||
(PciDss, InsufficientLogging) => vec![Control::new("10.2.1", "Audit logs capture all access to system components and cardholder data")],
|
||||
(PciDss, Csrf) => vec![Control::new("6.2.4", "Custom software mitigates cross-site request forgery")],
|
||||
(PciDss, Other) => vec![Control::new("6.3.1", "Security vulnerabilities are identified, risk-ranked and managed")],
|
||||
|
||||
// ---- HIPAA Security Rule ----------------------------------------
|
||||
(Hipaa, Injection) => vec![Control::new("§164.312(c)(1)", "Integrity — protect ePHI from improper alteration or destruction"), Control::new("§164.308(a)(1)(ii)(A)", "Risk analysis identifies vulnerabilities to ePHI")],
|
||||
(Hipaa, BrokenAccessControl) => vec![Control::new("§164.312(a)(1)", "Access control — allow access to ePHI only to authorized persons"), Control::new("§164.308(a)(4)", "Information access management")],
|
||||
(Hipaa, BrokenAuth) => vec![Control::new("§164.312(d)", "Person or entity authentication verifies identity before ePHI access"), Control::new("§164.308(a)(5)(ii)(D)", "Password management")],
|
||||
(Hipaa, CryptoTransport) => vec![Control::new("§164.312(e)(1)", "Transmission security guards against unauthorized access to ePHI in transit"), Control::new("§164.312(e)(2)(ii)", "Encryption of ePHI in transit")],
|
||||
(Hipaa, SensitiveDataExposure) => vec![Control::new("§164.312(a)(2)(iv)", "Encryption and decryption of ePHI at rest"), Control::new("§164.502(b)", "Minimum necessary — limit ePHI disclosure")],
|
||||
(Hipaa, SecurityMisconfig) => vec![Control::new("§164.308(a)(1)(ii)(B)", "Risk management — implement measures to reduce risks and vulnerabilities")],
|
||||
(Hipaa, Ssrf) => vec![Control::new("§164.312(a)(1)", "Access control over internal systems reachable from the application")],
|
||||
(Hipaa, VulnerableComponent) => vec![Control::new("§164.308(a)(1)(ii)(B)", "Risk management includes remediating known vulnerabilities")],
|
||||
(Hipaa, InsufficientLogging) => vec![Control::new("§164.312(b)", "Audit controls record and examine activity in systems with ePHI")],
|
||||
(Hipaa, Csrf) => vec![Control::new("§164.312(a)(1)", "Access control — prevent actions performed without authorization")],
|
||||
(Hipaa, Other) => vec![Control::new("§164.308(a)(1)(ii)(A)", "Risk analysis of vulnerabilities to ePHI")],
|
||||
|
||||
// ---- SOC 2 (Trust Services Criteria) ----------------------------
|
||||
(Soc2, Injection) => vec![Control::new("CC7.1", "Detect and remediate vulnerabilities in the system"), Control::new("CC8.1", "Change management prevents introduction of insecure code")],
|
||||
(Soc2, BrokenAccessControl) => vec![Control::new("CC6.1", "Logical access controls restrict access to authorized users"), Control::new("CC6.3", "Access is granted based on least privilege")],
|
||||
(Soc2, BrokenAuth) => vec![Control::new("CC6.1", "Identification and authentication of users before access")],
|
||||
(Soc2, CryptoTransport) => vec![Control::new("CC6.7", "Data in transit is protected with encryption")],
|
||||
(Soc2, SensitiveDataExposure) => vec![Control::new("C1.1", "Confidential information is protected from unauthorized disclosure"), Control::new("CC6.7", "Protection of information during transmission and disposal")],
|
||||
(Soc2, SecurityMisconfig) => vec![Control::new("CC6.6", "Boundary protection and secure configuration of the system")],
|
||||
(Soc2, Ssrf) => vec![Control::new("CC6.6", "Boundary protection restricts unauthorized internal connections")],
|
||||
(Soc2, VulnerableComponent) => vec![Control::new("CC7.1", "Vulnerabilities in components are identified and remediated")],
|
||||
(Soc2, InsufficientLogging) => vec![Control::new("CC7.2", "Security events are monitored and logged for analysis")],
|
||||
(Soc2, Csrf) => vec![Control::new("CC6.1", "Actions are performed only by authenticated, authorized users")],
|
||||
(Soc2, Other) => vec![Control::new("CC7.1", "Vulnerabilities are identified, evaluated and remediated")],
|
||||
}
|
||||
}
|
||||
|
||||
/// A finding, mapped to the controls it bears on in one framework.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MappedFinding {
|
||||
pub finding_id: String,
|
||||
pub title: String,
|
||||
pub severity: String,
|
||||
pub controls: Vec<Control>,
|
||||
}
|
||||
|
||||
/// The compliance view of an engagement, for one framework.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComplianceReport {
|
||||
pub framework: String,
|
||||
pub framework_title: String,
|
||||
pub mapped: Vec<MappedFinding>,
|
||||
/// Controls with at least one finding against them, most-cited first.
|
||||
pub controls_with_gaps: Vec<ControlGap>,
|
||||
pub tested_finding_count: usize,
|
||||
}
|
||||
|
||||
/// A control and the findings that bear on it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControlGap {
|
||||
pub control: Control,
|
||||
pub finding_ids: Vec<String>,
|
||||
/// Highest severity among the findings that hit this control.
|
||||
pub max_severity: String,
|
||||
}
|
||||
|
||||
fn sev_rank(s: &str) -> u8 {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"critical" => 5,
|
||||
"high" => 4,
|
||||
"medium" => 3,
|
||||
"low" => 2,
|
||||
"info" | "informational" => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the compliance view for one framework.
|
||||
///
|
||||
/// Only findings that survived validation are mapped — an unconfirmed finding
|
||||
/// is not a control gap, it is a lead, and putting leads in front of an auditor
|
||||
/// as gaps is how a report loses the room.
|
||||
pub fn map_findings(findings: &[Finding], fw: Framework, confirmed_only: bool) -> ComplianceReport {
|
||||
use std::collections::BTreeMap;
|
||||
let mut mapped = Vec::new();
|
||||
let mut gaps: BTreeMap<String, ControlGap> = BTreeMap::new();
|
||||
|
||||
for f in findings {
|
||||
if confirmed_only && !f.validated {
|
||||
continue;
|
||||
}
|
||||
let cat = categorize(f);
|
||||
let controls = controls_for(cat, fw);
|
||||
for c in &controls {
|
||||
let entry = gaps.entry(c.id.clone()).or_insert_with(|| ControlGap {
|
||||
control: c.clone(),
|
||||
finding_ids: Vec::new(),
|
||||
max_severity: "info".into(),
|
||||
});
|
||||
entry.finding_ids.push(f.id.clone());
|
||||
if sev_rank(&f.severity) > sev_rank(&entry.max_severity) {
|
||||
entry.max_severity = f.severity.clone();
|
||||
}
|
||||
}
|
||||
mapped.push(MappedFinding {
|
||||
finding_id: f.id.clone(),
|
||||
title: f.title.clone(),
|
||||
severity: f.severity.clone(),
|
||||
controls,
|
||||
});
|
||||
}
|
||||
|
||||
let mut controls_with_gaps: Vec<ControlGap> = gaps.into_values().collect();
|
||||
controls_with_gaps.sort_by(|a, b| {
|
||||
sev_rank(&b.max_severity)
|
||||
.cmp(&sev_rank(&a.max_severity))
|
||||
.then(b.finding_ids.len().cmp(&a.finding_ids.len()))
|
||||
.then(a.control.id.cmp(&b.control.id))
|
||||
});
|
||||
|
||||
ComplianceReport {
|
||||
framework: fw.as_str().into(),
|
||||
framework_title: fw.title().into(),
|
||||
tested_finding_count: mapped.len(),
|
||||
mapped,
|
||||
controls_with_gaps,
|
||||
}
|
||||
}
|
||||
|
||||
impl ComplianceReport {
|
||||
/// The disclaimer that keeps the framing honest. Rendered at the top of the
|
||||
/// compliance section, non-negotiably.
|
||||
pub fn disclaimer(&self) -> String {
|
||||
format!(
|
||||
"This mapping shows where the findings in this engagement bear on {} controls. It is an input to a \
|
||||
compliance assessment, NOT a compliance determination: only a qualified assessor, over the full \
|
||||
environment, can conclude compliance. Absence of a finding against a control is not evidence that the \
|
||||
control is met — only that this engagement did not test for a gap there.",
|
||||
self.framework_title
|
||||
)
|
||||
}
|
||||
|
||||
/// A compact Markdown section for the report.
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut s = format!("## Compliance mapping — {}\n\n> {}\n\n", self.framework_title, self.disclaimer());
|
||||
if self.controls_with_gaps.is_empty() {
|
||||
s.push_str("No confirmed finding in this engagement mapped to a control in this framework.\n");
|
||||
return s;
|
||||
}
|
||||
s.push_str(&format!("{} confirmed finding(s) bear on {} control(s):\n\n", self.tested_finding_count, self.controls_with_gaps.len()));
|
||||
s.push_str("| Control | Requirement | Severity | Findings |\n|---|---|---|---|\n");
|
||||
for g in &self.controls_with_gaps {
|
||||
s.push_str(&format!(
|
||||
"| **{}** | {} | {} | {} |\n",
|
||||
g.control.id,
|
||||
g.control.requirement,
|
||||
g.max_severity,
|
||||
g.finding_ids.len()
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn f(id: &str, cwe: &str, title: &str, sev: &str, validated: bool) -> Finding {
|
||||
Finding { id: id.into(), cwe: cwe.into(), title: title.into(), severity: sev.into(), validated, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_injection_finding_maps_to_secure_coding_across_frameworks() {
|
||||
let sqli = f("f1", "CWE-89", "SQL injection", "high", true);
|
||||
let pci = map_findings(&[sqli.clone()], Framework::PciDss, true);
|
||||
assert!(pci.controls_with_gaps.iter().any(|g| g.control.id == "6.2.4"));
|
||||
|
||||
let soc = map_findings(&[sqli.clone()], Framework::Soc2, true);
|
||||
assert!(soc.controls_with_gaps.iter().any(|g| g.control.id == "CC7.1"));
|
||||
|
||||
let hipaa = map_findings(&[sqli], Framework::Hipaa, true);
|
||||
assert!(hipaa.controls_with_gaps.iter().any(|g| g.control.id.contains("164.312(c)")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleartext_maps_to_transmission_security() {
|
||||
let ct = f("f2", "CWE-319", "Cleartext transmission", "medium", true);
|
||||
let hipaa = map_findings(&[ct.clone()], Framework::Hipaa, true);
|
||||
assert!(hipaa.controls_with_gaps.iter().any(|g| g.control.id.contains("164.312(e)")));
|
||||
let pci = map_findings(&[ct], Framework::PciDss, true);
|
||||
assert!(pci.controls_with_gaps.iter().any(|g| g.control.id == "4.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconfirmed_findings_are_not_control_gaps() {
|
||||
let lead = f("f3", "CWE-89", "possible SQLi", "high", false);
|
||||
let pci = map_findings(&[lead], Framework::PciDss, true);
|
||||
assert!(pci.controls_with_gaps.is_empty(), "a lead is not a gap an auditor should see");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_gaps_are_ordered_by_severity() {
|
||||
let findings = vec![
|
||||
f("low", "CWE-1021", "clickjacking", "low", true),
|
||||
f("crit", "CWE-89", "SQLi", "critical", true),
|
||||
];
|
||||
let pci = map_findings(&findings, Framework::PciDss, true);
|
||||
assert_eq!(sev_rank(&pci.controls_with_gaps[0].max_severity), 5, "the critical control gap leads");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_disclaimer_refuses_to_claim_compliance() {
|
||||
let r = map_findings(&[f("f", "CWE-79", "XSS", "high", true)], Framework::PciDss, true);
|
||||
let d = r.disclaimer();
|
||||
assert!(d.contains("NOT a compliance determination"));
|
||||
assert!(d.contains("Absence of a finding"), "silence must not read as compliance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_renders_a_table_with_the_disclaimer_on_top() {
|
||||
let r = map_findings(&[f("f", "CWE-306", "missing auth", "high", true)], Framework::Soc2, true);
|
||||
let md = r.to_markdown();
|
||||
assert!(md.starts_with("## Compliance mapping"));
|
||||
assert!(md.contains("NOT a compliance determination"));
|
||||
assert!(md.contains("| Control |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_parsing_accepts_the_common_spellings() {
|
||||
assert_eq!(Framework::parse("pci"), Some(Framework::PciDss));
|
||||
assert_eq!(Framework::parse("PCI-DSS"), Some(Framework::PciDss));
|
||||
assert_eq!(Framework::parse("soc 2"), Some(Framework::Soc2));
|
||||
assert_eq!(Framework::parse("HIPAA"), Some(Framework::Hipaa));
|
||||
assert_eq!(Framework::parse("nist"), None);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod budget;
|
||||
pub mod capability;
|
||||
pub mod chain;
|
||||
pub mod claims;
|
||||
pub mod compliance;
|
||||
pub mod creds;
|
||||
pub mod grounding;
|
||||
pub mod hygiene;
|
||||
@@ -24,7 +25,9 @@ pub mod internal;
|
||||
pub mod knowledge_graph;
|
||||
pub mod memory;
|
||||
pub mod policy;
|
||||
pub mod poc;
|
||||
pub mod pomdp;
|
||||
pub mod proxy;
|
||||
pub mod prosecutor;
|
||||
pub mod provenance;
|
||||
pub mod models;
|
||||
@@ -35,6 +38,7 @@ pub mod probe;
|
||||
pub mod replay;
|
||||
pub mod report;
|
||||
pub mod rl;
|
||||
pub mod sandbox;
|
||||
pub mod scope;
|
||||
pub mod transport;
|
||||
pub mod types;
|
||||
|
||||
@@ -738,6 +738,64 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intercepting proxy. Whether it is Burp, an own recording interceptor, or
|
||||
// both, the effect is one env var the reqwest client already honours, plus
|
||||
// the same var exported to agent child commands — so the whole engagement
|
||||
// flows through one point the operator can watch and replay.
|
||||
if let Some(spec) = cfg.intercept.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
match crate::proxy::ProxyConfig::parse(spec) {
|
||||
Ok(pc) => {
|
||||
if pc.own_interceptor {
|
||||
let jsonl = cfg.workdir.as_deref().map(|d| format!("{}/flows.jsonl", d.trim_end_matches('/')));
|
||||
let upstream = pc.upstream.as_ref().map(|t| t.proxy_url());
|
||||
let mut interceptor = crate::proxy::Interceptor::new(upstream);
|
||||
if let Some(j) = jsonl { interceptor = interceptor.logging_to(j); }
|
||||
match interceptor.listen(pc.bind).await {
|
||||
Ok(()) => { let _ = tx.send(format!("notify: 🕵 {}", pc.summary())).await; }
|
||||
Err(e) => { let _ = tx.send(format!("notify: ⚠ own interceptor did not bind ({e}) — routing direct")).await; }
|
||||
}
|
||||
} else if let Some(t) = &pc.upstream {
|
||||
// A bare tool: warn if it is not actually listening rather
|
||||
// than failing every request three minutes in.
|
||||
if !t.is_listening().await {
|
||||
let _ = tx.send(format!("notify: ⚠ {} is configured but nothing is listening at {} — start it or requests will fail", t.name(), t.proxy_url())).await;
|
||||
} else {
|
||||
let _ = tx.send(format!("notify: 🕵 {}", pc.summary())).await;
|
||||
}
|
||||
}
|
||||
if let Some(url) = pc.client_proxy_url() {
|
||||
// The reqwest client (probe + replay) reads this; child
|
||||
// commands get it through the process environment.
|
||||
std::env::set_var("NEUROSPLOIT_PROXY", &url);
|
||||
for (k, v) in pc.env() { std::env::set_var(k, v); }
|
||||
}
|
||||
}
|
||||
Err(e) => { let _ = tx.send(format!("notify: ⚠ intercept: {e} — routing direct")).await; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sandbox: run the engagement's tool commands inside a container instead of
|
||||
// on the host. Ensured up-front so a missing runtime is reported before any
|
||||
// work, and never silently downgraded to host execution.
|
||||
if let Some(image) = cfg.sandbox.as_deref() {
|
||||
let mut sc = crate::sandbox::SandboxConfig::default();
|
||||
if !image.trim().is_empty() { sc = sc.with_image(image); }
|
||||
if let Some(w) = cfg.workdir.as_deref() { sc = sc.mounting(w); }
|
||||
let proxy_env: Vec<(String,String)> = std::env::var("NEUROSPLOIT_PROXY").ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|p| vec![("HTTP_PROXY".into(), p.clone()), ("HTTPS_PROXY".into(), p)])
|
||||
.unwrap_or_default();
|
||||
sc = sc.with_env(proxy_env);
|
||||
match crate::sandbox::Sandbox::new(sc) {
|
||||
Ok(sb) => match sb.ensure().await {
|
||||
Ok(msg) => { let _ = tx.send(format!("notify: 📦 {msg}")).await; }
|
||||
Err(e) => { let _ = tx.send(format!("notify: ⚠ sandbox: {e}")).await; }
|
||||
},
|
||||
Err(e) => { let _ = tx.send(format!("notify: ⚠ {e}")).await; }
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx
|
||||
.send(format!(
|
||||
"Loaded {} agents ({} vuln / {} recon / {} code / {} meta) · models: {} · vote_n={} · concurrency={}{} · budget: {}",
|
||||
@@ -2037,6 +2095,32 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
|
||||
)).await;
|
||||
}
|
||||
|
||||
// PoC re-validation: re-run each finding's recorded proof and demote any
|
||||
// that no longer reproduces. This is the harness checking its own work — a
|
||||
// bug that was hotfixed between discovery and reporting, or a "proof" that
|
||||
// was a fluke, is caught here rather than by the client.
|
||||
if cfg.revalidate_poc {
|
||||
let validator = crate::poc::PocValidator::new(effective_scope(&cfg));
|
||||
let results = validator.validate_all(&findings).await;
|
||||
let _ = tx.send(format!("notify: 🔁 {}", crate::poc::summary(&results))).await;
|
||||
let by_id: std::collections::HashMap<&str, &crate::poc::PocResult> = results.iter().map(|r| (r.finding_id.as_str(), r)).collect();
|
||||
for f in findings.iter_mut() {
|
||||
if let Some(r) = by_id.get(f.id.as_str()) {
|
||||
crate::poc::apply(f, r);
|
||||
if r.reproduction.demotes() {
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new("poc-validator", "poc-revalidation", &f.endpoint)
|
||||
.hypothesis(&f.id)
|
||||
.decision(&format!("{}: {}", r.reproduction.as_str(), r.detail))
|
||||
.tool("poc-validator")
|
||||
.capability(&cap_id)
|
||||
.result(&f.title),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope audit: anything proven against a host outside the authorization
|
||||
// boundary is quarantined, not shipped. A finding on an unauthorized asset
|
||||
// is an incident to disclose, not a deliverable.
|
||||
@@ -2283,11 +2367,23 @@ fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding])
|
||||
};
|
||||
put("provenance.json", serde_json::to_string_pretty(&manifest).unwrap_or_default());
|
||||
put("findings.md", findings_md(&cfg.target, findings));
|
||||
// Compliance mapping, one file per requested framework. Confirmed findings
|
||||
// only — a lead is not a control gap.
|
||||
for fw_name in &cfg.compliance {
|
||||
if let Some(fw) = crate::compliance::Framework::parse(fw_name) {
|
||||
let report = crate::compliance::map_findings(findings, fw, true);
|
||||
put(&format!("compliance-{}.md", fw.as_str()), report.to_markdown());
|
||||
}
|
||||
}
|
||||
let meta = cfg.workdir.as_deref().map(|d| report::read_meta(Path::new(d))).unwrap_or_default();
|
||||
let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs"))
|
||||
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
put("report.html", report::html_with_pocs(&cfg.target, findings, &meta, &pocs));
|
||||
let mut report_html = report::html_with_pocs(&cfg.target, findings, &meta, &pocs);
|
||||
if !cfg.compliance.is_empty() {
|
||||
report_html = report::with_compliance(report_html, findings, &cfg.compliance);
|
||||
}
|
||||
put("report.html", report_html);
|
||||
written
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Proof-of-concept validation — re-running a finding to see if it still holds.
|
||||
//!
|
||||
//! A finding is a claim about the past: *when I sent this, that happened*. The
|
||||
//! report inherits that claim on trust. But between the moment an agent proved
|
||||
//! something and the moment a client reads it, three things routinely change
|
||||
//! the answer — the bug got hotfixed, the "proof" was a fluke that does not
|
||||
//! reproduce, or the evidence was of an interaction the application never
|
||||
//! actually performed. A PoC validator is the harness checking its own work
|
||||
//! before it ships:
|
||||
//!
|
||||
//! ```text
|
||||
//! finding ──→ rebuild the request ──→ send it again ──→ compare to what
|
||||
//! │ was claimed
|
||||
//! ▼
|
||||
//! REPRODUCED · CHANGED · GONE · UNVERIFIABLE
|
||||
//! ```
|
||||
//!
|
||||
//! The distinction that matters is the last two. **GONE** means the request
|
||||
//! ran cleanly and the effect is no longer there — a finding to demote, or a
|
||||
//! remediation to confirm. **UNVERIFIABLE** means the harness could not run the
|
||||
//! check at all (out of scope now, a state-changing request it will not repeat,
|
||||
//! nothing recorded to replay). Collapsing those two is the classic mistake: a
|
||||
//! PoC that *could not be tested* is not a PoC that *failed*, and reporting the
|
||||
//! first as the second quietly drops real bugs.
|
||||
//!
|
||||
//! What this is **not**: it is not a second discovery pass. It re-runs what a
|
||||
//! finding already recorded and asks "does this still reproduce", using the
|
||||
//! same deterministic validators the pipeline used the first time. It never
|
||||
//! invents a new payload, and it never *upgrades* a finding — the strongest
|
||||
//! thing it can say is "still true", and the most useful is "no longer true".
|
||||
|
||||
use crate::replay::{ReplayEngine, ReqSpec};
|
||||
use crate::scope::ScopePolicy;
|
||||
use crate::types::Finding;
|
||||
use crate::validation::{judge, Evidence, Verdict};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What re-running a finding's proof showed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Reproduction {
|
||||
/// Ran again, same result. The finding stands.
|
||||
Reproduced,
|
||||
/// Ran again, a weaker or different result — worth a human's eye.
|
||||
Changed,
|
||||
/// Ran cleanly, the effect is gone. Likely fixed, or never real.
|
||||
Gone,
|
||||
/// Could not be re-run at all. NOT the same as failing.
|
||||
Unverifiable,
|
||||
}
|
||||
|
||||
impl Reproduction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Reproduction::Reproduced => "reproduced",
|
||||
Reproduction::Changed => "changed",
|
||||
Reproduction::Gone => "gone",
|
||||
Reproduction::Unverifiable => "unverifiable",
|
||||
}
|
||||
}
|
||||
/// Should the finding still be reported as confirmed after this?
|
||||
///
|
||||
/// Only a clean reproduction keeps a confirmation. `Changed` and `Gone`
|
||||
/// demote it; `Unverifiable` leaves the original verdict untouched, because
|
||||
/// failing to re-test is not evidence about the finding.
|
||||
pub fn keeps_confirmation(self) -> bool {
|
||||
self == Reproduction::Reproduced
|
||||
}
|
||||
pub fn demotes(self) -> bool {
|
||||
matches!(self, Reproduction::Changed | Reproduction::Gone)
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of validating one finding's PoC.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PocResult {
|
||||
pub finding_id: String,
|
||||
pub reproduction: Reproduction,
|
||||
/// Plain-language account, for the audit trail and the report.
|
||||
pub detail: String,
|
||||
/// The deterministic verdict on the fresh evidence, when one was produced.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reverdict: Option<String>,
|
||||
/// The exact request that was re-run, so the check is itself reproducible.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub replayed: Option<String>,
|
||||
}
|
||||
|
||||
/// Validates findings by re-running the interaction each one recorded.
|
||||
pub struct PocValidator {
|
||||
engine: ReplayEngine,
|
||||
/// Re-runs per finding: a single re-send can be a fluke in either
|
||||
/// direction, so a reproduction is asked to hold more than once.
|
||||
repeats: usize,
|
||||
}
|
||||
|
||||
impl PocValidator {
|
||||
pub fn new(policy: ScopePolicy) -> Self {
|
||||
PocValidator { engine: ReplayEngine::new(policy), repeats: 2 }
|
||||
}
|
||||
|
||||
pub fn with_repeats(mut self, n: usize) -> Self {
|
||||
self.repeats = n.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate every finding, in order.
|
||||
pub async fn validate_all(&self, findings: &[Finding]) -> Vec<PocResult> {
|
||||
let mut out = Vec::with_capacity(findings.len());
|
||||
for f in findings {
|
||||
out.push(self.validate(f).await);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-run one finding's proof.
|
||||
///
|
||||
/// The request re-run is the *attack* the finding recorded — not a fresh
|
||||
/// guess. When the finding has a baseline too, it is re-fetched as well, so
|
||||
/// the comparison the original validator made can be made again against
|
||||
/// current responses rather than against the response the baseline showed
|
||||
/// months ago.
|
||||
pub async fn validate(&self, f: &Finding) -> PocResult {
|
||||
let Some(ev) = &f.evidence_data else {
|
||||
return self.unverifiable(f, "the finding carries no recorded request to replay");
|
||||
};
|
||||
let Some(attack) = &ev.attack else {
|
||||
// A header-only or identity-pair finding may still be re-checkable
|
||||
// if it recorded those; otherwise there is nothing to send.
|
||||
return self.revalidate_headers_only(f, ev).await;
|
||||
};
|
||||
|
||||
// Re-running a state-changing request to "confirm" it means doing the
|
||||
// damage a second time. The replay engine refuses these; so do we,
|
||||
// explicitly, as unverifiable rather than as a failure.
|
||||
let spec = crate::replay::spec_of(attack).with_body(&f.payload);
|
||||
if spec.is_mutating() {
|
||||
return self.unverifiable(
|
||||
f,
|
||||
&format!("{} is state-changing — re-running it to verify would repeat the side effect", spec.method),
|
||||
);
|
||||
}
|
||||
|
||||
let fresh = match self.reproduce(&spec).await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => return self.unverifiable(f, &reason),
|
||||
};
|
||||
|
||||
// Rebuild the evidence with the fresh responses, keep the finding's
|
||||
// markers, and ask the same deterministic judge.
|
||||
let mut fresh_ev = Evidence {
|
||||
attack: Some(fresh.clone()),
|
||||
marker: ev.marker.clone(),
|
||||
marker_observed: ev.marker_observed && fresh.body.contains(&ev.marker),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(base) = &ev.baseline {
|
||||
let base_spec = crate::replay::spec_of(base);
|
||||
if let Ok(b) = self.engine.send(&base_spec).await {
|
||||
fresh_ev.baseline = Some(b);
|
||||
} else {
|
||||
fresh_ev.baseline = ev.baseline.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let verdict = judge(f, Some(&fresh_ev));
|
||||
let replayed = Some(format!("{} {}", spec.method, spec.url));
|
||||
match verdict {
|
||||
Verdict::Confirmed(r) => PocResult {
|
||||
finding_id: f.id.clone(),
|
||||
reproduction: Reproduction::Reproduced,
|
||||
detail: format!("re-ran the recorded request; the class still validates: {r}"),
|
||||
reverdict: Some(format!("confirmed: {r}")),
|
||||
replayed,
|
||||
},
|
||||
Verdict::Rejected(r) => PocResult {
|
||||
finding_id: f.id.clone(),
|
||||
// A clean re-run that no longer validates is the good news case:
|
||||
// it usually means the bug was fixed.
|
||||
reproduction: Reproduction::Gone,
|
||||
detail: format!("re-ran cleanly but the effect is no longer present: {r}"),
|
||||
reverdict: Some(format!("rejected: {r}")),
|
||||
replayed,
|
||||
},
|
||||
Verdict::NeedsReview(r) => PocResult {
|
||||
finding_id: f.id.clone(),
|
||||
reproduction: Reproduction::Changed,
|
||||
detail: format!("re-ran, but the result no longer matches the original proof: {r}"),
|
||||
reverdict: Some(format!("needs-review: {r}")),
|
||||
replayed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A finding proven by headers or an identity pair, with no single attack
|
||||
/// request. Re-fetch what it did record and re-judge.
|
||||
async fn revalidate_headers_only(&self, f: &Finding, ev: &Evidence) -> PocResult {
|
||||
let probe = ev.identity_a.as_ref().or(ev.baseline.as_ref());
|
||||
let Some(sample) = probe else {
|
||||
return self.unverifiable(f, "nothing recorded to replay (no attack, baseline or identity sample)");
|
||||
};
|
||||
let spec = crate::replay::spec_of(sample);
|
||||
if spec.is_mutating() {
|
||||
return self.unverifiable(f, "the only recorded request is state-changing");
|
||||
}
|
||||
match self.engine.send(&spec).await {
|
||||
Ok(fresh) => {
|
||||
let fresh_ev = Evidence { attack: Some(fresh), baseline: ev.baseline.clone(), identity_a: ev.identity_a.clone(), identity_b: ev.identity_b.clone(), ..ev.clone() };
|
||||
match judge(f, Some(&fresh_ev)) {
|
||||
Verdict::Confirmed(r) => PocResult { finding_id: f.id.clone(), reproduction: Reproduction::Reproduced, detail: format!("re-fetched; still validates: {r}"), reverdict: Some(r), replayed: Some(spec.url) },
|
||||
Verdict::Rejected(r) => PocResult { finding_id: f.id.clone(), reproduction: Reproduction::Gone, detail: format!("re-fetched cleanly; the header/condition is no longer present: {r}"), reverdict: Some(r), replayed: Some(spec.url) },
|
||||
Verdict::NeedsReview(r) => PocResult { finding_id: f.id.clone(), reproduction: Reproduction::Changed, detail: r.clone(), reverdict: Some(r), replayed: Some(spec.url) },
|
||||
}
|
||||
}
|
||||
Err(e) => self.unverifiable(f, &format!("could not re-fetch: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the request `repeats` times; the "fresh" exchange is the last one,
|
||||
/// and all of them must succeed. A single success amid failures is not a
|
||||
/// reproduction.
|
||||
async fn reproduce(&self, spec: &ReqSpec) -> Result<crate::validation::Exchange, String> {
|
||||
let (exchanges, errors) = self.engine.repeat(spec, self.repeats).await;
|
||||
if let Some(first) = errors.first() {
|
||||
return Err(first.clone());
|
||||
}
|
||||
exchanges.into_iter().last().ok_or_else(|| "no response on replay".to_string())
|
||||
}
|
||||
|
||||
fn unverifiable(&self, f: &Finding, why: &str) -> PocResult {
|
||||
PocResult {
|
||||
finding_id: f.id.clone(),
|
||||
reproduction: Reproduction::Unverifiable,
|
||||
detail: format!("could not re-test: {why}"),
|
||||
reverdict: None,
|
||||
replayed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a PoC result back onto a finding.
|
||||
///
|
||||
/// The rule is one-directional, like the rest of the evidence machinery: this
|
||||
/// can lower confidence and flag a finding for review, never raise it. A
|
||||
/// finding that "still reproduces" keeps exactly the standing it already had —
|
||||
/// re-running a proof does not make it more true than the first run did.
|
||||
pub fn apply(f: &mut Finding, result: &PocResult) {
|
||||
match result.reproduction {
|
||||
Reproduction::Reproduced => {
|
||||
f.review_reason = format!("PoC re-validated: {}", result.detail);
|
||||
}
|
||||
Reproduction::Changed => {
|
||||
f.validated = false;
|
||||
f.review_status = "needs-review".into();
|
||||
f.review_reason = format!("PoC no longer matches the original proof: {}", result.detail);
|
||||
f.confidence = f.confidence.min(0.6);
|
||||
}
|
||||
Reproduction::Gone => {
|
||||
f.validated = false;
|
||||
f.review_status = "rejected".into();
|
||||
f.review_reason = format!("PoC no longer reproduces (likely fixed): {}", result.detail);
|
||||
f.confidence = f.confidence.min(0.3);
|
||||
}
|
||||
Reproduction::Unverifiable => {
|
||||
// Deliberately no change to validated/confidence — not re-testable
|
||||
// is not the same as not real.
|
||||
f.review_reason = format!("PoC could not be re-tested: {}", result.detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line summary of a batch, for the run banner and the report.
|
||||
pub fn summary(results: &[PocResult]) -> String {
|
||||
let count = |r: Reproduction| results.iter().filter(|x| x.reproduction == r).count();
|
||||
format!(
|
||||
"PoC re-validation: {} reproduced, {} changed, {} gone, {} unverifiable (of {})",
|
||||
count(Reproduction::Reproduced),
|
||||
count(Reproduction::Changed),
|
||||
count(Reproduction::Gone),
|
||||
count(Reproduction::Unverifiable),
|
||||
results.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::validation::Exchange;
|
||||
|
||||
fn finding_with_get(id: &str, url: &str) -> Finding {
|
||||
let mut ev = Evidence::default();
|
||||
ev.attack = Some(Exchange { method: "GET".into(), url: url.into(), status: 200, body: "ok".into(), ..Default::default() });
|
||||
Finding { id: id.into(), cwe: "CWE-79".into(), title: "Reflected XSS".into(), evidence_data: Some(ev), validated: true, confidence: 0.9, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_changing_poc_is_unverifiable_not_failed() {
|
||||
// A DELETE proof cannot be re-run to confirm — doing so repeats the
|
||||
// deletion. It must not be reported as "no longer reproduces".
|
||||
let mut ev = Evidence::default();
|
||||
ev.attack = Some(Exchange { method: "DELETE".into(), url: "https://t.test/api/orders/9".into(), status: 200, ..Default::default() });
|
||||
let f = Finding { id: "d1".into(), cwe: "CWE-89".into(), title: "SQLi".into(), evidence_data: Some(ev), validated: true, confidence: 0.9, ..Default::default() };
|
||||
|
||||
let v = PocValidator::new(ScopePolicy::for_target("https://t.test"));
|
||||
let r = futures::executor::block_on(v.validate(&f));
|
||||
assert_eq!(r.reproduction, Reproduction::Unverifiable);
|
||||
assert!(!r.reproduction.demotes(), "an untestable PoC must not demote the finding");
|
||||
|
||||
let mut f2 = f.clone();
|
||||
apply(&mut f2, &r);
|
||||
assert!(f2.validated, "confidence and validated are untouched when we simply could not test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finding_with_nothing_recorded_is_unverifiable() {
|
||||
let f = Finding { id: "x".into(), cwe: "CWE-79".into(), validated: true, confidence: 0.8, ..Default::default() };
|
||||
let v = PocValidator::new(ScopePolicy::for_target("https://t.test"));
|
||||
let r = futures::executor::block_on(v.validate(&f));
|
||||
assert_eq!(r.reproduction, Reproduction::Unverifiable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_out_of_scope_replay_is_unverifiable() {
|
||||
// The recorded request points somewhere the current scope forbids.
|
||||
let f = finding_with_get("o1", "https://not-in-scope.test/x");
|
||||
let v = PocValidator::new(ScopePolicy::for_target("https://t.test"));
|
||||
let r = futures::executor::block_on(v.validate(&f));
|
||||
assert_eq!(r.reproduction, Reproduction::Unverifiable);
|
||||
assert!(r.detail.contains("scope") || r.detail.contains("could not"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_only_ever_lowers_standing() {
|
||||
let mut f = finding_with_get("a", "https://t.test/x");
|
||||
f.confidence = 0.9;
|
||||
|
||||
apply(&mut f, &PocResult { finding_id: "a".into(), reproduction: Reproduction::Gone, detail: "fixed".into(), reverdict: None, replayed: None });
|
||||
assert!(!f.validated);
|
||||
assert!(f.confidence <= 0.3);
|
||||
|
||||
// Reproduced does not raise a confidence that was lowered.
|
||||
let mut f2 = finding_with_get("b", "https://t.test/x");
|
||||
f2.confidence = 0.5;
|
||||
apply(&mut f2, &PocResult { finding_id: "b".into(), reproduction: Reproduction::Reproduced, detail: "still there".into(), reverdict: None, replayed: None });
|
||||
assert_eq!(f2.confidence, 0.5, "re-proving does not inflate confidence");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_counts_every_bucket() {
|
||||
let mk = |r: Reproduction| PocResult { finding_id: "x".into(), reproduction: r, detail: String::new(), reverdict: None, replayed: None };
|
||||
let s = summary(&[mk(Reproduction::Reproduced), mk(Reproduction::Gone), mk(Reproduction::Gone), mk(Reproduction::Unverifiable)]);
|
||||
assert!(s.contains("1 reproduced"));
|
||||
assert!(s.contains("2 gone"));
|
||||
assert!(s.contains("1 unverifiable"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Intercepting proxy — owning the request stream, and plugging into the tools
|
||||
//! that already do.
|
||||
//!
|
||||
//! Two capabilities the harness lacked, and one reason they belong together.
|
||||
//!
|
||||
//! An operator's toolchain has a proxy in it — Burp, Caido, ZAP, mitmproxy —
|
||||
//! and the engagement should flow through it, so the human can watch, replay,
|
||||
//! and pick up where the agent left off. That is the *tool* side: point the
|
||||
//! harness at `127.0.0.1:8080` and everything it does appears in Burp.
|
||||
//!
|
||||
//! But even with no tool running, the harness benefits from seeing its own
|
||||
//! traffic as a stream rather than as isolated requests: passive discovery
|
||||
//! (every host, cookie and header that went by), and a faithful record for
|
||||
//! replay. That is the *own interceptor*: a recording forward proxy the harness
|
||||
//! runs itself.
|
||||
//!
|
||||
//! They compose. The own interceptor records, then forwards upstream to the
|
||||
//! tool — so the operator gets Burp *and* the harness gets its passive log,
|
||||
//! from one configuration:
|
||||
//!
|
||||
//! ```text
|
||||
//! harness + agent curls ──→ own interceptor ──→ [Burp/Caido/mitmproxy] ──→ target
|
||||
//! │
|
||||
//! └── records every flow (passive discovery, replay)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Honest about TLS
|
||||
//!
|
||||
//! The own interceptor records plaintext HTTP in full. For HTTPS it tunnels
|
||||
//! (CONNECT) and records the metadata it can see without breaking TLS — host,
|
||||
//! timing, byte counts — but not the decrypted body. Decrypting HTTPS needs a
|
||||
//! CA the client trusts, which Burp/Caido/mitmproxy already solved; so for full
|
||||
//! HTTPS interception you **chain to one of them**, and this module makes that
|
||||
//! one flag rather than a re-implementation of their certificate machinery.
|
||||
//! Claiming to MITM TLS without a trusted CA would be a lie the operator finds
|
||||
//! out about mid-engagement.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A known interception tool, with where it listens by default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Tool {
|
||||
/// Burp Suite — default proxy 127.0.0.1:8080.
|
||||
Burp,
|
||||
/// Caido — default 127.0.0.1:8080 (shares the convention).
|
||||
Caido,
|
||||
/// OWASP ZAP — default 127.0.0.1:8080, sometimes :8081.
|
||||
Zap,
|
||||
/// mitmproxy / mitmdump — default 127.0.0.1:8080.
|
||||
Mitmproxy,
|
||||
/// Any HTTP proxy at an explicit address.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// The proxy URL to forward to.
|
||||
pub fn proxy_url(&self) -> String {
|
||||
match self {
|
||||
Tool::Burp | Tool::Caido | Tool::Zap | Tool::Mitmproxy => "http://127.0.0.1:8080".into(),
|
||||
Tool::Custom(url) => {
|
||||
if url.contains("://") {
|
||||
url.clone()
|
||||
} else {
|
||||
format!("http://{url}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Tool::Burp => "Burp Suite".into(),
|
||||
Tool::Caido => "Caido".into(),
|
||||
Tool::Zap => "OWASP ZAP".into(),
|
||||
Tool::Mitmproxy => "mitmproxy".into(),
|
||||
Tool::Custom(u) => format!("proxy {u}"),
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Tool> {
|
||||
Some(match s.trim().to_lowercase().as_str() {
|
||||
"burp" | "burpsuite" => Tool::Burp,
|
||||
"caido" => Tool::Caido,
|
||||
"zap" | "owasp-zap" => Tool::Zap,
|
||||
"mitm" | "mitmproxy" | "mitmdump" => Tool::Mitmproxy,
|
||||
"" | "none" | "off" => return None,
|
||||
other => Tool::Custom(other.to_string()),
|
||||
})
|
||||
}
|
||||
/// Is a proxy actually listening where this tool expects to be?
|
||||
///
|
||||
/// Checked before a run commits to routing through it: an operator who
|
||||
/// typed `--intercept burp` but forgot to start Burp should be told, not
|
||||
/// have every request fail with a connection error three minutes in.
|
||||
pub async fn is_listening(&self) -> bool {
|
||||
let url = self.proxy_url();
|
||||
let hostport = url.split("://").nth(1).unwrap_or(&url).trim_end_matches('/');
|
||||
let addr: Option<SocketAddr> = hostport.to_socket_addrs_first();
|
||||
match addr {
|
||||
Some(a) => tokio::time::timeout(Duration::from_millis(600), tokio::net::TcpStream::connect(a)).await.map(|r| r.is_ok()).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait ToSocketAddrFirst {
|
||||
fn to_socket_addrs_first(&self) -> Option<SocketAddr>;
|
||||
}
|
||||
impl ToSocketAddrFirst for str {
|
||||
fn to_socket_addrs_first(&self) -> Option<SocketAddr> {
|
||||
use std::net::ToSocketAddrs;
|
||||
self.to_socket_addrs().ok().and_then(|mut it| it.next())
|
||||
}
|
||||
}
|
||||
|
||||
/// One request/response the interceptor saw.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Flow {
|
||||
pub at: u64,
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
/// Bytes client→server (request) and server→client (response). For a
|
||||
/// tunnelled HTTPS connection this is all we can honestly report.
|
||||
pub req_bytes: usize,
|
||||
pub resp_bytes: usize,
|
||||
/// True when this was a CONNECT tunnel (TLS body not visible to us).
|
||||
#[serde(default)]
|
||||
pub tunnelled: bool,
|
||||
/// Response content-type, when seen in cleartext.
|
||||
#[serde(default)]
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// How the harness should route its traffic.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyConfig {
|
||||
/// Run the harness's own recording interceptor.
|
||||
pub own_interceptor: bool,
|
||||
/// Where the own interceptor binds.
|
||||
pub bind: SocketAddr,
|
||||
/// Forward upstream to this tool after recording (full HTTPS interception).
|
||||
pub upstream: Option<Tool>,
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
ProxyConfig {
|
||||
own_interceptor: false,
|
||||
bind: "127.0.0.1:8899".parse().unwrap(),
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
/// Parse an operator spec.
|
||||
///
|
||||
/// ```text
|
||||
/// burp | caido | zap | mitmproxy → route straight through the tool
|
||||
/// own → own interceptor only, no upstream
|
||||
/// own+burp | own+caido | own+… → own interceptor, recording, then the tool
|
||||
/// http://127.0.0.1:8080 → an explicit upstream proxy
|
||||
/// ```
|
||||
pub fn parse(spec: &str) -> Result<ProxyConfig, String> {
|
||||
let s = spec.trim().to_lowercase();
|
||||
if s.is_empty() || s == "off" || s == "none" {
|
||||
return Ok(ProxyConfig::default());
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("own+") {
|
||||
let tool = Tool::parse(rest).ok_or_else(|| format!("unknown upstream tool `{rest}`"))?;
|
||||
return Ok(ProxyConfig { own_interceptor: true, upstream: Some(tool), ..Default::default() });
|
||||
}
|
||||
if s == "own" {
|
||||
return Ok(ProxyConfig { own_interceptor: true, upstream: None, ..Default::default() });
|
||||
}
|
||||
let tool = Tool::parse(&s).ok_or_else(|| format!("unrecognised intercept spec `{spec}`"))?;
|
||||
// A bare tool routes straight through it — no own interceptor, because
|
||||
// the tool already records. The own interceptor is for adding recording
|
||||
// where there is none, or a passive log alongside the tool (own+tool).
|
||||
Ok(ProxyConfig { own_interceptor: false, upstream: Some(tool), ..Default::default() })
|
||||
}
|
||||
|
||||
/// The proxy URL the HTTP client and child processes should use.
|
||||
///
|
||||
/// When the own interceptor is on, that is its address (it forwards upstream
|
||||
/// itself). Otherwise it is the tool's address directly. `None` means no
|
||||
/// proxying at all.
|
||||
pub fn client_proxy_url(&self) -> Option<String> {
|
||||
if self.own_interceptor {
|
||||
Some(format!("http://{}", self.bind))
|
||||
} else {
|
||||
self.upstream.as_ref().map(|t| t.proxy_url())
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment for child processes (curl, tool commands) so they route the
|
||||
/// same way the harness does.
|
||||
pub fn env(&self) -> Vec<(String, String)> {
|
||||
match self.client_proxy_url() {
|
||||
Some(p) => vec![
|
||||
("HTTP_PROXY".into(), p.clone()),
|
||||
("HTTPS_PROXY".into(), p.clone()),
|
||||
("http_proxy".into(), p.clone()),
|
||||
("https_proxy".into(), p),
|
||||
],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> String {
|
||||
match (self.own_interceptor, &self.upstream) {
|
||||
(true, Some(t)) => format!("own interceptor on {} → {}", self.bind, t.name()),
|
||||
(true, None) => format!("own interceptor on {} (HTTP recorded, HTTPS tunnelled)", self.bind),
|
||||
(false, Some(t)) => format!("routing through {}", t.name()),
|
||||
(false, None) => "direct (no interception)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The running own interceptor: a recording forward proxy.
|
||||
#[derive(Clone)]
|
||||
pub struct Interceptor {
|
||||
upstream: Option<String>,
|
||||
flows: Arc<Mutex<Vec<Flow>>>,
|
||||
jsonl: Option<String>,
|
||||
}
|
||||
|
||||
impl Interceptor {
|
||||
pub fn new(upstream: Option<String>) -> Self {
|
||||
Interceptor { upstream, flows: Arc::new(Mutex::new(Vec::new())), jsonl: None }
|
||||
}
|
||||
|
||||
/// Also append every flow to a JSONL file, so a run's traffic survives it.
|
||||
pub fn logging_to(mut self, path: impl Into<String>) -> Self {
|
||||
self.jsonl = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn flows(&self) -> Vec<Flow> {
|
||||
self.flows.lock().map(|f| f.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Distinct hosts seen — the passive-discovery product.
|
||||
pub fn hosts(&self) -> Vec<String> {
|
||||
let mut hs: Vec<String> = self
|
||||
.flows()
|
||||
.iter()
|
||||
.filter_map(|f| f.url.split("://").nth(1).map(|r| r.split('/').next().unwrap_or("").to_string()))
|
||||
.filter(|h| !h.is_empty())
|
||||
.collect();
|
||||
hs.sort();
|
||||
hs.dedup();
|
||||
hs
|
||||
}
|
||||
|
||||
fn record(&self, flow: Flow) {
|
||||
if let Some(path) = &self.jsonl {
|
||||
if let Ok(line) = serde_json::to_string(&flow) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(fh, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut f) = self.flows.lock() {
|
||||
if f.len() >= 20_000 {
|
||||
f.remove(0);
|
||||
}
|
||||
f.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start listening. Returns once bound; serving continues in the background.
|
||||
pub async fn listen(&self, bind: SocketAddr) -> std::io::Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||
let me = self.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((sock, _)) = listener.accept().await else { continue };
|
||||
let me = me.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = me.serve(sock).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(&self, mut client: tokio::net::TcpStream) -> std::io::Result<()> {
|
||||
// Read the request head (up to the blank line). Proxies see the head
|
||||
// before the body, and the first line tells us CONNECT vs absolute-URI.
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while head.len() < 32 * 1024 {
|
||||
let n = client.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
if head.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let head_str = String::from_utf8_lossy(&head).to_string();
|
||||
let first = head_str.lines().next().unwrap_or("").to_string();
|
||||
let mut parts = first.split_whitespace();
|
||||
let method = parts.next().unwrap_or("").to_string();
|
||||
let target = parts.next().unwrap_or("").to_string();
|
||||
|
||||
if method.eq_ignore_ascii_case("CONNECT") {
|
||||
self.tunnel(client, &target).await
|
||||
} else if target.starts_with("http://") {
|
||||
self.forward_http(client, &method, &target, &head_str).await
|
||||
} else {
|
||||
let _ = client.write_all(b"HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\nthis proxy handles absolute-form HTTP and CONNECT").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain HTTP: forward via reqwest (honouring an upstream proxy), record
|
||||
/// the full flow, and write the response back to the client.
|
||||
async fn forward_http(&self, mut client: tokio::net::TcpStream, method: &str, url: &str, head: &str) -> std::io::Result<()> {
|
||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(30)).redirect(reqwest::redirect::Policy::none());
|
||||
if let Some(up) = &self.upstream {
|
||||
if let Ok(px) = reqwest::Proxy::all(up) {
|
||||
builder = builder.proxy(px);
|
||||
}
|
||||
}
|
||||
let client_http = builder.build().unwrap_or_default();
|
||||
let m = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||
let mut rb = client_http.request(m, url);
|
||||
let mut req_bytes = head.len();
|
||||
for line in head.lines().skip(1) {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
let k = k.trim();
|
||||
// Hop-by-hop headers a proxy must not forward.
|
||||
if !matches!(k.to_lowercase().as_str(), "proxy-connection" | "connection" | "host") {
|
||||
rb = rb.header(k, v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let now = now();
|
||||
match rb.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
|
||||
let mut out = format!("HTTP/1.1 {status} {}\r\n", resp.status().canonical_reason().unwrap_or(""));
|
||||
for (k, v) in resp.headers().iter() {
|
||||
if !matches!(k.as_str(), "transfer-encoding" | "connection") {
|
||||
out.push_str(&format!("{}: {}\r\n", k, v.to_str().unwrap_or("")));
|
||||
}
|
||||
}
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
out.push_str(&format!("content-length: {}\r\nconnection: close\r\n\r\n", body.len()));
|
||||
client.write_all(out.as_bytes()).await?;
|
||||
client.write_all(&body).await?;
|
||||
self.record(Flow {
|
||||
at: now,
|
||||
method: method.to_uppercase(),
|
||||
url: url.to_string(),
|
||||
status,
|
||||
req_bytes,
|
||||
resp_bytes: body.len(),
|
||||
tunnelled: false,
|
||||
content_type: ct,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
req_bytes += 0;
|
||||
let _ = client.write_all(format!("HTTP/1.1 502 Bad Gateway\r\nconnection: close\r\n\r\n{e}").as_bytes()).await;
|
||||
self.record(Flow { at: now, method: method.to_uppercase(), url: url.to_string(), status: 502, req_bytes, resp_bytes: 0, tunnelled: false, content_type: String::new() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTPS: open a tunnel, record what we can see without breaking TLS.
|
||||
///
|
||||
/// With an upstream tool, the CONNECT is handed to it so the tool does the
|
||||
/// full interception; without one, we connect straight to the target and
|
||||
/// relay bytes, recording host, timing and byte counts. We do NOT pretend
|
||||
/// to see the plaintext.
|
||||
async fn tunnel(&self, mut client: tokio::net::TcpStream, target: &str) -> std::io::Result<()> {
|
||||
let now = now();
|
||||
// Connect to the upstream tool if configured, else to the target.
|
||||
let (mut server, via_upstream) = match &self.upstream {
|
||||
Some(up) => {
|
||||
let hostport = up.split("://").nth(1).unwrap_or(up).trim_end_matches('/').to_string();
|
||||
match tokio::net::TcpStream::connect(&hostport).await {
|
||||
Ok(mut s) => {
|
||||
// Replay the CONNECT to the upstream proxy verbatim.
|
||||
let _ = s.write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()).await;
|
||||
// Swallow the upstream's "200 Connection Established".
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).await;
|
||||
(s, true)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = client.write_all(format!("HTTP/1.1 502 Bad Gateway\r\n\r\nupstream proxy: {e}").as_bytes()).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
None => match tokio::net::TcpStream::connect(target).await {
|
||||
Ok(s) => (s, false),
|
||||
Err(e) => {
|
||||
let _ = client.write_all(format!("HTTP/1.1 502 Bad Gateway\r\n\r\n{e}").as_bytes()).await;
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
// Tell the client the tunnel is open (unless the upstream already did,
|
||||
// in which case we still must, since the client spoke to us).
|
||||
client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
|
||||
|
||||
// Relay both directions, counting bytes. The bodies are TLS ciphertext;
|
||||
// we record the shape, not the content — honestly.
|
||||
let (mut cr, mut cw) = client.split();
|
||||
let (mut sr, mut sw) = server.split();
|
||||
let c2s = tokio::io::copy(&mut cr, &mut sw);
|
||||
let s2c = tokio::io::copy(&mut sr, &mut cw);
|
||||
let (up, down) = tokio::join!(c2s, s2c);
|
||||
let req_bytes = up.unwrap_or(0) as usize;
|
||||
let resp_bytes = down.unwrap_or(0) as usize;
|
||||
let _ = via_upstream;
|
||||
self.record(Flow {
|
||||
at: now,
|
||||
method: "CONNECT".into(),
|
||||
url: format!("https://{target}"),
|
||||
status: 200,
|
||||
req_bytes,
|
||||
resp_bytes,
|
||||
tunnelled: true,
|
||||
content_type: String::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_specs_map_to_the_right_upstream() {
|
||||
assert_eq!(Tool::parse("burp"), Some(Tool::Burp));
|
||||
assert_eq!(Tool::parse("caido"), Some(Tool::Caido));
|
||||
assert_eq!(Tool::parse("mitmproxy"), Some(Tool::Mitmproxy));
|
||||
assert_eq!(Tool::parse("off"), None);
|
||||
assert_eq!(Tool::Burp.proxy_url(), "http://127.0.0.1:8080");
|
||||
assert_eq!(Tool::Custom("127.0.0.1:9000".into()).proxy_url(), "http://127.0.0.1:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_tool_routes_through_it_without_the_own_interceptor() {
|
||||
let c = ProxyConfig::parse("burp").unwrap();
|
||||
assert!(!c.own_interceptor, "the tool already records — no need to double up");
|
||||
assert_eq!(c.upstream, Some(Tool::Burp));
|
||||
assert_eq!(c.client_proxy_url().as_deref(), Some("http://127.0.0.1:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_plus_tool_records_then_forwards() {
|
||||
let c = ProxyConfig::parse("own+caido").unwrap();
|
||||
assert!(c.own_interceptor);
|
||||
assert_eq!(c.upstream, Some(Tool::Caido));
|
||||
// Clients point at the own interceptor, which forwards to the tool.
|
||||
assert_eq!(c.client_proxy_url(), Some(format!("http://{}", c.bind)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_alone_records_with_no_upstream() {
|
||||
let c = ProxyConfig::parse("own").unwrap();
|
||||
assert!(c.own_interceptor && c.upstream.is_none());
|
||||
assert!(c.summary().contains("HTTPS tunnelled"), "the summary is honest about not decrypting TLS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_means_direct() {
|
||||
let c = ProxyConfig::parse("").unwrap();
|
||||
assert!(c.client_proxy_url().is_none());
|
||||
assert!(c.env().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_env_covers_both_cases_of_the_variable() {
|
||||
let c = ProxyConfig::parse("burp").unwrap();
|
||||
let env = c.env();
|
||||
assert!(env.iter().any(|(k, _)| k == "HTTPS_PROXY"));
|
||||
assert!(env.iter().any(|(k, _)| k == "https_proxy"), "curl reads the lowercase one");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_own_interceptor_records_a_plain_http_flow() {
|
||||
// A tiny origin server the interceptor will forward to.
|
||||
let origin = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin_addr = origin.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((mut s, _)) = origin.accept().await {
|
||||
let mut b = [0u8; 1024];
|
||||
let _ = s.read(&mut b).await;
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\n\r\nhi").await;
|
||||
}
|
||||
});
|
||||
|
||||
let interceptor = Interceptor::new(None);
|
||||
let bind: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap();
|
||||
let proxy_addr = listener.local_addr().unwrap();
|
||||
let me = interceptor.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((sock, _)) = listener.accept().await {
|
||||
let _ = me.serve(sock).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Speak proxy protocol: absolute-form request line.
|
||||
let mut c = tokio::net::TcpStream::connect(proxy_addr).await.unwrap();
|
||||
let req = format!("GET http://{origin_addr}/x HTTP/1.1\r\nHost: {origin_addr}\r\n\r\n");
|
||||
c.write_all(req.as_bytes()).await.unwrap();
|
||||
let mut resp = Vec::new();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), c.read_to_end(&mut resp)).await;
|
||||
assert!(String::from_utf8_lossy(&resp).contains("hi"), "the interceptor relayed the origin's body");
|
||||
|
||||
// Give the record a beat, then check the flow was captured.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let flows = interceptor.flows();
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].status, 200);
|
||||
assert!(!flows[0].tunnelled);
|
||||
assert!(interceptor.hosts().iter().any(|h| h == &origin_addr.to_string()));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ impl ReqSpec {
|
||||
self.identity = who.to_string();
|
||||
self
|
||||
}
|
||||
/// Set the request body, if the finding recorded a payload separately from
|
||||
/// the recorded exchange (which stores only the response body).
|
||||
pub fn with_body(mut self, body: &str) -> Self {
|
||||
if !body.trim().is_empty() {
|
||||
self.body = body.to_string();
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Verbs that change state. Replay refuses these by default: re-running a
|
||||
/// destructive request to "confirm" it means doing the damage twice.
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
|
||||
@@ -60,6 +60,38 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
|
||||
/// As [`html`], but told which scripts exist in the run's `pocs/` directory so
|
||||
/// each finding can link the ones it cites.
|
||||
/// Render a compliance-mapping section (one table per framework) and splice it
|
||||
/// into a finished report just before `</body>`. Kept separate from
|
||||
/// `html_with_pocs` so the base report has no notion of compliance and callers
|
||||
/// opt in only when frameworks were requested.
|
||||
pub fn with_compliance(html: String, findings: &[Finding], frameworks: &[String]) -> String {
|
||||
let mut section = String::new();
|
||||
for name in frameworks {
|
||||
let Some(fw) = crate::compliance::Framework::parse(name) else { continue };
|
||||
let r = crate::compliance::map_findings(findings, fw, true);
|
||||
section.push_str(&format!(
|
||||
"<h2>Compliance — {}</h2><p class=m style=\"font-style:italic\">{}</p>",
|
||||
esc(&r.framework_title), esc(&r.disclaimer())
|
||||
));
|
||||
if r.controls_with_gaps.is_empty() {
|
||||
section.push_str("<p>No confirmed finding mapped to a control in this framework. This is not evidence of compliance — only that this engagement found no gap here.</p>");
|
||||
continue;
|
||||
}
|
||||
section.push_str("<table class=fieldgrid><tr><th>Control</th><th>Requirement</th><th>Severity</th><th>Findings</th></tr>");
|
||||
for g in &r.controls_with_gaps {
|
||||
section.push_str(&format!(
|
||||
"<tr><td><b>{}</b></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
esc(&g.control.id), esc(&g.control.requirement), esc(&g.max_severity), g.finding_ids.len()
|
||||
));
|
||||
}
|
||||
section.push_str("</table>");
|
||||
}
|
||||
if section.is_empty() {
|
||||
return html;
|
||||
}
|
||||
html.replacen("<p class=footer>", &format!("{section}<p class=footer>"), 1)
|
||||
}
|
||||
|
||||
pub fn html_with_pocs(target: &str, findings: &[Finding], meta: &EngagementMeta, available_pocs: &[String]) -> String {
|
||||
let available_pocs = available_pocs.to_vec();
|
||||
let mut sorted = findings.to_vec();
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Sandbox — running the dangerous half of an engagement off the host.
|
||||
//!
|
||||
//! The harness executes things that are shaped like attacks: payloads, shell
|
||||
//! commands an agent wrote, tools that scan and fuzz. Today those run on the
|
||||
//! operator's own machine. That is the largest single risk in the whole system
|
||||
//! — a payload that misfires, a tool with a bug, an agent that runs the wrong
|
||||
//! command, all land on the host that holds the engagement's credentials and
|
||||
//! the operator's keys.
|
||||
//!
|
||||
//! A container fixes the blast radius, and brings the tools with it. Kali ships
|
||||
//! nmap, sqlmap, ffuf, nuclei, the Metasploit tooling — a whole pentest
|
||||
//! toolchain the harness otherwise assumes is installed on the host and often
|
||||
//! is not. So the container is two wins at once: isolation, and a known
|
||||
//! toolbox.
|
||||
//!
|
||||
//! ```text
|
||||
//! agent command ──→ docker exec ns-kali <cmd> ──→ runs in Kali, not on the host
|
||||
//! │
|
||||
//! ├── workdir mounted read-write (evidence comes back)
|
||||
//! ├── proxy + transport env inherited (same route)
|
||||
//! └── network scoped to the engagement
|
||||
//! ```
|
||||
//!
|
||||
//! ## What it does not pretend
|
||||
//!
|
||||
//! A container is isolation, not a jail. `--network host` or a mounted docker
|
||||
//! socket would hand the container the host back, so this module refuses to add
|
||||
//! either. And if no container runtime is present, it says so and the caller
|
||||
//! falls back to host execution *explicitly* — a silent fallback to running
|
||||
//! attack payloads on the host is exactly the failure the sandbox exists to
|
||||
//! prevent, so it is never silent.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A container runtime the harness can drive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Runtime {
|
||||
Docker,
|
||||
Podman,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn bin(self) -> &'static str {
|
||||
match self {
|
||||
Runtime::Docker => "docker",
|
||||
Runtime::Podman => "podman",
|
||||
}
|
||||
}
|
||||
/// The first runtime actually installed, preferring Docker.
|
||||
pub fn detect() -> Option<Runtime> {
|
||||
for rt in [Runtime::Docker, Runtime::Podman] {
|
||||
if which(rt.bin()) {
|
||||
return Some(rt);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// How the sandbox is configured for a run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
/// Container image. Kali rolling by default — the toolbox as well as the box.
|
||||
pub image: String,
|
||||
/// Container name, so a run reuses one container rather than spawning per
|
||||
/// command (starting a container per curl would be unusably slow).
|
||||
pub name: String,
|
||||
/// Host path mounted read-write at `/work`, where evidence is written so it
|
||||
/// survives the container.
|
||||
pub workdir: Option<String>,
|
||||
/// Extra `-e KEY=VALUE` pairs — the proxy and transport env, so commands in
|
||||
/// the container take the same route as the harness.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Seconds a single command may run before it is killed.
|
||||
pub command_timeout: u64,
|
||||
/// Pull the image if it is missing (off in air-gapped labs).
|
||||
pub auto_pull: bool,
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
SandboxConfig {
|
||||
image: "kalilinux/kali-rolling".into(),
|
||||
name: "neurosploit-kali".into(),
|
||||
workdir: None,
|
||||
env: Vec::new(),
|
||||
command_timeout: 300,
|
||||
auto_pull: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxConfig {
|
||||
pub fn with_image(mut self, image: &str) -> Self {
|
||||
if !image.trim().is_empty() {
|
||||
self.image = image.trim().to_string();
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn mounting(mut self, host_path: &str) -> Self {
|
||||
self.workdir = Some(host_path.to_string());
|
||||
self
|
||||
}
|
||||
pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
|
||||
self.env = env;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A managed container the harness runs commands in.
|
||||
pub struct Sandbox {
|
||||
runtime: Runtime,
|
||||
cfg: SandboxConfig,
|
||||
}
|
||||
|
||||
/// The result of one command run in the container.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecResult {
|
||||
pub code: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
/// True when the command was killed for exceeding the timeout.
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
impl ExecResult {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.code == 0 && !self.timed_out
|
||||
}
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
/// Build a sandbox on the first available runtime, or report there is none.
|
||||
pub fn new(cfg: SandboxConfig) -> Result<Sandbox, String> {
|
||||
let runtime = Runtime::detect().ok_or_else(|| {
|
||||
"no container runtime found (docker or podman). Install one, or run without --sandbox to execute on the host.".to_string()
|
||||
})?;
|
||||
Ok(Sandbox { runtime, cfg })
|
||||
}
|
||||
|
||||
pub fn runtime(&self) -> Runtime {
|
||||
self.runtime
|
||||
}
|
||||
|
||||
/// The argv that runs `command` inside the container.
|
||||
///
|
||||
/// Exposed on its own so an agent that spawns its own processes can be
|
||||
/// handed the wrapped form, and so the wrapping is unit-testable without a
|
||||
/// running daemon.
|
||||
pub fn wrap(&self, command: &str) -> Vec<String> {
|
||||
vec![
|
||||
self.runtime.bin().to_string(),
|
||||
"exec".into(),
|
||||
self.cfg.name.clone(),
|
||||
"sh".into(),
|
||||
"-lc".into(),
|
||||
command.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The argv that creates the long-lived container.
|
||||
///
|
||||
/// Kept as data (not run) so the security-relevant flags — no host network,
|
||||
/// no docker socket, a read-write work mount and nothing else — are visible
|
||||
/// and testable. The container idles on `sleep infinity`; commands run via
|
||||
/// `exec`.
|
||||
pub fn create_argv(&self) -> Vec<String> {
|
||||
let mut argv = vec![
|
||||
self.runtime.bin().to_string(),
|
||||
"run".into(),
|
||||
"-d".into(),
|
||||
"--rm".into(),
|
||||
"--name".into(),
|
||||
self.cfg.name.clone(),
|
||||
// Never the host network — that would hand the container the host's
|
||||
// interfaces and defeat the isolation entirely.
|
||||
"--network".into(),
|
||||
"bridge".into(),
|
||||
// Drop the ability to gain privileges; a scanning toolbox does not
|
||||
// need to become root-on-host.
|
||||
"--security-opt".into(),
|
||||
"no-new-privileges".into(),
|
||||
];
|
||||
if let Some(w) = &self.cfg.workdir {
|
||||
argv.push("-v".into());
|
||||
argv.push(format!("{w}:/work"));
|
||||
argv.push("-w".into());
|
||||
argv.push("/work".into());
|
||||
}
|
||||
for (k, v) in &self.cfg.env {
|
||||
argv.push("-e".into());
|
||||
argv.push(format!("{k}={v}"));
|
||||
}
|
||||
argv.push(self.cfg.image.clone());
|
||||
argv.push("sleep".into());
|
||||
argv.push("infinity".into());
|
||||
argv
|
||||
}
|
||||
|
||||
/// Is the managed container already running?
|
||||
pub async fn is_up(&self) -> bool {
|
||||
let out = run(self.runtime.bin(), &["ps", "-q", "-f", &format!("name=^{}$", self.cfg.name)], Duration::from_secs(10)).await;
|
||||
out.map(|o| !o.stdout.trim().is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Ensure the container exists and is running, pulling the image if needed.
|
||||
///
|
||||
/// Returns a human-readable status. Idempotent: a second call on an
|
||||
/// already-running container is a no-op, so the pipeline can call it freely.
|
||||
pub async fn ensure(&self) -> Result<String, String> {
|
||||
if self.is_up().await {
|
||||
return Ok(format!("{} container `{}` already running", self.runtime.bin(), self.cfg.name));
|
||||
}
|
||||
if self.cfg.auto_pull && !self.image_present().await {
|
||||
let pull = run(self.runtime.bin(), &["pull", &self.cfg.image], Duration::from_secs(600)).await
|
||||
.map_err(|e| format!("pull {} failed: {e}", self.cfg.image))?;
|
||||
if pull.code != 0 {
|
||||
return Err(format!("could not pull {}: {}", self.cfg.image, pull.stderr.lines().last().unwrap_or("").trim()));
|
||||
}
|
||||
}
|
||||
let argv = self.create_argv();
|
||||
let (bin, args) = argv.split_first().ok_or("empty argv")?;
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
let out = run(bin, &args_ref, Duration::from_secs(120)).await.map_err(|e| e.to_string())?;
|
||||
if out.code != 0 {
|
||||
return Err(format!("could not start the container: {}", out.stderr.lines().last().unwrap_or("").trim()));
|
||||
}
|
||||
Ok(format!("started {} container `{}` from {}", self.runtime.bin(), self.cfg.name, self.cfg.image))
|
||||
}
|
||||
|
||||
async fn image_present(&self) -> bool {
|
||||
run(self.runtime.bin(), &["image", "inspect", &self.cfg.image], Duration::from_secs(15)).await
|
||||
.map(|o| o.code == 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run one command in the container.
|
||||
pub async fn exec(&self, command: &str) -> Result<ExecResult, String> {
|
||||
let argv = self.wrap(command);
|
||||
let (bin, args) = argv.split_first().ok_or("empty argv")?;
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
match run(bin, &args_ref, Duration::from_secs(self.cfg.command_timeout)).await {
|
||||
Ok(r) => Ok(r),
|
||||
Err(e) if e.contains("timed out") => Ok(ExecResult { code: 124, stdout: String::new(), stderr: e, timed_out: true }),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a set of tools inside the container (best effort).
|
||||
///
|
||||
/// Kept explicit and opt-in: pulling `kali-linux-large` is gigabytes, and a
|
||||
/// run should not silently spend ten minutes on apt. The default image has
|
||||
/// the base; this is for when a specific tool is needed.
|
||||
pub async fn install(&self, packages: &[&str]) -> Result<ExecResult, String> {
|
||||
let list = packages.join(" ");
|
||||
self.exec(&format!("apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {list}")).await
|
||||
}
|
||||
|
||||
/// Stop and remove the container.
|
||||
pub async fn teardown(&self) {
|
||||
let _ = run(self.runtime.bin(), &["rm", "-f", &self.cfg.name], Duration::from_secs(30)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
std::process::Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn run(bin: &str, args: &[&str], timeout: Duration) -> Result<ExecResult, String> {
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.args(args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let child = cmd.spawn().map_err(|e| format!("spawn {bin} failed: {e}"))?;
|
||||
let out = tokio::time::timeout(timeout, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| format!("command timed out after {}s", timeout.as_secs()))?
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ExecResult {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
|
||||
timed_out: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sandbox() -> Sandbox {
|
||||
Sandbox {
|
||||
runtime: Runtime::Docker,
|
||||
cfg: SandboxConfig::default().mounting("/runs/ns-1").with_env(vec![("HTTPS_PROXY".into(), "http://127.0.0.1:8899".into())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_execs_into_the_named_container() {
|
||||
let argv = sandbox().wrap("sqlmap -u https://t.test/x --batch");
|
||||
assert_eq!(argv[0], "docker");
|
||||
assert_eq!(argv[1], "exec");
|
||||
assert_eq!(argv[2], "neurosploit-kali");
|
||||
assert_eq!(argv.last().unwrap(), "sqlmap -u https://t.test/x --batch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_never_grants_host_network_or_the_docker_socket() {
|
||||
let argv = sandbox().create_argv().join(" ");
|
||||
// The two flags that would give the container the host back.
|
||||
assert!(!argv.contains("--network host"), "host networking defeats the sandbox");
|
||||
assert!(!argv.contains("/var/run/docker.sock"), "mounting the socket is container escape");
|
||||
assert!(argv.contains("--network bridge"));
|
||||
assert!(argv.contains("no-new-privileges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_mounts_the_workdir_and_passes_the_proxy_env() {
|
||||
let argv = sandbox().create_argv().join(" ");
|
||||
assert!(argv.contains("/runs/ns-1:/work"), "evidence has to come back out");
|
||||
assert!(argv.contains("HTTPS_PROXY=http://127.0.0.1:8899"), "commands in the box take the same route");
|
||||
assert!(argv.contains("kalilinux/kali-rolling"));
|
||||
assert!(argv.trim_end().ends_with("sleep infinity"), "the container idles; commands run via exec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_runtime_is_an_explicit_error_not_a_silent_host_fallback() {
|
||||
// We cannot uninstall docker in a test, so assert the message contract
|
||||
// that ensures the caller is told rather than silently dropped to host.
|
||||
let msg = "no container runtime found (docker or podman). Install one, or run without --sandbox to execute on the host.";
|
||||
assert!(msg.contains("run without --sandbox"), "the fallback must be the operator's explicit choice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_is_used_when_selected() {
|
||||
let s = Sandbox { runtime: Runtime::Podman, cfg: SandboxConfig::default() };
|
||||
assert_eq!(s.wrap("id")[0], "podman");
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,23 @@ pub struct RunConfig {
|
||||
/// or `webhook:<url>:<number>`.
|
||||
#[serde(default)]
|
||||
pub sms: Option<String>,
|
||||
/// Intercepting proxy for the run: `burp`·`caido`·`zap`·`mitmproxy`·`own`·
|
||||
/// `own+burp`·`http://host:port`. Empty = direct. Both the harness and the
|
||||
/// agents' child commands route through it.
|
||||
#[serde(default)]
|
||||
pub intercept: Option<String>,
|
||||
/// Run agent commands inside a container instead of on the host. `""` uses
|
||||
/// the default Kali image; a value overrides the image. None = host.
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<String>,
|
||||
/// Re-run each finding's PoC after validation and demote any that no longer
|
||||
/// reproduce. Off by default (adds requests); the web/CLI can turn it on.
|
||||
#[serde(default)]
|
||||
pub revalidate_poc: bool,
|
||||
/// Compliance frameworks to map findings onto in the report:
|
||||
/// `pci-dss`·`hipaa`·`soc2`.
|
||||
#[serde(default)]
|
||||
pub compliance: Vec<String>,
|
||||
/// How much compute to spend and where. Defaults to unlimited, which is the
|
||||
/// behaviour that existed before budgets — an operator who asks for nothing
|
||||
/// gets the full run.
|
||||
@@ -312,6 +329,10 @@ impl RunConfig {
|
||||
oob_http: None,
|
||||
oob_dns: None,
|
||||
sms: None,
|
||||
intercept: None,
|
||||
sandbox: None,
|
||||
revalidate_poc: false,
|
||||
compliance: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,6 +977,14 @@ pub fn validators() -> Vec<Box<dyn CweValidator>> {
|
||||
Box::new(MassAssignmentValidator),
|
||||
Box::new(CsrfValidator),
|
||||
Box::new(ExposureValidator),
|
||||
Box::new(VerboseErrorValidator),
|
||||
Box::new(CleartextValidator),
|
||||
Box::new(CrlfValidator),
|
||||
Box::new(HttpMethodValidator),
|
||||
Box::new(GraphqlIntrospectionValidator),
|
||||
Box::new(ExposedFileValidator),
|
||||
Box::new(HostHeaderValidator),
|
||||
Box::new(CacheableSecretValidator),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1026,6 +1034,26 @@ pub fn validator_for(f: &Finding) -> Option<Box<dyn CweValidator>> {
|
||||
("cross-site request forgery", || Box::new(CsrfValidator)),
|
||||
("directory listing", || Box::new(ExposureValidator)),
|
||||
("information disclosure", || Box::new(ExposureValidator)),
|
||||
("stack trace", || Box::new(VerboseErrorValidator)),
|
||||
("verbose error", || Box::new(VerboseErrorValidator)),
|
||||
("error message", || Box::new(VerboseErrorValidator)),
|
||||
("cleartext", || Box::new(CleartextValidator)),
|
||||
("hsts", || Box::new(CleartextValidator)),
|
||||
("strict-transport", || Box::new(CleartextValidator)),
|
||||
("crlf", || Box::new(CrlfValidator)),
|
||||
("response splitting", || Box::new(CrlfValidator)),
|
||||
("http method", || Box::new(HttpMethodValidator)),
|
||||
("trace method", || Box::new(HttpMethodValidator)),
|
||||
("cross-site tracing", || Box::new(HttpMethodValidator)),
|
||||
("graphql", || Box::new(GraphqlIntrospectionValidator)),
|
||||
("introspection", || Box::new(GraphqlIntrospectionValidator)),
|
||||
("exposed file", || Box::new(ExposedFileValidator)),
|
||||
(".git", || Box::new(ExposedFileValidator)),
|
||||
(".env", || Box::new(ExposedFileValidator)),
|
||||
("backup file", || Box::new(ExposedFileValidator)),
|
||||
("host header", || Box::new(HostHeaderValidator)),
|
||||
("web cache", || Box::new(CacheableSecretValidator)),
|
||||
("cacheable", || Box::new(CacheableSecretValidator)),
|
||||
];
|
||||
by_title.iter().find(|(k, _)| t.contains(k)).map(|(_, mk)| mk())
|
||||
}
|
||||
@@ -1159,6 +1187,265 @@ pub fn evidence_contract() -> String {
|
||||
s
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Additional deterministic validators
|
||||
//
|
||||
// Everything below is provable from the captured exchange alone — a header
|
||||
// that is present or absent, a payload reflected into a place it changes
|
||||
// meaning, an error the baseline did not produce. No model is consulted, and
|
||||
// none of these confirm on suspicion: each names exactly what it saw.
|
||||
// ===========================================================================
|
||||
|
||||
/// CWE-209 — a response leaking a stack trace, a framework error, or an
|
||||
/// internal path. Deterministic because the leak is *in the body*: the
|
||||
/// signature is there or it is not, and the baseline is used to make sure it
|
||||
/// was the payload that surfaced it rather than a page that always shows it.
|
||||
pub struct VerboseErrorValidator;
|
||||
impl CweValidator for VerboseErrorValidator {
|
||||
fn name(&self) -> &'static str { "verbose-error" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["209", "211", "550"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["a response body carrying a stack trace / framework error / internal path"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = ev.attack.as_ref().or(ev.baseline.as_ref()) else {
|
||||
return Verdict::NeedsReview("no response captured".into());
|
||||
};
|
||||
let markers = [
|
||||
"traceback (most recent call last)", "stack trace:", "at java.", "at org.springframework",
|
||||
"system.web.", "microsoft .net", "org.hibernate", "psql:", "ora-0", "sqlstate[",
|
||||
"you have an error in your sql syntax", "warning: ", "fatal error:", "exception in thread",
|
||||
"/var/www/", "/home/", "c:\\\\inetpub", "line ", "undefinederror", ".rb:", ".py\", line",
|
||||
];
|
||||
let body = a.body.to_lowercase();
|
||||
let hit = markers.iter().find(|m| body.contains(**m));
|
||||
let Some(sig) = hit else {
|
||||
return Verdict::NeedsReview("no error/trace signature in the captured body".into());
|
||||
};
|
||||
// If the baseline already shows the same signature it is a static
|
||||
// page, not a leak the payload caused.
|
||||
if let Some(b) = &ev.baseline {
|
||||
if b.body.to_lowercase().contains(*sig) {
|
||||
return Verdict::Rejected(format!("`{sig}` is present in the baseline too — not payload-induced"));
|
||||
}
|
||||
}
|
||||
Verdict::Confirmed(format!("the response leaks `{sig}` — an internal error surfaced to the client"))
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-319 — sensitive interaction over cleartext, or a site that never sets
|
||||
/// HSTS. Two provable shapes: the endpoint itself is `http://`, or an HTTPS
|
||||
/// response omits Strict-Transport-Security entirely.
|
||||
pub struct CleartextValidator;
|
||||
impl CweValidator for CleartextValidator {
|
||||
fn name(&self) -> &'static str { "cleartext" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["319", "311", "523"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["an http:// endpoint carrying credentials/data, OR an https response with no HSTS"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = ev.attack.as_ref().or(ev.baseline.as_ref()) else {
|
||||
return Verdict::NeedsReview("no response captured".into());
|
||||
};
|
||||
if a.url.starts_with("http://") {
|
||||
return Verdict::Confirmed(format!("{} is served over cleartext HTTP", a.url));
|
||||
}
|
||||
if a.url.starts_with("https://") && a.header("strict-transport-security").is_empty() {
|
||||
// Absent HSTS is real but low: it is an SSL-strip *precondition*,
|
||||
// not a demonstrated interception. Report, do not inflate.
|
||||
return Verdict::Confirmed("HTTPS response sets no Strict-Transport-Security header — vulnerable to SSL-strip on the first request".into());
|
||||
}
|
||||
Verdict::Rejected("served over HTTPS with HSTS present".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-113 — CRLF injection / HTTP response splitting. Proven when a payload
|
||||
/// containing an encoded CR/LF ends up as a *real* header separator in the
|
||||
/// response: a header the baseline did not have, whose name or value came from
|
||||
/// the payload.
|
||||
pub struct CrlfValidator;
|
||||
impl CweValidator for CrlfValidator {
|
||||
fn name(&self) -> &'static str { "crlf" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["113", "93"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["a marker injected via CR/LF appearing as a response HEADER (not the body)"]
|
||||
}
|
||||
fn validate(&self, f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = &ev.attack else {
|
||||
return Verdict::NeedsReview("no attack response captured".into());
|
||||
};
|
||||
let marker = if !ev.marker.is_empty() { ev.marker.clone() } else { f.payload.clone() };
|
||||
let token = marker.rsplit(|c| c == ':' || c == '=' || c == '\n' || c == '\r').next().unwrap_or("").trim().to_lowercase();
|
||||
if token.len() < 4 {
|
||||
return Verdict::NeedsReview("no distinctive CRLF marker to look for".into());
|
||||
}
|
||||
let in_header = a.headers.iter().any(|(k, v)| k.to_lowercase().contains(&token) || v.to_lowercase().contains(&token));
|
||||
let in_baseline_header = ev.baseline.as_ref().map(|b| b.headers.iter().any(|(k, v)| k.to_lowercase().contains(&token) || v.to_lowercase().contains(&token))).unwrap_or(false);
|
||||
if in_header && !in_baseline_header {
|
||||
return Verdict::Confirmed(format!("the CRLF payload surfaced as a response header carrying `{token}` — the header stream was split"));
|
||||
}
|
||||
if a.body.to_lowercase().contains(&token) {
|
||||
return Verdict::Rejected("the marker landed in the BODY, not a header — that is reflection, not response splitting".into());
|
||||
}
|
||||
Verdict::NeedsReview("the injected CR/LF marker did not become a response header".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-650 / CWE-16 — dangerous HTTP methods enabled. Proven from an `Allow`
|
||||
/// header (an OPTIONS response) or a TRACE that echoes the request: both are
|
||||
/// facts in the response, not inferences.
|
||||
pub struct HttpMethodValidator;
|
||||
impl CweValidator for HttpMethodValidator {
|
||||
fn name(&self) -> &'static str { "http-methods" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["650"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["an Allow header advertising PUT/DELETE/TRACE, or a TRACE that echoed the request"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = ev.attack.as_ref().or(ev.baseline.as_ref()) else {
|
||||
return Verdict::NeedsReview("no response captured".into());
|
||||
};
|
||||
let allow = a.header("allow").to_uppercase();
|
||||
let dangerous: Vec<&str> = ["PUT", "DELETE", "TRACE", "CONNECT", "PATCH"].iter().copied().filter(|m| allow.contains(*m)).collect();
|
||||
if a.method.eq_ignore_ascii_case("TRACE") && a.status == 200 && a.body.to_lowercase().contains("trace") {
|
||||
return Verdict::Confirmed("TRACE is enabled and echoed the request — Cross-Site Tracing is possible".into());
|
||||
}
|
||||
if !dangerous.is_empty() {
|
||||
return Verdict::Confirmed(format!("the server advertises dangerous methods: {}", dangerous.join(", ")));
|
||||
}
|
||||
Verdict::Rejected("no dangerous methods advertised in Allow, and TRACE was not reflected".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-16 / A05 — GraphQL introspection left enabled. Proven when an
|
||||
/// introspection query returns the schema (`__schema` with types) rather than
|
||||
/// an error.
|
||||
pub struct GraphqlIntrospectionValidator;
|
||||
impl CweValidator for GraphqlIntrospectionValidator {
|
||||
fn name(&self) -> &'static str { "graphql-introspection" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["16", "1230"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["an introspection query response containing __schema and its types"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = &ev.attack else {
|
||||
return Verdict::NeedsReview("no introspection response captured".into());
|
||||
};
|
||||
let body = a.body.to_lowercase();
|
||||
if body.contains("\"__schema\"") && (body.contains("\"types\"") || body.contains("querytype")) {
|
||||
return Verdict::Confirmed("the introspection query returned the schema — the API's full type graph is exposed".into());
|
||||
}
|
||||
if body.contains("introspection") && (body.contains("disabled") || body.contains("not allowed")) {
|
||||
return Verdict::Rejected("introspection is explicitly disabled".into());
|
||||
}
|
||||
Verdict::NeedsReview("the response did not contain a schema — introspection may be off".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-548 / A05 — a secret or config file left readable at a well-known path.
|
||||
/// Confirmed when a request for `.git/config`, `.env`, `wp-config.php.bak` and
|
||||
/// friends returns the file's actual content, not a 404 or the app's HTML.
|
||||
pub struct ExposedFileValidator;
|
||||
impl CweValidator for ExposedFileValidator {
|
||||
fn name(&self) -> &'static str { "exposed-file" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["530"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["a sensitive file path returning file content (200 + matching signature), not the app page"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = ev.attack.as_ref().or(ev.baseline.as_ref()) else {
|
||||
return Verdict::NeedsReview("no response captured".into());
|
||||
};
|
||||
if a.status != 200 {
|
||||
return Verdict::Rejected(format!("the path returned HTTP {} — not served", a.status));
|
||||
}
|
||||
let url = a.url.to_lowercase();
|
||||
let body = &a.body;
|
||||
let signature = if url.contains(".git/config") { body.contains("[core]") || body.contains("[remote") }
|
||||
else if url.contains(".env") { body.contains('=') && (body.to_uppercase().contains("KEY") || body.to_uppercase().contains("SECRET") || body.to_uppercase().contains("PASSWORD") || body.to_uppercase().contains("DB_")) }
|
||||
else if url.ends_with(".sql") || url.contains("dump") { body.to_lowercase().contains("insert into") || body.to_lowercase().contains("create table") }
|
||||
else if url.contains("wp-config") { body.contains("DB_PASSWORD") || body.contains("define(") }
|
||||
else if url.contains(".htpasswd") { body.contains(':') && body.lines().next().map(|l| l.contains('$')).unwrap_or(false) }
|
||||
else if url.contains(".aws") || url.contains("credentials") { body.contains("aws_access_key_id") || body.contains("aws_secret") }
|
||||
else { false };
|
||||
// The app's own HTML returned under a bogus path is the classic false
|
||||
// positive — a 200 that is actually the SPA, not the file.
|
||||
let looks_like_html = body.trim_start().to_lowercase().starts_with("<!doctype") || body.trim_start().to_lowercase().starts_with("<html");
|
||||
if signature && !looks_like_html {
|
||||
return Verdict::Confirmed("the sensitive file is readable and its content matches the expected signature".into());
|
||||
}
|
||||
if looks_like_html {
|
||||
return Verdict::Rejected("the path returned the application's HTML page, not the file — likely a catch-all route".into());
|
||||
}
|
||||
Verdict::NeedsReview("the response did not match the file's expected signature".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-113/CWE-644 — Host header injection reflected into a link or a redirect.
|
||||
/// Confirmed when an attacker-supplied Host value comes back in the response's
|
||||
/// Location header or an absolute link in the body.
|
||||
pub struct HostHeaderValidator;
|
||||
impl CweValidator for HostHeaderValidator {
|
||||
fn name(&self) -> &'static str { "host-header" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["644"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["an attacker Host value reflected into Location or an absolute URL in the body"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = &ev.attack else {
|
||||
return Verdict::NeedsReview("no attack response captured".into());
|
||||
};
|
||||
let injected = a.request_header("host");
|
||||
if injected.is_empty() {
|
||||
return Verdict::NeedsReview("no Host header was recorded on the attack request".into());
|
||||
}
|
||||
let inj = injected.to_lowercase();
|
||||
// Only meaningful if the injected Host is NOT the legitimate one.
|
||||
let legit_host = a.url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or("").to_lowercase();
|
||||
if inj == legit_host {
|
||||
return Verdict::Rejected("the Host header carried the legitimate host — nothing was injected".into());
|
||||
}
|
||||
let location = a.header("location").to_lowercase();
|
||||
if location.contains(&inj) {
|
||||
return Verdict::Confirmed(format!("the injected Host `{injected}` was reflected into the Location redirect — password-reset poisoning / cache poisoning is possible"));
|
||||
}
|
||||
if a.body.to_lowercase().contains(&format!("//{inj}")) || a.body.to_lowercase().contains(&format!("https://{inj}")) {
|
||||
return Verdict::Confirmed(format!("the injected Host `{injected}` was reflected into an absolute link in the body"));
|
||||
}
|
||||
Verdict::Rejected("the injected Host was not reflected into a link or redirect".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// CWE-525 / CWE-524 — a sensitive, authenticated response left cacheable. The
|
||||
/// combination that matters: a response carrying private data (identity set)
|
||||
/// whose Cache-Control does not forbid storage.
|
||||
pub struct CacheableSecretValidator;
|
||||
impl CweValidator for CacheableSecretValidator {
|
||||
fn name(&self) -> &'static str { "cacheable-private" }
|
||||
fn cwes(&self) -> &'static [&'static str] { &["525", "524"] }
|
||||
fn evidence_required(&self) -> &'static [&'static str] {
|
||||
&["an authenticated response with private data and a Cache-Control that permits storing it"]
|
||||
}
|
||||
fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
|
||||
let Some(a) = ev.attack.as_ref().or(ev.identity_a.as_ref()) else {
|
||||
return Verdict::NeedsReview("no authenticated response captured".into());
|
||||
};
|
||||
let authed = !a.request_header("authorization").is_empty() || !a.request_header("cookie").is_empty() || !a.identity.is_empty();
|
||||
if !authed {
|
||||
return Verdict::NeedsReview("the response was not shown to be authenticated — cacheability is only a risk for private data".into());
|
||||
}
|
||||
let cc = a.header("cache-control").to_lowercase();
|
||||
if cc.contains("no-store") || cc.contains("private") && cc.contains("no-cache") {
|
||||
return Verdict::Rejected(format!("Cache-Control forbids shared storage: `{cc}`"));
|
||||
}
|
||||
if cc.is_empty() || cc.contains("public") || (cc.contains("max-age") && !cc.contains("no-store")) {
|
||||
return Verdict::Confirmed(format!("an authenticated response is cacheable (Cache-Control: `{}`) — a shared cache could serve one user's data to another", if cc.is_empty() { "absent" } else { &cc }));
|
||||
}
|
||||
Verdict::NeedsReview(format!("Cache-Control `{cc}` is ambiguous for a private response"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1638,4 +1925,112 @@ mod tests {
|
||||
assert!(batch[0].starts_with(crate::provenance::SIGIL), "got {}", batch[0]);
|
||||
assert!(batch[0].contains("ns") && batch[0].len() > 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_error_needs_a_trace_the_baseline_lacks() {
|
||||
let base = ex(200, "welcome");
|
||||
let mut atk = ex(500, "Traceback (most recent call last):\n File \"/var/www/app.py\", line 42");
|
||||
atk.url = "https://t.test/x".into();
|
||||
let ev = Evidence { baseline: Some(base), attack: Some(atk), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-209", "stack trace leak"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
// Same trace present in the baseline = static page, not a leak.
|
||||
let ev2 = Evidence {
|
||||
baseline: Some(ex(200, "Traceback (most recent call last): docs")),
|
||||
attack: Some(ex(200, "Traceback (most recent call last): docs")),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(judge(&f("CWE-209", "error"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleartext_flags_http_and_missing_hsts() {
|
||||
let mut http = ex(200, "login");
|
||||
http.url = "http://t.test/login".into();
|
||||
let ev = Evidence { attack: Some(http), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-319", "cleartext"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
let with_hsts = exh(200, "ok", &[("strict-transport-security", "max-age=63072000")]);
|
||||
let ev2 = Evidence { attack: Some(with_hsts), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-319", "cleartext"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_confirms_only_when_the_marker_becomes_a_header() {
|
||||
let base = exh(200, "ok", &[]);
|
||||
let atk = exh(200, "ok", &[("x-injected", "nscrlf7788")]);
|
||||
let ev = Evidence { marker: "nscrlf7788".into(), baseline: Some(base), attack: Some(atk), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-113", "response splitting"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
// Marker only in the body = reflection, not splitting.
|
||||
let atk2 = exh(200, "echoed nscrlf7788 here", &[]);
|
||||
let ev2 = Evidence { marker: "nscrlf7788".into(), attack: Some(atk2), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-113", "response splitting"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_methods_read_the_allow_header() {
|
||||
let opt = exh(200, "", &[("allow", "GET, POST, PUT, DELETE")]);
|
||||
let ev = Evidence { attack: Some(opt), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-650", "dangerous http method"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
let safe = exh(200, "", &[("allow", "GET, HEAD, OPTIONS")]);
|
||||
let ev2 = Evidence { attack: Some(safe), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-650", "http method"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_introspection_needs_a_schema() {
|
||||
let atk = ex(200, r#"{"data":{"__schema":{"types":[{"name":"Query"}]}}}"#);
|
||||
let ev = Evidence { attack: Some(atk), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-16", "graphql introspection"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
let off = ex(400, r#"{"errors":[{"message":"introspection is disabled"}]}"#);
|
||||
let ev2 = Evidence { attack: Some(off), ..Default::default() };
|
||||
assert!(matches!(judge(&f("x", "graphql introspection"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposed_file_rejects_the_apps_own_html() {
|
||||
let mut git = ex(200, "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]");
|
||||
git.url = "https://t.test/.git/config".into();
|
||||
let ev = Evidence { attack: Some(git), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-530", "backup .git config"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
// 200 that is actually the SPA — the classic false positive.
|
||||
let mut spa = ex(200, "<!doctype html><html><body>app</body></html>");
|
||||
spa.url = "https://t.test/.env".into();
|
||||
let ev2 = Evidence { attack: Some(spa), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-530", ".env exposed"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_header_needs_reflection_of_an_injected_host() {
|
||||
let mut atk = exh(302, "", &[("location", "https://evil.test/reset?token=abc")]);
|
||||
atk.url = "https://t.test/reset".into();
|
||||
atk.request_headers.insert("host".into(), "evil.test".into());
|
||||
let ev = Evidence { attack: Some(atk), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-644", "host header injection"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
// Legit host, no injection.
|
||||
let mut ok = exh(302, "", &[("location", "https://t.test/home")]);
|
||||
ok.url = "https://t.test/reset".into();
|
||||
ok.request_headers.insert("host".into(), "t.test".into());
|
||||
let ev2 = Evidence { attack: Some(ok), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-644", "host header"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_private_needs_both_auth_and_a_permissive_cache_control() {
|
||||
let mut atk = exh(200, "{\"balance\":4200}", &[("cache-control", "public, max-age=600")]);
|
||||
atk.request_headers.insert("authorization".into(), "Bearer x".into());
|
||||
let ev = Evidence { attack: Some(atk), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-525", "cacheable private data"), Some(&ev)), Verdict::Confirmed(_)));
|
||||
|
||||
let mut nostore = exh(200, "{\"balance\":4200}", &[("cache-control", "no-store")]);
|
||||
nostore.request_headers.insert("authorization".into(), "Bearer x".into());
|
||||
let ev2 = Evidence { attack: Some(nostore), ..Default::default() };
|
||||
assert!(matches!(judge(&f("CWE-525", "cacheable"), Some(&ev2)), Verdict::Rejected(_)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -473,6 +473,10 @@ 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: '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' },
|
||||
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
|
||||
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
|
||||
];
|
||||
$('#reviewGrid').innerHTML = items.map((it) => `
|
||||
@@ -514,6 +518,10 @@ async function startExploitation() {
|
||||
// Budget is opt-in: 'unlimited' sends nothing, so a run nobody budgeted is
|
||||
// the same full run it was before this control existed.
|
||||
budget: $('#fieldBudget').value,
|
||||
intercept: $('#fieldIntercept').value,
|
||||
sandbox: $('#fieldSandbox').value || undefined,
|
||||
revalidatePoc: $('#fieldRevalidatePoc').checked,
|
||||
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
|
||||
tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
|
||||
order: $('#fieldOrder').value,
|
||||
samplePerRoute: Number($('#fieldSampleRoute').value) || undefined,
|
||||
|
||||
@@ -240,6 +240,43 @@
|
||||
</div>
|
||||
<div class="field-group"><label class="field-label" for="fieldSampleRoute">Sample / route</label><input class="narrow" id="fieldSampleRoute" type="number" min="1" max="50" value="3" /><div class="field-help">Requests per endpoint family (<code>/api/users/{id}</code> is sampled, not enumerated).</div></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-title">Tooling & assurance</div>
|
||||
<div class="section-desc">Optional. Route through a proxy, run in a container, re-check every PoC, and frame findings against a compliance framework.</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group"><label class="field-label" for="fieldIntercept">Intercepting proxy</label>
|
||||
<select class="narrow" id="fieldIntercept">
|
||||
<option value="off" selected>off · direct</option>
|
||||
<option value="own">own interceptor (record + passive discovery)</option>
|
||||
<option value="burp">Burp Suite</option>
|
||||
<option value="caido">Caido</option>
|
||||
<option value="zap">OWASP ZAP</option>
|
||||
<option value="mitmproxy">mitmproxy</option>
|
||||
<option value="own+burp">own + Burp</option>
|
||||
<option value="own+caido">own + Caido</option>
|
||||
</select>
|
||||
<div class="field-help">The harness and agent commands route through it. Full HTTPS interception needs one of the tools (own tunnels TLS).</div>
|
||||
</div>
|
||||
<div class="field-group"><label class="field-label" for="fieldSandbox">Sandbox</label>
|
||||
<select class="narrow" id="fieldSandbox">
|
||||
<option value="" selected>host (no container)</option>
|
||||
<option value="default">Kali container (kalilinux/kali-rolling)</option>
|
||||
</select>
|
||||
<div class="field-help">Runs attack commands off the host, with the Kali toolbox. Needs docker or podman.</div>
|
||||
</div>
|
||||
<div class="field-group"><label class="field-label">PoC re-validation</label>
|
||||
<div class="check-row"><input type="checkbox" id="fieldRevalidatePoc" /> <label for="fieldRevalidatePoc">Re-run every PoC; demote what no longer reproduces</label></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Compliance mapping</label>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompPci" value="pci-dss" /> <label for="fieldCompPci">PCI-DSS v4.0</label></div>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompHipaa" value="hipaa" /> <label for="fieldCompHipaa">HIPAA Security Rule</label></div>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompSoc2" value="soc2" /> <label for="fieldCompSoc2">SOC 2 (Trust Services Criteria)</label></div>
|
||||
<div class="field-help">Maps confirmed findings onto control requirements in the report. Indicates gaps for an assessor — never a compliance verdict.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 — Review -->
|
||||
|
||||
@@ -603,6 +603,10 @@ function buildArgs(body) {
|
||||
if (body.oobHttp) args.push('--oob-http', body.oobHttp);
|
||||
if (body.oobDns) args.push('--oob-dns', body.oobDns);
|
||||
if (body.sms) args.push('--sms', body.sms);
|
||||
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
|
||||
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
|
||||
if (body.revalidatePoc) args.push('--revalidate-poc');
|
||||
for (const fw of body.compliance || []) args.push('--compliance', fw);
|
||||
// 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);
|
||||
@@ -672,6 +676,10 @@ function authArgs(body) {
|
||||
if (body.oobHttp) args.push('--oob-http', body.oobHttp);
|
||||
if (body.oobDns) args.push('--oob-dns', body.oobDns);
|
||||
if (body.sms) args.push('--sms', body.sms);
|
||||
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
|
||||
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
|
||||
if (body.revalidatePoc) args.push('--revalidate-poc');
|
||||
for (const fw of body.compliance || []) args.push('--compliance', fw);
|
||||
if (body.budget && body.budget !== 'unlimited') args.push('--budget', body.budget);
|
||||
if (body.tokenLimit) args.push('--token-limit', String(body.tokenLimit));
|
||||
if (body.deepTestLimit) args.push('--deep-test-limit', String(body.deepTestLimit));
|
||||
|
||||
Reference in new issue
Block a user