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