mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 13:09:36 +02:00
feat(report): CVSS v3.1 with vectors, a PDF that survives long payloads, capped over-claims
Driven by the Arena Hockey engagement, where all 24 findings shipped with an
empty CVSS field and the PDF ran payloads off the page edge.
CVSS
- Derived deterministically from what the harness knows: the weakness class
sets the impact shape, the PROVEN exploitability sets attack complexity, and
the auth context sets privileges required. The vector is emitted with the
score, because a score without its vector cannot be checked and an unchecked
score is just a bigger adjective.
- The base equation is the v3.1 specification verbatim, including round-up and
the scope-changed privileges table. Tests anchor it against known values
(9.8 unauthenticated RCE, 10.0 with scope change, 6.1 reflected XSS, 0.0 for
no impact).
- An unknown weakness stays conservative — guessing high impact from a class
nobody mapped is how reports get inflated. An agent-supplied score is never
overwritten.
PDF
- Steps are passed as an ARRAY and rendered as a real numbered list, one command
per box. The previous template flattened them into a single `raw` block, which
rendered five separate commands as one run-on paragraph.
- Finding blocks are breakable, so a long evidence dump flows to the next page
instead of off the bottom of this one.
- `wrappable()` inserts zero-width breaks so encoded payloads wrap. The first
attempt broke prose mid-word ("rota ted", "lockoutOnFailu re=false") by
breaking every N characters regardless of context; it now works per token and
leaves anything that fits on a line exactly as it was.
- rebuild() re-enriches before rendering, so a run that finished before a
mapping existed picks it up instead of reprinting the gap forever.
Over-claimed findings are capped, not deleted
- The engagement rejected "no rate limiting on the password-reset flow" because
the agent claimed inbox flooding and only proved 25 unthrottled requests. The
claim was inflated; the measurement was real, and dropping it hid a genuine
gap. A unanimously rejected finding that still carries a checkable receipt is
now capped to Low and flagged for review, with the validator's reason
attached — the reader gets the fact without the story built on it.
- The agent contract now says impact must be what was MEASURED, and warns that
inflating it costs the whole finding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
936e358850
commit
53a6a45448
4 files changed
+436
-23
No files matched your search
@@ -41,6 +41,145 @@ fn exploitability(sev: &str, conf: f64) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CVSS v3.1 base score
|
||||
//
|
||||
// Every finding in the last engagement shipped with an empty CVSS field: the
|
||||
// schema had the column, nothing filled it, and no agent volunteered one. A
|
||||
// report that grades severity as a word and leaves the industry-standard number
|
||||
// blank forces the reader to re-derive it by hand, or to trust the word.
|
||||
//
|
||||
// So it is derived here, deterministically, from what the harness already
|
||||
// knows: the weakness class (CWE) sets the impact shape, the proven
|
||||
// exploitability sets attack complexity, and the auth context sets privileges
|
||||
// required. The VECTOR is emitted alongside the number — a score without its
|
||||
// vector cannot be checked, and an unchecked score is just a bigger adjective.
|
||||
//
|
||||
// This is an estimate from observed properties, not a replacement for an
|
||||
// analyst's judgement on business context (which CVSS environmental metrics
|
||||
// exist for). The report says so.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The metric choices behind one score, kept so the vector can be printed.
|
||||
struct Cvss {
|
||||
av: &'static str, // attack vector
|
||||
ac: &'static str, // attack complexity
|
||||
pr: &'static str, // privileges required
|
||||
ui: &'static str, // user interaction
|
||||
s: &'static str, // scope
|
||||
c: &'static str, // confidentiality
|
||||
i: &'static str, // integrity
|
||||
a: &'static str, // availability
|
||||
}
|
||||
|
||||
impl Cvss {
|
||||
fn vector(&self) -> String {
|
||||
format!(
|
||||
"CVSS:3.1/AV:{}/AC:{}/PR:{}/UI:{}/S:{}/C:{}/I:{}/A:{}",
|
||||
self.av, self.ac, self.pr, self.ui, self.s, self.c, self.i, self.a
|
||||
)
|
||||
}
|
||||
|
||||
/// The v3.1 base equation, verbatim from the specification.
|
||||
fn score(&self) -> f64 {
|
||||
let w = |v: &str, table: &[(&str, f64)]| table.iter().find(|(k, _)| *k == v).map(|(_, n)| *n).unwrap_or(0.0);
|
||||
let av = w(self.av, &[("N", 0.85), ("A", 0.62), ("L", 0.55), ("P", 0.2)]);
|
||||
let ac = w(self.ac, &[("L", 0.77), ("H", 0.44)]);
|
||||
let ui = w(self.ui, &[("N", 0.85), ("R", 0.62)]);
|
||||
let scope_changed = self.s == "C";
|
||||
// Privileges-required weights differ when scope changes — the one place
|
||||
// the equation is not a simple lookup.
|
||||
let pr = match (self.pr, scope_changed) {
|
||||
("N", _) => 0.85,
|
||||
("L", false) => 0.62,
|
||||
("L", true) => 0.68,
|
||||
("H", false) => 0.27,
|
||||
("H", true) => 0.5,
|
||||
_ => 0.85,
|
||||
};
|
||||
let cia = |v: &str| w(v, &[("H", 0.56), ("L", 0.22), ("N", 0.0)]);
|
||||
let iss = 1.0 - (1.0 - cia(self.c)) * (1.0 - cia(self.i)) * (1.0 - cia(self.a));
|
||||
let impact = if scope_changed {
|
||||
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
|
||||
} else {
|
||||
6.42 * iss
|
||||
};
|
||||
if impact <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let exploitability = 8.22 * av * ac * pr * ui;
|
||||
let base = if scope_changed {
|
||||
(1.08 * (impact + exploitability)).min(10.0)
|
||||
} else {
|
||||
(impact + exploitability).min(10.0)
|
||||
};
|
||||
// CVSS rounds UP to one decimal, which is not the same as rounding.
|
||||
(base * 10.0).ceil() / 10.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a CVSS v3.1 base score + vector for a finding.
|
||||
pub fn cvss_for(f: &Finding) -> (f64, String) {
|
||||
let n: u32 = f.cwe.chars().skip_while(|c| !c.is_ascii_digit()).take_while(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
|
||||
let authenticated = f.auth_context.eq_ignore_ascii_case("authenticated") || !f.account.is_empty();
|
||||
|
||||
// Impact shape by weakness class. Anything unmapped stays conservative:
|
||||
// guessing high impact from an unknown class is how scores get inflated.
|
||||
let (c, i, a, scope) = match n {
|
||||
// Injection / execution: full compromise of the interpreter's context.
|
||||
77 | 78 | 94 | 95 | 502 | 917 | 1336 => ("H", "H", "H", "C"),
|
||||
// SQL injection: reads and writes the datastore.
|
||||
89 | 943 | 564 => ("H", "H", "L", "U"),
|
||||
// Path traversal / file read.
|
||||
22 | 23 | 35 | 98 | 73 => ("H", "N", "N", "U"),
|
||||
// SSRF: reaches other systems.
|
||||
918 => ("H", "L", "N", "C"),
|
||||
// Access control / IDOR / auth bypass: another user's data.
|
||||
639 | 862 | 863 | 284 | 285 | 306 | 566 | 425 => ("H", "H", "N", "U"),
|
||||
// Broken authentication / token verification.
|
||||
287 | 288 | 289 | 290 | 347 | 345 | 384 => ("H", "H", "N", "U"),
|
||||
// XSS: runs in the victim's session, in the browser's scope.
|
||||
79 | 80 | 83 | 87 => ("L", "L", "N", "C"),
|
||||
// XXE.
|
||||
611 | 776 | 827 => ("H", "N", "L", "C"),
|
||||
// Credential exposure / cleartext transmission.
|
||||
319 | 522 | 798 | 312 | 256 | 257 | 321 => ("H", "N", "N", "U"),
|
||||
// Secrets / sensitive data disclosure.
|
||||
200 | 209 | 538 | 540 | 548 | 532 => ("L", "N", "N", "U"),
|
||||
// Enumeration / observable discrepancy: identities, not content.
|
||||
204 | 203 | 208 => ("L", "N", "N", "U"),
|
||||
// Missing rate limiting: an enabler, and a resource cost.
|
||||
307 | 799 | 770 | 400 => ("L", "N", "L", "U"),
|
||||
// CSRF: acts as the victim.
|
||||
352 => ("N", "H", "N", "U"),
|
||||
// Open redirect: phishing leverage, no direct data loss.
|
||||
601 => ("N", "L", "N", "C"),
|
||||
// Cookie flags / missing hardening: exposure only under another
|
||||
// condition (an attacker already on the network, a second bug).
|
||||
614 | 1004 | 1275 | 693 | 1021 | 1018 => ("L", "N", "N", "U"),
|
||||
// CORS with credentials.
|
||||
942 | 346 | 1385 => ("H", "L", "N", "C"),
|
||||
// Mass assignment.
|
||||
915 | 913 => ("L", "H", "N", "U"),
|
||||
_ => ("L", "N", "N", "U"),
|
||||
};
|
||||
|
||||
let m = Cvss {
|
||||
av: "N", // everything the harness tests black-box is network-reachable
|
||||
// "How hard was it?" is not a guess here — the harness recorded whether
|
||||
// the exploit was trivial or took work.
|
||||
ac: if f.exploitability.eq_ignore_ascii_case("hard") { "H" } else { "L" },
|
||||
pr: if authenticated { "L" } else { "N" },
|
||||
// CSRF and XSS need a victim to act; nothing else here does.
|
||||
ui: if matches!(n, 352 | 79 | 80 | 83 | 87 | 601) { "R" } else { "N" },
|
||||
s: scope,
|
||||
c,
|
||||
i,
|
||||
a,
|
||||
};
|
||||
(m.score(), m.vector())
|
||||
}
|
||||
|
||||
/// Fill in any empty mapping fields on each finding (does not overwrite model-set values).
|
||||
pub fn enrich(findings: &mut [Finding]) {
|
||||
for f in findings.iter_mut() {
|
||||
@@ -50,6 +189,12 @@ pub fn enrich(findings: &mut [Finding]) {
|
||||
if f.stage.is_empty() { f.stage = stage.into(); }
|
||||
if f.exploitability.is_empty() { f.exploitability = exploitability(&f.severity, f.confidence).into(); }
|
||||
if f.business_impact.is_empty() { f.business_impact = f.impact.clone(); }
|
||||
// A severity word without the industry-standard number makes the reader
|
||||
// re-derive it by hand or take it on faith.
|
||||
if f.cvss.is_empty() {
|
||||
let (score, vector) = cvss_for(f);
|
||||
if score > 0.0 { f.cvss = format!("{score:.1} ({vector})"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +281,72 @@ fn sanitize_id(s: &str) -> String {
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('"', "'").replace('\n', " ").chars().take(60).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cvss_tests {
|
||||
use super::*;
|
||||
|
||||
fn f(cwe: &str, exploitability: &str, auth: &str) -> Finding {
|
||||
Finding { cwe: cwe.into(), exploitability: exploitability.into(), auth_context: auth.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Known-good anchors from the CVSS v3.1 specification's own arithmetic.
|
||||
#[test]
|
||||
fn the_base_equation_matches_the_specification() {
|
||||
// AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8 (the classic unauthenticated RCE)
|
||||
let m = Cvss { av: "N", ac: "L", pr: "N", ui: "N", s: "U", c: "H", i: "H", a: "H" };
|
||||
assert!((m.score() - 9.8).abs() < 0.05, "got {}", m.score());
|
||||
// Scope change pushes the same impact to 10.0
|
||||
let m = Cvss { av: "N", ac: "L", pr: "N", ui: "N", s: "C", c: "H", i: "H", a: "H" };
|
||||
assert!((m.score() - 10.0).abs() < 0.05, "got {}", m.score());
|
||||
// Reflected XSS: AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N = 6.1
|
||||
let m = Cvss { av: "N", ac: "L", pr: "N", ui: "R", s: "C", c: "L", i: "L", a: "N" };
|
||||
assert!((m.score() - 6.1).abs() < 0.05, "got {}", m.score());
|
||||
// No impact at all must score zero, not a floor.
|
||||
let m = Cvss { av: "N", ac: "L", pr: "N", ui: "N", s: "U", c: "N", i: "N", a: "N" };
|
||||
assert_eq!(m.score(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vector_is_emitted_so_the_score_can_be_checked() {
|
||||
let (score, vector) = cvss_for(&f("CWE-89", "trivial", ""));
|
||||
assert!(score >= 9.0, "unauthenticated trivial SQLi should be critical: {score}");
|
||||
assert!(vector.starts_with("CVSS:3.1/AV:N/AC:L/PR:N"), "{vector}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proven_difficulty_and_required_privileges_move_the_score() {
|
||||
let easy = cvss_for(&f("CWE-639", "trivial", "")).0;
|
||||
let hard = cvss_for(&f("CWE-639", "hard", "")).0;
|
||||
let authed = cvss_for(&f("CWE-639", "trivial", "authenticated")).0;
|
||||
assert!(hard < easy, "a hard exploit must not score like a trivial one ({hard} vs {easy})");
|
||||
assert!(authed < easy, "needing an account must lower the score ({authed} vs {easy})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardening_gaps_do_not_score_like_compromises() {
|
||||
let cookie = cvss_for(&f("CWE-614", "trivial", "")).0;
|
||||
let headers = cvss_for(&f("CWE-693", "trivial", "")).0;
|
||||
let rce = cvss_for(&f("CWE-78", "trivial", "")).0;
|
||||
assert!(cookie < 6.0 && headers < 6.0, "cookie {cookie}, headers {headers}");
|
||||
assert!(rce > 9.0, "command injection {rce}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_weakness_stays_conservative() {
|
||||
let (score, _) = cvss_for(&f("CWE-99999", "trivial", ""));
|
||||
assert!(score < 6.0, "guessing high impact from an unknown class inflates reports: {score}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_fills_the_field_and_leaves_an_agent_supplied_score_alone() {
|
||||
let mut v = vec![
|
||||
Finding { cwe: "CWE-307".into(), severity: "Medium".into(), ..Default::default() },
|
||||
Finding { cwe: "CWE-89".into(), cvss: "7.0 (analyst override)".into(), ..Default::default() },
|
||||
];
|
||||
enrich(&mut v);
|
||||
assert!(v[0].cvss.starts_with(|c: char| c.is_ascii_digit()), "got {:?}", v[0].cvss);
|
||||
assert!(v[0].cvss.contains("CVSS:3.1/"), "the vector must travel with the score");
|
||||
assert_eq!(v[1].cvss, "7.0 (analyst override)", "a supplied score is not overwritten");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user