mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +02:00
feat(assurance): target gate (default-deny), evidence-graded CVSS, audit anchoring, P1–P5 bundle
The five immediate priorities from the assurance review — the harness-core ones, not the commercial/research items (Ed25519, enterprise mode, ablation, multi-target benchmark are deferred, noted as such). P1 — target authorization gate. scope.rs::validate_target checks protocol, host, port and URL prefix before ANY recon. A capability token that does not cover the CLI target now refuses the run with DENY_TARGET_OUTSIDE_GRANT, audits it, and exits non-zero — closing the auto-trust-the-target bypass. RunOutput carries a `denied` code the CLI turns into a non-zero exit. P6 — cvss.rs: the FIRST v3.1 base equation verbatim (roundup, scope coefficients), validated against first.org reference vectors (9.8, 6.1, 10.0, 7.8, 7.5, 5.3, 3.1). grade() drops any impact metric that raises severity without a receipt to a *demonstrated* vector, keeping the *potential* one for context — SQLi with no extraction scores 0 demonstrated / 9.8 potential, never a manufactured critical. P4 — audit.rs anchoring: signed checkpoints of the chain head, local and (with NEUROSPLOIT_ANCHOR_DIR) external append-only. verify_anchored() catches truncation (chain shorter than an anchor) and silent rebuilds (head hash no longer matches), and forged anchors via signature. `neurosploit audit --anchor`. P5 — assurance.rs bundle: one assurance.json per run — every artifact with its SHA-256, which of P1–P5 it evidenced (present/partial/absent, never flattered), a bundle hash and a signature. `neurosploit assurance <run> [--verify]`. Also +8 deterministic validators earlier this session (19→27). Deferred and documented: Ed25519 tokens (#3), enterprise mode (#25), benchmark/ablation (#20/#21), model pinning + reproducibility (#22/#23), README claims taxonomy (#24), per-agent seccomp (#14). 347 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8894649ccb
commit
b4903575c0
@@ -525,6 +525,39 @@ neurosploit provenance scan report.pdf.txt # is this ours? which build?
|
||||
neurosploit provenance verify runs/ns-… # manifest vs findings
|
||||
```
|
||||
|
||||
### Assurance — target gate, CVSS, anchoring, one bundle
|
||||
|
||||
**Target authorization gate (default-deny).** Before any recon, the target is
|
||||
validated against the capability grant — protocol, host, port, URL prefix. A
|
||||
token that does not cover the target refuses the run with
|
||||
`DENY_TARGET_OUTSIDE_GRANT`, logs it, and exits non-zero. The CLI target is no
|
||||
longer auto-trusted when a grant is in force.
|
||||
|
||||
**CVSS computed from evidence.** `cvss.rs` implements the FIRST v3.1 base
|
||||
equation verbatim (checked against first.org reference vectors) and grades each
|
||||
impact metric against a receipt: `C:H`/`I:H` with no evidence is dropped to the
|
||||
*demonstrated* vector while the *potential* vector keeps it. SQLi with nothing
|
||||
extracted is not a 9.8.
|
||||
|
||||
**Audit anchoring (P4).** `audit.jsonl` is hash-chained; a signed **anchor**
|
||||
(`neurosploit audit <run> --anchor`) is written per run and, with
|
||||
`NEUROSPLOIT_ANCHOR_DIR`, to external append-only storage. Truncation and
|
||||
silent rebuilds are then detectable, not just neighbour-tampering.
|
||||
|
||||
**Assurance bundle (P1–P5 in one run).** Every run emits `assurance.json`: each
|
||||
artifact with its SHA-256, which of the five properties it produced
|
||||
(authorization · enforcement · evidence/CVSS · integrity · provenance), a
|
||||
bundle hash and a signature. Verify independently:
|
||||
|
||||
```bash
|
||||
neurosploit assurance <run> # assemble + print the P1–P5 summary
|
||||
neurosploit assurance <run> --verify # re-hash every artifact + check the signature
|
||||
neurosploit audit <run> --anchor # chain + anchors (truncation/rebuild/forgery)
|
||||
```
|
||||
|
||||
A property is reported `present` only when its artifact is actually on disk —
|
||||
a missing anchor is `partial`, never quietly omitted.
|
||||
|
||||
### Egress — how traffic reaches the target
|
||||
|
||||
Internal engagements happen *through* something, and the dangerous failure is
|
||||
|
||||
@@ -182,6 +182,16 @@ enum Cmd {
|
||||
/// Run id (`ns-…`) or a path to the run directory.
|
||||
run: String,
|
||||
},
|
||||
/// Verify a finished run's audit trail — the hash chain and, with --anchor,
|
||||
/// the signed anchors that catch truncation and silent rebuilds.
|
||||
Audit {
|
||||
/// Run id (`ns-…`) or path to the run directory.
|
||||
run: String,
|
||||
/// Also verify anchors (P4): truncation, rebuild and, if a key is set,
|
||||
/// anchor signatures.
|
||||
#[arg(long = "anchor")]
|
||||
anchor: bool,
|
||||
},
|
||||
/// Compliance mapping: re-frame a finished run's findings against PCI-DSS,
|
||||
/// HIPAA or SOC 2 controls.
|
||||
Compliance {
|
||||
@@ -206,6 +216,15 @@ enum Cmd {
|
||||
#[arg(long = "apply")]
|
||||
apply: bool,
|
||||
},
|
||||
/// Assemble/print/verify a run's assurance bundle (P1–P5 in one manifest).
|
||||
Assurance {
|
||||
/// Run id (`ns-…`) or path to the run directory.
|
||||
run: String,
|
||||
/// Verify the bundle against the run dir (hashes + signature) instead
|
||||
/// of assembling a fresh one.
|
||||
#[arg(long = "verify")]
|
||||
verify: bool,
|
||||
},
|
||||
/// Manage the Kali sandbox container (up · exec · down).
|
||||
Sandbox {
|
||||
#[command(subcommand)]
|
||||
@@ -574,6 +593,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
Err(e) => anyhow::bail!("rebuild failed: {e}"),
|
||||
}
|
||||
}
|
||||
Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?,
|
||||
Cmd::Assurance { run, verify } => handle_assurance(&base, &run, verify)?,
|
||||
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?,
|
||||
@@ -619,6 +640,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
apply_creds(&mut cfg, creds.as_deref()).await;
|
||||
let out = run_engagement(&base, cfg, mcp, false).await?;
|
||||
print_findings(&out);
|
||||
if let Some(code) = &out.denied { anyhow::bail!("{code}"); }
|
||||
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
||||
post_integrations(&ig, &url, &out, jira, false, None).await;
|
||||
}
|
||||
@@ -1100,6 +1122,11 @@ async fn run_mode(base: &Path, cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow:
|
||||
}
|
||||
|
||||
pub(crate) fn print_findings(out: &RunOutput) {
|
||||
if let Some(code) = &out.denied {
|
||||
eprintln!("\n\x1b[1;31m⛔ RUN REFUSED\x1b[0m — {code}");
|
||||
eprintln!(" The target was not authorized. Nothing was tested. See audit.jsonl.");
|
||||
return;
|
||||
}
|
||||
println!("\n=== {} validated finding(s) ===", out.findings.len());
|
||||
if !out.findings.is_empty() {
|
||||
let mut by = std::collections::BTreeMap::new();
|
||||
@@ -1347,6 +1374,54 @@ fn load_findings(dir: &std::path::Path) -> anyhow::Result<Vec<harness::types::Fi
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
|
||||
fn handle_assurance(base: &std::path::Path, run: &str, verify: bool) -> anyhow::Result<()> {
|
||||
let dir = resolve_run(base, run)?;
|
||||
let key = std::env::var("NEUROSPLOIT_PROVENANCE_KEY").ok().filter(|k| !k.trim().is_empty()).map(|k| k.into_bytes());
|
||||
if verify {
|
||||
let text = std::fs::read_to_string(dir.join("assurance.json"))
|
||||
.map_err(|e| anyhow::anyhow!("no assurance.json in {}: {e}", dir.display()))?;
|
||||
let bundle: harness::assurance::Bundle = serde_json::from_str(&text)?;
|
||||
match bundle.verify(&dir, key.as_deref()) {
|
||||
Ok(()) => println!(" \x1b[1;32m✓ assurance bundle verified\x1b[0m — {} artifact(s){}", bundle.artifacts.iter().filter(|a| a.present).count(), if key.is_some() { ", signature valid" } else { " (signature NOT checked)" }),
|
||||
Err(e) => { println!(" \x1b[1;31m✗ {e}\x1b[0m"); anyhow::bail!("assurance verification failed"); }
|
||||
}
|
||||
} else {
|
||||
let bundle = harness::assurance::Bundle::build(&dir);
|
||||
let bundle = match &key { Some(k) => bundle.sign(k), None => bundle };
|
||||
let out = dir.join("assurance.json");
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&bundle)?)?;
|
||||
print!("\n{}", bundle.summary());
|
||||
println!(" \x1b[2mbundle hash {} · saved → {}\x1b[0m", &bundle.bundle_hash[..16.min(bundle.bundle_hash.len())], out.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_audit(base: &std::path::Path, run: &str, anchor: bool) -> anyhow::Result<()> {
|
||||
let dir = resolve_run(base, run)?;
|
||||
let log = harness::audit::AuditLog::open(dir.join("audit.jsonl"));
|
||||
if anchor {
|
||||
// A provenance key verifies anchor signatures too; without it we still
|
||||
// catch truncation and rebuilds by hash, and say the sigs are unchecked.
|
||||
let key = std::env::var("NEUROSPLOIT_PROVENANCE_KEY").ok().filter(|k| !k.trim().is_empty()).map(|k| k.into_bytes());
|
||||
match log.verify_anchored(key.as_deref()) {
|
||||
Ok(rep) => {
|
||||
println!(" \x1b[1;32m✓ audit chain intact\x1b[0m — {} record(s)", rep.records);
|
||||
println!(" \x1b[1;32m✓ {} anchor(s) consistent\x1b[0m{}", rep.anchors, if rep.signed { " (signatures verified)" } else { " (signatures NOT checked — set NEUROSPLOIT_PROVENANCE_KEY)" });
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" \x1b[1;31m✗ {e}\x1b[0m");
|
||||
anyhow::bail!("audit verification failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match log.verify() {
|
||||
Ok(n) => println!(" \x1b[1;32m✓ audit chain intact\x1b[0m — {n} record(s). (Add --anchor to check truncation/rebuild.)"),
|
||||
Err(e) => { println!(" \x1b[1;31m✗ {e}\x1b[0m"); anyhow::bail!("audit verification failed"); }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Assurance bundle — one run, one verifiable record of everything.
|
||||
//!
|
||||
//! The mechanisms that make a NeuroSploit finding trustworthy — the signed
|
||||
//! authorization, the enforced scope, the allow/deny decisions, the hash-chained
|
||||
//! audit trail with external anchors, the evidence ledger, the deterministic
|
||||
//! CVSS, the multi-model votes, the prosecutor's verdict, the PoCs and
|
||||
//! screenshots — are each written to their own file during a run. Scattered,
|
||||
//! they are hard to hand to a reviewer and impossible to prove complete.
|
||||
//!
|
||||
//! This assembles them into one manifest per run: every artifact, its SHA-256,
|
||||
//! whether it is present, and which of the five assurance properties (P1–P5)
|
||||
//! the run actually produced. Then it signs the manifest. The result is a
|
||||
//! single file a reviewer can verify independently:
|
||||
//!
|
||||
//! ```text
|
||||
//! P1 authorization capability token + effective scope + ALLOW/DENY log
|
||||
//! P2 enforcement scope decisions, out-of-scope quarantine
|
||||
//! P3 evidence evidence ledger, PoCs, screenshots, CVSS vectors
|
||||
//! P4 integrity audit chain + external anchors
|
||||
//! P5 attribution provenance manifest, structural signature
|
||||
//! └─────────────────────┬──────────────────────┘
|
||||
//! assurance.json (+ signature over all hashes)
|
||||
//! ```
|
||||
//!
|
||||
//! The honesty rule: a property is reported present only when its artifact is
|
||||
//! actually on disk and non-empty. A missing anchor file is reported as "P4:
|
||||
//! partial", never quietly omitted — the bundle's job is to say what a run can
|
||||
//! and cannot prove, not to flatter it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
/// One artifact in the run, with its hash.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
/// Path relative to the run directory.
|
||||
pub name: String,
|
||||
pub present: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
pub bytes: u64,
|
||||
/// What this artifact is for, in one phrase.
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Whether an assurance property was produced, and by what.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Property {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// present · partial · absent.
|
||||
pub status: String,
|
||||
pub evidenced_by: Vec<String>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// The whole bundle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bundle {
|
||||
pub engine: String,
|
||||
pub version: String,
|
||||
pub build: String,
|
||||
pub run: String,
|
||||
pub target: String,
|
||||
pub generated: u64,
|
||||
pub findings: usize,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
pub properties: Vec<Property>,
|
||||
/// SHA-256 over the sorted (name, sha256) pairs — one hash that changes if
|
||||
/// any artifact changes. The thing the signature actually covers.
|
||||
pub bundle_hash: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// The known artifacts a run can produce, and what each proves.
|
||||
const KNOWN: &[(&str, &str)] = &[
|
||||
("findings.json", "the findings, each stamped with the engine build (P5)"),
|
||||
("report.html", "the human report"),
|
||||
("recon.json", "reconnaissance facts"),
|
||||
("audit.jsonl", "hash-chained decision log — every ALLOW/DENY (P1/P2/P4)"),
|
||||
("audit.jsonl.anchors", "external anchors of the audit chain (P4)"),
|
||||
("provenance.json", "signed provenance manifest — build + structural signature (P5)"),
|
||||
("out-of-scope-findings.json", "findings quarantined for being outside scope (P2)"),
|
||||
("flows.jsonl", "intercepted request/response flows"),
|
||||
("meta.json", "target metadata"),
|
||||
];
|
||||
|
||||
fn hash_file(path: &Path) -> Option<(String, u64)> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let hash: String = Sha256::digest(&data).iter().map(|b| format!("{b:02x}")).collect();
|
||||
Some((hash, data.len() as u64))
|
||||
}
|
||||
|
||||
fn count_glob(dir: &Path, sub: &str) -> usize {
|
||||
std::fs::read_dir(dir.join(sub)).map(|rd| rd.filter_map(|e| e.ok()).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Assemble the bundle from a finished run directory.
|
||||
pub fn build(dir: &Path) -> Bundle {
|
||||
let run = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
|
||||
let prov = crate::provenance::Provenance::process();
|
||||
|
||||
let mut artifacts = Vec::new();
|
||||
for (name, role) in KNOWN {
|
||||
let path = dir.join(name);
|
||||
match hash_file(&path) {
|
||||
Some((sha, bytes)) if bytes > 0 => artifacts.push(Artifact { name: (*name).into(), present: true, sha256: Some(sha), bytes, role: (*role).into() }),
|
||||
_ => artifacts.push(Artifact { name: (*name).into(), present: false, sha256: None, bytes: 0, role: (*role).into() }),
|
||||
}
|
||||
}
|
||||
// Directories of many files: PoCs, screenshots, evidence.
|
||||
let pocs = count_glob(dir, "pocs");
|
||||
let shots = count_glob(dir, "screenshots").max(count_glob(dir, "shots"));
|
||||
let evidence = count_glob(dir, "evidence");
|
||||
|
||||
// Findings + CVSS/votes/prosecutor presence, read from findings.json.
|
||||
let findings: Vec<crate::types::Finding> = std::fs::read_to_string(dir.join("findings.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default();
|
||||
let with_cvss = findings.iter().filter(|f| !f.cvss.trim().is_empty()).count();
|
||||
let with_votes = findings.iter().filter(|f| !f.votes.trim().is_empty()).count();
|
||||
let with_evidence = findings.iter().filter(|f| f.evidence_data.is_some()).count();
|
||||
let has_capability = findings.iter().any(|_| false); // capability lives in the audit, checked below
|
||||
|
||||
let present = |name: &str| artifacts.iter().any(|a| a.name == name && a.present);
|
||||
let audit_has = |needle: &str| std::fs::read_to_string(dir.join("audit.jsonl")).map(|t| t.contains(needle)).unwrap_or(false);
|
||||
let _ = has_capability;
|
||||
|
||||
let mut properties = Vec::new();
|
||||
// P1 — authorization: a capability was verified and ALLOW/DENY recorded.
|
||||
{
|
||||
let cap = audit_has("capability_token") || audit_has("capability");
|
||||
let decisions = present("audit.jsonl");
|
||||
let (status, note) = match (cap, decisions) {
|
||||
(true, true) => ("present", "capability recorded and decisions logged"),
|
||||
(false, true) => ("partial", "decisions logged, but no capability token in the trail (local-config authorization)"),
|
||||
_ => ("absent", "no audit trail"),
|
||||
};
|
||||
properties.push(Property { id: "P1".into(), name: "Signed authorization".into(), status: status.into(), evidenced_by: vec!["audit.jsonl".into()], note: note.into() });
|
||||
}
|
||||
// P2 — enforcement: scope decisions, out-of-scope quarantine.
|
||||
{
|
||||
let denies = audit_has("deny") || audit_has("DENY") || audit_has("out-of-scope");
|
||||
let (status, note) = if present("audit.jsonl") {
|
||||
if denies { ("present", "scope decisions recorded, including denials/quarantine") }
|
||||
else { ("present", "scope decisions recorded (no denials this run)") }
|
||||
} else { ("absent", "no decision log") };
|
||||
let mut ev = vec!["audit.jsonl".into()];
|
||||
if present("out-of-scope-findings.json") { ev.push("out-of-scope-findings.json".into()); }
|
||||
properties.push(Property { id: "P2".into(), name: "Scope enforcement".into(), status: status.into(), evidenced_by: ev, note: note.into() });
|
||||
}
|
||||
// P3 — evidence: ledger, PoCs, screenshots, CVSS vectors.
|
||||
{
|
||||
let has = with_evidence > 0 || pocs > 0 || shots > 0 || with_cvss > 0;
|
||||
let status = if findings.is_empty() { "partial" } else if has { "present" } else { "partial" };
|
||||
let note = format!("{with_evidence}/{} findings carry structured evidence · {with_cvss} with CVSS · {with_votes} voted · {pocs} PoC(s) · {shots} screenshot(s) · {evidence} evidence file(s)", findings.len());
|
||||
properties.push(Property { id: "P3".into(), name: "Evidence & CVSS".into(), status: status.into(), evidenced_by: vec!["findings.json".into()], note });
|
||||
}
|
||||
// P4 — integrity: audit chain + external anchors.
|
||||
{
|
||||
let chain = present("audit.jsonl");
|
||||
let anchors = present("audit.jsonl.anchors");
|
||||
let (status, note) = match (chain, anchors) {
|
||||
(true, true) => ("present", "hash chain plus signed anchors (truncation/rebuild detectable)"),
|
||||
(true, false) => ("partial", "hash chain present but no anchors — a full rebuild would be silent"),
|
||||
_ => ("absent", "no audit chain"),
|
||||
};
|
||||
properties.push(Property { id: "P4".into(), name: "Audit integrity".into(), status: status.into(), evidenced_by: vec!["audit.jsonl".into(), "audit.jsonl.anchors".into()], note: note.into() });
|
||||
}
|
||||
// P5 — attribution: provenance manifest + structural signature.
|
||||
{
|
||||
let prov_file = present("provenance.json");
|
||||
let stamped = findings.iter().any(|f| serde_json::to_string(f).map(|s| s.contains("_engine")).unwrap_or(false)) || !findings.is_empty();
|
||||
let (status, note) = match (prov_file, stamped) {
|
||||
(true, _) => ("present", "signed provenance manifest with structural signature"),
|
||||
(false, true) => ("partial", "findings stamped but no provenance.json"),
|
||||
_ => ("absent", "no provenance"),
|
||||
};
|
||||
properties.push(Property { id: "P5".into(), name: "Provenance".into(), status: status.into(), evidenced_by: vec!["provenance.json".into()], note: note.into() });
|
||||
}
|
||||
|
||||
// Bundle hash: one value over every artifact's identity.
|
||||
let mut pairs: Vec<String> = artifacts.iter().filter(|a| a.present).map(|a| format!("{}={}", a.name, a.sha256.clone().unwrap_or_default())).collect();
|
||||
pairs.sort();
|
||||
let bundle_hash: String = Sha256::digest(pairs.join("\n").as_bytes()).iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
Bundle {
|
||||
engine: crate::provenance::ENGINE.into(),
|
||||
version: prov.version.clone(),
|
||||
build: prov.build.clone(),
|
||||
run,
|
||||
target: std::fs::read_to_string(dir.join("meta.json")).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).and_then(|v| v.get("target").and_then(|x| x.as_str()).map(|s| s.to_string())).unwrap_or_default(),
|
||||
generated: now(),
|
||||
findings: findings.len(),
|
||||
artifacts,
|
||||
properties,
|
||||
bundle_hash,
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign the bundle hash. Without a key it still ships — the hashes are the
|
||||
/// substance, the signature just proves who assembled them.
|
||||
pub fn sign(mut self, key: &[u8]) -> Bundle {
|
||||
self.signature = Some(hmac_hex(key, self.bundle_hash.as_bytes()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Verify a bundle against the run directory it describes: every present
|
||||
/// artifact still hashes the same, and the signature (if any) matches.
|
||||
pub fn verify(&self, dir: &Path, key: Option<&[u8]>) -> Result<(), String> {
|
||||
for a in self.artifacts.iter().filter(|a| a.present) {
|
||||
let (sha, _) = hash_file(&dir.join(&a.name)).ok_or_else(|| format!("artifact {} named in the bundle is missing", a.name))?;
|
||||
if Some(&sha) != a.sha256.as_ref() {
|
||||
return Err(format!("artifact {} was modified since the bundle was sealed", a.name));
|
||||
}
|
||||
}
|
||||
if let Some(k) = key {
|
||||
let sig = self.signature.as_ref().ok_or("bundle is unsigned")?;
|
||||
if hmac_hex(k, self.bundle_hash.as_bytes()) != *sig {
|
||||
return Err("bundle signature does not match this key".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-screen operator summary.
|
||||
pub fn summary(&self) -> String {
|
||||
let mut s = format!("assurance bundle for {} — {} artifact(s), {} finding(s)\n", self.run, self.artifacts.iter().filter(|a| a.present).count(), self.findings);
|
||||
for p in &self.properties {
|
||||
let mark = match p.status.as_str() { "present" => "✓", "partial" => "~", _ => "✗" };
|
||||
s.push_str(&format!(" {mark} {} {} — {}\n", p.id, p.name, p.note));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn hmac_hex(key: &[u8], data: &[u8]) -> String {
|
||||
const BLOCK: usize = 64;
|
||||
let mut k = [0u8; BLOCK];
|
||||
if key.len() > BLOCK {
|
||||
let d = Sha256::digest(key);
|
||||
k[..32].copy_from_slice(&d);
|
||||
} else {
|
||||
k[..key.len()].copy_from_slice(key);
|
||||
}
|
||||
let mut ipad = [0x36u8; BLOCK];
|
||||
let mut opad = [0x5cu8; BLOCK];
|
||||
for i in 0..BLOCK {
|
||||
ipad[i] ^= k[i];
|
||||
opad[i] ^= k[i];
|
||||
}
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(ipad);
|
||||
inner.update(data);
|
||||
let inner = inner.finalize();
|
||||
let mut outer = Sha256::new();
|
||||
outer.update(opad);
|
||||
outer.update(inner);
|
||||
outer.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run_dir() -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
// Uniqueness independent of clock resolution — two tests in the same
|
||||
// second must not share a directory.
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("ns-assur-{}-{}-{}", std::process::id(), now(), n));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_run_reports_all_five_properties_present() {
|
||||
let dir = run_dir();
|
||||
std::fs::write(dir.join("meta.json"), r#"{"target":"https://t.test"}"#).unwrap();
|
||||
std::fs::write(dir.join("findings.json"), r#"[{"id":"f1","title":"XSS","severity":"high","cvss":"6.1 (CVSS:3.1/AV:N/...)","votes":"3/3","_engine":"abc"}]"#).unwrap();
|
||||
std::fs::write(dir.join("audit.jsonl"), "{\"capability_token\":\"ns-cap...\",\"policy_decision\":\"allow\"}\n{\"policy_decision\":\"deny\"}\n").unwrap();
|
||||
std::fs::write(dir.join("audit.jsonl.anchors"), "{\"count\":2,\"chain_hash\":\"x\"}\n").unwrap();
|
||||
std::fs::write(dir.join("provenance.json"), r#"{"build":"abc"}"#).unwrap();
|
||||
|
||||
let bundle = Bundle::build(&dir);
|
||||
let status = |id: &str| bundle.properties.iter().find(|p| p.id == id).unwrap().status.clone();
|
||||
assert_eq!(status("P1"), "present");
|
||||
assert_eq!(status("P2"), "present");
|
||||
assert_eq!(status("P4"), "present");
|
||||
assert_eq!(status("P5"), "present");
|
||||
assert!(!bundle.bundle_hash.is_empty());
|
||||
|
||||
// Sign + verify round-trips; tampering is caught.
|
||||
let signed = bundle.sign(b"k");
|
||||
assert!(signed.verify(&dir, Some(b"k")).is_ok());
|
||||
assert!(signed.verify(&dir, Some(b"wrong")).is_err());
|
||||
std::fs::write(dir.join("findings.json"), "[]").unwrap();
|
||||
assert!(signed.verify(&dir, Some(b"k")).is_err(), "a changed artifact must break verification");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_anchor_is_reported_partial_not_omitted() {
|
||||
let dir = run_dir();
|
||||
std::fs::write(dir.join("audit.jsonl"), "{\"policy_decision\":\"allow\"}\n").unwrap();
|
||||
std::fs::write(dir.join("findings.json"), "[]").unwrap();
|
||||
let bundle = Bundle::build(&dir);
|
||||
let p4 = bundle.properties.iter().find(|p| p.id == "P4").unwrap();
|
||||
assert_eq!(p4.status, "partial", "no anchors = partial, never silently present");
|
||||
assert!(p4.note.contains("rebuild would be silent"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundle_hash_changes_when_an_artifact_changes() {
|
||||
let dir = run_dir();
|
||||
std::fs::write(dir.join("findings.json"), "[]").unwrap();
|
||||
let h1 = Bundle::build(&dir).bundle_hash;
|
||||
std::fs::write(dir.join("findings.json"), r#"[{"id":"x"}]"#).unwrap();
|
||||
let h2 = Bundle::build(&dir).bundle_hash;
|
||||
assert_ne!(h1, h2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,163 @@ impl AuditLog {
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Sign the current chain head and record an anchor.
|
||||
///
|
||||
/// This is P4: a hash chain proves a record was not altered *relative to its
|
||||
/// neighbours*, but says nothing against someone who rebuilds the whole file
|
||||
/// consistently, or truncates its tail. An anchor is a signed statement —
|
||||
/// "at phase X the chain had N records ending in hash H" — written to a
|
||||
/// separate file and, when `NEUROSPLOIT_ANCHOR_DIR` is set, to external
|
||||
/// (ideally WORM/Object-Lock) storage. A later truncation or silent rebuild
|
||||
/// then contradicts an anchor the attacker cannot forge without the key.
|
||||
pub fn checkpoint(&self, key: &[u8], phase: &str) -> Anchor {
|
||||
let records = self.read_all();
|
||||
let count = records.len() as u64;
|
||||
let head = records.last().map(|r| r.hash.clone()).unwrap_or_default();
|
||||
let ts = now();
|
||||
let body = format!("{count}|{head}|{phase}|{ts}");
|
||||
let anchor = Anchor {
|
||||
phase: phase.to_string(),
|
||||
count,
|
||||
chain_hash: head,
|
||||
at: ts,
|
||||
signature: hmac_hex(key, body.as_bytes()),
|
||||
};
|
||||
if let Ok(line) = serde_json::to_string(&anchor) {
|
||||
use std::io::Write;
|
||||
let ap = self.anchors_path();
|
||||
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&ap) {
|
||||
let _ = writeln!(fh, "{line}");
|
||||
}
|
||||
// External copy: append-only, so a local tamper cannot also rewrite
|
||||
// the off-box record. WORM/Object-Lock is the operator's to enforce
|
||||
// on that directory; we just write there.
|
||||
if let Ok(dir) = std::env::var("NEUROSPLOIT_ANCHOR_DIR") {
|
||||
if !dir.trim().is_empty() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let name = self.path.file_stem().and_then(|s| s.to_str()).unwrap_or("audit");
|
||||
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(std::path::Path::new(&dir).join(format!("{name}.anchors.jsonl"))) {
|
||||
let _ = writeln!(fh, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anchor
|
||||
}
|
||||
|
||||
fn anchors_path(&self) -> PathBuf {
|
||||
let mut p = self.path.clone();
|
||||
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("audit.jsonl").to_string();
|
||||
p.set_file_name(format!("{name}.anchors"));
|
||||
p
|
||||
}
|
||||
|
||||
/// Read the anchors recorded for this trail (local file).
|
||||
pub fn anchors(&self) -> Vec<Anchor> {
|
||||
std::fs::read_to_string(self.anchors_path())
|
||||
.map(|t| t.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Verify the chain AND every anchor against it.
|
||||
///
|
||||
/// Catches the two attacks a bare chain misses: **truncation** (the chain is
|
||||
/// now shorter than an anchor's `count`, so the tail was removed) and a
|
||||
/// **silent rebuild** (an anchor's `chain_hash` no longer matches the record
|
||||
/// at that position). With `key`, anchor signatures are verified too, so a
|
||||
/// forged anchor is caught as well.
|
||||
pub fn verify_anchored(&self, key: Option<&[u8]>) -> Result<AnchorReport, String> {
|
||||
let n = self.verify()?; // chain integrity first
|
||||
let records = self.read_all();
|
||||
let anchors = self.anchors();
|
||||
let mut checked = 0usize;
|
||||
for a in &anchors {
|
||||
if let Some(k) = key {
|
||||
let body = format!("{}|{}|{}|{}", a.count, a.chain_hash, a.phase, a.at);
|
||||
if !constant_time_eq(hmac_hex(k, body.as_bytes()).as_bytes(), a.signature.as_bytes()) {
|
||||
return Err(format!("anchor for phase '{}' (#{} records) has an invalid signature", a.phase, a.count));
|
||||
}
|
||||
}
|
||||
if (records.len() as u64) < a.count {
|
||||
return Err(format!(
|
||||
"TRUNCATION: an anchor attests {} record(s) but the chain now has {} — the tail was removed",
|
||||
a.count, records.len()
|
||||
));
|
||||
}
|
||||
let at_pos = records.get(a.count.saturating_sub(1) as usize).map(|r| r.hash.clone()).unwrap_or_default();
|
||||
if a.count > 0 && at_pos != a.chain_hash {
|
||||
return Err(format!(
|
||||
"REBUILD: the chain hash at record #{} does not match its anchor — the log was rewritten",
|
||||
a.count
|
||||
));
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
Ok(AnchorReport { records: n, anchors: checked, signed: key.is_some() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed statement about the chain at a moment in time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
/// Which phase produced it (a checkpoint per phase, plus one at the end).
|
||||
pub phase: String,
|
||||
/// How many records the chain had.
|
||||
pub count: u64,
|
||||
/// The hash of the last record — the chain head.
|
||||
pub chain_hash: String,
|
||||
/// Unix seconds.
|
||||
pub at: u64,
|
||||
/// HMAC over `count|chain_hash|phase|at`.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// The result of an anchored verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnchorReport {
|
||||
pub records: usize,
|
||||
pub anchors: usize,
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn hmac_hex(key: &[u8], data: &[u8]) -> String {
|
||||
const BLOCK: usize = 64;
|
||||
let mut k = [0u8; BLOCK];
|
||||
if key.len() > BLOCK {
|
||||
let d = Sha256::digest(key);
|
||||
k[..32].copy_from_slice(&d);
|
||||
} else {
|
||||
k[..key.len()].copy_from_slice(key);
|
||||
}
|
||||
let mut ipad = [0x36u8; BLOCK];
|
||||
let mut opad = [0x5cu8; BLOCK];
|
||||
for i in 0..BLOCK {
|
||||
ipad[i] ^= k[i];
|
||||
opad[i] ^= k[i];
|
||||
}
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(ipad);
|
||||
inner.update(data);
|
||||
let inner = inner.finalize();
|
||||
let mut outer = Sha256::new();
|
||||
outer.update(opad);
|
||||
outer.update(inner);
|
||||
outer.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// Why a run was killed. Each variant is a condition that either occurred or
|
||||
@@ -573,4 +730,54 @@ mod tests {
|
||||
assert!((2020..2100).contains(&year), "{ts}");
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchoring_detects_truncation_and_rebuild() {
|
||||
let dir = std::env::temp_dir().join(format!("ns-audit-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("audit.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(dir.join("audit.jsonl.anchors"));
|
||||
let key = b"anchor-key";
|
||||
|
||||
let log = AuditLog::open(&path);
|
||||
for i in 0..5 {
|
||||
log.append(AuditRecord::new("a", "act", &format!("t{i}")));
|
||||
}
|
||||
let a = log.checkpoint(key, "phase-1");
|
||||
assert_eq!(a.count, 5);
|
||||
|
||||
// Clean state verifies, signature checked.
|
||||
let rep = log.verify_anchored(Some(key)).expect("clean");
|
||||
assert_eq!(rep.records, 5);
|
||||
assert_eq!(rep.anchors, 1);
|
||||
assert!(rep.signed);
|
||||
|
||||
// Truncate the tail: remove the last two records from the file.
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let kept: Vec<&str> = text.lines().take(3).collect();
|
||||
std::fs::write(&path, kept.join("\n") + "\n").unwrap();
|
||||
let reopened = AuditLog::open(&path);
|
||||
let err = reopened.verify_anchored(Some(key)).unwrap_err();
|
||||
assert!(err.contains("TRUNCATION"), "got: {err}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forged_anchor_is_caught_by_the_signature() {
|
||||
let dir = std::env::temp_dir().join(format!("ns-audit-forge-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("audit.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(dir.join("audit.jsonl.anchors"));
|
||||
let log = AuditLog::open(&path);
|
||||
log.append(AuditRecord::new("a", "act", "t"));
|
||||
log.checkpoint(b"real-key", "p");
|
||||
// Verifying under a different key rejects the anchor.
|
||||
let err = log.verify_anchored(Some(b"wrong-key")).unwrap_err();
|
||||
assert!(err.contains("invalid signature"), "got: {err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! CVSS — computed from evidence, not guessed by a model.
|
||||
//!
|
||||
//! The number on a finding decides whether someone is paged at 2am, so it has
|
||||
//! to be defensible. Two failures make it not:
|
||||
//!
|
||||
//! 1. **A model picks the score.** Ask an LLM for "the CVSS" and it pattern-
|
||||
//! matches SQLi→9.8 whether or not anything was extracted. The number then
|
||||
//! reflects the class, not the engagement.
|
||||
//! 2. **The metrics have no receipts.** `C:H` (high confidentiality impact)
|
||||
//! means data was read. If nothing shows data being read, `C:H` is a claim,
|
||||
//! not a measurement.
|
||||
//!
|
||||
//! So the split here is deliberate:
|
||||
//!
|
||||
//! ```text
|
||||
//! LLM/agent → proposes metrics, each pointing at an evidence id
|
||||
//! this module → (a) recomputes the score with the FIRST v3.1 equation,
|
||||
//! verbatim — deterministic, no model in the loop
|
||||
//! (b) refuses any metric that raises severity without a
|
||||
//! receipt, dropping it to the demonstrated floor
|
||||
//! (c) keeps TWO vectors: demonstrated (what evidence proves)
|
||||
//! and potential (what the class could reach)
|
||||
//! ```
|
||||
//!
|
||||
//! The base-score arithmetic is the official CVSS v3.1 specification,
|
||||
//! reproduced exactly and checked against FIRST's own reference vectors in the
|
||||
//! tests — a score that disagrees with the calculator on first.org is a bug
|
||||
//! here, by construction.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Attack Vector.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Av { Network, Adjacent, Local, Physical }
|
||||
/// Attack Complexity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Ac { Low, High }
|
||||
/// Privileges Required.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Pr { None, Low, High }
|
||||
/// User Interaction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Ui { None, Required }
|
||||
/// Scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Scope { Unchanged, Changed }
|
||||
/// Confidentiality / Integrity / Availability impact.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Imp { None, Low, High }
|
||||
|
||||
impl Av {
|
||||
fn score(self) -> f64 { match self { Av::Network => 0.85, Av::Adjacent => 0.62, Av::Local => 0.55, Av::Physical => 0.2 } }
|
||||
fn code(self) -> &'static str { match self { Av::Network => "N", Av::Adjacent => "A", Av::Local => "L", Av::Physical => "P" } }
|
||||
fn parse(c: &str) -> Option<Av> { Some(match c { "N" => Av::Network, "A" => Av::Adjacent, "L" => Av::Local, "P" => Av::Physical, _ => return None }) }
|
||||
}
|
||||
impl Ac {
|
||||
fn score(self) -> f64 { match self { Ac::Low => 0.77, Ac::High => 0.44 } }
|
||||
fn code(self) -> &'static str { match self { Ac::Low => "L", Ac::High => "H" } }
|
||||
fn parse(c: &str) -> Option<Ac> { Some(match c { "L" => Ac::Low, "H" => Ac::High, _ => return None }) }
|
||||
}
|
||||
impl Pr {
|
||||
/// PR is scope-dependent: a changed scope makes low/high privileges worth
|
||||
/// more to an attacker, so the coefficients differ.
|
||||
fn score(self, scope: Scope) -> f64 {
|
||||
match (self, scope) {
|
||||
(Pr::None, _) => 0.85,
|
||||
(Pr::Low, Scope::Unchanged) => 0.62,
|
||||
(Pr::Low, Scope::Changed) => 0.68,
|
||||
(Pr::High, Scope::Unchanged) => 0.27,
|
||||
(Pr::High, Scope::Changed) => 0.5,
|
||||
}
|
||||
}
|
||||
fn code(self) -> &'static str { match self { Pr::None => "N", Pr::Low => "L", Pr::High => "H" } }
|
||||
fn parse(c: &str) -> Option<Pr> { Some(match c { "N" => Pr::None, "L" => Pr::Low, "H" => Pr::High, _ => return None }) }
|
||||
}
|
||||
impl Ui {
|
||||
fn score(self) -> f64 { match self { Ui::None => 0.85, Ui::Required => 0.62 } }
|
||||
fn code(self) -> &'static str { match self { Ui::None => "N", Ui::Required => "R" } }
|
||||
fn parse(c: &str) -> Option<Ui> { Some(match c { "N" => Ui::None, "R" => Ui::Required, _ => return None }) }
|
||||
}
|
||||
impl Scope {
|
||||
fn code(self) -> &'static str { match self { Scope::Unchanged => "U", Scope::Changed => "C" } }
|
||||
fn parse(c: &str) -> Option<Scope> { Some(match c { "U" => Scope::Unchanged, "C" => Scope::Changed, _ => return None }) }
|
||||
}
|
||||
impl Imp {
|
||||
fn score(self) -> f64 { match self { Imp::None => 0.0, Imp::Low => 0.22, Imp::High => 0.56 } }
|
||||
fn code(self) -> &'static str { match self { Imp::None => "N", Imp::Low => "L", Imp::High => "H" } }
|
||||
fn parse(c: &str) -> Option<Imp> { Some(match c { "N" => Imp::None, "L" => Imp::Low, "H" => Imp::High, _ => return None }) }
|
||||
}
|
||||
|
||||
/// A full CVSS v3.1 base vector.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Vector {
|
||||
pub av: Av,
|
||||
pub ac: Ac,
|
||||
pub pr: Pr,
|
||||
pub ui: Ui,
|
||||
pub scope: Scope,
|
||||
pub c: Imp,
|
||||
pub i: Imp,
|
||||
pub a: Imp,
|
||||
}
|
||||
|
||||
impl Vector {
|
||||
/// The base score, computed by the FIRST v3.1 equation. Deterministic.
|
||||
pub fn base_score(&self) -> f64 {
|
||||
// Impact Sub-Score.
|
||||
let iss = 1.0 - ((1.0 - self.c.score()) * (1.0 - self.i.score()) * (1.0 - self.a.score()));
|
||||
let impact = match self.scope {
|
||||
Scope::Unchanged => 6.42 * iss,
|
||||
Scope::Changed => 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15),
|
||||
};
|
||||
if impact <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let exploitability = 8.22 * self.av.score() * self.ac.score() * self.pr.score(self.scope) * self.ui.score();
|
||||
let raw = match self.scope {
|
||||
Scope::Unchanged => (impact + exploitability).min(10.0),
|
||||
Scope::Changed => (1.08 * (impact + exploitability)).min(10.0),
|
||||
};
|
||||
roundup(raw)
|
||||
}
|
||||
|
||||
/// Severity band for the score, per the FIRST qualitative scale.
|
||||
pub fn severity(&self) -> &'static str {
|
||||
band(self.base_score())
|
||||
}
|
||||
|
||||
/// The canonical `CVSS:3.1/AV:…/…` string.
|
||||
pub fn vector_string(&self) -> String {
|
||||
format!(
|
||||
"CVSS:3.1/AV:{}/AC:{}/PR:{}/UI:{}/S:{}/C:{}/I:{}/A:{}",
|
||||
self.av.code(), self.ac.code(), self.pr.code(), self.ui.code(),
|
||||
self.scope.code(), self.c.code(), self.i.code(), self.a.code()
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a `CVSS:3.1/…` vector string. Order-independent; unknown or missing
|
||||
/// metrics fail rather than default silently — a half-parsed vector would
|
||||
/// score wrong.
|
||||
pub fn parse(s: &str) -> Option<Vector> {
|
||||
let mut av = None; let mut ac = None; let mut pr = None; let mut ui = None;
|
||||
let mut scope = None; let mut c = None; let mut i = None; let mut a = None;
|
||||
for part in s.trim().split('/') {
|
||||
let (k, v) = part.split_once(':')?;
|
||||
match k.to_uppercase().as_str() {
|
||||
"CVSS" => { if !v.starts_with("3.") { return None; } }
|
||||
"AV" => av = Av::parse(v),
|
||||
"AC" => ac = Ac::parse(v),
|
||||
"PR" => pr = Pr::parse(v),
|
||||
"UI" => ui = Ui::parse(v),
|
||||
"S" => scope = Scope::parse(v),
|
||||
"C" => c = Imp::parse(v),
|
||||
"I" => i = Imp::parse(v),
|
||||
"A" => a = Imp::parse(v),
|
||||
_ => {} // temporal/environmental metrics ignored for the base
|
||||
}
|
||||
}
|
||||
Some(Vector { av: av?, ac: ac?, pr: pr?, ui: ui?, scope: scope?, c: c?, i: i?, a: a? })
|
||||
}
|
||||
}
|
||||
|
||||
/// CVSS v3.1 roundup: the smallest number to one decimal place that is >= input.
|
||||
fn roundup(input: f64) -> f64 {
|
||||
let int_input = (input * 100_000.0).round() as i64;
|
||||
if int_input % 10_000 == 0 {
|
||||
int_input as f64 / 100_000.0
|
||||
} else {
|
||||
((int_input as f64 / 10_000.0).floor() + 1.0) / 10.0
|
||||
}
|
||||
}
|
||||
|
||||
/// FIRST qualitative severity bands.
|
||||
pub fn band(score: f64) -> &'static str {
|
||||
match score {
|
||||
s if s == 0.0 => "None",
|
||||
s if s < 4.0 => "Low",
|
||||
s if s < 7.0 => "Medium",
|
||||
s if s < 9.0 => "High",
|
||||
_ => "Critical",
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Evidence-graded scoring
|
||||
// ===========================================================================
|
||||
|
||||
/// One metric value, and the receipt behind it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricClaim {
|
||||
/// Metric name (`C`, `I`, `A`, `S`, …).
|
||||
pub metric: String,
|
||||
/// The proposed value (`H`, `L`, `N`, `C`, `U`, …).
|
||||
pub value: String,
|
||||
/// Evidence id that supports it, if any. A raising value with no receipt is
|
||||
/// what gets refused.
|
||||
#[serde(default)]
|
||||
pub evidence_id: Option<String>,
|
||||
/// Why this value — recorded so the score is auditable metric-by-metric.
|
||||
#[serde(default)]
|
||||
pub justification: String,
|
||||
}
|
||||
|
||||
/// The evidence-graded result for a finding: two scores and what was uncertain.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Graded {
|
||||
/// What the evidence actually demonstrates. This is the reported score.
|
||||
pub demonstrated: Vector,
|
||||
pub demonstrated_score: f64,
|
||||
pub demonstrated_severity: String,
|
||||
/// What the class could reach if fully exploited — context, not the number.
|
||||
pub potential: Vector,
|
||||
pub potential_score: f64,
|
||||
pub potential_severity: String,
|
||||
/// Metrics whose raising value had no receipt and were dropped to the
|
||||
/// demonstrated floor. Non-empty means the finding needs human review of
|
||||
/// the score, not that it is wrong.
|
||||
pub uncertain: Vec<String>,
|
||||
}
|
||||
|
||||
impl Graded {
|
||||
/// Does the score need a human's eye?
|
||||
pub fn needs_review(&self) -> bool {
|
||||
!self.uncertain.is_empty()
|
||||
}
|
||||
pub fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"{:.1} {} demonstrated ({})",
|
||||
self.demonstrated_score, self.demonstrated_severity, self.demonstrated.vector_string()
|
||||
);
|
||||
if (self.potential_score - self.demonstrated_score).abs() > 0.05 {
|
||||
s.push_str(&format!(" · potential {:.1} {}", self.potential_score, self.potential_severity));
|
||||
}
|
||||
if !self.uncertain.is_empty() {
|
||||
s.push_str(&format!(" · unproven metric(s) dropped: {}", self.uncertain.join(", ")));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Grade a proposed vector against the evidence behind each impact metric.
|
||||
///
|
||||
/// `has_evidence(metric)` answers "is there a receipt that this metric's value
|
||||
/// is real?" — the caller wires it to the finding's evidence. Impact metrics
|
||||
/// (C/I/A) that claim `High` or `Low` without a receipt are dropped to `None`
|
||||
/// in the *demonstrated* vector, while the *potential* vector keeps them. The
|
||||
/// gap between the two is exactly "what we could show" versus "what this class
|
||||
/// can do", which is the distinction a scanner that prints one number loses.
|
||||
pub fn grade<F>(proposed: Vector, has_evidence: F) -> Graded
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let mut demonstrated = proposed;
|
||||
let mut uncertain = Vec::new();
|
||||
for (name, value) in [("C", proposed.c), ("I", proposed.i), ("A", proposed.a)] {
|
||||
if value != Imp::None && !has_evidence(name) {
|
||||
uncertain.push(format!("{name}:{}", value.code()));
|
||||
match name {
|
||||
"C" => demonstrated.c = Imp::None,
|
||||
"I" => demonstrated.i = Imp::None,
|
||||
"A" => demonstrated.a = Imp::None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = demonstrated.base_score();
|
||||
let p = proposed.base_score();
|
||||
Graded {
|
||||
demonstrated,
|
||||
demonstrated_score: d,
|
||||
demonstrated_severity: band(d).into(),
|
||||
potential: proposed,
|
||||
potential_score: p,
|
||||
potential_severity: band(p).into(),
|
||||
uncertain,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(s: &str) -> Vector { Vector::parse(s).unwrap_or_else(|| panic!("parse {s}")) }
|
||||
|
||||
#[test]
|
||||
fn base_scores_match_the_first_reference_vectors() {
|
||||
// These are the canonical scores from first.org's own calculator.
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").base_score(), 9.8);
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N").base_score(), 6.1); // reflected XSS
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N").base_score(), 3.1);
|
||||
assert_eq!(v("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H").base_score(), 7.8); // local privesc
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H").base_score(), 7.5); // DoS
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H").base_score(), 10.0); // scope-changed RCE
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N").base_score(), 5.3); // info leak
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_impact_is_zero() {
|
||||
assert_eq!(v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N").base_score(), 0.0);
|
||||
assert_eq!(band(0.0), "None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_string_roundtrips() {
|
||||
let s = "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N";
|
||||
assert_eq!(v(s).vector_string(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_follow_the_first_scale() {
|
||||
assert_eq!(band(3.9), "Low");
|
||||
assert_eq!(band(4.0), "Medium");
|
||||
assert_eq!(band(6.9), "Medium");
|
||||
assert_eq!(band(7.0), "High");
|
||||
assert_eq!(band(8.9), "High");
|
||||
assert_eq!(band(9.0), "Critical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_vector_refuses_to_parse() {
|
||||
// Missing A: — better to fail than to score a guess.
|
||||
assert!(Vector::parse("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H").is_none());
|
||||
assert!(Vector::parse("nonsense").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grading_drops_impact_without_a_receipt() {
|
||||
// SQLi proposed as C:H/I:H (full read+write) but only the read (C) has
|
||||
// a receipt. The demonstrated score keeps C, drops I.
|
||||
let proposed = v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N");
|
||||
let g = grade(proposed, |m| m == "C"); // only C has evidence
|
||||
assert!(g.needs_review());
|
||||
assert!(g.uncertain.contains(&"I:H".to_string()));
|
||||
assert_eq!(g.demonstrated.i, Imp::None);
|
||||
assert_eq!(g.demonstrated.c, Imp::High);
|
||||
// Demonstrated is lower than potential — the gap is the unproven write.
|
||||
assert!(g.demonstrated_score < g.potential_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grading_with_full_evidence_keeps_the_score() {
|
||||
let proposed = v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
let g = grade(proposed, |_| true);
|
||||
assert!(!g.needs_review());
|
||||
assert_eq!(g.demonstrated_score, 9.8);
|
||||
assert_eq!(g.demonstrated_score, g.potential_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqli_without_any_extraction_is_not_critical() {
|
||||
// The article's exact example: SQLi proven (injection works) but
|
||||
// nothing extracted → no impact receipt → demonstrated impact is None,
|
||||
// so the number is NOT 9.8.
|
||||
let proposed = v("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
let g = grade(proposed, |_| false); // no impact receipts at all
|
||||
assert_eq!(g.demonstrated.c, Imp::None);
|
||||
assert_eq!(g.demonstrated_score, 0.0, "class alone must not manufacture a critical");
|
||||
assert_eq!(g.potential_score, 9.8, "the potential is still recorded as context");
|
||||
assert_eq!(g.uncertain.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! **N-model voting** before scoring and reporting.
|
||||
|
||||
pub mod agents;
|
||||
pub mod assurance;
|
||||
pub mod attack_graph;
|
||||
pub mod audit;
|
||||
pub mod belief;
|
||||
@@ -17,6 +18,7 @@ pub mod chain;
|
||||
pub mod claims;
|
||||
pub mod compliance;
|
||||
pub mod creds;
|
||||
pub mod cvss;
|
||||
pub mod grounding;
|
||||
pub mod hygiene;
|
||||
pub mod inbox;
|
||||
|
||||
@@ -20,6 +20,11 @@ pub struct RunOutput {
|
||||
pub workdir: String,
|
||||
/// Paths to persisted artifacts (recon/exploit/findings/report), if any.
|
||||
pub artifacts: Vec<String>,
|
||||
/// Set when the run was refused before it started — a scope/authorization
|
||||
/// denial the caller must surface with a non-zero exit, not a clean "0
|
||||
/// findings". Carries the machine-readable reason code.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denied: Option<String>,
|
||||
}
|
||||
|
||||
/// A run that stopped before it started.
|
||||
@@ -29,6 +34,13 @@ pub struct RunOutput {
|
||||
/// it the same way it reports any other run, and an empty findings list is the
|
||||
/// honest answer to "what did you find" when nothing was ever tested.
|
||||
fn aborted(cfg: &RunConfig) -> RunOutput {
|
||||
aborted_with(cfg, None)
|
||||
}
|
||||
|
||||
/// As [`aborted`], but records a machine-readable denial code so the CLI can
|
||||
/// exit non-zero and the reason is auditable — a refused engagement is a
|
||||
/// result the operator must be able to prove, not a silent no-op.
|
||||
fn aborted_with(cfg: &RunConfig, denied: Option<String>) -> RunOutput {
|
||||
RunOutput {
|
||||
target: cfg.target.clone(),
|
||||
findings: vec![],
|
||||
@@ -37,6 +49,7 @@ fn aborted(cfg: &RunConfig) -> RunOutput {
|
||||
recon: String::new(),
|
||||
workdir: cfg.workdir.clone().unwrap_or_default(),
|
||||
artifacts: vec![],
|
||||
denied,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,6 +663,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
candidates: 0,
|
||||
recon: String::new(),
|
||||
artifacts: vec![],
|
||||
denied: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -675,6 +689,26 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let prov = crate::provenance::Provenance::bind_run(&run_id(&cfg));
|
||||
let _ = tx.send(format!("notify: 🧬 provenance {} (build {})", prov.tag(), prov.build)).await;
|
||||
|
||||
// P1 — target authorization gate, default-deny, BEFORE any reconnaissance.
|
||||
// A capability token that does not cover the target is the exact bypass
|
||||
// this closes: the run refuses the target instead of quietly seeding it
|
||||
// into scope. Legacy (no token) still authorizes a bare target.
|
||||
{
|
||||
let has_grant = matches!(verify_capability(&cfg), Ok(Some(_)));
|
||||
let escope = effective_scope(&cfg);
|
||||
if let Err(reason) = escope.validate_target(&cfg.target, has_grant) {
|
||||
let _ = tx.send(format!("notify: ⛔ DENY_TARGET_OUTSIDE_GRANT — {reason}")).await;
|
||||
let audit = audit_log(&cfg);
|
||||
audit.append(
|
||||
crate::audit::AuditRecord::new("scope-guard", "deny-target-outside-grant", &cfg.target)
|
||||
.decision(&format!("DENY_TARGET_OUTSIDE_GRANT: {reason}"))
|
||||
.tool("scope-guard")
|
||||
.result("run refused before reconnaissance"),
|
||||
);
|
||||
return aborted_with(&cfg, Some(format!("DENY_TARGET_OUTSIDE_GRANT: {reason}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Egress, fail-closed. An internal target with the VPN down belongs to
|
||||
// whatever network this host is on — not the client's — so the run stops
|
||||
// here rather than producing a confident report about the wrong machines.
|
||||
@@ -822,7 +856,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
.unwrap_or_else(|| "no HTTP response".into());
|
||||
let _ = tx.send(format!("✗ target unreachable — {} is DOWN ({why}). Aborting; check the URL/port or that the service is up.", cfg.target)).await;
|
||||
let artifacts = persist(&cfg, "{}", "", &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: vec![], candidates: 0, recon: String::new(), artifacts };
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: vec![], candidates: 0, recon: String::new(), artifacts, denied: None };
|
||||
}
|
||||
// Identify the ASSET (product + stack), not just the URL, for the report.
|
||||
let asset = identify_asset(&p);
|
||||
@@ -855,7 +889,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let _ = tx.send(n).await;
|
||||
}
|
||||
let artifacts = persist(&cfg, &recon, "", &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts, denied: None };
|
||||
}
|
||||
|
||||
// Use the model to pick the agents whose preconditions match the recon —
|
||||
@@ -1071,7 +1105,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
|
||||
|
||||
if cfg.offline || bytes == 0 {
|
||||
let artifacts = persist(&cfg, "{}", &context, &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts, denied: None };
|
||||
}
|
||||
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
@@ -1190,7 +1224,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
||||
let _ = tx.send(format!("offline: selected {} agent(s); no live exploitation", selected.len())).await;
|
||||
let artifacts = persist(&cfg, &recon, &code_leads, &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![],
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts, denied: None };
|
||||
}
|
||||
|
||||
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
|
||||
@@ -2173,6 +2207,17 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
|
||||
.capability(&cap_id)
|
||||
.result(&format!("{} finding(s) reported", findings.len())),
|
||||
);
|
||||
// P4 — anchor the chain: a signed checkpoint of the head, written locally
|
||||
// and (if NEUROSPLOIT_ANCHOR_DIR is set) to external append-only storage.
|
||||
// This is what makes a later truncation or silent rebuild detectable.
|
||||
let anchor_key = provenance_key().unwrap_or_else(|| crate::provenance::Provenance::build_fingerprint().into_bytes());
|
||||
let anchor = audit.checkpoint(&anchor_key, "engagement-end");
|
||||
let _ = tx.send(format!(
|
||||
"notify: ⚓ audit anchored — {} record(s), head {}{}",
|
||||
anchor.count,
|
||||
anchor.chain_hash.chars().take(12).collect::<String>(),
|
||||
if provenance_key().is_some() { " (signed)" } else { " (unsigned — set NEUROSPLOIT_PROVENANCE_KEY)" }
|
||||
)).await;
|
||||
match audit.verify() {
|
||||
Ok(n) => {
|
||||
let _ = tx.send(format!("audit trail: {n} record(s), hash chain intact → audit.jsonl")).await;
|
||||
@@ -2213,6 +2258,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
|
||||
recon,
|
||||
artifacts,
|
||||
denied: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2366,6 +2412,15 @@ fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding])
|
||||
None => manifest,
|
||||
};
|
||||
put("provenance.json", serde_json::to_string_pretty(&manifest).unwrap_or_default());
|
||||
// P5 — the assurance bundle: one manifest of every artifact + hashes,
|
||||
// signed, so a reviewer can verify the whole run independently. Built last
|
||||
// so it hashes the files just written above.
|
||||
let bundle = crate::assurance::Bundle::build(&dir);
|
||||
let bundle = match provenance_key() {
|
||||
Some(k) => bundle.sign(&k),
|
||||
None => bundle,
|
||||
};
|
||||
put("assurance.json", serde_json::to_string_pretty(&bundle).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.
|
||||
@@ -2905,7 +2960,7 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
|
||||
let _ = tx.send(format!("offline: selected {} infra agent(s); no live testing", selected.len())).await;
|
||||
let artifacts = persist(&cfg, &recon, "", &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![],
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts, denied: None };
|
||||
}
|
||||
|
||||
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
|
||||
|
||||
@@ -453,6 +453,57 @@ impl ScopePolicy {
|
||||
p
|
||||
}
|
||||
|
||||
/// Validate a run target against the boundary, before any reconnaissance.
|
||||
///
|
||||
/// This is the P1 gate: default-deny at the front door. It checks the
|
||||
/// protocol, the host (or resolved IP), the port and the URL prefix — every
|
||||
/// axis on which a target can slip past a scope that only compared the
|
||||
/// hostname string. Returns the reason on refusal so the caller can log
|
||||
/// `DENY_TARGET_OUTSIDE_GRANT` and stop with a non-zero exit.
|
||||
///
|
||||
/// `require_explicit` is the difference between "the operator ran a bare
|
||||
/// target with no grant" (legacy: allowed) and "a capability token is in
|
||||
/// force" (the target MUST be inside it — a token that does not cover the
|
||||
/// target is the exact bypass this closes).
|
||||
pub fn validate_target(&self, target: &str, require_explicit: bool) -> Result<(), String> {
|
||||
let url = if target.contains("://") { target.to_string() } else { format!("https://{target}") };
|
||||
|
||||
// Protocol: only http(s) is a web target. A javascript:, file: or
|
||||
// gopher: "target" is never authorized by a web scope.
|
||||
let scheme = url.split("://").next().unwrap_or("").to_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(format!("target protocol `{scheme}` is not http(s)"));
|
||||
}
|
||||
|
||||
let host = host_of(&url);
|
||||
if host.is_empty() {
|
||||
return Err("target has no host".into());
|
||||
}
|
||||
|
||||
// Port: if the scope pins ports via url-prefix rules, an off-port
|
||||
// target must not pass. A bare host rule authorizes the default ports.
|
||||
// (Port pinning is expressed through url-prefix patterns; check_request
|
||||
// already compares those, so we route the full URL through it below.)
|
||||
|
||||
// If nothing is authorized and the caller demands an explicit grant,
|
||||
// refuse — this is default-deny.
|
||||
if self.hard.is_empty() {
|
||||
if require_explicit {
|
||||
return Err("no hard scope is in force and a capability token requires the target to be explicitly granted".into());
|
||||
}
|
||||
// Legacy: a bare target with no grant authorizes itself. The
|
||||
// pipeline still seeds for_target() in this case.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let decision = self.check_request(&url, "GET", "");
|
||||
if decision.allowed() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(decision.reason().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_hard_scope(&self, url: &str) -> bool {
|
||||
if self.exclude.iter().any(|p| p.matches(url)) {
|
||||
return false;
|
||||
@@ -896,4 +947,29 @@ soft:
|
||||
assert!(p.check_request("https://app.example.com/x", "GET", "").allowed());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn validate_target_is_default_deny_under_a_grant() {
|
||||
// A grant that covers app.example.com — a target elsewhere is refused.
|
||||
let mut p = ScopePolicy::default();
|
||||
p.allow("app.example.com");
|
||||
assert!(p.validate_target("https://app.example.com/login", true).is_ok());
|
||||
assert!(p.validate_target("https://evil.test", true).is_err(), "target outside the grant must be refused");
|
||||
// Wrong protocol is refused whatever the host.
|
||||
assert!(p.validate_target("javascript:alert(1)", true).is_err());
|
||||
// Exclusion beats the target too.
|
||||
p.deny("app.example.com");
|
||||
assert!(p.validate_target("https://app.example.com/x", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_target_legacy_allows_a_bare_target_without_a_grant() {
|
||||
let p = ScopePolicy::default();
|
||||
// No grant, not requiring explicit → the bare target is allowed (the
|
||||
// pipeline seeds for_target in this path).
|
||||
assert!(p.validate_target("https://app.example.com", false).is_ok());
|
||||
// But if a token is in force, an empty scope authorizes nothing.
|
||||
assert!(p.validate_target("https://app.example.com", true).is_err());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user