//! **CAPEC → ATT&CK mapping** import (feature `scan`) — the identity/mapping leg.
//!
//! MITRE **CAPEC** (Common Attack Pattern Enumeration) ships one XML catalog whose
//! every `Attack_Pattern` links to the weaknesses it exploits
//! (`Related_Weaknesses` → CWE ids) AND, via `Taxonomy_Mappings`, to the MITRE
//! **ATT&CK** techniques that realize it. Bridging those two edges gives a
//! **CWE → {ATT&CK technique}** map, so a finding's CWE(s) (every advisory carries
//! `cwe: Vec<String>` in the `CWE-NNN` form) tag it with the adversary techniques —
//! the "map findings to techniques" the ranked list + red-marked graph want.
//!
//! Like the SPDX license list this is **reference data**: it populates no warehouse
//! table and touches no hot verdict path — it drives the opt-in `attack-map` verb
//! (and any future finding-enrichment). Pure roxmltree (already a `manifests`/`scan`
//! dep), no network.
use std::collections::{BTreeMap, BTreeSet};
/// One ATT&CK technique: `id` in the canonical `T####` form, plus its name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AttackTechnique {
pub id: String,
pub name: String,
}
/// `CWE-NNN` → the set of ATT&CK techniques its exploiting attack patterns map to.
#[derive(Debug, Default)]
pub struct CweAttackMap {
map: BTreeMap<String, BTreeSet<AttackTechnique>>,
}
/// Canonicalize a CWE id to `CWE-<n>` (CAPEC's `Related_Weakness` gives the bare
/// number `89`; advisories carry `CWE-89`). Strips any leading non-digit prefix
/// (`CWE-`, `cwe-`) then re-prefixes canonically.
fn normalize_cwe(s: &str) -> Option<String> {
let digits: String = s
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
return None;
}
Some(format!("CWE-{digits}"))
}
/// Parse a MITRE CAPEC XML catalog into the CWE → ATT&CK-technique map. An
/// attack pattern with no ATT&CK taxonomy mapping (or no related weakness)
/// contributes nothing. Tolerant of the catalog's many other elements.
pub fn parse_capec_xml(xml: &str) -> Result<CweAttackMap, roxmltree::Error> {
let doc = roxmltree::Document::parse(xml)?;
let mut out = CweAttackMap::default();
for ap in doc
.descendants()
.filter(|n| n.has_tag_name("Attack_Pattern"))
{
// The ATT&CK techniques this pattern maps to (`Taxonomy_Name == "ATTACK"`).
let techs: Vec<AttackTechnique> = ap
.descendants()
.filter(|n| {
n.has_tag_name("Taxonomy_Mapping")
&& n.attribute("Taxonomy_Name")
.map(|t| t.eq_ignore_ascii_case("ATTACK"))
.unwrap_or(false)
})
.filter_map(|tm| {
let eid = tm
.children()
.find(|c| c.has_tag_name("Entry_ID"))
.and_then(|c| c.text())?
.trim();
if eid.is_empty() {
return None;
}
let name = tm
.children()
.find(|c| c.has_tag_name("Entry_Name"))
.and_then(|c| c.text())
.unwrap_or("")
.trim()
.to_string();
Some(AttackTechnique {
id: format!("T{eid}"),
name,
})
})
.collect();
if techs.is_empty() {
continue; // no ATT&CK edge → nothing to bridge
}
for rw in ap
.descendants()
.filter(|n| n.has_tag_name("Related_Weakness"))
{
if let Some(cwe) = rw.attribute("CWE_ID").and_then(normalize_cwe) {
let entry = out.map.entry(cwe).or_default();
entry.extend(techs.iter().cloned());
}
}
}
Ok(out)
}
impl CweAttackMap {
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
/// Number of distinct CWEs that have at least one ATT&CK technique.
pub fn len(&self) -> usize {
self.map.len()
}
/// The ATT&CK techniques for a single CWE id (`CWE-89` or bare `89`), sorted.
pub fn techniques_for_cwe(&self, cwe: &str) -> Vec<AttackTechnique> {
normalize_cwe(cwe)
.and_then(|k| self.map.get(&k))
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
/// The deduped ATT&CK techniques across a set of CWEs (a finding's `cwe` list),
/// sorted — the finding-enrichment entry point smid's lens calls.
pub fn techniques_for_cwes<'a>(
&self,
cwes: impl IntoIterator<Item = &'a str>,
) -> Vec<AttackTechnique> {
let mut set: BTreeSet<AttackTechnique> = BTreeSet::new();
for c in cwes {
if let Some(k) = normalize_cwe(c) {
if let Some(s) = self.map.get(&k) {
set.extend(s.iter().cloned());
}
}
}
set.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog() -> CweAttackMap {
// Shape mirrors the CAPEC XML: CAPEC-66 (SQLi) exploits CWE-89 and maps to
// ATT&CK 1190; CAPEC-999 has a related CWE but NO ATT&CK mapping (must
// contribute nothing).
let xml = r#"<?xml version="1.0"?>
<Attack_Pattern_Catalog>
<Attack_Patterns>
<Attack_Pattern ID="66" Name="SQL Injection">
<Related_Weaknesses>
<Related_Weakness CWE_ID="89"/>
<Related_Weakness CWE_ID="1286"/>
</Related_Weaknesses>
<Taxonomy_Mappings>
<Taxonomy_Mapping Taxonomy_Name="ATTACK">
<Entry_ID>1190</Entry_ID>
<Entry_Name>Exploit Public-Facing Application</Entry_Name>
</Taxonomy_Mapping>
</Taxonomy_Mappings>
</Attack_Pattern>
<Attack_Pattern ID="999" Name="No ATTACK Mapping">
<Related_Weaknesses><Related_Weakness CWE_ID="200"/></Related_Weaknesses>
</Attack_Pattern>
</Attack_Patterns>
</Attack_Pattern_Catalog>"#;
parse_capec_xml(xml).unwrap()
}
#[test]
fn bridges_cwe_to_attack_technique() {
let c = catalog();
assert_eq!(
c.len(),
2,
"CWE-89 and CWE-1286 mapped; CWE-200 (no ATT&CK) is absent"
);
let t = c.techniques_for_cwe("CWE-89");
assert_eq!(t.len(), 1);
assert_eq!(t[0].id, "T1190");
assert_eq!(t[0].name, "Exploit Public-Facing Application");
// A bare number normalizes to the same key.
assert_eq!(c.techniques_for_cwe("89"), t, "bare `89` == `CWE-89`");
// The no-ATTACK pattern's CWE contributes nothing.
assert!(
c.techniques_for_cwe("CWE-200").is_empty(),
"no ATT&CK edge ⇒ no technique"
);
}
#[test]
fn multi_cwe_enrichment_is_deduped_and_sorted() {
let c = catalog();
// Both CWE-89 and CWE-1286 point at the same T1190 → deduped to one.
let t = c.techniques_for_cwes(["CWE-89", "CWE-1286", "CWE-404"]);
assert_eq!(
t.len(),
1,
"the shared technique is deduped, the unmapped CWE ignored"
);
assert_eq!(t[0].id, "T1190");
}
}