From 1be053c4a2e295df93135608ed5c754d1d94aa2b Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Wed, 24 Jun 2026 21:14:06 -0300 Subject: [PATCH] v3.5.0: attack graph + kill chain (OWASP/CWE/MITRE) + GPT 5.5/5.4/5.3-codex/5.2 + report graph - Finding enriched with owasp / mitre / kill-chain stage / exploitability / business_impact / chains_from (attack-path edges). - attack_graph module: derive OWASP Top 10 + MITRE ATT&CK technique + kill-chain stage from CWE (heuristic, no extra model call); render a Mermaid attack-path flowchart (findings grouped by stage, explicit + implicit edges) and an ASCII kill chain for the REPL. - enrich() runs in finish() for every engagement. - HTML report gains an "Attack Path & Kill Chain" section (Mermaid via CDN, dark) plus a stage/sev/OWASP/MITRE/exploitability table. - REPL print_findings shows the ASCII kill-chain + severity summary after a run. - models: add GPT-5.5, GPT-5.4, GPT-5.4-mini, GPT-5.3-codex, GPT-5.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- neurosploit-rs/app/src/main.rs | 12 +- .../crates/harness/src/attack_graph.rs | 138 ++++++++++++++++++ neurosploit-rs/crates/harness/src/lib.rs | 1 + neurosploit-rs/crates/harness/src/models.rs | 2 +- neurosploit-rs/crates/harness/src/pipeline.rs | 5 +- neurosploit-rs/crates/harness/src/report.rs | 22 ++- neurosploit-rs/crates/harness/src/types.rs | 25 ++++ 7 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 neurosploit-rs/crates/harness/src/attack_graph.rs diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 7810a46..d7080b2 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -345,9 +345,17 @@ async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> any pub(crate) fn print_findings(out: &RunOutput) { println!("\n=== {} validated finding(s) ===", out.findings.len()); - println!("{}", serde_json::to_string_pretty(&out.findings).unwrap_or_default()); + if !out.findings.is_empty() { + let mut by = std::collections::BTreeMap::new(); + for f in &out.findings { *by.entry(f.severity.as_str()).or_insert(0) += 1; } + let chips: Vec = by.iter().map(|(k, v)| format!("{k}:{v}")).collect(); + println!(" severity: {}", chips.join(" ")); + println!("\n \x1b[1mAttack path / kill chain\x1b[0m"); + print!("{}", harness::attack_graph::ascii_killchain(&out.findings)); + } if !out.artifacts.is_empty() { - println!("artifacts: {}", out.artifacts.join(", ")); + println!("\n artifacts: {}", out.artifacts.join(", ")); + println!(" (full attack graph rendered in report.html)"); } } diff --git a/neurosploit-rs/crates/harness/src/attack_graph.rs b/neurosploit-rs/crates/harness/src/attack_graph.rs new file mode 100644 index 0000000..320a736 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/attack_graph.rs @@ -0,0 +1,138 @@ +//! Attack graph & kill-chain mapping. +//! +//! Enriches findings with OWASP Top 10 / MITRE ATT&CK / kill-chain stage / +//! exploitability (derived from CWE + severity when the model didn't supply +//! them), then renders an attack-path graph (Mermaid) and a kill-chain table for +//! the report, plus a compact ASCII summary for the REPL. + +use crate::types::Finding; + +/// CWE → (OWASP Top 10 2021, MITRE ATT&CK technique, kill-chain stage). +fn map_cwe(cwe: &str) -> (&'static str, &'static str, &'static str) { + let n: u32 = cwe.trim_start_matches("CWE-").parse().unwrap_or(0); + match n { + 89 | 943 => ("A03:2021-Injection", "T1190", "initial-access"), + 77 | 78 | 94 | 95 | 917 | 1336 => ("A03:2021-Injection", "T1059", "execution"), + 79 | 80 => ("A03:2021-Injection", "T1059.007", "execution"), + 90 => ("A03:2021-Injection", "T1190", "initial-access"), + 611 | 776 => ("A05:2021-Security-Misconfiguration", "T1190", "initial-access"), + 918 => ("A10:2021-SSRF", "T1090", "lateral"), + 22 | 23 | 98 | 73 => ("A01:2021-Broken-Access-Control", "T1083", "execution"), + 639 | 862 | 863 | 284 | 285 => ("A01:2021-Broken-Access-Control", "T1078", "privesc"), + 287 | 384 | 613 | 620 => ("A07:2021-Auth-Failures", "T1078", "initial-access"), + 798 | 522 | 321 | 256 | 257 | 312 | 319 => ("A07:2021-Auth-Failures", "T1552", "credential-access"), + 502 => ("A08:2021-Software-Data-Integrity", "T1059", "execution"), + 327 | 328 | 916 | 326 | 330 => ("A02:2021-Cryptographic-Failures", "T1600", "credential-access"), + 200 | 209 | 538 | 540 | 532 => ("A05:2021-Security-Misconfiguration", "T1592", "recon"), + 601 => ("A01:2021-Broken-Access-Control", "T1566", "initial-access"), + 352 => ("A01:2021-Broken-Access-Control", "T1189", "execution"), + 434 => ("A04:2021-Insecure-Design", "T1505.003", "execution"), + 1321 | 915 => ("A08:2021-Software-Data-Integrity", "T1059", "execution"), + 400 | 770 | 1333 | 799 => ("A04:2021-Insecure-Design", "T1499", "impact"), + _ => ("A04:2021-Insecure-Design", "T1190", "initial-access"), + } +} + +fn exploitability(sev: &str, conf: f64) -> &'static str { + match (sev, conf) { + (_, c) if c >= 0.85 => "trivial", + ("Critical" | "High", _) => "moderate", + _ => "hard", + } +} + +/// 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() { + let (owasp, mitre, stage) = map_cwe(&f.cwe); + if f.owasp.is_empty() { f.owasp = owasp.into(); } + if f.mitre.is_empty() { f.mitre = mitre.into(); } + 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(); } + } +} + +const STAGE_ORDER: &[&str] = &[ + "recon", "initial-access", "execution", "credential-access", "privesc", "lateral", "exfil", "impact", +]; + +fn stage_rank(s: &str) -> usize { + STAGE_ORDER.iter().position(|x| *x == s).unwrap_or(STAGE_ORDER.len()) +} + +/// Mermaid flowchart of the attack path: findings grouped by kill-chain stage, +/// with explicit chains_from edges plus implicit stage→stage progression. +pub fn mermaid(findings: &[Finding]) -> String { + if findings.is_empty() { + return String::new(); + } + let mut out = String::from("flowchart LR\n"); + // stage subgraphs + let mut by_stage: std::collections::BTreeMap> = Default::default(); + for f in findings { + by_stage.entry(stage_rank(&f.stage)).or_default().push(f); + } + let node_id = |f: &Finding| -> String { + format!("n{}", sanitize_id(&f.id)) + }; + for (rank, group) in &by_stage { + let stage = STAGE_ORDER.get(*rank).copied().unwrap_or("other"); + out.push_str(&format!(" subgraph S{rank}[\"{}\"]\n", stage)); + for f in group { + out.push_str(&format!(" {}[\"{}
{} · {}\"]\n", + node_id(f), esc(&f.title), esc(&f.severity), esc(&f.owasp))); + } + out.push_str(" end\n"); + } + // explicit chain edges + let ids: std::collections::HashMap<&str, &Finding> = findings.iter().map(|f| (f.id.as_str(), f)).collect(); + let mut had_edge = false; + for f in findings { + for src in &f.chains_from { + if let Some(sf) = ids.get(src.as_str()) { + out.push_str(&format!(" {} --> {}\n", node_id(sf), node_id(f))); + had_edge = true; + } + } + } + // implicit progression between consecutive populated stages if no explicit edges + if !had_edge && by_stage.len() > 1 { + let ranks: Vec = by_stage.keys().copied().collect(); + for w in ranks.windows(2) { + if let (Some(a), Some(b)) = (by_stage[&w[0]].first(), by_stage[&w[1]].first()) { + out.push_str(&format!(" {} -.-> {}\n", node_id(a), node_id(b))); + } + } + } + out +} + +/// Compact ASCII kill-chain for the REPL: one line per stage with its findings. +pub fn ascii_killchain(findings: &[Finding]) -> String { + if findings.is_empty() { + return " (no findings to map)".into(); + } + let mut by_stage: std::collections::BTreeMap> = Default::default(); + for f in findings { + by_stage.entry(stage_rank(&f.stage)).or_default().push(f); + } + let mut out = String::new(); + for (rank, group) in &by_stage { + let stage = STAGE_ORDER.get(*rank).copied().unwrap_or("other"); + out.push_str(&format!(" ▸ {:<16} ", stage)); + let items: Vec = group.iter() + .map(|f| format!("[{}] {} ({})", f.severity, f.title, f.mitre)) + .collect(); + out.push_str(&items.join("\n ")); + out.push('\n'); + } + out +} + +fn sanitize_id(s: &str) -> String { + s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).take(24).collect() +} +fn esc(s: &str) -> String { + s.replace('"', "'").replace('\n', " ").chars().take(60).collect() +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index 36f6e92..6a80737 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -7,6 +7,7 @@ //! **N-model voting** before scoring and reporting. pub mod agents; +pub mod attack_graph; pub mod creds; pub mod models; pub mod pipeline; diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index a286241..fb9e17d 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -25,7 +25,7 @@ pub fn providers() -> Vec { Provider { key: "anthropic", label: "Anthropic Claude", base_url: "https://api.anthropic.com/v1", env_key: "ANTHROPIC_API_KEY", kind: "cli", models: vec!["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] }, Provider { key: "openai", label: "OpenAI (ChatGPT)", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli", - models: vec!["gpt-5.1", "gpt-5.1-codex", "o4"] }, + models: vec!["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2", "gpt-5.1", "gpt-5.1-codex", "o4"] }, Provider { key: "xai", label: "xAI Grok", base_url: "https://api.x.ai/v1", env_key: "XAI_API_KEY", kind: "cli", models: vec!["grok-4", "grok-4-fast"] }, Provider { key: "gemini", label: "Google Gemini", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", env_key: "GEMINI_API_KEY", kind: "cli", diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index facd3f9..263d90d 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -596,9 +596,11 @@ async fn validate(candidates: Vec, pool: &ModelPool, sys: &str, vote_n: validated.into_iter().filter(|f| f.validated).collect() } -async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, findings: Vec, +async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, mut findings: Vec, selected: Vec, rl: &mut RlState, tx: Sender) -> RunOutput { let _ = tx.send(format!("{} validated finding(s)", findings.len())).await; + // Map findings to OWASP / MITRE / kill-chain stage for the attack graph. + crate::attack_graph::enrich(&mut findings); // RL update: reward agents that produced validated findings; gently decay idle. let hit: std::collections::HashMap<&str, f64> = findings.iter().fold(Default::default(), |mut m, f| { @@ -725,6 +727,7 @@ fn extract_findings(text: &str, agent: &str) -> Vec { confidence: conf(o.get("confidence")), validated: false, votes: String::new(), + ..Default::default() }) }) .collect() diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index 9ac787f..854399b 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -69,8 +69,26 @@ pub fn html(target: &str, findings: &[Finding]) -> String { rows }; + // Attack graph (Mermaid) + kill-chain table. + let graph = crate::attack_graph::mermaid(&sorted); + let graph_block = if graph.is_empty() { + String::new() + } else { + let rows: String = sorted.iter().map(|f| format!( + "{}{}{}{}{}{}", + esc(&f.stage), sev_color(&f.severity), esc(&f.severity), esc(&f.title), + esc(&f.owasp), esc(&f.mitre), esc(&f.exploitability))).collect(); + format!( + "

Attack Path & Kill Chain

\ +
{graph}
\ + {rows}
StageSevFindingOWASPMITREExploitability
\ + " + ) + }; format!( "NeuroSploit Report — {t}\

NeuroSploit Penetration Test Report

\
Target: {t} · v3.5.0 Rust harness · multi-model validated
\ -
{chips}

Findings ({n})

{body}\ +
{chips}
{graph_block}

Findings ({n})

{body}\

Authorized testing only. Findings confirmed by multi-model adversarial voting.
NeuroSploit v3.5.0 · by Joas A Santos & Red Team Leaders

", - t = esc(target), chips = chips, n = sorted.len(), body = body, + t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block, ) } diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index 43753a1..fc8b115 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -28,6 +28,25 @@ pub struct Finding { /// Per-model vote summary, e.g. "3/4 confirmed". #[serde(default)] pub votes: String, + // --- attack-graph / kill-chain mapping (best-effort, optional) --- + /// OWASP Top 10 category, e.g. "A03:2021-Injection". + #[serde(default)] + pub owasp: String, + /// MITRE ATT&CK technique id, e.g. "T1190". + #[serde(default)] + pub mitre: String, + /// Kill-chain stage: recon|initial-access|execution|privesc|lateral|exfil|impact. + #[serde(default)] + pub stage: String, + /// Exploitability: trivial|moderate|hard. + #[serde(default)] + pub exploitability: String, + /// Business impact, one line. + #[serde(default)] + pub business_impact: String, + /// IDs of findings this one chains from (attack-path edges). + #[serde(default)] + pub chains_from: Vec, } impl Default for Finding { @@ -47,6 +66,12 @@ impl Default for Finding { confidence: 0.0, validated: false, votes: String::new(), + owasp: String::new(), + mitre: String::new(), + stage: String::new(), + exploitability: String::new(), + business_impact: String::new(), + chains_from: Vec::new(), } } }