v3.5.1: live findings + /finding + Ctrl+O/expand + 3-way /stop (soft validate) + report URL + structured Typst + IIS/CMS/CVE agents

REPL interactivity & findings:
- Live findings registered during a run: /results shows them accumulating;
  /finding opens a selection menu with FULL details (PoC, command, evidence,
  CVSS, OWASP/CWE, remediation). Past runs too.
- /expand (and Ctrl+O) dump the last full, untruncated commands.
- Findings colored by severity in the feed (not all-yellow); confirmed vote = green.

Stop & report:
- CRITICAL: /stop no longer kills validation. New SOFT stop (pool.soft) halts
  launching new agents but lets in-flight + VALIDATION finish — so confirmed
  findings are kept. /stop now asks 3 ways: [1] validate then report,
  [2] report raw (no validation), [3] discard.
- Report file:// URL printed on completion/stop.

Report:
- Typst report restructured: executive summary, a Vulnerability Summary TABLE
  (#, vuln, severity, CVSS, OWASP/CWE), and per-finding sections with criticality,
  CVSS, OWASP/CWE, description/impact, PoC, evidence, remediation. owasp passed through.

Agents: +14 app-stack/CVE (IIS tilde/WebDAV/ViewState/debug/handler-bypass,
CMS fingerprint + WordPress/Joomla/Drupal/default-admin, app-server consoles,
exposed VCS, known-CVE & outdated-component exploitation) → 343 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 23:21:43 -03:00
parent df73c0e134
commit eb4e13efea
24 changed files with 906 additions and 33 deletions
+7
View File
@@ -22,3 +22,10 @@ run
/results
/report
/exit
/run
/status
/help
/results
/stop
/results
/exit
+7
View File
@@ -19,5 +19,12 @@
"target": "http://testasp.vulnweb.com/",
"workdir": "",
"findings": []
},
{
"id": 4,
"mode": "black-box",
"target": "http://testasp.vulnweb.com/",
"workdir": "/opt/NeurosploitFinal/runs/ns-1782353097-testasp_vulnweb_com",
"findings": []
}
]
+25 -3
View File
@@ -359,6 +359,7 @@ pub(crate) struct Spawned {
pub task: tokio::task::JoinHandle<RunOutput>,
pub rx: tokio::sync::mpsc::Receiver<String>,
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub soft: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub workdir: PathBuf,
}
@@ -408,6 +409,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
let refs: Vec<ModelRef> = cfg.models.iter().map(|s| ModelRef::parse(s)).collect();
let pool = ModelPool::with_auth(refs, cfg.concurrency, cfg.subscription, mcp_config);
let cancel = pool.cancel_handle();
let soft = pool.soft_handle();
let (tx, rx) = tokio::sync::mpsc::channel::<String>(256);
let task = tokio::spawn(async move {
match mode {
@@ -417,7 +419,25 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
Mode::Black => harness::run(cfg, &lib, &pool, tx).await,
}
});
Spawned { task, rx, cancel, workdir }
Spawned { task, rx, cancel, soft, workdir }
}
/// Absolute file:// URL of a run's report (PDF if present, else HTML).
pub(crate) fn report_url(workdir: &Path) -> String {
let pdf = workdir.join("report.pdf");
let f = if pdf.is_file() { pdf } else { workdir.join("report.html") };
let abs = f.canonicalize().unwrap_or(f);
format!("file://{}", abs.display())
}
/// Generate a report directly from raw (unvalidated) findings — used by the REPL
/// when the user chooses "report without validating" on /stop.
pub(crate) fn report_raw(target: &str, findings: &[harness::types::Finding], workdir: &Path) {
let mut fs = findings.to_vec();
harness::attack_graph::enrich(&mut fs);
std::fs::write(workdir.join("findings.json"), serde_json::to_string_pretty(&fs).unwrap_or_default()).ok();
let _ = harness::report::typst_report(target, &fs, workdir);
write_status(workdir, "stopped-raw", &format!("\"findings\":{}", fs.len()));
}
/// Generate the report + final status for a finished run, ensuring the workdir
@@ -433,7 +453,7 @@ pub(crate) fn finalize_run(mut out: RunOutput, workdir: &Path) -> RunOutput {
}
async fn run_mode(base: &Path, cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result<RunOutput> {
let Spawned { mut task, mut rx, cancel, workdir } = spawn_engagement(base, cfg, mcp, mode);
let Spawned { mut task, mut rx, cancel, workdir, .. } = spawn_engagement(base, cfg, mcp, mode);
let printer = tokio::spawn(async move {
while let Some(line) = rx.recv().await { render_line(&line); }
});
@@ -465,7 +485,8 @@ async fn run_mode(base: &Path, cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow:
}
let out = finalize_run(out, &workdir);
println!(" ✓ COMPLETE — {} validated finding(s) · status: {}/status.json", out.findings.len(), workdir.display());
println!(" ✓ COMPLETE — {} validated finding(s)", out.findings.len());
println!(" \x1b[36mreport: {}\x1b[0m", report_url(&workdir));
Ok(out)
}
@@ -560,6 +581,7 @@ pub(crate) fn render_compact(raw: &str) -> Option<String> {
if let Some((label, rest)) = stripped.split_once(' ') { who = format!("[{label}] "); line = rest; }
}
let (tag, rest) = line.split_once(": ").unwrap_or(("", line));
if tag == "finding_json" { return None; } // captured for /results & /finding, not shown
let s = match tag {
"exec" | "danger" => format!("\x1b[33m ⌘ {who}{}\x1b[0m", trunc1(rest, 110)),
"net" => format!("\x1b[36m 🌐 {who}{}\x1b[0m", trunc1(rest, 110)),
+150 -11
View File
@@ -13,7 +13,7 @@ use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::FileHistory;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{CompletionType, Config, Context, Editor, ExternalPrinter, Helper};
use rustyline::{Cmd, CompletionType, Config, Context, Editor, ExternalPrinter, Helper, KeyEvent};
use serde::{Deserialize, Serialize};
use std::io::IsTerminal;
use std::path::Path;
@@ -28,7 +28,9 @@ struct RunLive {
mode: &'static str,
phase: String,
started: Instant,
findings: Vec<(String, String)>, // sev, title
findings: Vec<(String, String)>, // sev, title (summary)
full: Vec<Finding>, // full candidate findings (PoC, evidence) for /finding
commands: Vec<String>, // full untruncated commands for /expand & Ctrl+O
agents: usize,
agents_done: usize,
}
@@ -67,14 +69,30 @@ impl RunLive {
}
}
}
// Full candidate finding (with PoC/evidence) for /results & /finding.
if let Some(j) = line.strip_prefix("finding_json: ") {
if let Ok(f) = serde_json::from_str::<Finding>(j) { self.full.push(f); }
}
// Full untruncated command for /expand & Ctrl+O.
let cmd_part = line.strip_prefix('@').and_then(|s| s.split_once(' ').map(|(_, r)| r)).unwrap_or(line);
if let Some(c) = cmd_part.strip_prefix("exec: ").or_else(|| cmd_part.strip_prefix("danger: ")) {
self.commands.push(c.to_string());
if self.commands.len() > 100 { self.commands.remove(0); }
}
}
}
/// What to do when the user stops a run.
#[derive(Clone, Copy, PartialEq)]
enum StopMode { Run, Validate, Raw, Discard }
/// A run executing in the background of the REPL.
struct ActiveRun {
live: Arc<Mutex<RunLive>>,
cancel: Arc<AtomicBool>,
soft: Arc<AtomicBool>,
done: Arc<AtomicBool>,
choice: Arc<Mutex<StopMode>>,
}
/// All slash-commands, for Tab completion.
@@ -205,6 +223,8 @@ impl Reader {
.completion_type(CompletionType::List).build();
if let Ok(mut ed) = Editor::<NsHelper, FileHistory>::with_config(cfg) {
ed.set_helper(Some(NsHelper));
// Ctrl+O pre-fills /expand to dump the last full (untruncated) commands.
ed.bind_sequence(KeyEvent::ctrl('o'), Cmd::Insert(1, "/expand".to_string()));
let hist = proj_dir().join("history.txt");
let _ = ed.load_history(&hist);
return Reader::Rl(Box::new(ed), hist);
@@ -376,7 +396,21 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
}
"/stop" => {
match &active {
Some(a) if !a.done.load(Ordering::Relaxed) => { a.cancel.store(true, Ordering::Relaxed); println!(" ⏸ stopping — finishing in-flight work; a report is generated on completion."); }
Some(a) if !a.done.load(Ordering::Relaxed) => {
println!(" \x1b[1mStop the run — choose:\x1b[0m");
println!(" \x1b[36m1\x1b[0m validate the findings found so far, then report \x1b[2m(recommended)\x1b[0m");
println!(" \x1b[36m2\x1b[0m report NOW without validating (raw findings)");
println!(" \x1b[36m3\x1b[0m discard (no report)");
let ans = ask_line(" choice [1/2/3]:");
match ans.trim() {
"2" => { *a.choice.lock().unwrap() = StopMode::Raw; a.cancel.store(true, Ordering::Relaxed);
println!(" ⏹ stopping — generating a RAW report from what was found…"); }
"3" => { *a.choice.lock().unwrap() = StopMode::Discard; a.cancel.store(true, Ordering::Relaxed);
println!(" 🗑 stopping — discarding this run."); }
_ => { *a.choice.lock().unwrap() = StopMode::Validate; a.soft.store(true, Ordering::Relaxed);
println!(" ⏸ stopping exploitation — validating what was found, then reporting…"); }
}
}
_ => println!(" no active run."),
}
}
@@ -394,7 +428,44 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
println!(" ↻ retest set up for {} ({} prior finding(s)) — /run to launch", r.target, titles.len());
}
}
"/results" => results(&history.lock().unwrap(), arg),
"/results" => {
// Live findings while a run is active (no arg), else a past run.
match &active {
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => {
let l = a.live.lock().unwrap();
println!(" ▶ live — {} possible finding(s) so far ({})", l.full.len(), l.phase);
let mut f = l.full.clone();
f.sort_by_key(|x| sev_rank(&x.severity));
for x in &f { println!(" • [{}] {} \x1b[2m({} · {})\x1b[0m", x.severity, x.title, x.agent, x.endpoint); }
if !f.is_empty() { println!(" \x1b[2m/finding — pick one to see the command & PoC\x1b[0m"); }
}
_ => results(&history.lock().unwrap(), arg),
}
}
"/finding" | "/findings" => {
// Build the finding pool: live run if active, else a past run.
let pool: Vec<Finding> = match &active {
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => a.live.lock().unwrap().full.clone(),
_ => { let h = history.lock().unwrap(); pick(&h, arg).map(|r| r.findings.clone()).unwrap_or_default() }
};
finding_detail(&pool);
}
"/expand" | "/full" => {
// Show full untruncated commands from the active run.
match &active {
Some(a) => {
let l = a.live.lock().unwrap();
let n: usize = arg.trim().parse().unwrap_or(5);
let cmds = &l.commands;
if cmds.is_empty() { println!(" no commands captured yet."); }
else {
println!(" ── last {} command(s) (full) ──", n.min(cmds.len()));
for c in cmds.iter().rev().take(n).rev() { println!(" \x1b[33m$ {c}\x1b[0m"); }
}
}
None => println!(" no active run — /expand shows full commands while a run streams."),
}
}
"/report" => open_report(&history.lock().unwrap(), arg),
"/status" => {
// Live status if a run is active, else a past run's status.json.
@@ -585,11 +656,14 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
let live = Arc::new(Mutex::new(RunLive {
target: target.clone(), mode: mode_s, phase: "starting".into(),
started: Instant::now(), findings: vec![], agents: 0, agents_done: 0,
started: Instant::now(), findings: vec![], full: vec![], commands: vec![],
agents: 0, agents_done: 0,
}));
let cancel = sp.cancel.clone();
let soft = sp.soft.clone();
let done = Arc::new(AtomicBool::new(false));
let (live2, done2, hist2) = (live.clone(), done.clone(), history);
let choice = Arc::new(Mutex::new(StopMode::Run));
let (live2, done2, hist2, choice2) = (live.clone(), done.clone(), history, choice.clone());
tokio::spawn(async move {
let crate::Spawned { task, mut rx, workdir, .. } = sp;
@@ -597,20 +671,40 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
live2.lock().unwrap().ingest(&line);
if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); }
}
let out = crate::finalize_run(task.await.unwrap_or_default(), &workdir);
let task_out = task.await.unwrap_or_default();
let mode_choice = *choice2.lock().unwrap();
if mode_choice == StopMode::Discard {
std::fs::remove_dir_all(&workdir).ok();
let _ = printer.print(format!("\x1b[33m🗑 run discarded — {}\x1b[0m", workdir.display()));
done2.store(true, Ordering::Relaxed);
return;
}
// Raw → report from the unvalidated candidates we captured live.
let (findings, validated_word) = if mode_choice == StopMode::Raw {
let raw = live2.lock().unwrap().full.clone();
crate::report_raw(&target, &raw, &workdir);
(raw, "unvalidated")
} else {
let out = crate::finalize_run(task_out, &workdir);
(out.findings, "validated")
};
let id = {
let mut h = hist2.lock().unwrap();
let id = h.len() + 1;
h.push(RunRecord { id, mode: mode_s.into(), target, workdir: out.workdir.clone(), findings: out.findings.clone() });
h.push(RunRecord { id, mode: mode_s.into(), target, workdir: workdir.display().to_string(), findings: findings.clone() });
if let Ok(j) = serde_json::to_string_pretty(&*h) { std::fs::write(proj_dir().join("runs.json"), j).ok(); }
id
};
let _ = printer.print(format!(
"\x1b[1;32m◀ run #{id} done — {} validated finding(s)\x1b[0m · /results {id} · /report {id}",
out.findings.len()));
"\x1b[1;32m◀ run #{id} done — {} {} finding(s)\x1b[0m · /results {id} · /finding",
findings.len(), validated_word));
let _ = printer.print(format!("\x1b[36m report: {}\x1b[0m", crate::report_url(&workdir)));
done2.store(true, Ordering::Relaxed);
});
Some(ActiveRun { live, cancel, done })
Some(ActiveRun { live, cancel, soft, done, choice })
}
/// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own
@@ -737,6 +831,51 @@ fn diff_runs(history: &[RunRecord]) {
if a == b { println!(" (no change in finding titles)"); }
}
fn sev_rank(s: &str) -> u8 {
match s { "Critical" => 0, "High" => 1, "Medium" => 2, "Low" => 3, _ => 4 }
}
/// Read one line synchronously (for the /stop choice prompt).
fn ask_line(prompt: &str) -> String {
use std::io::Write;
print!("{prompt} ");
std::io::stdout().flush().ok();
let mut s = String::new();
std::io::stdin().read_line(&mut s).ok();
s
}
/// Arrow-key selection menu over findings; prints EVERYTHING about the chosen one
/// (command/PoC, evidence, impact, remediation, votes, confidence).
fn finding_detail(pool: &[Finding]) {
if pool.is_empty() { println!(" no findings to inspect yet."); return; }
let mut f = pool.to_vec();
f.sort_by_key(|x| sev_rank(&x.severity));
let items: Vec<String> = f.iter().map(|x| format!("[{}] {}{}", x.severity, x.title, x.cwe)).collect();
let idx = if std::io::stdin().is_terminal() {
match dialoguer::Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a finding (↑/↓, enter)").items(&items).default(0).interact_opt() {
Ok(Some(i)) => i, _ => return,
}
} else { 0 };
let x = &f[idx];
println!("\n ┌─ \x1b[1m{}\x1b[0m", x.title);
println!(" │ severity : {}", x.severity);
println!(" │ cwe / cvss : {} · {}", x.cwe, x.cvss);
println!(" │ agent : {}", x.agent);
println!(" │ endpoint : {}", x.endpoint);
println!(" │ votes/conf : {} · {:.2}", x.votes, x.confidence);
println!(" ├─ \x1b[33mPayload / PoC\x1b[0m");
for l in x.payload.lines() { println!("{l}"); }
println!(" ├─ \x1b[36mEvidence (tool output)\x1b[0m");
for l in x.evidence.lines() { println!("{l}"); }
println!(" ├─ Impact");
for l in x.impact.lines() { println!("{l}"); }
println!(" ├─ Remediation");
for l in x.remediation.lines() { println!("{l}"); }
println!(" └─────");
}
fn run_status(history: &[RunRecord], arg: &str) {
let Some(r) = pick(history, arg) else { return };
match std::fs::read_to_string(Path::new(&r.workdir).join("status.json")) {
@@ -159,7 +159,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let directives = directives.clone();
let txc = tx.clone();
async move {
if pool.is_cancelled() {
if pool.stop_exploiting() {
return (ag.name.clone(), String::new(), vec![]);
}
if verbose {
@@ -184,6 +184,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
// Live findings feed: surface each candidate the moment it appears.
for c in &f {
let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await;
if let Ok(j) = serde_json::to_string(c) { let _ = txc.send(format!("finding_json: {j}")).await; }
}
(ag.name.clone(), text, f)
}
@@ -378,7 +379,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
let leads = leads_ctx.clone();
let txc = tx.clone();
async move {
if pool.is_cancelled() {
if pool.stop_exploiting() {
return (ag.name.clone(), String::new(), vec![]);
}
if verbose {
@@ -931,7 +932,7 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
let directives = directives.clone();
let txc = tx.clone();
async move {
if pool.is_cancelled() { return (ag.name.clone(), String::new(), vec![]); }
if pool.stop_exploiting() { return (ag.name.clone(), String::new(), vec![]); }
if verbose {
let _ = txc.send(format!(" ▶ launching agent: {} ({})", ag.name, ag.title.replace(" Agent", ""))).await;
}
@@ -944,7 +945,10 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
Ok((m, text)) => {
let f = extract_findings(&text, &ag.name);
let _ = txc.send(format!("test {} via {}{} candidate(s)", ag.name, m.label(), f.len())).await;
for c in &f { let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await; }
for c in &f {
let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await;
if let Ok(j) = serde_json::to_string(c) { let _ = txc.send(format!("finding_json: {j}")).await; }
}
(ag.name.clone(), text, f)
}
Err(e) => { let _ = txc.send(format!("test {} failed: {e}", ag.name)).await;
+16 -3
View File
@@ -34,9 +34,11 @@ pub struct ModelPool {
/// Progress channel: when set, the subscription CLI streams structured
/// activity (tools called, commands run, files read) here live.
progress: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<String>>>,
/// Cooperative cancellation: when set, in-flight model calls short-circuit
/// and the pipeline stops launching new agents (graceful stop).
/// HARD cancellation: when set, in-flight model calls short-circuit (abort).
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// SOFT stop: stop launching new EXPLOIT agents, but let in-flight finish and
/// VALIDATION still run — so "stop and validate what was found" works.
soft: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl ModelPool {
@@ -65,6 +67,7 @@ impl ModelPool {
mcp_config,
progress: std::sync::Mutex::new(None),
cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)),
soft: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
@@ -80,13 +83,23 @@ impl ModelPool {
self.progress.lock().ok().and_then(|g| g.clone())
}
/// Handle to request graceful cancellation of an in-progress engagement.
/// Handle to request HARD cancellation (abort all model calls).
pub fn cancel_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
self.cancel.clone()
}
/// Handle to request a SOFT stop (stop launching new exploit agents; keep
/// validation running).
pub fn soft_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
self.soft.clone()
}
pub fn is_cancelled(&self) -> bool {
self.cancel.load(std::sync::atomic::Ordering::Relaxed)
}
/// Should the exploit phase stop launching new agents? (hard OR soft stop)
pub fn stop_exploiting(&self) -> bool {
self.cancel.load(std::sync::atomic::Ordering::Relaxed)
|| self.soft.load(std::sync::atomic::Ordering::Relaxed)
}
/// One completion for a model, via subscription CLI (optionally with MCP) or
/// HTTP API, with a short retry/backoff. `label` (e.g. the agent name) tags
+3 -2
View File
@@ -139,9 +139,10 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
));
data.push_str("#let findings = (\n");
for f in sorted_findings(findings) {
let owasp = if f.owasp.is_empty() { f.cwe.clone() } else { f.owasp.clone() };
data.push_str(&format!(
" (severity: {}, title: {}, agent: {}, cwe: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}),\n",
tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&f.cvss),
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}),\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(&f.evidence), tq(&f.impact),
tq(&f.remediation), tq(&f.votes), f.confidence,
));
+35 -10
View File
@@ -71,13 +71,32 @@
)
]
#let sorted = findings.sorted(key: f => sevrank(f.severity))
// ---- Vulnerability summary table ----
#if sorted.len() > 0 [
#v(8pt)
== Vulnerability Summary
#v(4pt)
#table(
columns: (auto, 1fr, auto, auto, auto),
inset: 6pt, align: (left + horizon, left + horizon, center + horizon, center + horizon, center + horizon),
stroke: 0.5pt + rgb("#dddddd"),
table.header(
text(weight: "bold")[\#], text(weight: "bold")[Vulnerability],
text(weight: "bold")[Severity], text(weight: "bold")[CVSS], text(weight: "bold")[OWASP / CWE],
),
..sorted.enumerate().map(((i, f)) => (
str(i + 1), f.title, sevbadge(f.severity), f.cvss, f.owasp,
)).flatten()
)
]
#v(10pt)
#line(length: 100%, stroke: 0.5pt + gray)
// ---- Findings ----
// ---- Detailed findings ----
= Findings
#let sorted = findings.sorted(key: f => sevrank(f.severity))
#if sorted.len() == 0 [
#text(fill: gray)[_Nothing to report._]
]
@@ -86,14 +105,20 @@
stroke: (left: 3pt + sevcolor.at(f.severity, default: gray), rest: 0.5pt + rgb("#dddddd")))[
#sevbadge(f.severity) #h(6pt) #text(12pt, weight: "bold")[#str(i + 1). #f.title]
#v(4pt)
#text(9pt, fill: gray)[
agent: #raw(f.agent) · CWE: #f.cwe · CVSS: #f.cvss · votes: #f.votes · confidence: #str(f.confidence)
]
#v(2pt) #text(9pt)[Endpoint: #raw(f.endpoint)]
#v(5pt) #strong[Payload] #linebreak() #raw(f.payload)
#table(
columns: (auto, 1fr, auto, 1fr),
inset: 4pt, stroke: none, align: left + horizon,
text(8pt, fill: gray)[Criticality], text(8pt)[#f.severity],
text(8pt, fill: gray)[CVSS], text(8pt)[#f.cvss],
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)[Location], text(8pt)[#raw(f.endpoint)],
text(8pt, fill: gray)[Agent], text(8pt)[#raw(f.agent)],
)
#v(4pt) #strong[Description / Impact] #linebreak() #text(9pt)[#f.impact]
#v(4pt) #strong[Proof of Concept] #linebreak() #raw(f.payload)
#v(3pt) #strong[Evidence] #linebreak() #raw(f.evidence)
#v(3pt) #strong[Impact:] #f.impact
#v(2pt) #strong[Remediation:] #f.remediation
#v(3pt) #strong[Remediation] #linebreak() #text(9pt)[#f.remediation]
]
#v(8pt)
]