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) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 21:14:06 -03:00
parent d864ea8b8a
commit 1be053c4a2
7 changed files with 199 additions and 6 deletions
+10 -2
View File
@@ -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<String> = 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)");
}
}
@@ -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<usize, Vec<&Finding>> = 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!(" {}[\"{}<br/>{} · {}\"]\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<usize> = 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<usize, Vec<&Finding>> = 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<String> = 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()
}
+1
View File
@@ -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;
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers() -> Vec<Provider> {
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",
@@ -596,9 +596,11 @@ async fn validate(candidates: Vec<Finding>, 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<Finding>,
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, mut findings: Vec<Finding>,
selected: Vec<Agent>, rl: &mut RlState, tx: Sender<String>) -> 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<Finding> {
confidence: conf(o.get("confidence")),
validated: false,
votes: String::new(),
..Default::default()
})
})
.collect()
+20 -2
View File
@@ -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!(
"<tr><td>{}</td><td><span class=sev style=background:{}>{}</span></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(&f.stage), sev_color(&f.severity), esc(&f.severity), esc(&f.title),
esc(&f.owasp), esc(&f.mitre), esc(&f.exploitability))).collect();
format!(
"<h2>Attack Path &amp; Kill Chain</h2>\
<div class=mermaid>{graph}</div>\
<table class=kc><tr><th>Stage</th><th>Sev</th><th>Finding</th><th>OWASP</th><th>MITRE</th><th>Exploitability</th></tr>{rows}</table>\
<script type=module>import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';mermaid.initialize({{startOnLoad:true,theme:'dark'}});</script>"
)
};
format!(
"<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — {t}</title><style>\
table.kc{{border-collapse:collapse;width:100%;margin:14px 0;font-size:13px}}table.kc th,table.kc td{{border:1px solid #e3e3e3;padding:6px 9px;text-align:left}}\
.mermaid{{background:#0f1117;border-radius:10px;padding:16px;margin:14px 0;overflow:auto}}\
body{{font:14px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;max-width:860px;margin:40px auto;padding:0 24px}}\
h1{{margin:0}}.meta{{color:#666;margin:4px 0 18px}}.chip{{color:#fff;border-radius:999px;padding:4px 12px;margin-right:8px;font-size:13px;font-weight:600}}\
.finding{{border:1px solid #e3e3e3;border-radius:12px;padding:16px 20px;margin:16px 0}}.finding h3{{margin:0 0 8px;font-size:16px}}\
@@ -80,9 +98,9 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
<div class=meta>Target: <b>{t}</b> · v3.5.0 Rust harness · multi-model validated</div>\
<div>{chips}</div><h2>Findings ({n})</h2>{body}\
<div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.5.0 · by <b>Joas A Santos</b> &amp; <b>Red Team Leaders</b></p></body></html>",
t = esc(target), chips = chips, n = sorted.len(), body = body,
t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block,
)
}
@@ -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<String>,
}
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(),
}
}
}