use crate::catalog::CATALOG;
use crate::mitre::AttackTechnique;
use std::collections::HashMap;
pub fn generate_navigator_layer(layer_name: &str) -> String {
let coverage = technique_coverage();
let mut techniques_json = Vec::new();
let mut sorted: Vec<(&str, &Vec<&str>)> = coverage.iter().map(|(k, v)| (*k, v)).collect();
sorted.sort_by_key(|(k, _)| *k);
for (technique_id, artifact_ids) in &sorted {
let count = artifact_ids.len();
let color = match count {
1 => "#cce5ff",
2 => "#66b3ff",
_ => "#0066cc",
};
let comment = format!("{count} artifact{}", if count == 1 { "" } else { "s" });
techniques_json.push(format!(
r#" {{"techniqueID": "{technique_id}", "score": {count}, "color": "{color}", "comment": "{comment}"}}"#,
));
}
let techniques_str = techniques_json.join(",\n");
format!(
r#"{{
"name": "{layer_name}",
"versions": {{"attack": "14", "navigator": "4.9", "layer": "4.5"}},
"domain": "enterprise-attack",
"description": "forensicnomicon coverage",
"techniques": [
{techniques_str}
]
}}"#,
)
}
#[must_use]
pub fn report_to_navigator_layer(report: &crate::report::Report, layer_name: &str) -> String {
findings_to_navigator_layer(&report.findings, layer_name)
}
#[must_use]
pub fn findings_to_navigator_layer(
findings: &[crate::report::Finding],
layer_name: &str,
) -> String {
use crate::report::Severity;
struct Agg {
severity: Option<Severity>,
occurrences: u64,
codes: Vec<String>,
}
let mut by_technique: std::collections::BTreeMap<String, Agg> =
std::collections::BTreeMap::new();
for finding in findings {
let occ = finding
.context
.occurrences
.map_or(1, std::num::NonZeroU64::get);
for reference in &finding.context.external_refs {
if reference.scheme != "mitre-attack" {
continue;
}
let agg = by_technique.entry(reference.id.clone()).or_insert(Agg {
severity: None,
occurrences: 0,
codes: Vec::new(),
});
if finding.severity > agg.severity {
agg.severity = finding.severity;
}
agg.occurrences = agg.occurrences.saturating_add(occ);
let code = finding.code.to_string();
if !agg.codes.contains(&code) {
agg.codes.push(code);
}
}
}
let mut techniques_json = Vec::new();
for (technique_id, agg) in &by_technique {
let comment = json_escape(&format!(
"{} ({} occurrence{})",
agg.codes.join(", "),
agg.occurrences,
if agg.occurrences == 1 { "" } else { "s" }
));
techniques_json.push(format!(
r#" {{"techniqueID": "{technique_id}", "score": {}, "color": "{}", "comment": "{comment}", "enabled": true}}"#,
severity_score(agg.severity),
severity_color(agg.severity),
));
}
let techniques_str = techniques_json.join(",\n");
let name = json_escape(layer_name);
format!(
r#"{{
"name": "{name}",
"versions": {{"attack": "14", "navigator": "4.9", "layer": "4.5"}},
"domain": "enterprise-attack",
"description": "Investigation findings — ATT&CK techniques observed, scored by severity",
"techniques": [
{techniques_str}
]
}}"#,
)
}
fn severity_score(severity: Option<crate::report::Severity>) -> u32 {
use crate::report::Severity;
match severity {
Some(Severity::Critical) => 100,
Some(Severity::High) => 78,
Some(Severity::Medium) => 55,
Some(Severity::Low) => 35,
Some(Severity::Info) => 15,
_ => 5, }
}
fn severity_color(severity: Option<crate::report::Severity>) -> &'static str {
use crate::report::Severity;
match severity {
Some(Severity::Critical) => "#c0392b",
Some(Severity::High) => "#e67e22",
Some(Severity::Medium) => "#f1c40f",
Some(Severity::Low) => "#b7d04a",
Some(Severity::Info) => "#7fb069",
_ => "#9e9e9e", }
}
fn json_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn technique_coverage() -> HashMap<&'static str, Vec<&'static str>> {
let mut map: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
for descriptor in CATALOG.list() {
for &technique in descriptor.mitre_techniques {
map.entry(technique).or_default().push(descriptor.id);
}
}
map
}
pub fn covered_technique_count() -> usize {
technique_coverage().len()
}
pub fn covered_techniques() -> Vec<AttackTechnique> {
let mut techniques: Vec<&'static str> = technique_coverage().into_keys().collect();
techniques.sort_unstable();
techniques
.into_iter()
.map(|id| AttackTechnique {
technique_id: id,
tactic: "unknown",
name: id,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigator_layer_is_valid_json_structure() {
let layer = generate_navigator_layer("test-layer");
assert!(layer.contains("\"name\": \"test-layer\""));
assert!(layer.contains("\"domain\": \"enterprise-attack\""));
assert!(layer.contains("\"techniques\":"));
assert!(layer.contains("\"techniqueID\":"));
}
#[test]
fn navigator_layer_contains_common_techniques() {
let layer = generate_navigator_layer("test");
assert!(
layer.contains("T1547") || layer.contains("T1059"),
"Navigator layer should contain common MITRE techniques"
);
}
#[test]
fn coverage_map_nonempty() {
let coverage = technique_coverage();
assert!(
!coverage.is_empty(),
"Should have at least some technique coverage"
);
}
#[test]
fn covered_technique_count_reasonable() {
let count = covered_technique_count();
assert!(
count >= 10,
"Should cover at least 10 ATT&CK techniques, got {count}"
);
assert!(count <= 500, "Technique count seems too high: {count}");
}
#[test]
fn layer_name_is_embedded() {
let layer = generate_navigator_layer("my-custom-layer");
assert!(layer.contains("my-custom-layer"));
}
#[test]
fn layer_has_color_coding() {
let layer = generate_navigator_layer("test");
assert!(
layer.contains("\"color\":"),
"Layer should have color coding"
);
}
#[test]
fn coverage_artifacts_are_valid_ids() {
let coverage = technique_coverage();
for artifact_ids in coverage.values() {
for id in artifact_ids {
assert!(
CATALOG.by_id(id).is_some(),
"coverage map references unknown artifact: {id}",
);
}
}
}
#[test]
fn findings_layer_dedups_by_technique_and_takes_max_severity() {
use crate::report::{Category, Finding, Severity};
let findings = vec![
Finding::observation(Severity::Medium, Category::Threat, "MEM-LSASS-ACCESS")
.mitre("T1003.001")
.occurrences(2)
.build(),
Finding::observation(Severity::Critical, Category::Threat, "MEM-CREDENTIAL-DUMP")
.mitre("T1003.001")
.build(),
Finding::observation(Severity::Low, Category::Concealment, "NTFS-TIMESTOMP")
.mitre("T1070.006")
.build(),
];
let layer = findings_to_navigator_layer(&findings, "case-001");
assert!(layer.contains(r#""name": "case-001""#));
assert!(layer.contains(r#""domain": "enterprise-attack""#));
assert_eq!(layer.matches(r#""techniqueID": "T1003.001""#).count(), 1);
let line = layer
.lines()
.find(|l| l.contains("T1003.001"))
.expect("T1003.001 entry");
assert!(line.contains(r#""score": 100"#), "got: {line}");
assert!(line.contains("MEM-LSASS-ACCESS") && line.contains("MEM-CREDENTIAL-DUMP"));
assert!(layer.contains(r#""techniqueID": "T1070.006""#));
}
#[test]
fn findings_without_mitre_refs_are_excluded() {
use crate::report::{Category, Finding, Severity};
let findings =
vec![
Finding::observation(Severity::High, Category::Integrity, "MBR-PART-OVERLAP")
.build(),
];
let layer = findings_to_navigator_layer(&findings, "x");
assert!(
!layer.contains("techniqueID"),
"no technique entries when findings carry no MITRE refs"
);
}
#[test]
fn report_to_layer_delegates_to_findings() {
use crate::report::{Category, Finding, Report, Severity};
let mut report = Report::default();
report.findings = vec![Finding::observation(Severity::High, Category::Threat, "X")
.mitre("T1059")
.build()];
let layer = report_to_navigator_layer(&report, "r");
assert!(layer.contains(r#""techniqueID": "T1059""#));
}
}