Skip to main content

tirith_core/
verdict.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// Unique identifier for each detection rule.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum RuleId {
8    // Hostname rules
9    NonAsciiHostname,
10    PunycodeDomain,
11    MixedScriptInLabel,
12    UserinfoTrick,
13    ConfusableDomain,
14    RawIpUrl,
15    NonStandardPort,
16    InvalidHostChars,
17    TrailingDotWhitespace,
18    LookalikeTld,
19
20    // Path rules
21    NonAsciiPath,
22    HomoglyphInPath,
23    DoubleEncoding,
24
25    // Transport rules
26    PlainHttpToSink,
27    SchemelessToSink,
28    InsecureTlsFlags,
29    ShortenedUrl,
30
31    // Terminal deception rules
32    AnsiEscapes,
33    ControlChars,
34    BidiControls,
35    ZeroWidthChars,
36    HiddenMultiline,
37    UnicodeTags,
38    InvisibleMathOperator,
39    VariationSelector,
40    InvisibleWhitespace,
41
42    // Command shape rules
43    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    // Code file scan rules
57    DynamicCodeExecution,
58    ObfuscatedPayload,
59    SuspiciousCodeExfiltration,
60
61    // Environment rules
62    ProxyEnvSet,
63    SensitiveEnvExport,
64    CodeInjectionEnv,
65    InterpreterHijackEnv,
66    ShellInjectionEnv,
67
68    // Network destination rules
69    MetadataEndpoint,
70    PrivateNetworkAccess,
71    CommandNetworkDeny,
72
73    // Config file rules
74    ConfigInjection,
75    ConfigSuspiciousIndicator,
76    ConfigMalformed,
77    ConfigNonAscii,
78    ConfigInvisibleUnicode,
79    McpInsecureServer,
80    McpUntrustedServer,
81    McpDuplicateServerName,
82    McpOverlyPermissive,
83    McpSuspiciousArgs,
84
85    // Ecosystem rules
86    GitTyposquat,
87    DockerUntrustedRegistry,
88    PipUrlInstall,
89    NpmUrlInstall,
90    Web3RpcEndpoint,
91    Web3AddressInUrl,
92    VetNotConfigured,
93
94    // Rendered content rules
95    HiddenCssContent,
96    HiddenColorContent,
97    HiddenHtmlAttribute,
98    MarkdownComment,
99    HtmlComment,
100
101    // Cloaking rules
102    ServerCloaking,
103
104    // Clipboard rules
105    ClipboardHidden,
106
107    // PDF rules
108    PdfHiddenText,
109
110    // Credential rules
111    CredentialInText,
112    HighEntropySecret,
113    PrivateKeyExposed,
114
115    // Policy rules
116    PolicyBlocklisted,
117
118    // Custom rules
119    CustomRuleMatch,
120
121    // License/infrastructure rules
122    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/// Severity level for findings.
136#[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/// Evidence supporting a finding.
159#[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    /// Detailed character analysis for homograph detection
186    HomoglyphAnalysis {
187        /// The raw input string
188        raw: String,
189        /// The ASCII/punycode escaped version
190        escaped: String,
191        /// Positions of suspicious characters (byte offset, char, description)
192        suspicious_chars: Vec<SuspiciousChar>,
193    },
194}
195
196/// A suspicious character with its position and details
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct SuspiciousChar {
199    /// Byte offset in the string
200    pub offset: usize,
201    /// The suspicious character
202    #[serde(rename = "character")]
203    pub character: char,
204    /// Unicode codepoint (e.g., "U+0456")
205    pub codepoint: String,
206    /// Human description (e.g., "Cyrillic Small Letter Byelorussian-Ukrainian I")
207    pub description: String,
208    /// Hex bytes of this character
209    pub hex_bytes: String,
210}
211
212/// A single detection finding.
213#[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    /// What a human sees (populated by Pro enrichment).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub human_view: Option<String>,
223    /// What an AI agent processes (populated by Pro enrichment).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub agent_view: Option<String>,
226    /// MITRE ATT&CK technique ID (populated by Team enrichment).
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub mitre_id: Option<String>,
229    /// User-defined custom rule ID (populated only for CustomRuleMatch findings).
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub custom_rule_id: Option<String>,
232}
233
234/// The action to take based on analysis.
235#[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/// Complete analysis verdict.
254#[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    /// Number of URLs extracted during Tier 3 analysis.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub urls_extracted_count: Option<usize>,
267
268    // --- Approval workflow metadata (Team, Phase 7) ---
269    /// Whether this verdict requires human approval before execution.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub requires_approval: Option<bool>,
272    /// Timeout in seconds for approval (0 = indefinite).
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub approval_timeout_secs: Option<u64>,
275    /// Fallback action when approval times out: "block", "warn", or "allow".
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub approval_fallback: Option<String>,
278    /// The rule_id that triggered the approval requirement.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub approval_rule: Option<String>,
281    /// Sanitized single-line description of why approval is required.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub approval_description: Option<String>,
284}
285
286/// Per-tier timing information.
287#[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    /// Create an allow verdict with no findings (fast path).
298    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    /// Determine action from findings: max severity → action mapping.
318    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, // arbitrary rule
360            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}