mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +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:
co-authored by
Claude Opus 5
parent
936e358850
commit
53a6a45448
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user