cleanlib_client/
derive_status.rs1use serde_json::Value;
38
39use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct DerivedStatus {
44 pub status: Status,
45 pub reason_code: ReasonCode,
46}
47
48pub 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 = Some(&envelope.availability);
63
64 let mut status: Status;
65 let mut reason: ReasonCode;
66 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 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 let rank = |s: &Status| -> u8 {
115 match s {
116 Status::Allow => 0,
117 Status::Warn => 1,
118 Status::Deny => 2,
119 }
120 };
121 let baseline_status = match envelope.status.as_str() {
122 "DENY" => Status::Deny,
123 "WARN" => Status::Warn,
124 _ => Status::Allow,
125 };
126 if rank(&baseline_status) > rank(&status) {
127 status = baseline_status;
128 if let Ok(br) = serde_json::from_value::<ReasonCode>(serde_json::Value::String(
130 envelope.reason_code.clone(),
131 )) {
132 reason = br;
133 }
134 }
135
136 if freshness_signal.as_deref() == Some("degraded_stale") {
138 reason = ReasonCode::VerdictDegradedStale;
139 }
140
141 let _ = &mut status;
145 let _ = &mut reason;
146
147 DerivedStatus { status, reason_code: reason }
148}
149
150fn get<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a Value> {
153 let v = obj?.get(key)?;
154 if v.is_null() {
155 None
156 } else {
157 Some(v)
158 }
159}
160
161fn as_str<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a str> {
162 get(obj, key).and_then(Value::as_str)
163}
164
165fn as_bool(obj: Option<&Value>, key: &str) -> bool {
166 get(obj, key).and_then(Value::as_bool).unwrap_or(false)
167}
168
169fn as_f64(obj: Option<&Value>, key: &str) -> f64 {
170 get(obj, key).and_then(Value::as_f64).unwrap_or(0.0)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn parse(json: &str) -> VerdictEnvelopeV1 {
178 serde_json::from_str(json).unwrap()
179 }
180
181 #[test]
182 fn clean_envelope_yields_allow_clean() {
183 let env = parse(
184 r#"{
185 "status":"ALLOW","reason_code":"VERDICT_CLEAN",
186 "human_message":"ok","as_of":"2026-05-28"
187 }"#,
188 );
189 let d = derive_status(&env);
190 assert_eq!(d.status, Status::Allow);
191 assert_eq!(d.reason_code, ReasonCode::VerdictClean);
192 }
193
194 #[test]
195 fn exploitation_critical_wins_over_kev_and_remediation() {
196 let env = parse(
198 r#"{
199 "status":"DENY","reason_code":"VERDICT_EXPLOITATION_CRITICAL",
200 "human_message":"e","as_of":"2026-05-28",
201 "exploitability":{
202 "exploitation_likelihood":"CRITICAL",
203 "in_kev":true,
204 "exploit_risk_score":99
205 },
206 "availability":{"kev":"available","exploitation_fusion":"available"},
207 "remediation":{"fix":{"availability":"available"}}
208 }"#,
209 );
210 let d = derive_status(&env);
211 assert_eq!(d.status, Status::Deny);
212 assert_eq!(d.reason_code, ReasonCode::VerdictExploitationCritical);
213 }
214
215 #[test]
216 fn freshness_override_preserves_tier_only_rewrites_reason() {
217 let env = parse(
220 r#"{
221 "status":"WARN","reason_code":"VERDICT_DEGRADED_STALE",
222 "human_message":"stale","as_of":"2026-05-28",
223 "remediation":{"fix":{"availability":"degraded_stale"}}
224 }"#,
225 );
226 let d = derive_status(&env);
227 assert_eq!(d.status, Status::Warn);
228 assert_eq!(d.reason_code, ReasonCode::VerdictDegradedStale);
229 }
230
231 #[test]
232 fn kev_requires_all_three_conditions() {
233 let env = parse(
235 r#"{
236 "status":"ALLOW","reason_code":"VERDICT_CLEAN",
237 "human_message":"x","as_of":"2026-05-28",
238 "exploitability":{"in_kev":true,"exploit_risk_score":50},
239 "availability":{"kev":"available"}
240 }"#,
241 );
242 let d = derive_status(&env);
243 assert_eq!(d.status, Status::Allow);
244 assert_eq!(d.reason_code, ReasonCode::VerdictClean);
245 }
246}