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(
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        // Ten criticals = 150 deduction, saturates at 0 (never negative).
128        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        // 2 distinct layers -> no compound finding.
152        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        // 3 distinct layers -> one SC-CROSS-001.
159        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        // Layers are sorted: "L3, L4, L7".
169        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        // Frozen wire contract (PRODUCT.md A.5).
187        assert_eq!(j["autoFixable"], true);
188        assert_eq!(j["owaspAsi"], "ASI01");
189        assert_eq!(j["maestroLayer"], "L4");
190        assert_eq!(j["severity"], "CRITICAL");
191        // Absent optionals are omitted, not null.
192        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}