feat(web+report): Wiz-style attack path nodes, inline finding details, PDF formatting

- Graph: circular SVG-icon nodes with colored rings by entity type
  (Host, Finding, Impact, Credential, etc.) — Wiz/Orca visual style
- Graph: per-finding inline detail panel with CWE/CVSS/OWASP/MITRE
  cards instead of modal popup
- Graph: legend uses colored dots matching node ring colors
- Settings: fix LLM Provider Keys modal using correct CSS classes
  (modal-head/modal-foot instead of modal-header/modal-footer)
- Report PDF: test accounts rendered as bulleted list instead of
  run-on paragraph; impact/remediation preserve paragraph breaks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqW6BbQgqGkEDn3RYuCVoM
This commit is contained in:
CyberSecurityUPandClaude Opus 4.6 committed 2026-10-16 17:38:02 -03:00
1 parent f796547803
commit edc3d83c64
5 files changed
+196 -88

No files matched your search

+2 -2
View File
@@ -940,7 +940,7 @@ dependencies = [
[[package]]
name = "neurosploit"
version = "4.3.2"
version = "4.3.3"
dependencies = [
"anyhow",
"clap",
@@ -957,7 +957,7 @@ dependencies = [
[[package]]
name = "neurosploit-harness"
version = "4.3.2"
version = "4.3.3"
dependencies = [
"anyhow",
"base64",
+26 -5
View File
@@ -501,6 +501,16 @@ fn tq(s: &str) -> String {
format!("\"{}\"", cleaned)
}
/// Like `tq` but preserves newlines as literal `\n` inside the Typst string,
/// so multi-paragraph text renders with proper line breaks.
fn tq_multi(s: &str) -> String {
let cleaned: String = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\r', "");
format!("\"{}\"", cleaned)
}
/// Generate a self-contained `report.typ` (data + bundled template) in `dir`
/// and compile it to `report.pdf` via the `typst` binary. Falls back to leaving
/// the `.typ` when `typst` is unavailable.
@@ -516,16 +526,27 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
let confirmed: Vec<&Finding> = sorted.iter().filter(|f| !needs_review(f)).collect();
let review: Vec<&Finding> = sorted.iter().filter(|f| needs_review(f)).collect();
let asset = if meta.asset.is_empty() { "unidentified web asset".to_string() } else { meta.asset.clone() };
let accounts = findings.iter().find(|f| f.id == "test-accounts").map(|f| f.evidence.clone()).unwrap_or_default();
let accounts_raw = findings.iter().find(|f| f.id == "test-accounts").map(|f| f.evidence.clone()).unwrap_or_default();
let account_lines: Vec<&str> = accounts_raw.split('\n')
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let accounts_header = account_lines.first().copied().unwrap_or("");
let account_items: Vec<&str> = account_lines.iter().skip(1)
.filter(|l| l.starts_with("•") || l.starts_with("-") || l.starts_with("*"))
.map(|l| l.trim_start_matches(['•', '-', '*', ' ']))
.collect();
let mut data = String::new();
let accounts_arr = format!("({})", account_items.iter().map(|a| format!("{},", tq(a))).collect::<String>());
data.push_str(&format!(
"#let meta = (target: {}, asset: {}, tech: {}, server: {}, run_id: {}, generated: {}, model: {}, exec: {}, conclusion: {}, accounts: {}, provenance: {})\n",
"#let meta = (target: {}, asset: {}, tech: {}, server: {}, run_id: {}, generated: {}, model: {}, exec: {}, conclusion: {}, accounts_header: {}, accounts: {}, provenance: {})\n",
tq(target), tq(&asset), tq(&meta.tech.join(", ")), tq(&meta.server),
tq(&run_id), tq("July 2026"), tq("multi-model"),
tq(&strip_md(&exec_summary(target, &meta, &confirmed, &review))),
tq(&strip_md(&conclusion(target, &meta, &confirmed, &review))),
tq(&strip_md(&accounts)),
tq(&strip_md(accounts_header)),
accounts_arr,
tq(&crate::provenance::Provenance::process().tag()),
));
data.push_str("#let findings = (\n");
@@ -543,8 +564,8 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
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(&wrappable(&f.payload)), tq(&wrappable(&technical_evidence(f))), tq(&f.impact),
tq(&f.remediation), tq(&f.votes), f.confidence, tq(status),
tq(&f.endpoint), tq(&wrappable(&f.payload)), tq(&wrappable(&technical_evidence(f))), tq_multi(&f.impact),
tq_multi(&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)),
+24 -4
View File
@@ -138,13 +138,33 @@
]
// ---- Test accounts created ----
#if meta.accounts != "" [
#let acct_list = meta.at("accounts", default: ())
#if type(acct_list) == array and acct_list.len() > 0 [
#v(8pt)
== Test Accounts Created (delete after)
#v(3pt)
#text(9pt, fill: gray)[Created to reach the authenticated surface. Credentials are in the run vault (.neurosploit/vault/<run-id>.json); delete once testing is complete.]
#v(3pt)
#block(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#faf7ff"), text(9pt)[#meta.accounts])
#text(9pt, fill: gray)[Created to reach the authenticated surface. Credentials are in the run vault (.neurosploit/vault/.json); delete once testing is complete.]
#v(2pt)
#let hdr = meta.at("accounts_header", default: "")
#if hdr != "" [ #text(9pt)[#hdr] #v(4pt) ]
#block(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#faf7ff"), stroke: 0.5pt + rgb("#e4e4e8"))[
#set text(size: 7.5pt, font: ("Menlo", "DejaVu Sans Mono", "Courier New"))
#set par(leading: 0.6em)
#for acct in acct_list [
#box(width: 4pt, text(fill: rgb("#7c5cff"))[•]) #acct \
]
]
] else [
// Fallback for old-format string accounts
#let acct_str = meta.at("accounts_header", default: "")
#if acct_str != "" [
#v(8pt)
== Test Accounts Created (delete after)
#v(3pt)
#text(9pt, fill: gray)[Created to reach the authenticated surface. Credentials are in the run vault (.neurosploit/vault/.json); delete once testing is complete.]
#v(3pt)
#block(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#faf7ff"), text(9pt)[#acct_str])
]
]
#v(10pt)