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(
70 AuditFinding::builder("SC-CROSS-001", Severity::High, "cross-layer")
71 .title("Cross-layer compound attack surface detected")
72 .description(format!(
73 "Findings span {} MAESTRO layers ({}). Compound attack surfaces enable chained exploits (e.g., supply chain → agent compromise → credential theft).",
74 affected.len(),
75 layers
76 ))
77 .evidence(format!("Affected layers: {}", layers))
78 .remediation("Address findings in each affected layer to reduce the compound attack surface. Prioritize layers with CRITICAL/HIGH findings.")
79 .references([
80 "https://cloudsecurityalliance.org/blog/2025/02/06/agentic-ai-threat-modeling-framework-maestro",
81 ])
82 .owasp_asi("ASI10")
83 .maestro(MaestroLayer::L6)
84 .nist(NistAttackType::Evasion)
85 .build(),
86 );
87 }
88 out
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn finding(sev: Severity, layer: Option<MaestroLayer>, auto: bool) -> AuditFinding {
96 AuditFinding {
97 id: "SC-TEST-001".into(),
98 severity: sev,
99 category: "test".into(),
100 title: "t".into(),
101 description: "d".into(),
102 evidence: "e".into(),
103 remediation: "r".into(),
104 auto_fixable: auto,
105 references: vec![],
106 owasp_asi: "ASI01".into(),
107 maestro_layer: layer,
108 nist_category: None,
109 }
110 }
111
112 #[test]
113 fn score_deducts_per_severity_and_saturates() {
114 assert_eq!(calculate_score(&[]), 100);
115 assert_eq!(
116 calculate_score(&[finding(Severity::Critical, None, false)]),
117 85
118 );
119 assert_eq!(
120 calculate_score(&[
121 finding(Severity::High, None, false),
122 finding(Severity::Medium, None, false),
123 finding(Severity::Low, None, false),
124 ]),
125 100 - 8 - 3 - 1
126 );
127 let many: Vec<_> = (0..10)
129 .map(|_| finding(Severity::Critical, None, false))
130 .collect();
131 assert_eq!(calculate_score(&many), 0);
132 }
133
134 #[test]
135 fn summary_counts_by_severity_and_autofixable() {
136 let f = vec![
137 finding(Severity::Critical, None, true),
138 finding(Severity::High, None, false),
139 finding(Severity::High, None, true),
140 finding(Severity::Info, None, false),
141 ];
142 let s = compute_summary(&f);
143 assert_eq!(s.critical, 1);
144 assert_eq!(s.high, 2);
145 assert_eq!(s.info, 1);
146 assert_eq!(s.auto_fixable, 2);
147 }
148
149 #[test]
150 fn cross_layer_fires_at_three_distinct_noninfo_layers() {
151 let two = vec![
153 finding(Severity::High, Some(MaestroLayer::L3), false),
154 finding(Severity::High, Some(MaestroLayer::L4), false),
155 ];
156 assert!(cross_layer_risk(&two).is_empty());
157
158 let three = vec![
160 finding(Severity::High, Some(MaestroLayer::L4), false),
161 finding(Severity::Medium, Some(MaestroLayer::L3), false),
162 finding(Severity::High, Some(MaestroLayer::L7), false),
163 ];
164 let out = cross_layer_risk(&three);
165 assert_eq!(out.len(), 1);
166 assert_eq!(out[0].id, "SC-CROSS-001");
167 assert_eq!(out[0].severity, Severity::High);
168 assert!(out[0].evidence.contains("L3, L4, L7"));
170 }
171
172 #[test]
173 fn info_findings_do_not_count_toward_cross_layer() {
174 let f = vec![
175 finding(Severity::Info, Some(MaestroLayer::L1), false),
176 finding(Severity::Info, Some(MaestroLayer::L2), false),
177 finding(Severity::Info, Some(MaestroLayer::L3), false),
178 ];
179 assert!(cross_layer_risk(&f).is_empty());
180 }
181
182 #[test]
183 fn finding_json_uses_camelcase_wire_names() {
184 let f = finding(Severity::Critical, Some(MaestroLayer::L4), true);
185 let j = serde_json::to_value(&f).unwrap();
186 assert_eq!(j["autoFixable"], true);
188 assert_eq!(j["owaspAsi"], "ASI01");
189 assert_eq!(j["maestroLayer"], "L4");
190 assert_eq!(j["severity"], "CRITICAL");
191 assert!(j.get("nistCategory").is_none());
193 }
194
195 #[test]
196 fn nist_category_serializes_lowercase() {
197 let mut f = finding(Severity::High, None, false);
198 f.nist_category = Some(NistAttackType::Poisoning);
199 let j = serde_json::to_value(&f).unwrap();
200 assert_eq!(j["nistCategory"], "poisoning");
201 }
202}