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