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:
CyberSecurityUP
2026-09-14 00:22:10 -03:00
co-authored by Claude Opus 5
parent 936e358850
commit 53a6a45448
4 changed files with 436 additions and 23 deletions
@@ -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");
}
}
+34 -1
View File
@@ -767,7 +767,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
Each item: {{id,title,severity,cwe,endpoint,location,payload,evidence,repro_steps,impact,remediation,confidence,auth_context,account,secret,screenshots}}. \
Write for a developer who has never seen this app and has to fix it today:\n\
- `location`: EXACTLY where it is — the parameter, form field, header, JSON key, or flow step (e.g. \"POST /api/orders, JSON field `role`\"). An endpoint alone sends them hunting.\n\
- `impact`: what an attacker gets, in one or two plain sentences tied to THIS app's data or users. No boilerplate, no hedging.\n\
- `impact`: what an attacker gets, stated as what you MEASURED — not what the class usually enables. If you proved 25 unthrottled requests, say that; do not claim inbox flooding unless you observed mail being sent. An inflated impact gets the whole finding rejected, and the real measurement is lost with it.\n\
- `remediation`: the concrete change, naming the control (parameterised query, server-side authorisation check, allowlist), not \"sanitise input\".\n\
- `repro_steps`: an ORDERED array of literal commands someone can paste one by one from a clean shell — baseline request first, then the attack, then how to read the result. Include the real URL and the real payload.\n\
- `evidence`: the concrete proof (request/response excerpt with status, key headers and the decisive part of the body). \
@@ -1405,6 +1405,26 @@ async fn validate(candidates: Vec<Finding>, 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<Finding>, 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
+108 -5
View File
@@ -397,6 +397,55 @@ pub fn poc_scripts(f: &Finding, available: &[String]) -> Vec<String> {
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::<String>());
// 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::<String>());
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::<Vec<_>>().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<PathBuf> {
let findings: Vec<Finding> = std::fs::read_to_string(dir.join("findings.json"))
let mut findings: Vec<Finding> = 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");
}
}
+80 -17
View File
@@ -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 ----