Gunnar

mikronova/modgunn

src/capec.rs

//! **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");
    }
}