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