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
51    // Environment rules
52    ProxyEnvSet,
53    SensitiveEnvExport,
54    CodeInjectionEnv,
55    InterpreterHijackEnv,
56    ShellInjectionEnv,
57
58    // Network destination rules
59    MetadataEndpoint,
60    PrivateNetworkAccess,
61    CommandNetworkDeny,
62
63    // Config file rules
64    ConfigInjection,
65    ConfigSuspiciousIndicator,
66    ConfigMalformed,
67    ConfigNonAscii,
68    ConfigInvisibleUnicode,
69    McpInsecureServer,
70    McpUntrustedServer,
71    McpDuplicateServerName,
72    McpOverlyPermissive,
73    McpSuspiciousArgs,
74
75    // Ecosystem rules
76    GitTyposquat,
77    DockerUntrustedRegistry,
78    PipUrlInstall,
79    NpmUrlInstall,
80    Web3RpcEndpoint,
81    Web3AddressInUrl,
82    VetNotConfigured,
83
84    // Rendered content rules
85    HiddenCssContent,
86    HiddenColorContent,
87    HiddenHtmlAttribute,
88    MarkdownComment,
89    HtmlComment,
90
91    // Cloaking rules
92    ServerCloaking,
93
94    // Clipboard rules
95    ClipboardHidden,
96
97    // PDF rules
98    PdfHiddenText,
99
100    // Policy rules
101    PolicyBlocklisted,
102
103    // Custom rules (Team-only, Phase 24)
104    CustomRuleMatch,
105
106    // License/infrastructure rules
107    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/// Severity level for findings.
121#[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/// Evidence supporting a finding.
144#[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    /// Detailed character analysis for homograph detection
171    HomoglyphAnalysis {
172        /// The raw input string
173        raw: String,
174        /// The ASCII/punycode escaped version
175        escaped: String,
176        /// Positions of suspicious characters (byte offset, char, description)
177        suspicious_chars: Vec<SuspiciousChar>,
178    },
179}
180
181/// A suspicious character with its position and details
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct SuspiciousChar {
184    /// Byte offset in the string
185    pub offset: usize,
186    /// The suspicious character
187    #[serde(rename = "character")]
188    pub character: char,
189    /// Unicode codepoint (e.g., "U+0456")
190    pub codepoint: String,
191    /// Human description (e.g., "Cyrillic Small Letter Byelorussian-Ukrainian I")
192    pub description: String,
193    /// Hex bytes of this character
194    pub hex_bytes: String,
195}
196
197/// A single detection finding.
198#[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    /// What a human sees (populated by Pro enrichment).
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub human_view: Option<String>,
208    /// What an AI agent processes (populated by Pro enrichment).
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub agent_view: Option<String>,
211    /// MITRE ATT&CK technique ID (populated by Team enrichment).
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub mitre_id: Option<String>,
214    /// User-defined custom rule ID (populated only for CustomRuleMatch findings).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub custom_rule_id: Option<String>,
217}
218
219/// The action to take based on analysis.
220#[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/// Complete analysis verdict.
239#[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    /// Number of URLs extracted during Tier 3 analysis.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub urls_extracted_count: Option<usize>,
252
253    // --- Approval workflow metadata (Team, Phase 7) ---
254    /// Whether this verdict requires human approval before execution.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub requires_approval: Option<bool>,
257    /// Timeout in seconds for approval (0 = indefinite).
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub approval_timeout_secs: Option<u64>,
260    /// Fallback action when approval times out: "block", "warn", or "allow".
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub approval_fallback: Option<String>,
263    /// The rule_id that triggered the approval requirement.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub approval_rule: Option<String>,
266    /// Sanitized single-line description of why approval is required.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub approval_description: Option<String>,
269}
270
271/// Per-tier timing information.
272#[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    /// Create an allow verdict with no findings (fast path).
283    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    /// Determine action from findings: max severity → action mapping.
303    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, // arbitrary rule
345            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}