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
42 PipeToInterpreter,
44 CurlPipeShell,
45 WgetPipeShell,
46 HttpiePipeShell,
47 XhPipeShell,
48 DotfileOverwrite,
49 ArchiveExtract,
50 ProcMemAccess,
51 DockerRemotePrivEsc,
52 CredentialFileSweep,
53 Base64DecodeExecute,
54 DataExfiltration,
55
56 DynamicCodeExecution,
58 ObfuscatedPayload,
59 SuspiciousCodeExfiltration,
60
61 ProxyEnvSet,
63 SensitiveEnvExport,
64 CodeInjectionEnv,
65 InterpreterHijackEnv,
66 ShellInjectionEnv,
67
68 MetadataEndpoint,
70 PrivateNetworkAccess,
71 CommandNetworkDeny,
72
73 ConfigInjection,
75 ConfigSuspiciousIndicator,
76 ConfigMalformed,
77 ConfigNonAscii,
78 ConfigInvisibleUnicode,
79 McpInsecureServer,
80 McpUntrustedServer,
81 McpDuplicateServerName,
82 McpOverlyPermissive,
83 McpSuspiciousArgs,
84
85 GitTyposquat,
87 DockerUntrustedRegistry,
88 PipUrlInstall,
89 NpmUrlInstall,
90 Web3RpcEndpoint,
91 Web3AddressInUrl,
92 VetNotConfigured,
93
94 HiddenCssContent,
96 HiddenColorContent,
97 HiddenHtmlAttribute,
98 MarkdownComment,
99 HtmlComment,
100
101 ServerCloaking,
103
104 ClipboardHidden,
106
107 PdfHiddenText,
109
110 CredentialInText,
112 HighEntropySecret,
113 PrivateKeyExposed,
114
115 PolicyBlocklisted,
117
118 CustomRuleMatch,
120
121 LicenseRequired,
123}
124
125impl fmt::Display for RuleId {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 let s = serde_json::to_value(self)
128 .ok()
129 .and_then(|v| v.as_str().map(String::from))
130 .unwrap_or_else(|| format!("{self:?}"));
131 write!(f, "{s}")
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
137#[serde(rename_all = "UPPERCASE")]
138pub enum Severity {
139 Info,
140 Low,
141 Medium,
142 High,
143 Critical,
144}
145
146impl fmt::Display for Severity {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 Severity::Info => write!(f, "INFO"),
150 Severity::Low => write!(f, "LOW"),
151 Severity::Medium => write!(f, "MEDIUM"),
152 Severity::High => write!(f, "HIGH"),
153 Severity::Critical => write!(f, "CRITICAL"),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "snake_case")]
161pub enum Evidence {
162 Url {
163 raw: String,
164 },
165 HostComparison {
166 raw_host: String,
167 similar_to: String,
168 },
169 CommandPattern {
170 pattern: String,
171 matched: String,
172 },
173 ByteSequence {
174 offset: usize,
175 hex: String,
176 description: String,
177 },
178 EnvVar {
179 name: String,
180 value_preview: String,
181 },
182 Text {
183 detail: String,
184 },
185 HomoglyphAnalysis {
187 raw: String,
189 escaped: String,
191 suspicious_chars: Vec<SuspiciousChar>,
193 },
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct SuspiciousChar {
199 pub offset: usize,
201 #[serde(rename = "character")]
203 pub character: char,
204 pub codepoint: String,
206 pub description: String,
208 pub hex_bytes: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Finding {
215 pub rule_id: RuleId,
216 pub severity: Severity,
217 pub title: String,
218 pub description: String,
219 pub evidence: Vec<Evidence>,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub human_view: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub agent_view: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub mitre_id: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub custom_rule_id: Option<String>,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum Action {
238 Allow,
239 Warn,
240 Block,
241}
242
243impl Action {
244 pub fn exit_code(self) -> i32 {
245 match self {
246 Action::Allow => 0,
247 Action::Block => 1,
248 Action::Warn => 2,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct Verdict {
256 pub action: Action,
257 pub findings: Vec<Finding>,
258 pub tier_reached: u8,
259 pub bypass_requested: bool,
260 pub bypass_honored: bool,
261 pub interactive_detected: bool,
262 pub policy_path_used: Option<String>,
263 pub timings_ms: Timings,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub urls_extracted_count: Option<usize>,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
271 pub requires_approval: Option<bool>,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub approval_timeout_secs: Option<u64>,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub approval_fallback: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub approval_rule: Option<String>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub approval_description: Option<String>,
284}
285
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
288pub struct Timings {
289 pub tier0_ms: f64,
290 pub tier1_ms: f64,
291 pub tier2_ms: Option<f64>,
292 pub tier3_ms: Option<f64>,
293 pub total_ms: f64,
294}
295
296impl Verdict {
297 pub fn allow_fast(tier_reached: u8, timings: Timings) -> Self {
299 Self {
300 action: Action::Allow,
301 findings: Vec::new(),
302 tier_reached,
303 bypass_requested: false,
304 bypass_honored: false,
305 interactive_detected: false,
306 policy_path_used: None,
307 timings_ms: timings,
308 urls_extracted_count: None,
309 requires_approval: None,
310 approval_timeout_secs: None,
311 approval_fallback: None,
312 approval_rule: None,
313 approval_description: None,
314 }
315 }
316
317 pub fn from_findings(findings: Vec<Finding>, tier_reached: u8, timings: Timings) -> Self {
319 let action = if findings.is_empty() {
320 Action::Allow
321 } else {
322 let max_severity = findings
323 .iter()
324 .map(|f| f.severity)
325 .max()
326 .unwrap_or(Severity::Low);
327 match max_severity {
328 Severity::Critical | Severity::High => Action::Block,
329 Severity::Medium | Severity::Low => Action::Warn,
330 Severity::Info => Action::Allow,
331 }
332 };
333 Self {
334 action,
335 findings,
336 tier_reached,
337 bypass_requested: false,
338 bypass_honored: false,
339 interactive_detected: false,
340 policy_path_used: None,
341 timings_ms: timings,
342 urls_extracted_count: None,
343 requires_approval: None,
344 approval_timeout_secs: None,
345 approval_fallback: None,
346 approval_rule: None,
347 approval_description: None,
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_info_severity_maps_to_allow() {
358 let findings = vec![Finding {
359 rule_id: RuleId::NonAsciiHostname, severity: Severity::Info,
361 title: "test".to_string(),
362 description: "test".to_string(),
363 evidence: vec![],
364 human_view: None,
365 agent_view: None,
366 mitre_id: None,
367 custom_rule_id: None,
368 }];
369 let verdict = Verdict::from_findings(findings, 3, Timings::default());
370 assert_eq!(verdict.action, Action::Allow);
371 }
372
373 #[test]
374 fn test_info_severity_display() {
375 assert_eq!(format!("{}", Severity::Info), "INFO");
376 }
377
378 #[test]
379 fn test_info_severity_ordering() {
380 assert!(Severity::Info < Severity::Low);
381 assert!(Severity::Low < Severity::Medium);
382 }
383}