Skip to main content

secureops_core/
scoring.rs

1//! Scoring and the MAESTRO cross-layer compound-risk pass.
2//!
3//! Faithful port of `SEVERITY_DEDUCTIONS`, `calculateScore`, `computeSummary`
4//! and `auditCrossLayerRisk` from `src/auditor.ts`.
5
6use crate::types::{AuditFinding, AuditSummary, MaestroLayer, NistAttackType, Severity};
7use std::collections::BTreeSet;
8
9/// Per-severity score deduction. CRITICAL 15 / HIGH 8 / MEDIUM 3 / LOW 1 / INFO 0.
10pub 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
20/// `score = 100 − Σ deductions`, saturating at 0.
21pub 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
29/// Tally findings by severity and count auto-fixable ones.
30pub 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
47/// MAESTRO cross-layer compound-risk pass.
48///
49/// Collects the unique MAESTRO layers that carry a non-INFO finding; if ≥3
50/// distinct layers are affected, emits `SC-CROSS-001` (HIGH). The `BTreeSet`
51/// yields the layers already sorted, matching the TS `Array.from(set).sort()`.
52pub 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        // Ten criticals = 150 deduction, saturates at 0 (never negative).
130        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        // 2 distinct layers -> no compound finding.
154        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        // 3 distinct layers -> one SC-CROSS-001.
161        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        // Layers are sorted: "L3, L4, L7".
171        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        // Frozen wire contract (PRODUCT.md A.5).
189        assert_eq!(j["autoFixable"], true);
190        assert_eq!(j["owaspAsi"], "ASI01");
191        assert_eq!(j["maestroLayer"], "L4");
192        assert_eq!(j["severity"], "CRITICAL");
193        // Absent optionals are omitted, not null.
194        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}