From b4903575c0112673ae24539584e67e7823dceeee Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Fri, 18 Sep 2026 21:25:21 -0300 Subject: [PATCH] =?UTF-8?q?feat(assurance):=20target=20gate=20(default-den?= =?UTF-8?q?y),=20evidence-graded=20CVSS,=20audit=20anchoring,=20P1?= =?UTF-8?q?=E2=80=93P5=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--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) --- README.md | 33 ++ neurosploit-rs/app/src/main.rs | 75 ++++ .../crates/harness/src/assurance.rs | 336 ++++++++++++++++ neurosploit-rs/crates/harness/src/audit.rs | 207 ++++++++++ neurosploit-rs/crates/harness/src/cvss.rs | 361 ++++++++++++++++++ neurosploit-rs/crates/harness/src/lib.rs | 2 + neurosploit-rs/crates/harness/src/pipeline.rs | 65 +++- neurosploit-rs/crates/harness/src/scope.rs | 76 ++++ 8 files changed, 1150 insertions(+), 5 deletions(-) create mode 100644 neurosploit-rs/crates/harness/src/assurance.rs create mode 100644 neurosploit-rs/crates/harness/src/cvss.rs diff --git a/README.md b/README.md index 23850e6..db58c10 100755 --- a/README.md +++ b/README.md @@ -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 --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 # assemble + print the P1–P5 summary +neurosploit assurance --verify # re-hash every artifact + check the signature +neurosploit audit --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 diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 81005a2..062b826 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -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 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)?; diff --git a/neurosploit-rs/crates/harness/src/assurance.rs b/neurosploit-rs/crates/harness/src/assurance.rs new file mode 100644 index 0000000..4ba7713 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/assurance.rs @@ -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, + 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, + 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, + pub properties: Vec, + /// 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, +} + +/// 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 = 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 = 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::(&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); + } +} diff --git a/neurosploit-rs/crates/harness/src/audit.rs b/neurosploit-rs/crates/harness/src/audit.rs index 5afeb12..0b70e54 100644 --- a/neurosploit-rs/crates/harness/src/audit.rs +++ b/neurosploit-rs/crates/harness/src/audit.rs @@ -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 { + 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 { + 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); + } + } diff --git a/neurosploit-rs/crates/harness/src/cvss.rs b/neurosploit-rs/crates/harness/src/cvss.rs new file mode 100644 index 0000000..ad56c78 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/cvss.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { + 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, + /// 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, +} + +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(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); + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index 9e63f82..5b777aa 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -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; diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 0c6404a..06eeaa9 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -20,6 +20,11 @@ pub struct RunOutput { pub workdir: String, /// Paths to persisted artifacts (recon/exploit/findings/report), if any. pub artifacts: Vec, + /// 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, } /// 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) -> 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)> = 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::(), + 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; diff --git a/neurosploit-rs/crates/harness/src/scope.rs b/neurosploit-rs/crates/harness/src/scope.rs index 5fe1b1b..e0ee89f 100644 --- a/neurosploit-rs/crates/harness/src/scope.rs +++ b/neurosploit-rs/crates/harness/src/scope.rs @@ -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()); + } + }