1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum RuleId {
8 NonAsciiHostname,
10 PunycodeDomain,
11 MixedScriptInLabel,
12 UserinfoTrick,
13 ConfusableDomain,
14 RawIpUrl,
15 NonStandardPort,
16 InvalidHostChars,
17 TrailingDotWhitespace,
18 LookalikeTld,
19
20 NonAsciiPath,
22 HomoglyphInPath,
23 DoubleEncoding,
24
25 PlainHttpToSink,
27 SchemelessToSink,
28 InsecureTlsFlags,
29 ShortenedUrl,
30
31 AnsiEscapes,
33 ControlChars,
34 BidiControls,
35 ZeroWidthChars,
36 HiddenMultiline,
37 UnicodeTags,
38 InvisibleMathOperator,
39 VariationSelector,
40 InvisibleWhitespace,
41 HangulFiller,
42 ConfusableText,
43
44 PipeToInterpreter,
46 CurlPipeShell,
47 WgetPipeShell,
48 HttpiePipeShell,
49 XhPipeShell,
50 DotfileOverwrite,
51 ArchiveExtract,
52 ProcMemAccess,
53 DockerRemotePrivEsc,
54 CredentialFileSweep,
55 Base64DecodeExecute,
56 DataExfiltration,
57
58 DynamicCodeExecution,
60 ObfuscatedPayload,
61 SuspiciousCodeExfiltration,
62
63 ProxyEnvSet,
65 SensitiveEnvExport,
66 CodeInjectionEnv,
67 InterpreterHijackEnv,
68 ShellInjectionEnv,
69
70 MetadataEndpoint,
72 PrivateNetworkAccess,
73 CommandNetworkDeny,
74
75 ConfigInjection,
77 ConfigSuspiciousIndicator,
78 ConfigMalformed,
79 ConfigNonAscii,
80 ConfigInvisibleUnicode,
81 McpInsecureServer,
82 McpUntrustedServer,
83 McpDuplicateServerName,
84 McpOverlyPermissive,
85 McpSuspiciousArgs,
86
87 GitTyposquat,
89 DockerUntrustedRegistry,
90 PipUrlInstall,
91 NpmUrlInstall,
92 Web3RpcEndpoint,
93 Web3AddressInUrl,
94 VetNotConfigured,
95
96 ThreatMaliciousPackage,
98 ThreatMaliciousIp,
99 ThreatPackageTyposquat,
100 ThreatPackageSimilarName,
101 ThreatMaliciousUrl,
103 ThreatPhishingUrl,
104 ThreatTorExitNode,
105 ThreatThreatFoxIoc,
106 ThreatOsvVulnerable,
108 ThreatCisaKev,
109 ThreatSuspiciousPackage,
110 ThreatSafeBrowsing,
111
112 HiddenCssContent,
114 HiddenColorContent,
115 HiddenHtmlAttribute,
116 MarkdownComment,
117 HtmlComment,
118
119 ServerCloaking,
121
122 ClipboardHidden,
124
125 PdfHiddenText,
127
128 CredentialInText,
130 HighEntropySecret,
131 PrivateKeyExposed,
132
133 PolicyBlocklisted,
135
136 CustomRuleMatch,
138
139 LicenseRequired,
141}
142
143impl fmt::Display for RuleId {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 let s = serde_json::to_value(self)
146 .ok()
147 .and_then(|v| v.as_str().map(String::from))
148 .unwrap_or_else(|| format!("{self:?}"));
149 write!(f, "{s}")
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "UPPERCASE")]
156pub enum Severity {
157 Info,
158 Low,
159 Medium,
160 High,
161 Critical,
162}
163
164impl fmt::Display for Severity {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 match self {
167 Severity::Info => write!(f, "INFO"),
168 Severity::Low => write!(f, "LOW"),
169 Severity::Medium => write!(f, "MEDIUM"),
170 Severity::High => write!(f, "HIGH"),
171 Severity::Critical => write!(f, "CRITICAL"),
172 }
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "type", rename_all = "snake_case")]
179pub enum Evidence {
180 Url {
181 raw: String,
182 },
183 HostComparison {
184 raw_host: String,
185 similar_to: String,
186 },
187 CommandPattern {
188 pattern: String,
189 matched: String,
190 },
191 ByteSequence {
192 offset: usize,
193 hex: String,
194 description: String,
195 },
196 EnvVar {
197 name: String,
198 value_preview: String,
199 },
200 Text {
201 detail: String,
202 },
203 ThreatIntel {
204 source: String,
205 threat_type: String,
206 confidence: crate::threatdb::Confidence,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 reference: Option<String>,
209 },
210 HomoglyphAnalysis {
212 raw: String,
214 escaped: String,
216 suspicious_chars: Vec<SuspiciousChar>,
218 },
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SuspiciousChar {
224 pub offset: usize,
226 #[serde(rename = "character")]
228 pub character: char,
229 pub codepoint: String,
231 pub description: String,
233 pub hex_bytes: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct Finding {
240 pub rule_id: RuleId,
241 pub severity: Severity,
242 pub title: String,
243 pub description: String,
244 pub evidence: Vec<Evidence>,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub human_view: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub agent_view: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub mitre_id: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub custom_rule_id: Option<String>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum Action {
263 Allow,
264 Warn,
265 Block,
266 WarnAck,
269}
270
271impl Action {
272 pub fn exit_code(self) -> i32 {
273 match self {
274 Action::Allow => 0,
275 Action::Block => 1,
276 Action::Warn => 2,
277 Action::WarnAck => 3,
278 }
279 }
280
281 pub fn rank(self) -> u8 {
282 match self {
283 Action::Allow => 0,
284 Action::Warn | Action::WarnAck => 1,
285 Action::Block => 2,
286 }
287 }
288}
289
290pub fn action_from_findings(findings: &[Finding]) -> Action {
291 if findings.is_empty() {
292 return Action::Allow;
293 }
294
295 let max_severity = findings
296 .iter()
297 .map(|f| f.severity)
298 .max()
299 .unwrap_or(Severity::Info);
300
301 match max_severity {
302 Severity::Critical | Severity::High => Action::Block,
303 Severity::Medium | Severity::Low => Action::Warn,
304 Severity::Info => Action::Allow,
305 }
306}
307
308pub fn upgraded_action_from_findings(findings: &[Finding], current: Action) -> Action {
309 let derived = action_from_findings(findings);
310 if derived.rank() > current.rank() {
311 derived
312 } else {
313 current
314 }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct Verdict {
320 pub action: Action,
321 pub findings: Vec<Finding>,
322 pub tier_reached: u8,
323 pub bypass_requested: bool,
324 pub bypass_honored: bool,
325 pub bypass_available: bool,
326 pub interactive_detected: bool,
327 pub policy_path_used: Option<String>,
328 pub timings_ms: Timings,
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub urls_extracted_count: Option<usize>,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub requires_approval: Option<bool>,
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub approval_timeout_secs: Option<u64>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub approval_fallback: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub approval_rule: Option<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub approval_description: Option<String>,
348
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub escalation_reason: Option<String>,
352}
353
354#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356pub struct Timings {
357 pub tier0_ms: f64,
358 pub tier1_ms: f64,
359 pub tier2_ms: Option<f64>,
360 pub tier3_ms: Option<f64>,
361 pub total_ms: f64,
362}
363
364impl Verdict {
365 pub fn allow_fast(tier_reached: u8, timings: Timings) -> Self {
367 Self {
368 action: Action::Allow,
369 findings: Vec::new(),
370 tier_reached,
371 bypass_requested: false,
372 bypass_honored: false,
373 bypass_available: false,
374 interactive_detected: false,
375 policy_path_used: None,
376 timings_ms: timings,
377 urls_extracted_count: None,
378 requires_approval: None,
379 approval_timeout_secs: None,
380 approval_fallback: None,
381 approval_rule: None,
382 approval_description: None,
383 escalation_reason: None,
384 }
385 }
386
387 pub fn from_findings(findings: Vec<Finding>, tier_reached: u8, timings: Timings) -> Self {
389 let action = action_from_findings(&findings);
390 Self {
391 action,
392 findings,
393 tier_reached,
394 bypass_requested: false,
395 bypass_honored: false,
396 bypass_available: false,
397 interactive_detected: false,
398 policy_path_used: None,
399 timings_ms: timings,
400 urls_extracted_count: None,
401 requires_approval: None,
402 approval_timeout_secs: None,
403 approval_fallback: None,
404 approval_rule: None,
405 approval_description: None,
406 escalation_reason: None,
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_info_severity_maps_to_allow() {
417 let findings = vec![Finding {
418 rule_id: RuleId::NonAsciiHostname, severity: Severity::Info,
420 title: "test".to_string(),
421 description: "test".to_string(),
422 evidence: vec![],
423 human_view: None,
424 agent_view: None,
425 mitre_id: None,
426 custom_rule_id: None,
427 }];
428 let verdict = Verdict::from_findings(findings, 3, Timings::default());
429 assert_eq!(verdict.action, Action::Allow);
430 }
431
432 #[test]
433 fn test_info_severity_display() {
434 assert_eq!(format!("{}", Severity::Info), "INFO");
435 }
436
437 #[test]
438 fn test_info_severity_ordering() {
439 assert!(Severity::Info < Severity::Low);
440 assert!(Severity::Low < Severity::Medium);
441 }
442
443 #[test]
444 fn test_upgraded_action_from_findings_upgrades_when_findings_are_stronger() {
445 let findings = vec![Finding {
446 rule_id: RuleId::ThreatSuspiciousPackage,
447 severity: Severity::Medium,
448 title: "test".to_string(),
449 description: "test".to_string(),
450 evidence: vec![],
451 human_view: None,
452 agent_view: None,
453 mitre_id: None,
454 custom_rule_id: None,
455 }];
456
457 assert_eq!(
458 upgraded_action_from_findings(&findings, Action::Allow),
459 Action::Warn
460 );
461 }
462
463 #[test]
464 fn test_upgraded_action_from_findings_preserves_stronger_current_action() {
465 let findings = vec![Finding {
466 rule_id: RuleId::ThreatSuspiciousPackage,
467 severity: Severity::Medium,
468 title: "test".to_string(),
469 description: "test".to_string(),
470 evidence: vec![],
471 human_view: None,
472 agent_view: None,
473 mitre_id: None,
474 custom_rule_id: None,
475 }];
476
477 assert_eq!(
478 upgraded_action_from_findings(&findings, Action::Block),
479 Action::Block
480 );
481 }
482
483 #[test]
484 fn test_action_from_findings_empty_returns_allow() {
485 assert_eq!(action_from_findings(&[]), Action::Allow);
486 }
487
488 #[test]
489 fn test_action_from_findings_high_returns_block() {
490 let findings = vec![Finding {
491 rule_id: RuleId::ThreatOsvVulnerable,
492 severity: Severity::High,
493 title: "test".to_string(),
494 description: "test".to_string(),
495 evidence: vec![],
496 human_view: None,
497 agent_view: None,
498 mitre_id: None,
499 custom_rule_id: None,
500 }];
501 assert_eq!(action_from_findings(&findings), Action::Block);
502 }
503
504 #[test]
505 fn test_action_from_findings_critical_returns_block() {
506 let findings = vec![Finding {
507 rule_id: RuleId::ThreatMaliciousPackage,
508 severity: Severity::Critical,
509 title: "test".to_string(),
510 description: "test".to_string(),
511 evidence: vec![],
512 human_view: None,
513 agent_view: None,
514 mitre_id: None,
515 custom_rule_id: None,
516 }];
517 assert_eq!(action_from_findings(&findings), Action::Block);
518 }
519
520 #[test]
521 fn test_action_from_findings_low_returns_warn() {
522 let findings = vec![Finding {
523 rule_id: RuleId::ThreatSuspiciousPackage,
524 severity: Severity::Low,
525 title: "test".to_string(),
526 description: "test".to_string(),
527 evidence: vec![],
528 human_view: None,
529 agent_view: None,
530 mitre_id: None,
531 custom_rule_id: None,
532 }];
533 assert_eq!(action_from_findings(&findings), Action::Warn);
534 }
535
536 #[test]
537 fn test_upgraded_action_preserves_current_on_empty_findings() {
538 assert_eq!(
539 upgraded_action_from_findings(&[], Action::Block),
540 Action::Block
541 );
542 }
543}