1#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum Tier {
42 Block,
44 Warn,
46 Clean,
48}
49
50impl Tier {
51 pub fn exit_code(self) -> i32 {
53 match self {
54 Tier::Block => 1,
55 Tier::Warn => 2,
56 Tier::Clean => 0,
57 }
58 }
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
65pub enum CustomerState {
66 BlockedByPolicy,
68 Compromised,
70 Malicious,
72 RansomwareLinked,
74 ActivelyExploited,
76 Vulnerable,
78 NotYetAssessed,
80 Clean,
82}
83
84impl CustomerState {
85 pub fn from_wire(source: &str) -> CustomerState {
102 match source {
103 "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
104 "CVE_FINDING" => CustomerState::Vulnerable,
105 "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
106 "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
107 "MALICIOUS_TRIAGE" => CustomerState::Malicious,
108 "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
109 "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
110 "VECTOR_VERDICT" => CustomerState::Vulnerable,
112 _ => CustomerState::NotYetAssessed,
114 }
115 }
116
117 pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
123 match (source, curated_compromise) {
124 ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
125 _ => CustomerState::from_wire(source),
126 }
127 }
128
129 pub fn as_str(self) -> &'static str {
132 match self {
133 CustomerState::BlockedByPolicy => "blocked_by_policy",
134 CustomerState::Compromised => "compromised",
135 CustomerState::Malicious => "malicious",
136 CustomerState::RansomwareLinked => "ransomware_linked",
137 CustomerState::ActivelyExploited => "actively_exploited",
138 CustomerState::Vulnerable => "vulnerable",
139 CustomerState::NotYetAssessed => "not_yet_assessed",
140 CustomerState::Clean => "clean",
141 }
142 }
143
144 pub fn label(self) -> &'static str {
146 match self {
147 CustomerState::BlockedByPolicy => "Blocked by policy",
148 CustomerState::Compromised => "Compromised",
149 CustomerState::Malicious => "Malicious",
150 CustomerState::RansomwareLinked => "Ransomware-linked",
151 CustomerState::ActivelyExploited => "Actively exploited",
152 CustomerState::Vulnerable => "Vulnerable",
153 CustomerState::NotYetAssessed => "Not yet assessed",
154 CustomerState::Clean => "Clean",
155 }
156 }
157
158 pub fn copy(self) -> &'static str {
161 match self {
162 CustomerState::BlockedByPolicy => {
163 "Blocked by your organization's policy. Contact your security \
164 owner or request an exception."
165 }
166 CustomerState::Compromised => {
167 "This version was tampered with in a supply-chain attack. Do \
168 not install — use a known-good version."
169 }
170 CustomerState::Malicious => {
171 "Known-malicious package (malware / typosquat). Do not install."
172 }
173 CustomerState::RansomwareLinked => {
174 "Carries a vulnerability tied to active ransomware campaigns. \
175 Upgrade before shipping."
176 }
177 CustomerState::ActivelyExploited => {
178 "Has a vulnerability that's being exploited in the wild (on the \
179 U.S. CISA exploited list). Upgrade urgently."
180 }
181 CustomerState::Vulnerable => {
182 "Has known vulnerabilities (CVEs). Review and upgrade to a \
183 fixed version."
184 }
185 CustomerState::NotYetAssessed => {
186 "No verdict on file yet — treat as needs-review until assessed."
187 }
188 CustomerState::Clean => "No known issues found.",
189 }
190 }
191
192 pub fn tier(self) -> Tier {
195 match self {
196 CustomerState::BlockedByPolicy
197 | CustomerState::Compromised
198 | CustomerState::Malicious
199 | CustomerState::RansomwareLinked
200 | CustomerState::ActivelyExploited => Tier::Block,
201 CustomerState::Vulnerable | CustomerState::NotYetAssessed => Tier::Warn,
202 CustomerState::Clean => Tier::Clean,
203 }
204 }
205
206 pub fn exit_code(self) -> i32 {
208 self.tier().exit_code()
209 }
210
211 pub fn color_token(self) -> &'static str {
214 match self {
215 CustomerState::BlockedByPolicy => "--cl-policy",
216 CustomerState::Compromised => "--cl-compromised",
217 CustomerState::Malicious => "--cl-malicious",
218 CustomerState::RansomwareLinked => "--cl-ransomware",
219 CustomerState::ActivelyExploited => "--cl-exploited",
220 CustomerState::Vulnerable => "--cl-vulnerable",
221 CustomerState::NotYetAssessed => "--cl-unknown",
222 CustomerState::Clean => "--cl-clean",
223 }
224 }
225
226 pub fn color_hex(self) -> &'static str {
230 match self {
231 CustomerState::BlockedByPolicy => "#C77DBB",
232 CustomerState::Compromised => "#D16D6A",
233 CustomerState::Malicious => "#D9534F",
234 CustomerState::RansomwareLinked => "#DB7B57",
235 CustomerState::ActivelyExploited => "#E0934A",
236 CustomerState::Vulnerable => "#D9A441",
237 CustomerState::NotYetAssessed => "#7C8696",
238 CustomerState::Clean => "#50C0E0",
239 }
240 }
241
242 pub fn emoji(self) -> &'static str {
246 match self {
247 CustomerState::BlockedByPolicy => "🛑",
248 CustomerState::Compromised => "⛔",
249 CustomerState::Malicious => "⛔",
250 CustomerState::RansomwareLinked => "🔴",
251 CustomerState::ActivelyExploited => "🔴",
252 CustomerState::Vulnerable => "⚠️",
253 CustomerState::NotYetAssessed => "❔",
254 CustomerState::Clean => "✅",
255 }
256 }
257
258 pub fn all() -> [CustomerState; 8] {
261 [
262 CustomerState::BlockedByPolicy,
263 CustomerState::Compromised,
264 CustomerState::Malicious,
265 CustomerState::RansomwareLinked,
266 CustomerState::ActivelyExploited,
267 CustomerState::Vulnerable,
268 CustomerState::NotYetAssessed,
269 CustomerState::Clean,
270 ]
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::envelope::ALL_VERDICT_SOURCES;
278
279 #[test]
281 fn wire_to_state_mapping_is_canonical() {
282 use CustomerState::*;
283 assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
284 assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
285 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
286 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
287 assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
288 assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
289 assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
290 assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
291 }
292
293 #[test]
294 fn every_known_wire_source_maps_without_falling_through() {
295 for &src in ALL_VERDICT_SOURCES {
299 let state = CustomerState::from_wire(src);
300 if src != "ALLOWED_NO_FINDINGS" {
302 assert_ne!(
303 state,
304 CustomerState::Clean,
305 "known non-clean source {src} mapped to Clean"
306 );
307 }
308 }
309 }
310
311 #[test]
312 fn unknown_source_fails_closed_to_needs_review() {
313 assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
314 assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
315 assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
316 }
317
318 #[test]
319 fn curated_compromise_routes_dm_block_to_compromised() {
320 assert_eq!(
321 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
322 CustomerState::Compromised
323 );
324 assert_eq!(
325 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
326 CustomerState::BlockedByPolicy
327 );
328 assert_eq!(
329 CustomerState::from_block_origin("CVE_FINDING", true),
330 CustomerState::Vulnerable
331 );
332 }
333
334 #[test]
335 fn tier_and_exit_codes() {
336 assert_eq!(CustomerState::Clean.exit_code(), 0);
337 assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
338 assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); for s in [
340 CustomerState::ActivelyExploited,
341 CustomerState::RansomwareLinked,
342 CustomerState::Malicious,
343 CustomerState::Compromised,
344 CustomerState::BlockedByPolicy,
345 ] {
346 assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
347 }
348 }
349
350 #[test]
351 fn no_internal_codenames_in_customer_strings() {
352 let banned = [
353 "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
354 "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
355 "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
356 ];
357 for s in CustomerState::all() {
358 for text in [s.label(), s.copy()] {
359 for b in banned {
360 assert!(
361 !text.contains(b),
362 "codename {b:?} leaked into customer string {text:?} for {s:?}"
363 );
364 }
365 }
366 }
367 }
368
369 #[test]
370 fn no_raw_tier_words_in_customer_copy() {
371 for s in CustomerState::all() {
372 for b in ["DENY", "WARN", "ALLOW"] {
373 assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
374 assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
375 }
376 }
377 }
378
379 #[test]
380 fn every_state_has_complete_token_set() {
381 for s in CustomerState::all() {
382 assert!(!s.as_str().is_empty());
383 assert!(!s.label().is_empty());
384 assert!(!s.copy().is_empty());
385 assert!(s.color_token().starts_with("--cl-"));
386 assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
387 assert!(!s.emoji().is_empty());
388 }
389 }
390
391 #[test]
392 fn clean_uses_brand_cyan_others_do_not() {
393 assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
394 for s in CustomerState::all() {
395 if s != CustomerState::Clean {
396 assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
397 }
398 }
399 }
400
401 #[test]
402 fn machine_strings_are_unique() {
403 let mut seen = std::collections::HashSet::new();
404 for s in CustomerState::all() {
405 assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
406 }
407 }
408}