use std::collections::{BTreeMap, BTreeSet};
use etdl_parser::ast::{EtlDocument, Node};
use etdl_tree_core::Tree;
use crate::validate::Diagnostic;
const SECURITY_SUPPLEMENT: &str = "etdl.security";
pub const SECURITY_SCHEMA: &str = "etdl.security/1.0";
const STRIDE_CATEGORIES: [&str; 6] = [
"spoofing",
"tampering",
"repudiation",
"information-disclosure",
"denial-of-service",
"elevation-of-privilege",
];
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ThreatModel {
pub id: String,
#[serde(rename = "treeRef")]
pub tree_ref: String,
#[serde(default, rename = "leafCategories")]
pub leaf_categories: BTreeMap<String, String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Control {
pub id: String,
#[serde(rename = "nodeRef")]
pub node_ref: String,
#[serde(default)]
pub framework: Option<String>,
#[serde(rename = "controlId")]
pub control_id: String,
#[serde(default)]
pub mitigates: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SecurityData {
pub threat_models: Vec<ThreatModel>,
pub controls: Vec<Control>,
}
pub fn parse_and_validate_security(doc: &EtlDocument) -> (SecurityData, Vec<Diagnostic>) {
let mut diagnostics = Vec::new();
let mut data = SecurityData::default();
if !crate::validate::declares_supplement(doc, SECURITY_SUPPLEMENT) {
return (data, diagnostics);
}
let Some(ext) = doc.extensions.get("x-security") else {
return (data, diagnostics);
};
let (trees, _tree_event_diagnostics) = crate::tree_event::parse_and_validate_trees(doc);
let trees_by_id: BTreeMap<&str, &Tree> = trees.iter().map(|t| (t.id.as_str(), t)).collect();
let mut all_leaf_ids: BTreeSet<String> = BTreeSet::new();
let mut categorized_leaf_ids: BTreeSet<String> = BTreeSet::new();
if let Some(raw) = ext.get("threatModels") {
match serde_yaml::from_value::<Vec<ThreatModel>>(raw.clone()) {
Ok(candidates) => {
let mut seen_ids = BTreeSet::new();
for threat_model in candidates {
let mut has_error = false;
if !seen_ids.insert(threat_model.id.clone()) {
diagnostics.push(Diagnostic::error(
"E-140",
format!("x-security: duplicate threat model id '{}'", threat_model.id),
));
has_error = true;
}
let tree = trees_by_id.get(threat_model.tree_ref.as_str()).copied();
if let Some(tree) = tree {
let leaves: BTreeSet<&str> = tree.leaves().into_iter().collect();
for (leaf_id, category) in &threat_model.leaf_categories {
all_leaf_ids.insert(leaf_id.clone());
categorized_leaf_ids.insert(leaf_id.clone());
if !STRIDE_CATEGORIES.contains(&category.as_str()) {
diagnostics.push(Diagnostic::error(
"E-140",
format!(
"x-security: threat model '{}': leafCategories['{}'] value '{}' is not a STRIDE category",
threat_model.id, leaf_id, category
),
));
has_error = true;
}
if !leaves.contains(leaf_id.as_str()) {
diagnostics.push(Diagnostic::error(
"E-141",
format!(
"x-security: threat model '{}': leafCategories key '{}' is not a leaf of tree '{}'",
threat_model.id, leaf_id, threat_model.tree_ref
),
));
has_error = true;
}
}
all_leaf_ids.extend(leaves.iter().map(|s| s.to_string()));
} else {
diagnostics.push(Diagnostic::error(
"E-140",
format!(
"x-security: threat model '{}': treeRef '{}' does not name a tree declared in x-tree-event.trees",
threat_model.id, threat_model.tree_ref
),
));
has_error = true;
}
if !has_error {
data.threat_models.push(threat_model);
}
}
}
Err(e) => {
diagnostics.push(Diagnostic::error(
"E-140",
format!("x-security: invalid threat model manifest: {e}"),
));
}
}
}
if let Some(raw) = ext.get("controls") {
match serde_yaml::from_value::<Vec<Control>>(raw.clone()) {
Ok(candidates) => {
let mut seen_ids = BTreeSet::new();
for control in candidates {
let mut has_error = false;
if !seen_ids.insert(control.id.clone()) {
diagnostics.push(Diagnostic::error(
"E-141",
format!("x-security: duplicate control id '{}'", control.id),
));
has_error = true;
}
if control.mitigates.is_empty() {
diagnostics.push(Diagnostic::error(
"E-141",
format!("x-security: control '{}': mitigates must be non-empty", control.id),
));
has_error = true;
}
if !resolve_barrier_ref(doc, &control.node_ref) {
diagnostics.push(Diagnostic::error(
"E-141",
format!(
"x-security: control '{}': nodeRef '{}' does not resolve to a Barrier node",
control.id, control.node_ref
),
));
has_error = true;
}
for leaf_id in &control.mitigates {
if !all_leaf_ids.contains(leaf_id) {
diagnostics.push(Diagnostic::error(
"E-141",
format!(
"x-security: control '{}': mitigates entry '{}' is not a leaf node id of any declared threat model's tree",
control.id, leaf_id
),
));
has_error = true;
} else if !categorized_leaf_ids.contains(leaf_id) {
diagnostics.push(Diagnostic::warning(
"W-411",
format!(
"x-security: control '{}': mitigates entry '{}' is not categorized by any declared threat model's leafCategories",
control.id, leaf_id
),
));
}
}
if !has_error {
data.controls.push(control);
}
}
}
Err(e) => {
diagnostics.push(Diagnostic::error(
"E-141",
format!("x-security: invalid control manifest: {e}"),
));
}
}
}
(data, diagnostics)
}
fn resolve_barrier_ref(doc: &EtlDocument, node_ref: &str) -> bool {
let rest = node_ref.trim_start_matches('#');
let Some(after) = rest.strip_prefix("/eventTrees/") else {
return false;
};
match after.split('/').collect::<Vec<_>>().as_slice() {
[tree_id, "nodes", node_id] if !tree_id.is_empty() && !node_id.is_empty() => doc
.event_trees
.get(*tree_id)
.and_then(|t| t.nodes.get(*node_id))
.is_some_and(|n| matches!(n, Node::Barrier(_))),
_ => false,
}
}
#[derive(Debug, Default)]
pub struct SecurityExtension;
impl SecurityExtension {
pub fn new() -> Self {
SecurityExtension
}
}
pub struct SecurityResult {
pub threat_models: Vec<ThreatModel>,
pub controls: Vec<Control>,
}
impl crate::extension::ExtensionResult for SecurityResult {
fn extension_id(&self) -> &str {
SECURITY_SUPPLEMENT
}
}
impl crate::extension::EtdlExtension for SecurityExtension {
fn id(&self) -> &str {
SECURITY_SUPPLEMENT
}
fn version(&self) -> &str {
"1.0"
}
fn descriptor(&self) -> crate::extension::SupplementDescriptor {
crate::extension::SupplementDescriptor {
summary: "STRIDE-classified attack trees (reusing etdl.tree-event's Tree structure) \
and Controls mapped onto core Barrier nodes.",
schema: Some(SECURITY_SCHEMA),
diagnostic_codes: &["E-140", "E-141", "W-411"],
requires: &["etdl.tree-event"],
}
}
fn validate(
&self,
doc: &EtlDocument,
_context: &crate::extension::ExtensionContext<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (_data, extra) = parse_and_validate_security(doc);
diagnostics.extend(extra);
}
fn process(
&self,
doc: &EtlDocument,
_context: &crate::extension::ExtensionContext<'_>,
_diagnostics: &mut Vec<Diagnostic>,
) -> Box<dyn crate::extension::ExtensionResult + '_> {
let (data, _extra) = parse_and_validate_security(doc);
Box::new(SecurityResult {
threat_models: data.threat_models,
controls: data.controls,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension::{builtin_registry, EtdlExtension, ExtensionContext};
fn doc_with_security(x_security_yaml: &str) -> EtlDocument {
let yaml = format!(
r##"
etdl: "1.0.0"
info: {{ title: "T", version: "1.0.0", domain: "D" }}
supplements:
- id: etdl.security
version: "1.0"
- id: etdl.tree-event
version: "1.0"
eventTrees:
OrderFulfillment:
initiatingEvent: {{ id: I, message: "a#/m", next: RateLimitBarrier }}
nodes:
RateLimitBarrier:
type: barrier
branches:
- outcome: SUCCESS
condition: default
probability: 1.0
next: ProcessPaymentOperation
ProcessPaymentOperation:
type: operation
action: execute
handler: "h"
next: C
C: {{ type: consequence, operation: terminate }}
x-tree-event:
trees:
- id: "gateway-compromise"
version: "1"
root: "GatewayCompromised"
nodes:
CredentialStuffing:
kind: leaf
ApiKeyLeak:
kind: leaf
GatewayCompromised:
kind: gate
gate: OR
children: ["CredentialStuffing", "ApiKeyLeak"]
x-security:
{x_security_yaml}
"##
);
serde_yaml::from_str(&yaml).unwrap()
}
#[test]
fn security_extension_is_registered_and_built_in() {
let registry = builtin_registry();
assert!(registry.contains(SECURITY_SUPPLEMENT));
assert!(registry.list().contains(&SECURITY_SUPPLEMENT));
}
#[test]
fn document_without_x_security_has_no_diagnostics() {
let yaml = r#"
etdl: "1.0.0"
info: { title: "T", version: "1.0.0", domain: "D" }
eventTrees:
T:
initiatingEvent: { id: I, message: "a#/m", next: C }
nodes:
C: { type: consequence, operation: terminate }
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(data.controls.is_empty());
assert!(diagnostics.is_empty());
}
#[test]
fn valid_threat_model_and_control_have_no_diagnostics() {
let doc = doc_with_security(
r##" threatModels:
- id: payment-gateway-attack-tree
treeRef: "gateway-compromise"
leafCategories:
CredentialStuffing: spoofing
ApiKeyLeak: information-disclosure
controls:
- id: gateway-rate-limiter
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
framework: "NIST-800-53"
controlId: "SC-5"
mitigates: ["CredentialStuffing"]
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
assert_eq!(data.threat_models.len(), 1);
assert_eq!(data.controls.len(), 1);
}
#[test]
fn missing_threat_models_and_controls_keys_are_not_an_error() {
let doc = doc_with_security(" {}");
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(data.controls.is_empty());
assert!(diagnostics.is_empty());
}
#[test]
fn malformed_threat_models_produces_e140() {
let doc = doc_with_security(" threatModels: \"oops\"");
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-140"));
}
#[test]
fn unresolvable_tree_ref_produces_e140() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "does-not-exist"
leafCategories: {}
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-140"));
}
#[test]
fn invalid_stride_category_produces_e140() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "gateway-compromise"
leafCategories:
CredentialStuffing: not-a-stride-category
"##,
);
let (_data, diagnostics) = parse_and_validate_security(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-140"));
}
#[test]
fn leaf_categories_key_at_non_leaf_produces_e141() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "gateway-compromise"
leafCategories:
GatewayCompromised: spoofing
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-141"));
}
#[test]
fn uncategorized_leaf_is_not_an_error() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "gateway-compromise"
leafCategories:
CredentialStuffing: spoofing
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
assert_eq!(data.threat_models.len(), 1);
}
#[test]
fn control_node_ref_at_wrong_node_kind_produces_e141() {
let doc = doc_with_security(
r##" controls:
- id: c1
nodeRef: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
controlId: "SC-5"
mitigates: ["x"]
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.controls.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-141"));
}
#[test]
fn empty_mitigates_produces_e141() {
let doc = doc_with_security(
r##" controls:
- id: c1
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
controlId: "SC-5"
mitigates: []
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.controls.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-141"));
}
#[test]
fn mitigates_entry_not_a_leaf_produces_e141() {
let doc = doc_with_security(
r##" controls:
- id: c1
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
controlId: "SC-5"
mitigates: ["NotALeaf"]
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.controls.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-141"));
}
#[test]
fn mitigates_entry_uncategorized_leaf_produces_w411() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "gateway-compromise"
leafCategories:
CredentialStuffing: spoofing
controls:
- id: c1
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
controlId: "SC-5"
mitigates: ["ApiKeyLeak"]
"##,
);
let (data, diagnostics) = parse_and_validate_security(&doc);
assert_eq!(data.controls.len(), 1);
assert!(diagnostics.iter().any(|d| d.code == "W-411"));
assert!(!diagnostics.iter().any(|d| d.is_error()));
}
#[test]
fn duplicate_threat_model_id_produces_e140() {
let doc = doc_with_security(
r##" threatModels:
- id: dup
treeRef: "gateway-compromise"
leafCategories: {}
- id: dup
treeRef: "gateway-compromise"
leafCategories: {}
"##,
);
let (_data, diagnostics) = parse_and_validate_security(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-140" && d.message.contains("duplicate threat model id")));
}
#[test]
fn duplicate_control_id_produces_e141() {
let doc = doc_with_security(
r##" controls:
- id: dup
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
controlId: "SC-5"
mitigates: ["x"]
- id: dup
nodeRef: "#/eventTrees/OrderFulfillment/nodes/RateLimitBarrier"
controlId: "SC-6"
mitigates: ["y"]
"##,
);
let (_data, diagnostics) = parse_and_validate_security(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-141" && d.message.contains("duplicate control id")));
}
#[test]
fn security_without_tree_event_declared_has_unresolvable_tree_refs() {
let yaml = r#"
etdl: "1.0.0"
info: { title: "T", version: "1.0.0", domain: "D" }
supplements:
- id: etdl.security
version: "1.0"
eventTrees:
T:
initiatingEvent: { id: I, message: "a#/m", next: C }
nodes:
C: { type: consequence, operation: terminate }
x-security:
threatModels:
- id: tm1
treeRef: "anything"
leafCategories: {}
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let (data, diagnostics) = parse_and_validate_security(&doc);
assert!(data.threat_models.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-140"));
}
#[test]
fn process_returns_typed_result_with_correct_extension_id() {
let doc = doc_with_security(
r##" threatModels:
- id: tm1
treeRef: "gateway-compromise"
leafCategories:
CredentialStuffing: spoofing
"##,
);
let ext = SecurityExtension::new();
let base = std::path::Path::new(".");
let ctx = ExtensionContext::new(&doc, base);
let mut diagnostics = Vec::new();
let result = ext.process(&doc, &ctx, &mut diagnostics);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
assert_eq!(result.extension_id(), SECURITY_SUPPLEMENT);
assert!(result.basic_event_overrides().is_empty());
}
}