Skip to main content

cleanlib_client/
derive_status.rs

1//! `derive_status` — canonical algorithm per App dispatch §4 binding contract.
2//!
3//! Each CleanLibrary SDK (sdk-js, sdk-py, sdk-go, cleanlib-client Rust)
4//! implements this algorithm INDEPENDENTLY from the spec; the cross-SDK
5//! contract test (`tests/contract.rs`) verifies byte-identical
6//! `(status, reason_code)` across all 4 implementations on every fixture
7//! in `cleanlib-contract-fixtures` v1.0.0.
8//!
9//! Precedence:
10//!
11//! 1. **Substance** (signals top-to-bottom; `unavailable` substrate falls through):
12//!    - `exploitability.exploitation_likelihood == "CRITICAL"`
13//!      → DENY + `VERDICT_EXPLOITATION_CRITICAL`
14//!    - `exploitability.in_kev` AND `availability.kev == "available"` AND
15//!      `exploitability.exploit_risk_score >= 70`
16//!      → DENY + `VERDICT_KEV_LISTED`
17//!    - `rich_data.has_obfuscation`
18//!      → DENY + `VERDICT_OBFUSCATED`
19//!    - `remediation.fix` OR `remediation.recommended_version` present
20//!      → WARN + `VERDICT_HAS_REMEDIATION`
21//!    - `rich_data.abandonment_score >= 0.7`
22//!      → WARN + `VERDICT_ABANDONED`
23//!    - `rich_data.recommended_version` present (under `rich_data`,
24//!       not `remediation`)
25//!      → ALLOW + `VERDICT_RECOMMENDED_VERSION_NEWER`
26//!    - default
27//!      → ALLOW + `VERDICT_CLEAN`
28//!
29//! 2. **Freshness override**: when the substance-driving signal's
30//!    `availability == "degraded_stale"`, the substance-derived status
31//!    tier is PRESERVED and the `reason_code` overrides to
32//!    `VERDICT_DEGRADED_STALE`.
33//!
34//! 3. **Block tie-break in remediation**: `fix` > `recommended_version`
35//!    > other blocks.
36
37use serde_json::Value;
38
39use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};
40
41/// Return value pair — sister of sdk-py `StatusResult` + sdk-go `StatusResult`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct DerivedStatus {
44    pub status: Status,
45    pub reason_code: ReasonCode,
46}
47
48/// Apply the substance-precedence + freshness-override rule to a parsed
49/// `VerdictEnvelopeV1`. Returns `(status, reason_code)` pair.
50///
51/// Cross-SDK contract: identical output across all 4 SDK implementations
52/// for every fixture in `cleanlib-contract-fixtures` v1.0.0.
53pub fn derive_status(envelope: &VerdictEnvelopeV1) -> DerivedStatus {
54    let rich = envelope.rich_data.as_ref();
55    let rem = envelope.remediation.as_ref();
56    let exp = envelope.exploitability.as_ref();
57    let avail = envelope.availability.as_ref();
58
59    let mut status: Status;
60    let mut reason: ReasonCode;
61    // `freshness_signal` is the substrate availability tag for the driving
62    // signal; the freshness-override step rewrites the reason_code without
63    // disturbing the status tier.
64    let mut freshness_signal: Option<String> = None;
65
66    if as_str(exp, "exploitation_likelihood") == Some("CRITICAL") {
67        status = Status::Deny;
68        reason = ReasonCode::VerdictExploitationCritical;
69        freshness_signal = as_str(avail, "exploitation_fusion").map(str::to_string);
70    } else if as_bool(exp, "in_kev")
71        && as_str(avail, "kev") == Some("available")
72        && as_f64(exp, "exploit_risk_score") >= 70.0
73    {
74        status = Status::Deny;
75        reason = ReasonCode::VerdictKevListed;
76        freshness_signal = as_str(avail, "kev").map(str::to_string);
77    } else if as_bool(rich, "has_obfuscation") {
78        status = Status::Deny;
79        reason = ReasonCode::VerdictObfuscated;
80    } else if get(rem, "fix").is_some() || get(rem, "recommended_version").is_some() {
81        status = Status::Warn;
82        reason = ReasonCode::VerdictHasRemediation;
83        // Block tie-break: `fix` > `recommended_version` > other blocks.
84        let block = get(rem, "fix").or_else(|| get(rem, "recommended_version"));
85        if let Some(b) = block {
86            if let Some(s) = b.get("availability").and_then(Value::as_str) {
87                freshness_signal = Some(s.to_string());
88            }
89        }
90    } else if as_f64(rich, "abandonment_score") >= 0.7 {
91        status = Status::Warn;
92        reason = ReasonCode::VerdictAbandoned;
93    } else if get(rich, "recommended_version").is_some() {
94        status = Status::Allow;
95        reason = ReasonCode::VerdictRecommendedVersionNewer;
96    } else {
97        status = Status::Allow;
98        reason = ReasonCode::VerdictClean;
99    }
100
101    // Freshness override: keep status tier, rewrite reason_code.
102    if freshness_signal.as_deref() == Some("degraded_stale") {
103        reason = ReasonCode::VerdictDegradedStale;
104    }
105
106    // Suppress unused-mut warning for `status` on paths that never reassign
107    // — `_ = &mut status;` keeps the let-mut binding load-bearing for
108    // future-rule additions without triggering a clippy alarm here.
109    let _ = &mut status;
110    let _ = &mut reason;
111
112    DerivedStatus { status, reason_code: reason }
113}
114
115// ─── small json-projection helpers (tolerate Option<Value> + type-skew) ─────
116
117fn get<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a Value> {
118    let v = obj?.get(key)?;
119    if v.is_null() {
120        None
121    } else {
122        Some(v)
123    }
124}
125
126fn as_str<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a str> {
127    get(obj, key).and_then(Value::as_str)
128}
129
130fn as_bool(obj: Option<&Value>, key: &str) -> bool {
131    get(obj, key).and_then(Value::as_bool).unwrap_or(false)
132}
133
134fn as_f64(obj: Option<&Value>, key: &str) -> f64 {
135    get(obj, key).and_then(Value::as_f64).unwrap_or(0.0)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn parse(json: &str) -> VerdictEnvelopeV1 {
143        serde_json::from_str(json).unwrap()
144    }
145
146    #[test]
147    fn clean_envelope_yields_allow_clean() {
148        let env = parse(
149            r#"{
150                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
151                "human_message":"ok","as_of":"2026-05-28"
152            }"#,
153        );
154        let d = derive_status(&env);
155        assert_eq!(d.status, Status::Allow);
156        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
157    }
158
159    #[test]
160    fn exploitation_critical_wins_over_kev_and_remediation() {
161        // EXPLOITATION_CRITICAL is the topmost precedence rule.
162        let env = parse(
163            r#"{
164                "status":"DENY","reason_code":"VERDICT_EXPLOITATION_CRITICAL",
165                "human_message":"e","as_of":"2026-05-28",
166                "exploitability":{
167                  "exploitation_likelihood":"CRITICAL",
168                  "in_kev":true,
169                  "exploit_risk_score":99
170                },
171                "availability":{"kev":"available","exploitation_fusion":"available"},
172                "remediation":{"fix":{"availability":"available"}}
173            }"#,
174        );
175        let d = derive_status(&env);
176        assert_eq!(d.status, Status::Deny);
177        assert_eq!(d.reason_code, ReasonCode::VerdictExploitationCritical);
178    }
179
180    #[test]
181    fn freshness_override_preserves_tier_only_rewrites_reason() {
182        // remediation.fix.availability="degraded_stale" → WARN preserved,
183        // reason rewritten to VERDICT_DEGRADED_STALE.
184        let env = parse(
185            r#"{
186                "status":"WARN","reason_code":"VERDICT_DEGRADED_STALE",
187                "human_message":"stale","as_of":"2026-05-28",
188                "remediation":{"fix":{"availability":"degraded_stale"}}
189            }"#,
190        );
191        let d = derive_status(&env);
192        assert_eq!(d.status, Status::Warn);
193        assert_eq!(d.reason_code, ReasonCode::VerdictDegradedStale);
194    }
195
196    #[test]
197    fn kev_requires_all_three_conditions() {
198        // in_kev=true alone is not enough; need kev=available AND score>=70.
199        let env = parse(
200            r#"{
201                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
202                "human_message":"x","as_of":"2026-05-28",
203                "exploitability":{"in_kev":true,"exploit_risk_score":50},
204                "availability":{"kev":"available"}
205            }"#,
206        );
207        let d = derive_status(&env);
208        assert_eq!(d.status, Status::Allow);
209        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
210    }
211}