1use crate::types::{AuditFinding, AuditSummary, MaestroLayer, NistAttackType, Severity};
7use std::collections::BTreeSet;
8
9pub fn severity_deduction(sev: Severity) -> u32 {
11 match sev {
12 Severity::Critical => 15,
13 Severity::High => 8,
14 Severity::Medium => 3,
15 Severity::Low => 1,
16 Severity::Info => 0,
17 }
18}
19
20pub fn calculate_score(findings: &[AuditFinding]) -> u32 {
22 let mut score: i32 = 100;
23 for f in findings {
24 score -= severity_deduction(f.severity) as i32;
25 }
26 score.max(0) as u32
27}
28
29pub fn compute_summary(findings: &[AuditFinding]) -> AuditSummary {
31 let mut s = AuditSummary::default();
32 for f in findings {
33 match f.severity {
34 Severity::Critical => s.critical += 1,
35 Severity::High => s.high += 1,
36 Severity::Medium => s.medium += 1,
37 Severity::Low => s.low += 1,
38 Severity::Info => s.info += 1,
39 }
40 if f.auto_fixable {
41 s.auto_fixable += 1;
42 }
43 }
44 s
45}
46
47pub fn cross_layer_risk(findings: &[AuditFinding]) -> Vec<AuditFinding> {
53 let mut affected: BTreeSet<MaestroLayer> = BTreeSet::new();
54 for f in findings {
55 if let Some(layer) = f.maestro_layer {
56 if f.severity != Severity::Info {
57 affected.insert(layer);
58 }
59 }
60 }
61
62 let mut out = Vec::new();
63 if affected.len() >= 3 {
64 let layers = affected
65 .iter()
66 .map(|l| l.as_str())
67 .collect::<Vec<_>>()
68 .join(", ");
69 out.push(AuditFinding {
70 id: "SC-CROSS-001".to_string(),
71 severity: Severity::High,
72 category: "cross-layer".to_string(),
73 title: "Cross-layer compound attack surface detected".to_string(),
74 description: format!(
75 "Findings span {} MAESTRO layers ({}). Compound attack surfaces enable chained exploits (e.g., supply chain → agent compromise → credential theft).",
76 affected.len(),
77 layers
78 ),
79 evidence: format!("Affected layers: {}", layers),
80 remediation: "Address findings in each affected layer to reduce the compound attack surface. Prioritize layers with CRITICAL/HIGH findings.".to_string(),
81 auto_fixable: false,
82 references: vec![
83 "https://cloudsecurityalliance.org/blog/2025/02/06/agentic-ai-threat-modeling-framework-maestro".to_string(),
84 ],
85 owasp_asi: "ASI10".to_string(),
86 maestro_layer: Some(MaestroLayer::L6),
87 nist_category: Some(NistAttackType::Evasion),
88 });
89 }
90 out
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn finding(sev: Severity, layer: Option<MaestroLayer>, auto: bool) -> AuditFinding {
98 AuditFinding {
99 id: "SC-TEST-001".into(),
100 severity: sev,
101 category: "test".into(),
102 title: "t".into(),
103 description: "d".into(),
104 evidence: "e".into(),
105 remediation: "r".into(),
106 auto_fixable: auto,
107 references: vec![],
108 owasp_asi: "ASI01".into(),
109 maestro_layer: layer,
110 nist_category: None,
111 }
112 }
113
114 #[test]
115 fn score_deducts_per_severity_and_saturates() {
116 assert_eq!(calculate_score(&[]), 100);
117 assert_eq!(
118 calculate_score(&[finding(Severity::Critical, None, false)]),
119 85
120 );
121 assert_eq!(
122 calculate_score(&[
123 finding(Severity::High, None, false),
124 finding(Severity::Medium, None, false),
125 finding(Severity::Low, None, false),
126 ]),
127 100 - 8 - 3 - 1
128 );
129 let many: Vec<_> = (0..10)
131 .map(|_| finding(Severity::Critical, None, false))
132 .collect();
133 assert_eq!(calculate_score(&many), 0);
134 }
135
136 #[test]
137 fn summary_counts_by_severity_and_autofixable() {
138 let f = vec![
139 finding(Severity::Critical, None, true),
140 finding(Severity::High, None, false),
141 finding(Severity::High, None, true),
142 finding(Severity::Info, None, false),
143 ];
144 let s = compute_summary(&f);
145 assert_eq!(s.critical, 1);
146 assert_eq!(s.high, 2);
147 assert_eq!(s.info, 1);
148 assert_eq!(s.auto_fixable, 2);
149 }
150
151 #[test]
152 fn cross_layer_fires_at_three_distinct_noninfo_layers() {
153 let two = vec![
155 finding(Severity::High, Some(MaestroLayer::L3), false),
156 finding(Severity::High, Some(MaestroLayer::L4), false),
157 ];
158 assert!(cross_layer_risk(&two).is_empty());
159
160 let three = vec![
162 finding(Severity::High, Some(MaestroLayer::L4), false),
163 finding(Severity::Medium, Some(MaestroLayer::L3), false),
164 finding(Severity::High, Some(MaestroLayer::L7), false),
165 ];
166 let out = cross_layer_risk(&three);
167 assert_eq!(out.len(), 1);
168 assert_eq!(out[0].id, "SC-CROSS-001");
169 assert_eq!(out[0].severity, Severity::High);
170 assert!(out[0].evidence.contains("L3, L4, L7"));
172 }
173
174 #[test]
175 fn info_findings_do_not_count_toward_cross_layer() {
176 let f = vec![
177 finding(Severity::Info, Some(MaestroLayer::L1), false),
178 finding(Severity::Info, Some(MaestroLayer::L2), false),
179 finding(Severity::Info, Some(MaestroLayer::L3), false),
180 ];
181 assert!(cross_layer_risk(&f).is_empty());
182 }
183
184 #[test]
185 fn finding_json_uses_camelcase_wire_names() {
186 let f = finding(Severity::Critical, Some(MaestroLayer::L4), true);
187 let j = serde_json::to_value(&f).unwrap();
188 assert_eq!(j["autoFixable"], true);
190 assert_eq!(j["owaspAsi"], "ASI01");
191 assert_eq!(j["maestroLayer"], "L4");
192 assert_eq!(j["severity"], "CRITICAL");
193 assert!(j.get("nistCategory").is_none());
195 }
196
197 #[test]
198 fn nist_category_serializes_lowercase() {
199 let mut f = finding(Severity::High, None, false);
200 f.nist_category = Some(NistAttackType::Poisoning);
201 let j = serde_json::to_value(&f).unwrap();
202 assert_eq!(j["nistCategory"], "poisoning");
203 }
204}