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:
CyberSecurityUP
2026-09-18 21:25:21 -03:00
co-authored by Claude Opus 5
parent 8894649ccb
commit b4903575c0
8 changed files with 1150 additions and 5 deletions
+33
View File
@@ -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
+75
View File
@@ -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);
}
}
+207
View File
@@ -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);
}
}
+361
View File
@@ -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);
}
}
+2
View File
@@ -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;
+60 -5
View File
@@ -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());
}
}