From 53a6a45448ba5be5f961dc8bc8eaaf8002868c64 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Mon, 14 Sep 2026 00:22:10 -0300 Subject: [PATCH] feat(report): CVSS v3.1 with vectors, a PDF that survives long payloads, capped over-claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../crates/harness/src/attack_graph.rs | 214 ++++++++++++++++++ neurosploit-rs/crates/harness/src/pipeline.rs | 35 ++- neurosploit-rs/crates/harness/src/report.rs | 113 ++++++++- neurosploit-rs/templates/report.typ | 97 ++++++-- 4 files changed, 436 insertions(+), 23 deletions(-) diff --git a/neurosploit-rs/crates/harness/src/attack_graph.rs b/neurosploit-rs/crates/harness/src/attack_graph.rs index 320a736..21fbc80 100644 --- a/neurosploit-rs/crates/harness/src/attack_graph.rs +++ b/neurosploit-rs/crates/harness/src/attack_graph.rs @@ -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::().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"); + } +} diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index c5da329..b1f10ba 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -767,7 +767,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender, pool: &ModelPool, sys: &str, vote_n: f.review_status = "needs-review".into(); f.review_reason = if total == 0 { "validator unavailable".into() } else { format!("below vote quorum ({yes}/{total})") }; + } else if grounded_receipt(&f) { + // Unanimously rejected, but the MECHANISM was demonstrated — + // a real engagement rejected "no rate limiting on the reset + // flow" because the agent claimed email flooding and only + // proved that 25 requests went through unthrottled. The + // claim was inflated; the measurement was real, and + // discarding it hid a genuine gap from the report. + // + // So an over-claimed finding is capped and flagged rather + // than deleted: the reader gets the fact, not the story + // that was built on it. + let cap = "Low"; + let was = f.severity.clone(); + f.severity = cap.to_string(); + f.review_status = "needs-review".into(); + f.review_reason = format!( + "impact not demonstrated — capped from {was} to {cap}. Validator: {}", + f.review_reason.trim() + ); + f.confidence = f.confidence.min(0.5); } let label = if f.validated { "CONFIRMED" } else if f.review_status == "needs-review" { "needs-review" } else { "rejected" }; let _ = txc.send(format!("vote {} → {} ({})", f.title, label, f.votes)).await; @@ -1420,6 +1440,19 @@ async fn validate(candidates: Vec, pool: &ModelPool, sys: &str, vote_n: flagged } +/// Does this finding carry evidence a reader could check, independent of the +/// claim built on top of it? Structured artifacts count outright; otherwise the +/// grounding pass's own verdict decides. +fn grounded_receipt(f: &Finding) -> bool { + if f.evidence_data.is_some() { + return true; + } + if f.review_reason.contains("receipt_missing") || f.votes.contains("receipt_missing") { + return false; + } + crate::grounding::ground(f, "", crate::grounding::GroundMode::Either).ok +} + /// Adversarial refutation pass: every confirmed **High/Critical** finding is /// re-examined by a skeptical panel that tries to prove it's a false positive. /// A finding that fails to withstand a majority of skeptics is dropped. Lower diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index 1cd48e5..08f2f58 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -397,6 +397,55 @@ pub fn poc_scripts(f: &Finding, available: &[String]) -> Vec { matches } +/// Insert zero-width break opportunities into text that would otherwise +/// overflow the page. +/// +/// Typst's `raw` does not wrap, so a 300-character URL-encoded POST body ran +/// off the page edge. The first attempt at a fix was worse: it inserted breaks +/// every N characters regardless of context, which chopped ordinary prose +/// mid-word — "rota ted", "lockoutOnFailu re=false". Evidence text is usually +/// prose with a few long machine tokens embedded in it. +/// +/// So the rule is per token: anything that fits on a line is left exactly as +/// it is, and only a token too long to fit gets internal break points. A +/// zero-width space carries no width and no content, so copying the text back +/// out yields the original either way. +pub fn wrappable(s: &str) -> String { + const ZWSP: char = '\u{200b}'; + // Roughly the character budget of one line in the report's 7.5pt mono at + // the page width. Prose words never reach it; encoded payloads always do. + const LONG: usize = 46; + const AFTER: &[char] = &['&', '?', '/', '=', ';', ',', '+', '%', '|']; + + let mut out = String::with_capacity(s.len() + s.len() / 16); + for chunk in s.split_inclusive(char::is_whitespace) { + let (token, trailing) = match chunk.find(char::is_whitespace) { + Some(i) => (&chunk[..i], &chunk[i..]), + None => (chunk, ""), + }; + if token.chars().count() <= LONG { + out.push_str(token); + } else { + let mut run = 0usize; + for ch in token.chars() { + out.push(ch); + run += 1; + if AFTER.contains(&ch) { + out.push(ZWSP); + run = 0; + } else if run >= LONG - 6 { + // A base64 blob or a hash has no separators at all; break it + // rather than let it push the margin. + out.push(ZWSP); + run = 0; + } + } + } + out.push_str(trailing); + } + out +} + /// Is the `typst` binary available on PATH? fn typst_available() -> bool { std::env::var_os("PATH") @@ -446,17 +495,21 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io:: let status = if needs_review(f) { "needs-review" } else { "confirmed" }; let shots = format!("({})", f.screenshots.iter().map(|p| format!("{},", tq(p))).collect::()); + // Steps go as an ARRAY so the template can render a real numbered + // list. Flattening them into one string is what produced the run-on + // paragraph in the last report, where five separate commands ran + // together as prose. + let steps = format!("({})", + repro_steps(f).iter().map(|st| format!("{},", tq(&wrappable(st)))).collect::()); data.push_str(&format!( " (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}, location: {}, steps: {}),\n", tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&owasp), tq(&f.cvss), - tq(&f.endpoint), tq(&f.payload), tq(&technical_evidence(f)), tq(&f.impact), + tq(&f.endpoint), tq(&wrappable(&f.payload)), tq(&wrappable(&technical_evidence(f))), tq(&f.impact), tq(&f.remediation), tq(&f.votes), f.confidence, tq(status), tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }), shots, tq(&location_line(f)), - // The PDF gets the same pasteable steps as the HTML — a printed - // report that cannot be reproduced is the one people argue with. - tq(&repro_steps(f).iter().enumerate().map(|(i, st)| format!("{}. {}", i + 1, st)).collect::>().join("\n\n")), + steps, )); } data.push_str(")\n\n"); @@ -736,10 +789,15 @@ pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Res /// operator has no way to get one without re-running the engagement. This /// regenerates from the evidence already on disk. pub fn rebuild(dir: &Path) -> std::io::Result { - let findings: Vec = std::fs::read_to_string(dir.join("findings.json")) + let mut findings: Vec = std::fs::read_to_string(dir.join("findings.json")) .ok() .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default(); + // Re-enrich on rebuild: a run finished before a mapping existed (CVSS, a + // new CWE→technique entry) would otherwise keep reprinting the gap forever, + // and the whole point of rebuilding is to get the current report. + crate::attack_graph::enrich(&mut findings); + let _ = std::fs::write(dir.join("findings.json"), serde_json::to_string_pretty(&findings).unwrap_or_default()); let status: serde_json::Value = std::fs::read_to_string(dir.join("status.json")) .ok() .and_then(|t| serde_json::from_str(&t).ok()) @@ -781,3 +839,48 @@ mod tests { assert_eq!(v["summary"]["confirmed"], 0); } } + +#[cfg(test)] +mod wrap_tests { + use super::*; + const ZWSP: char = '\u{200b}'; + + /// The regression: evidence is prose with machine tokens in it, and the + /// first implementation broke the prose. + #[test] + fn ordinary_words_are_never_split() { + let prose = "token not rotated/consumed, so ASP.NET Core Identity configured with lockoutOnFailure=false"; + let out = wrappable(prose); + assert!(!out.contains(ZWSP), "no word here is long enough to need breaking: {out:?}"); + assert_eq!(out, prose); + } + + #[test] + fn a_long_encoded_payload_gets_break_points() { + let payload = "Input.Nome=poc&Input.Email=victim@example.test&Input.Password=NrSplt!Test123&Input.ConfirmPassword=NrSplt!Test123&__RequestVerificationToken=CfDJ8A0uCaR&_handler=register"; + let out = wrappable(payload); + assert!(out.contains(ZWSP), "a 170-character token must be breakable"); + // The text itself is unchanged once the invisible marks are removed. + assert_eq!(out.replace(ZWSP, ""), payload); + } + + #[test] + fn an_unbroken_blob_is_still_breakable() { + let blob = "A".repeat(200); + let out = wrappable(&blob); + assert!(out.contains(ZWSP), "a base64 blob has no separators and still must wrap"); + assert_eq!(out.replace(ZWSP, ""), blob); + } + + #[test] + fn whitespace_and_newlines_survive_untouched() { + let s = "line one\n indented two\ttabbed"; + assert_eq!(wrappable(s), s); + } + + #[test] + fn a_url_just_under_the_limit_is_left_alone() { + let url = "https://arenahockeypara.com.br/Account/Login"; + assert_eq!(wrappable(url), url, "a normal URL fits and must not be peppered with breaks"); + } +} diff --git a/neurosploit-rs/templates/report.typ b/neurosploit-rs/templates/report.typ index 015044d..9e1b9ce 100644 --- a/neurosploit-rs/templates/report.typ +++ b/neurosploit-rs/templates/report.typ @@ -23,6 +23,34 @@ ) #let sevrank(s) = (Critical: 0, High: 1, Medium: 2, Low: 3, Info: 4).at(s, default: 5) +// A section label inside a finding: consistent spacing, so the eye can find +// "How to fix it" without reading the paragraph above it. +#let sectionhead(t) = [ + #v(7pt) + #text(9pt, weight: "bold", fill: rgb("#2c3e50"), upper(t)) + #v(3pt) +] + +// Machine text: monospaced, on a tinted ground, wrapping at the zero-width +// breaks the generator inserted. `raw` is deliberately NOT used — it refuses to +// wrap, which is what pushed payloads off the page edge. +#let codebox(body) = block( + width: 100%, breakable: true, fill: rgb("#f7f7f9"), inset: 6pt, radius: 4pt, + stroke: 0.5pt + rgb("#e4e4e8"), +)[ + #set par(justify: false, leading: 0.55em) + #text(font: ("Menlo", "DejaVu Sans Mono", "Courier New"), size: 7.5pt)[#body] +] + +// "9.8 (CVSS:3.1/AV:N/...)" — the number reads large, the vector stays legible +// underneath so the score can be checked rather than believed. +#let cvssline(v) = { + let parts = v.split(" (") + let score = parts.at(0, default: v) + let vector = if parts.len() > 1 { parts.at(1).trim(")") } else { "" } + [#text(weight: "bold")[#score] #if vector != "" [ #linebreak() #text(6.5pt, fill: gray, font: ("Menlo", "Courier New"))[#vector] ]] +} + #set page(margin: 2cm, numbering: "1", footer: context [ #set text(size: 8pt, fill: gray) NeuroSploit v3.5.1 · #meta.target · confidential @@ -124,32 +152,69 @@ #text(fill: gray)[_Nothing to report._] ] #for (i, f) in sorted.enumerate() [ - #block(breakable: false, width: 100%, inset: 10pt, radius: 6pt, + // Breakable: a finding with a long evidence dump must flow onto the next + // page instead of overflowing off the bottom of this one. + #block(breakable: true, width: 100%, inset: 10pt, radius: 6pt, above: 10pt, stroke: (left: 3pt + sevcolor.at(f.severity, default: gray), rest: 0.5pt + rgb("#dddddd")))[ #sevbadge(f.severity) #h(6pt) #if f.status == "needs-review" [ #box(fill: rgb("#8e44ad"), inset: (x: 5pt, y: 2pt), radius: 3pt, text(fill: white, weight: "bold", size: 8pt)[NEEDS REVIEW]) #h(6pt) ] #text(12pt, weight: "bold")[#str(i + 1). #f.title] - #v(4pt) + #v(5pt) #table( columns: (auto, 1fr, auto, 1fr), - inset: 4pt, stroke: none, align: left + horizon, - text(8pt, fill: gray)[Criticality], text(8pt)[#f.severity], + inset: 4pt, stroke: none, align: left + top, + text(8pt, fill: gray)[Severity], text(8pt)[#f.severity], text(8pt, fill: gray)[Status], text(8pt)[#f.status], - text(8pt, fill: gray)[OWASP/CWE], text(8pt)[#f.owasp · #f.cwe], - text(8pt, fill: gray)[Confidence], text(8pt)[#f.votes votes · #str(f.confidence)], - text(8pt, fill: gray)[Auth context], text(8pt)[#f.auth], - text(8pt, fill: gray)[Location], text(8pt)[#raw(f.endpoint)], + text(8pt, fill: gray)[OWASP / CWE], text(8pt)[#f.owasp · #f.cwe], + text(8pt, fill: gray)[Confidence], text(8pt)[#f.votes · #str(f.confidence)], + ..(if f.at("cvss", default: "") != "" { + (text(8pt, fill: gray)[CVSS v3.1], text(8pt)[#cvssline(f.cvss)], + text(8pt, fill: gray)[Auth context], text(8pt)[#f.auth]) + } else { + (text(8pt, fill: gray)[Auth context], text(8pt)[#f.auth], [], []) + }), text(8pt, fill: gray)[Agent], text(8pt)[#raw(f.agent)], + [], [], ) - #v(4pt) #strong[Where the problem is] #linebreak() #text(9pt)[#f.at("location", default: f.endpoint)] - #v(4pt) #strong[What it means] #linebreak() #text(9pt)[#f.impact] - #v(4pt) #strong[How to fix it] #linebreak() #text(9pt)[#f.remediation] - #v(4pt) #strong[Proof of concept — step by step] #linebreak() #raw(f.at("steps", default: f.payload)) - #if f.payload != "" [ #v(3pt) #strong[Payload] #linebreak() #raw(f.payload) ] - #v(3pt) #strong[Technical evidence] #linebreak() #raw(f.evidence) + #v(2pt) + #sectionhead("Where the problem is") + #text(9pt)[#f.at("location", default: f.endpoint)] + #sectionhead("What it means") + #text(9pt)[#f.impact] + #sectionhead("How to fix it") + #text(9pt)[#f.remediation] + + #let steps = f.at("steps", default: ()) + #if type(steps) == array and steps.len() > 0 [ + #sectionhead("Proof of concept — step by step") + // A real numbered list, one command per item. The previous template + // pushed every step into a single raw block, which rendered as one + // run-on paragraph nobody could follow or paste. + #for (n, st) in steps.enumerate() [ + #grid(columns: (16pt, 1fr), gutter: 4pt, + text(8pt, fill: gray, weight: "bold")[#str(n + 1).], + codebox(st), + ) + #v(3pt) + ] + ] else if f.payload != "" [ + #sectionhead("Proof of concept") + #codebox(f.payload) + ] + + #if f.payload != "" and type(steps) == array and steps.len() > 0 [ + #sectionhead("Payload") + #codebox(f.payload) + ] + + #if f.evidence != "" [ + #sectionhead("Technical evidence") + #codebox(f.evidence) + ] + #let shots = f.at("screenshots", default: ()) #if shots.len() > 0 [ - #v(4pt) #strong[Proof Screenshots] + #sectionhead("Proof screenshots") #for sp in shots [ #v(3pt) #block(breakable: false, width: 100%)[ @@ -158,9 +223,7 @@ ] ] ] - ] - #v(8pt) ] // ---- Conclusion ----