feat(chain): attack paths derived per vulnerability, after every agent has reported

The Arena engagement produced 24 findings, a graph with 83 edges, and exactly
one chain edge. Two defects, both a step nobody was doing rather than a model
reasoning badly.

chains_from came back empty on every finding. An agent works one vulnerability
and has no view of what the other twelve found, so asking it to link its result
to findings it never saw was asking for something it cannot know. Chaining now
happens after the whole set is visible, on rules about ENABLEMENT: what one
weakness yields that another needs. Account enumeration yields valid
identities; absent throttling turns them into unlimited guesses; a permissive
password policy makes the guessing land. None is severe alone, and that
sequence is how accounts get taken over — on the real data it now reads
CWE-307 <- CWE-204, CWE-208 and CWE-614 <- CWE-319.

The CWE->stage fallback sent 23 of 24 findings to initial-access, so the kill
chain had one populated column and drew a star. Enumeration and side channels
are discovery; missing throttling, password policy, cookie flags and session
fixation are credential-access; hardening headers are recon. The same run now
spreads across credential-access 13, discovery 6, initial-access 5.

Two bugs the tests and the real data caught:
- CWE-614 both yields session material and needs it, so a class chained to
  itself: duplicates formed a circular "attack path" from a cookie flag to the
  same cookie flag. A weakness class no longer enables itself.
- apply_links only fills an empty chains_from, which is right for asserted
  chains and wrong for links written by an older version of these rules — a
  report kept the circular link through two rebuilds because nothing was
  allowed to touch it. repair() now drops links that cannot be true whoever
  wrote them: self-references, same-class links, dangling ids, cross-host
  links.

enrich() still only fills empty fields during a run (an agent's judgement
should survive), but a rebuild applies the current mappings via remap_stages —
otherwise a finished run is frozen with whatever taxonomy existed that day.

path_for() gives the per-vulnerability view: what precedes this finding, what
it enables, and the narrative to print beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-09-14 00:49:52 -03:00
co-authored by Claude Opus 5
parent 0422b8dc41
commit fb65074cab
5 changed files with 528 additions and 0 deletions
@@ -25,6 +25,25 @@ fn map_cwe(cwe: &str) -> (&'static str, &'static str, &'static str) {
502 => ("A08:2021-Software-Data-Integrity", "T1059", "execution"), 502 => ("A08:2021-Software-Data-Integrity", "T1059", "execution"),
327 | 328 | 916 | 326 | 330 => ("A02:2021-Cryptographic-Failures", "T1600", "credential-access"), 327 | 328 | 916 | 326 | 330 => ("A02:2021-Cryptographic-Failures", "T1600", "credential-access"),
200 | 209 | 538 | 540 | 532 => ("A05:2021-Security-Misconfiguration", "T1592", "recon"), 200 | 209 | 538 | 540 | 532 => ("A05:2021-Security-Misconfiguration", "T1592", "recon"),
// Enumeration and side channels are DISCOVERY, not initial access. The
// fallback arm below was sending these to initial-access, which put 23
// of 24 findings from a real engagement into one stage and flattened
// the kill chain into a star.
203 | 204 | 208 => ("A01:2021-Broken-Access-Control", "T1589", "discovery"),
// Missing throttling enables credential attacks; it is not access.
307 | 770 | 799 => ("A07:2021-Auth-Failures", "T1110", "credential-access"),
// Password policy.
521 | 261 | 262 | 263 => ("A07:2021-Auth-Failures", "T1110.001", "credential-access"),
// Missing hardening headers are a configuration observation.
693 | 1021 | 1018 => ("A05:2021-Security-Misconfiguration", "T1592", "recon"),
// Cookie flags expose session material.
614 | 1004 | 1275 => ("A05:2021-Security-Misconfiguration", "T1539", "credential-access"),
// CORS reads across origins.
942 | 346 | 1385 => ("A05:2021-Security-Misconfiguration", "T1190", "initial-access"),
// Mass assignment writes state.
915 | 913 => ("A08:2021-Software-Data-Integrity", "T1565", "impact"),
// Session fixation.
384 => ("A07:2021-Auth-Failures", "T1539", "credential-access"),
601 => ("A01:2021-Broken-Access-Control", "T1566", "initial-access"), 601 => ("A01:2021-Broken-Access-Control", "T1566", "initial-access"),
352 => ("A01:2021-Broken-Access-Control", "T1189", "execution"), 352 => ("A01:2021-Broken-Access-Control", "T1189", "execution"),
434 => ("A04:2021-Insecure-Design", "T1505.003", "execution"), 434 => ("A04:2021-Insecure-Design", "T1505.003", "execution"),
@@ -376,6 +395,33 @@ pub fn enrich(findings: &mut [Finding]) {
} }
} }
/// Recompute the kill-chain stage from the CWE, overwriting what is there.
///
/// `enrich` only fills empty fields, which is right during a run — an agent's
/// own judgement should survive. On a REBUILD it is wrong: a finished run's
/// stages were written by an older mapping, and the whole point of rebuilding
/// is to apply the current one. A real engagement had 23 of 24 findings sitting
/// in `initial-access` because the old fallback put them there, and no rebuild
/// could fix it.
pub fn remap_stages(findings: &mut [Finding]) -> usize {
let mut changed = 0;
for f in findings.iter_mut() {
if f.cwe.is_empty() {
continue;
}
let (owasp, mitre, stage) = map_cwe(&f.cwe);
if f.stage != stage {
f.stage = stage.into();
changed += 1;
}
// OWASP and MITRE travel with the stage; leaving them stale would make
// the report internally inconsistent.
f.owasp = owasp.into();
f.mitre = mitre.into();
}
changed
}
const STAGE_ORDER: &[&str] = &[ const STAGE_ORDER: &[&str] = &[
"recon", "initial-access", "execution", "credential-access", "privesc", "lateral", "exfil", "impact", "recon", "initial-access", "execution", "credential-access", "privesc", "lateral", "exfil", "impact",
]; ];
+468
View File
@@ -0,0 +1,468 @@
//! Attack paths, derived per vulnerability.
//!
//! The Arena engagement produced 24 findings, a graph with 83 edges — and
//! exactly **one** chain edge, inferred. Two defects caused that, and both are
//! about a step nobody was doing rather than a model reasoning badly:
//!
//! 1. `chains_from` came back empty on every finding. Agents work one
//! vulnerability at a time and have no view of what the other twelve agents
//! found, so asking each of them to link its result to findings it never saw
//! is asking for something it cannot know.
//! 2. The CWE→stage mapping sent 23 of 24 findings to `initial-access` through
//! its fallback arm. With one populated stage there is no progression to
//! draw, so the kill-chain view collapsed into a star.
//!
//! So chaining happens **here**, after every agent has reported, where the
//! whole finding set is visible at once. The rules are about *enablement*: what
//! one weakness gives an attacker that another one needs. Account enumeration
//! yields a list of valid identities; absent rate limiting turns that list into
//! guessing attempts; a permissive password policy makes the guessing land.
//! None of those three is severe alone, and the sequence is how accounts get
//! taken over.
//!
//! Every derived edge says it was derived, carries the reason, and never
//! outranks an edge an agent asserted from evidence.
use crate::types::Finding;
use serde::{Deserialize, Serialize};
/// What a weakness *gives* an attacker, or *needs* from one. Chaining is the
/// join between the two: a producer of `ValidIdentities` feeds a consumer of
/// them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Capability {
/// Knowledge of which accounts/objects exist.
ValidIdentities,
/// Unlimited attempts against a control.
UnlimitedAttempts,
/// Guessable or reusable credentials.
WeakCredentials,
/// A session or token belonging to someone.
SessionMaterial,
/// Internal detail: versions, paths, stack traces, config.
InternalKnowledge,
/// Code or queries running on the target.
CodeExecution,
/// Reach into systems behind the target.
InternalNetwork,
/// Read of data the attacker should not have.
DataAccess,
/// Acting as another identity.
PrivilegedContext,
}
impl Capability {
pub fn as_str(self) -> &'static str {
match self {
Capability::ValidIdentities => "valid identities",
Capability::UnlimitedAttempts => "unlimited attempts",
Capability::WeakCredentials => "weak credentials",
Capability::SessionMaterial => "session material",
Capability::InternalKnowledge => "internal knowledge",
Capability::CodeExecution => "code execution",
Capability::InternalNetwork => "internal network reach",
Capability::DataAccess => "data access",
Capability::PrivilegedContext => "privileged context",
}
}
}
fn cwe_num(cwe: &str) -> u32 {
cwe.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0)
}
/// What this weakness hands an attacker.
pub fn provides(f: &Finding) -> Vec<Capability> {
let t = format!("{} {}", f.title, f.evidence).to_lowercase();
match cwe_num(&f.cwe) {
// Enumeration / observable discrepancy / timing.
200 | 203 | 204 | 208 => {
if t.contains("account") || t.contains("user") || t.contains("email") || t.contains("enumerat") {
vec![Capability::ValidIdentities, Capability::InternalKnowledge]
} else {
vec![Capability::InternalKnowledge]
}
}
209 | 532 | 538 | 540 | 548 | 693 | 1021 => vec![Capability::InternalKnowledge],
// Missing throttling turns any guess into an unlimited one.
307 | 770 | 799 | 400 => vec![Capability::UnlimitedAttempts],
// Password policy.
521 | 261 | 262 | 263 => vec![Capability::WeakCredentials],
// Credential exposure and transport.
319 | 522 | 798 | 312 | 256 | 257 | 321 | 614 | 1004 | 1275 | 384 => {
vec![Capability::SessionMaterial, Capability::WeakCredentials]
}
// Injection / execution.
77 | 78 | 94 | 95 | 502 | 917 | 1336 => vec![Capability::CodeExecution, Capability::DataAccess],
89 | 943 | 564 => vec![Capability::DataAccess, Capability::WeakCredentials],
// File read.
22 | 23 | 35 | 98 | 73 => vec![Capability::DataAccess, Capability::InternalKnowledge],
// SSRF reaches behind the edge.
918 | 611 | 776 => vec![Capability::InternalNetwork],
// Access control and auth bypass hand over someone else's context.
639 | 862 | 863 | 284 | 285 | 306 | 287 | 288 | 347 | 345 | 566 | 425 => {
vec![Capability::PrivilegedContext, Capability::DataAccess]
}
// XSS runs in a victim's session.
79 | 80 | 83 | 87 => vec![Capability::SessionMaterial],
_ => vec![],
}
}
/// What an attacker must already have for this weakness to be worth much.
pub fn requires(f: &Finding) -> Vec<Capability> {
match cwe_num(&f.cwe) {
// Guessing needs someone to guess against.
307 | 770 | 799 => vec![Capability::ValidIdentities],
// A weak password matters once you can try it, repeatedly.
521 | 261 | 262 | 263 => vec![Capability::ValidIdentities, Capability::UnlimitedAttempts],
// Taking over an account needs credentials or a session.
287 | 288 | 384 | 640 => vec![Capability::WeakCredentials],
// Reading another user's object needs to know it exists.
639 | 566 | 425 => vec![Capability::ValidIdentities],
// Stealing a session needs somewhere to steal it from.
614 | 1004 | 1275 => vec![Capability::SessionMaterial],
// Escalation needs a foothold.
269 | 250 | 668 => vec![Capability::PrivilegedContext],
_ => vec![],
}
}
/// One derived link.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Link {
pub from: usize,
pub to: usize,
/// The capability that makes the link real.
pub via: Capability,
pub reason: String,
/// Derived rather than asserted by an agent.
pub inferred: bool,
}
/// Derive enablement links across a finding set.
///
/// Only same-host links are drawn: two weaknesses on unrelated assets do not
/// chain just because their classes fit, and a graph that claims they do is
/// worse than one with no edges.
pub fn derive_links(findings: &[Finding]) -> Vec<Link> {
let mut links = Vec::new();
for (i, producer) in findings.iter().enumerate() {
let gives = provides(producer);
if gives.is_empty() {
continue;
}
for (j, consumer) in findings.iter().enumerate() {
if i == j {
continue;
}
if host_of(&producer.endpoint) != host_of(&consumer.endpoint) {
continue;
}
// A weakness does not enable itself. CWE-614 both yields session
// material and needs it, so on a real run its duplicates chained to
// each other in a circle — an "attack path" from a cookie flag to
// the same cookie flag.
if cwe_num(&producer.cwe) == cwe_num(&consumer.cwe) {
continue;
}
if !producer.id.is_empty() && producer.id == consumer.id {
continue;
}
for need in requires(consumer) {
if gives.contains(&need) {
links.push(Link {
from: i,
to: j,
via: need,
reason: format!(
"{} yields {}, which {} needs to be worth exploiting",
short(&producer.title),
need.as_str(),
short(&consumer.title)
),
inferred: true,
});
}
}
}
}
links
}
/// One finding's own attack path: what leads to it, and what it leads to.
///
/// This is the per-vulnerability view — a reader looking at "weak password
/// policy" wants to know it only matters because enumeration produced a list
/// and nothing throttles the guessing, not to hunt for that in a global graph.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AttackPath {
/// Findings that must come first, nearest cause last.
pub preceded_by: Vec<PathStep>,
/// What this one opens up.
pub enables: Vec<PathStep>,
/// The narrative, ready to print.
pub narrative: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PathStep {
pub finding_id: String,
pub title: String,
pub via: Capability,
pub severity: String,
}
/// Build the path around one finding.
pub fn path_for(index: usize, findings: &[Finding], links: &[Link]) -> AttackPath {
let step = |k: usize, via: Capability| PathStep {
finding_id: findings[k].id.clone(),
title: findings[k].title.clone(),
via,
severity: findings[k].severity.clone(),
};
let preceded_by: Vec<PathStep> = links.iter().filter(|l| l.to == index).map(|l| step(l.from, l.via)).collect();
let enables: Vec<PathStep> = links.iter().filter(|l| l.from == index).map(|l| step(l.to, l.via)).collect();
let me = &findings[index];
let mut narrative = String::new();
if preceded_by.is_empty() && enables.is_empty() {
narrative = format!("{} stands alone in this engagement — nothing else found feeds it, and it feeds nothing else.", short(&me.title));
} else {
if !preceded_by.is_empty() {
narrative.push_str(&format!(
"Reaching this needs {}: {}. ",
preceded_by.iter().map(|s| s.via.as_str()).collect::<Vec<_>>().join(" and "),
preceded_by.iter().map(|s| short(&s.title)).collect::<Vec<_>>().join("; ")
));
}
if !enables.is_empty() {
narrative.push_str(&format!(
"It then supplies {} to {}.",
enables.iter().map(|s| s.via.as_str()).collect::<Vec<_>>().join(" and "),
enables.iter().map(|s| short(&s.title)).collect::<Vec<_>>().join("; ")
));
}
}
AttackPath { preceded_by, enables, narrative }
}
/// Fill in `chains_from` where agents left it empty.
///
/// An agent sees one vulnerability and cannot link to findings it never saw, so
/// asking it to was always going to return nothing. Derived links are written
/// only into findings that have none — an asserted chain is evidence and is
/// never overwritten by a rule.
pub fn apply_links(findings: &mut [Finding]) -> usize {
let links = derive_links(findings);
// Ids are collected first: `chains_from` holds ids, and writing into one
// finding while reading another's id needs the reads done up front.
let ids: Vec<String> = findings.iter().map(|f| f.id.clone()).collect();
let mut written = 0usize;
for (i, f) in findings.iter_mut().enumerate() {
if !f.chains_from.is_empty() {
continue;
}
let sources: Vec<String> = links
.iter()
.filter(|l| l.to == i)
.map(|l| l.from)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter_map(|k| ids.get(k).cloned().filter(|s| !s.is_empty()))
.collect();
if !sources.is_empty() {
f.chains_from = sources;
written += 1;
}
}
written
}
/// Drop chain links that violate the invariants, wherever they came from.
///
/// `apply_links` only fills an empty `chains_from`, which is right — an
/// asserted chain is evidence. But a link written by an EARLIER version of
/// these rules is not evidence, and on a rebuild it would survive forever: a
/// real report kept "cookie flag ← the same cookie flag" through two rebuilds
/// because nothing was allowed to touch it. A repair pass removes links that
/// cannot be true regardless of who wrote them: a finding citing itself, a
/// weakness citing its own class, and an id that names nothing.
pub fn repair(findings: &mut [Finding]) -> usize {
let by_id: std::collections::HashMap<String, (String, String)> = findings
.iter()
.filter(|f| !f.id.is_empty())
.map(|f| (f.id.clone(), (f.cwe.clone(), f.endpoint.clone())))
.collect();
let mut removed = 0usize;
for f in findings.iter_mut() {
let before = f.chains_from.len();
let me_cwe = cwe_num(&f.cwe);
let me_id = f.id.clone();
let me_host = host_of(&f.endpoint);
f.chains_from.retain(|src| {
if *src == me_id {
return false;
}
match by_id.get(src) {
// An id nobody answers to is a dangling reference, not a chain.
None => false,
Some((cwe, endpoint)) => cwe_num(cwe) != me_cwe && host_of(endpoint) == me_host,
}
});
f.chains_from.sort();
f.chains_from.dedup();
removed += before - f.chains_from.len();
}
removed
}
fn host_of(endpoint: &str) -> String {
crate::scope::host_of(endpoint)
}
fn short(s: &str) -> String {
let t: String = s.chars().take(64).collect();
if s.chars().count() > 64 { format!("{t}…") } else { t }
}
#[cfg(test)]
mod tests {
use super::*;
fn f(id: &str, cwe: &str, title: &str, sev: &str) -> Finding {
Finding {
id: id.into(),
cwe: cwe.into(),
title: title.into(),
severity: sev.into(),
endpoint: "https://arenahockeypara.com.br/Account/Login".into(),
..Default::default()
}
}
/// The Arena chain, which the engagement found as three unrelated Mediums.
fn arena() -> Vec<Finding> {
vec![
f("enum", "CWE-204", "Account enumeration via verbose /Account/Register response", "Medium"),
f("rate", "CWE-307", "No rate limiting or account lockout on /Account/Login", "Medium"),
f("pw", "CWE-521", "Weak password policy — breached passwords accepted", "Medium"),
]
}
#[test]
fn enumeration_feeds_brute_force_which_feeds_weak_passwords() {
let links = derive_links(&arena());
let via = |from: usize, to: usize| links.iter().find(|l| l.from == from && l.to == to).map(|l| l.via);
assert_eq!(via(0, 1), Some(Capability::ValidIdentities), "enumeration gives the list brute force needs");
assert_eq!(via(0, 2), Some(Capability::ValidIdentities));
assert_eq!(via(1, 2), Some(Capability::UnlimitedAttempts), "no throttling is what makes a weak password land");
}
#[test]
fn a_per_vulnerability_path_reads_as_a_sentence() {
let fs = arena();
let links = derive_links(&fs);
let p = path_for(2, &fs, &links); // the weak password policy
assert_eq!(p.preceded_by.len(), 2, "{:?}", p);
assert!(p.enables.is_empty());
assert!(p.narrative.contains("valid identities"));
assert!(p.narrative.contains("unlimited attempts"));
}
#[test]
fn a_finding_that_chains_with_nothing_says_so() {
let fs = vec![f("hsts", "CWE-319", "Missing HSTS", "Low")];
let links = derive_links(&fs);
let p = path_for(0, &fs, &links);
assert!(p.narrative.contains("stands alone"), "{}", p.narrative);
}
#[test]
fn chains_from_is_filled_only_where_the_agent_left_it_empty() {
let mut fs = arena();
fs[1].chains_from = vec!["asserted-by-an-agent".into()];
let written = apply_links(&mut fs);
assert_eq!(written, 1, "only the finding with no asserted chain is written");
assert_eq!(fs[1].chains_from, vec!["asserted-by-an-agent"], "an asserted chain is evidence and is never overwritten");
assert_eq!(fs[2].chains_from, vec!["enum", "rate"]);
assert!(fs[0].chains_from.is_empty(), "the first step has nothing before it");
}
#[test]
fn weaknesses_on_different_hosts_do_not_chain() {
let mut fs = arena();
fs[1].endpoint = "https://other.example.org/login".into();
let links = derive_links(&fs);
assert!(
!links.iter().any(|l| (l.from == 1 || l.to == 1)),
"classes fitting is not a reason to chain across unrelated assets"
);
}
#[test]
fn a_cookie_without_secure_chains_from_whatever_produced_the_session() {
let fs = vec![
f("xss", "CWE-79", "Reflected XSS in search", "Medium"),
f("cookie", "CWE-614", "Session cookie without Secure", "Low"),
];
let links = derive_links(&fs);
assert_eq!(links.iter().find(|l| l.from == 0 && l.to == 1).map(|l| l.via), Some(Capability::SessionMaterial));
}
/// CWE-614 yields session material and also needs it. On a real run its
/// duplicates chained to each other, producing an "attack path" from a
/// cookie flag to the same cookie flag.
#[test]
fn a_weakness_class_never_chains_to_itself() {
let fs = vec![
f("c1", "CWE-614", "Antiforgery cookie without Secure", "Low"),
f("c2", "CWE-614 (Sensitive Cookie…)", "Antiforgery cookie set without Secure over HTTPS", "Low"),
];
assert!(derive_links(&fs).is_empty(), "one cookie flag does not enable another");
}
#[test]
fn repair_removes_links_that_cannot_be_true() {
let mut fs = arena();
fs[0].id = "enum".into();
// Everything a stale rule could have written.
fs[1].chains_from = vec!["rate".into(), "enum".into(), "ghost-id".into(), "enum".into()];
fs[2].chains_from = vec!["pw".into()];
let removed = repair(&mut fs);
assert_eq!(fs[1].chains_from, vec!["enum"], "self-reference, dangling id and duplicate all go");
assert!(fs[2].chains_from.is_empty(), "a finding citing itself keeps nothing");
assert_eq!(removed, 4);
}
#[test]
fn repair_drops_a_link_to_the_same_weakness_class() {
let mut fs = vec![
f("c1", "CWE-614", "Cookie without Secure", "Low"),
f("c2", "CWE-614", "Cookie without Secure over HTTPS", "Low"),
];
fs[1].chains_from = vec!["c1".into()];
assert_eq!(repair(&mut fs), 1);
assert!(fs[1].chains_from.is_empty());
}
#[test]
fn repair_keeps_a_legitimate_link() {
let mut fs = arena();
fs[1].chains_from = vec!["enum".into()];
assert_eq!(repair(&mut fs), 0);
assert_eq!(fs[1].chains_from, vec!["enum"]);
}
#[test]
fn a_weakness_with_nothing_to_give_produces_no_links() {
let fs = vec![f("x", "CWE-99999", "Something unmapped", "Info"), f("rate", "CWE-307", "No rate limiting", "Medium")];
assert!(derive_links(&fs).is_empty(), "an unmapped class must not invent enablement");
}
}
+2
View File
@@ -12,6 +12,7 @@ pub mod audit;
pub mod belief; pub mod belief;
pub mod browser; pub mod browser;
pub mod capability; pub mod capability;
pub mod chain;
pub mod claims; pub mod claims;
pub mod creds; pub mod creds;
pub mod grounding; pub mod grounding;
@@ -46,6 +47,7 @@ pub use pool::{ModelPool, Task};
pub use audit::{AuditLog, AuditRecord, KillReason, KillSwitch}; pub use audit::{AuditLog, AuditRecord, KillReason, KillSwitch};
pub use capability::{Capability, TokenError}; pub use capability::{Capability, TokenError};
pub use browser::{BrowserProbe, BrowserResult}; pub use browser::{BrowserProbe, BrowserResult};
pub use chain::{AttackPath, Capability as ChainCapability, Link};
pub use claims::{Claim, ClaimSet, ClaimStatus, Decision, EvidenceLedger}; pub use claims::{Claim, ClaimSet, ClaimStatus, Decision, EvidenceLedger};
pub use policy::{Act, ActionKind, BlastRadius, EngagementPolicy, Environment, Protocol, Risk, RiskDecision, SafetyPolicy}; pub use policy::{Act, ActionKind, BlastRadius, EngagementPolicy, Environment, Protocol, Risk, RiskDecision, SafetyPolicy};
pub use prosecutor::{ProsecutorVerdict, PROSECUTOR_SYS}; pub use prosecutor::{ProsecutorVerdict, PROSECUTOR_SYS};
@@ -1681,6 +1681,13 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
stamp_attribution(&mut findings); stamp_attribution(&mut findings);
// Map findings to OWASP / MITRE / kill-chain stage for the attack graph. // Map findings to OWASP / MITRE / kill-chain stage for the attack graph.
crate::attack_graph::enrich(&mut findings); crate::attack_graph::enrich(&mut findings);
// Chain them to each other. An agent sees one vulnerability and cannot link
// to findings it never saw, which is why `chains_from` came back empty on
// every finding of a real engagement; here the whole set is visible at once.
let chained = crate::chain::apply_links(&mut findings);
if chained > 0 {
let _ = tx.send(format!("attack paths: {chained} finding(s) linked to what enables them")).await;
}
// Collect proof screenshots into evidence/<finding-id>-N.png so the report // Collect proof screenshots into evidence/<finding-id>-N.png so the report
// can embed each image beside its vulnerability. // can embed each image beside its vulnerability.
if let Some(dir) = cfg.workdir.as_deref() { if let Some(dir) = cfg.workdir.as_deref() {
@@ -797,6 +797,11 @@ pub fn rebuild(dir: &Path) -> std::io::Result<PathBuf> {
// new CWE→technique entry) would otherwise keep reprinting the gap forever, // new CWE→technique entry) would otherwise keep reprinting the gap forever,
// and the whole point of rebuilding is to get the current report. // and the whole point of rebuilding is to get the current report.
crate::attack_graph::enrich(&mut findings); crate::attack_graph::enrich(&mut findings);
// A rebuild applies the CURRENT mappings, including ones that did not exist
// when the run finished.
crate::attack_graph::remap_stages(&mut findings);
crate::chain::repair(&mut findings);
crate::chain::apply_links(&mut findings);
let _ = std::fs::write(dir.join("findings.json"), serde_json::to_string_pretty(&findings).unwrap_or_default()); 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")) let status: serde_json::Value = std::fs::read_to_string(dir.join("status.json"))
.ok() .ok()