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    // CLEANLIB-104 M3: `availability` is non-Optional (`serde_json::Value`);
58    // wrap in Some(&_) so the untyped `as_str/as_bool` helpers below (which
59    // take `Option<&Value>`) continue to work. `Value::Null` on absent-key
60    // payloads makes helper lookups return None — same fail-open behavior
61    // as the pre-M3 outer-Option `None` case.
62    let avail = Some(&envelope.availability);
63
64    let mut status: Status;
65    let mut reason: ReasonCode;
66    // `freshness_signal` is the substrate availability tag for the driving
67    // signal; the freshness-override step rewrites the reason_code without
68    // disturbing the status tier.
69    let mut freshness_signal: Option<String> = None;
70
71    if as_str(exp, "exploitation_likelihood") == Some("CRITICAL") {
72        status = Status::Deny;
73        reason = ReasonCode::VerdictExploitationCritical;
74        freshness_signal = as_str(avail, "exploitation_fusion").map(str::to_string);
75    } else if as_bool(exp, "in_kev")
76        && as_str(avail, "kev") == Some("available")
77        && as_f64(exp, "exploit_risk_score") >= 70.0
78    {
79        status = Status::Deny;
80        reason = ReasonCode::VerdictKevListed;
81        freshness_signal = as_str(avail, "kev").map(str::to_string);
82    } else if as_bool(rich, "has_obfuscation") {
83        status = Status::Deny;
84        reason = ReasonCode::VerdictObfuscated;
85    } else if get(rem, "fix").is_some() || get(rem, "recommended_version").is_some() {
86        status = Status::Warn;
87        reason = ReasonCode::VerdictHasRemediation;
88        // Block tie-break: `fix` > `recommended_version` > other blocks.
89        let block = get(rem, "fix").or_else(|| get(rem, "recommended_version"));
90        if let Some(b) = block {
91            if let Some(s) = b.get("availability").and_then(Value::as_str) {
92                freshness_signal = Some(s.to_string());
93            }
94        }
95    } else if as_f64(rich, "abandonment_score") >= 0.7 {
96        status = Status::Warn;
97        reason = ReasonCode::VerdictAbandoned;
98    } else if get(rich, "recommended_version").is_some() {
99        status = Status::Allow;
100        reason = ReasonCode::VerdictRecommendedVersionNewer;
101    } else {
102        status = Status::Allow;
103        reason = ReasonCode::VerdictClean;
104    }
105
106    // Freshness override: keep status tier, rewrite reason_code.
107    if freshness_signal.as_deref() == Some("degraded_stale") {
108        reason = ReasonCode::VerdictDegradedStale;
109    }
110
111    // Suppress unused-mut warning for `status` on paths that never reassign
112    // — `_ = &mut status;` keeps the let-mut binding load-bearing for
113    // future-rule additions without triggering a clippy alarm here.
114    let _ = &mut status;
115    let _ = &mut reason;
116
117    DerivedStatus { status, reason_code: reason }
118}
119
120// ─── small json-projection helpers (tolerate Option<Value> + type-skew) ─────
121
122fn get<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a Value> {
123    let v = obj?.get(key)?;
124    if v.is_null() {
125        None
126    } else {
127        Some(v)
128    }
129}
130
131fn as_str<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a str> {
132    get(obj, key).and_then(Value::as_str)
133}
134
135fn as_bool(obj: Option<&Value>, key: &str) -> bool {
136    get(obj, key).and_then(Value::as_bool).unwrap_or(false)
137}
138
139fn as_f64(obj: Option<&Value>, key: &str) -> f64 {
140    get(obj, key).and_then(Value::as_f64).unwrap_or(0.0)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn parse(json: &str) -> VerdictEnvelopeV1 {
148        serde_json::from_str(json).unwrap()
149    }
150
151    #[test]
152    fn clean_envelope_yields_allow_clean() {
153        let env = parse(
154            r#"{
155                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
156                "human_message":"ok","as_of":"2026-05-28"
157            }"#,
158        );
159        let d = derive_status(&env);
160        assert_eq!(d.status, Status::Allow);
161        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
162    }
163
164    #[test]
165    fn exploitation_critical_wins_over_kev_and_remediation() {
166        // EXPLOITATION_CRITICAL is the topmost precedence rule.
167        let env = parse(
168            r#"{
169                "status":"DENY","reason_code":"VERDICT_EXPLOITATION_CRITICAL",
170                "human_message":"e","as_of":"2026-05-28",
171                "exploitability":{
172                  "exploitation_likelihood":"CRITICAL",
173                  "in_kev":true,
174                  "exploit_risk_score":99
175                },
176                "availability":{"kev":"available","exploitation_fusion":"available"},
177                "remediation":{"fix":{"availability":"available"}}
178            }"#,
179        );
180        let d = derive_status(&env);
181        assert_eq!(d.status, Status::Deny);
182        assert_eq!(d.reason_code, ReasonCode::VerdictExploitationCritical);
183    }
184
185    #[test]
186    fn freshness_override_preserves_tier_only_rewrites_reason() {
187        // remediation.fix.availability="degraded_stale" → WARN preserved,
188        // reason rewritten to VERDICT_DEGRADED_STALE.
189        let env = parse(
190            r#"{
191                "status":"WARN","reason_code":"VERDICT_DEGRADED_STALE",
192                "human_message":"stale","as_of":"2026-05-28",
193                "remediation":{"fix":{"availability":"degraded_stale"}}
194            }"#,
195        );
196        let d = derive_status(&env);
197        assert_eq!(d.status, Status::Warn);
198        assert_eq!(d.reason_code, ReasonCode::VerdictDegradedStale);
199    }
200
201    #[test]
202    fn kev_requires_all_three_conditions() {
203        // in_kev=true alone is not enough; need kev=available AND score>=70.
204        let env = parse(
205            r#"{
206                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
207                "human_message":"x","as_of":"2026-05-28",
208                "exploitability":{"in_kev":true,"exploit_risk_score":50},
209                "availability":{"kev":"available"}
210            }"#,
211        );
212        let d = derive_status(&env);
213        assert_eq!(d.status, Status::Allow);
214        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
215    }
216}