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    HangulFiller,
42    ConfusableText,
43
44    // Command shape rules
45    PipeToInterpreter,
46    CurlPipeShell,
47    WgetPipeShell,
48    HttpiePipeShell,
49    XhPipeShell,
50    DotfileOverwrite,
51    ArchiveExtract,
52    ProcMemAccess,
53    DockerRemotePrivEsc,
54    CredentialFileSweep,
55    Base64DecodeExecute,
56    DataExfiltration,
57
58    // Code file scan rules
59    DynamicCodeExecution,
60    ObfuscatedPayload,
61    SuspiciousCodeExfiltration,
62
63    // Environment rules
64    ProxyEnvSet,
65    SensitiveEnvExport,
66    CodeInjectionEnv,
67    InterpreterHijackEnv,
68    ShellInjectionEnv,
69
70    // Network destination rules
71    MetadataEndpoint,
72    PrivateNetworkAccess,
73    CommandNetworkDeny,
74
75    // Config file rules
76    ConfigInjection,
77    ConfigSuspiciousIndicator,
78    ConfigMalformed,
79    ConfigNonAscii,
80    ConfigInvisibleUnicode,
81    McpInsecureServer,
82    McpUntrustedServer,
83    McpDuplicateServerName,
84    McpOverlyPermissive,
85    McpSuspiciousArgs,
86
87    // Ecosystem rules
88    GitTyposquat,
89    DockerUntrustedRegistry,
90    PipUrlInstall,
91    NpmUrlInstall,
92    Web3RpcEndpoint,
93    Web3AddressInUrl,
94    VetNotConfigured,
95
96    // Threat intelligence rules — local DB
97    ThreatMaliciousPackage,
98    ThreatMaliciousIp,
99    ThreatPackageTyposquat,
100    ThreatPackageSimilarName,
101    // Supplemental-feed rules are defined now so RuleId stays stable.
102    ThreatMaliciousUrl,
103    ThreatPhishingUrl,
104    ThreatTorExitNode,
105    ThreatThreatFoxIoc,
106    // Real-time lookup rules
107    ThreatOsvVulnerable,
108    ThreatCisaKev,
109    ThreatSuspiciousPackage,
110    ThreatSafeBrowsing,
111
112    // Rendered content rules
113    HiddenCssContent,
114    HiddenColorContent,
115    HiddenHtmlAttribute,
116    MarkdownComment,
117    HtmlComment,
118
119    // Cloaking rules
120    ServerCloaking,
121
122    // Clipboard rules
123    ClipboardHidden,
124
125    // PDF rules
126    PdfHiddenText,
127
128    // Credential rules
129    CredentialInText,
130    HighEntropySecret,
131    PrivateKeyExposed,
132
133    // Policy rules
134    PolicyBlocklisted,
135
136    // Custom rules
137    CustomRuleMatch,
138
139    // License/infrastructure rules
140    LicenseRequired,
141}
142
143impl fmt::Display for RuleId {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        let s = serde_json::to_value(self)
146            .ok()
147            .and_then(|v| v.as_str().map(String::from))
148            .unwrap_or_else(|| format!("{self:?}"));
149        write!(f, "{s}")
150    }
151}
152
153/// Severity level for findings.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "UPPERCASE")]
156pub enum Severity {
157    Info,
158    Low,
159    Medium,
160    High,
161    Critical,
162}
163
164impl fmt::Display for Severity {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Severity::Info => write!(f, "INFO"),
168            Severity::Low => write!(f, "LOW"),
169            Severity::Medium => write!(f, "MEDIUM"),
170            Severity::High => write!(f, "HIGH"),
171            Severity::Critical => write!(f, "CRITICAL"),
172        }
173    }
174}
175
176/// Evidence supporting a finding.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "type", rename_all = "snake_case")]
179pub enum Evidence {
180    Url {
181        raw: String,
182    },
183    HostComparison {
184        raw_host: String,
185        similar_to: String,
186    },
187    CommandPattern {
188        pattern: String,
189        matched: String,
190    },
191    ByteSequence {
192        offset: usize,
193        hex: String,
194        description: String,
195    },
196    EnvVar {
197        name: String,
198        value_preview: String,
199    },
200    Text {
201        detail: String,
202    },
203    ThreatIntel {
204        source: String,
205        threat_type: String,
206        confidence: crate::threatdb::Confidence,
207        #[serde(skip_serializing_if = "Option::is_none")]
208        reference: Option<String>,
209    },
210    /// Detailed character analysis for homograph detection
211    HomoglyphAnalysis {
212        /// The raw input string
213        raw: String,
214        /// The ASCII/punycode escaped version
215        escaped: String,
216        /// Positions of suspicious characters (byte offset, char, description)
217        suspicious_chars: Vec<SuspiciousChar>,
218    },
219}
220
221/// A suspicious character with its position and details
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SuspiciousChar {
224    /// Byte offset in the string
225    pub offset: usize,
226    /// The suspicious character
227    #[serde(rename = "character")]
228    pub character: char,
229    /// Unicode codepoint (e.g., "U+0456")
230    pub codepoint: String,
231    /// Human description (e.g., "Cyrillic Small Letter Byelorussian-Ukrainian I")
232    pub description: String,
233    /// Hex bytes of this character
234    pub hex_bytes: String,
235}
236
237/// A single detection finding.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct Finding {
240    pub rule_id: RuleId,
241    pub severity: Severity,
242    pub title: String,
243    pub description: String,
244    pub evidence: Vec<Evidence>,
245    /// What a human sees (populated by Pro enrichment).
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub human_view: Option<String>,
248    /// What an AI agent processes (populated by Pro enrichment).
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub agent_view: Option<String>,
251    /// MITRE ATT&CK technique ID (populated by Team enrichment).
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub mitre_id: Option<String>,
254    /// User-defined custom rule ID (populated only for CustomRuleMatch findings).
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub custom_rule_id: Option<String>,
257}
258
259/// The action to take based on analysis.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum Action {
263    Allow,
264    Warn,
265    Block,
266    /// Warn findings require explicit interactive acknowledgement.
267    /// Used by `strict_warn` in hook-driven mode (exit code 3).
268    WarnAck,
269}
270
271impl Action {
272    pub fn exit_code(self) -> i32 {
273        match self {
274            Action::Allow => 0,
275            Action::Block => 1,
276            Action::Warn => 2,
277            Action::WarnAck => 3,
278        }
279    }
280
281    pub fn rank(self) -> u8 {
282        match self {
283            Action::Allow => 0,
284            Action::Warn | Action::WarnAck => 1,
285            Action::Block => 2,
286        }
287    }
288}
289
290pub fn action_from_findings(findings: &[Finding]) -> Action {
291    if findings.is_empty() {
292        return Action::Allow;
293    }
294
295    let max_severity = findings
296        .iter()
297        .map(|f| f.severity)
298        .max()
299        .unwrap_or(Severity::Info);
300
301    match max_severity {
302        Severity::Critical | Severity::High => Action::Block,
303        Severity::Medium | Severity::Low => Action::Warn,
304        Severity::Info => Action::Allow,
305    }
306}
307
308pub fn upgraded_action_from_findings(findings: &[Finding], current: Action) -> Action {
309    let derived = action_from_findings(findings);
310    if derived.rank() > current.rank() {
311        derived
312    } else {
313        current
314    }
315}
316
317/// Complete analysis verdict.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct Verdict {
320    pub action: Action,
321    pub findings: Vec<Finding>,
322    pub tier_reached: u8,
323    pub bypass_requested: bool,
324    pub bypass_honored: bool,
325    pub bypass_available: bool,
326    pub interactive_detected: bool,
327    pub policy_path_used: Option<String>,
328    pub timings_ms: Timings,
329    /// Number of URLs extracted during Tier 3 analysis.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub urls_extracted_count: Option<usize>,
332
333    /// Whether this verdict requires human approval before execution.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub requires_approval: Option<bool>,
336    /// Timeout in seconds for approval (0 = indefinite).
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub approval_timeout_secs: Option<u64>,
339    /// Fallback action when approval times out: "block", "warn", or "allow".
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub approval_fallback: Option<String>,
342    /// The rule_id that triggered the approval requirement.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub approval_rule: Option<String>,
345    /// Sanitized single-line description of why approval is required.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub approval_description: Option<String>,
348
349    /// Human-readable reason when escalation upgraded the action.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub escalation_reason: Option<String>,
352}
353
354/// Per-tier timing information.
355#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356pub struct Timings {
357    pub tier0_ms: f64,
358    pub tier1_ms: f64,
359    pub tier2_ms: Option<f64>,
360    pub tier3_ms: Option<f64>,
361    pub total_ms: f64,
362}
363
364impl Verdict {
365    /// Create an allow verdict with no findings (fast path).
366    pub fn allow_fast(tier_reached: u8, timings: Timings) -> Self {
367        Self {
368            action: Action::Allow,
369            findings: Vec::new(),
370            tier_reached,
371            bypass_requested: false,
372            bypass_honored: false,
373            bypass_available: false,
374            interactive_detected: false,
375            policy_path_used: None,
376            timings_ms: timings,
377            urls_extracted_count: None,
378            requires_approval: None,
379            approval_timeout_secs: None,
380            approval_fallback: None,
381            approval_rule: None,
382            approval_description: None,
383            escalation_reason: None,
384        }
385    }
386
387    /// Determine action from findings: max severity → action mapping.
388    pub fn from_findings(findings: Vec<Finding>, tier_reached: u8, timings: Timings) -> Self {
389        let action = action_from_findings(&findings);
390        Self {
391            action,
392            findings,
393            tier_reached,
394            bypass_requested: false,
395            bypass_honored: false,
396            bypass_available: false,
397            interactive_detected: false,
398            policy_path_used: None,
399            timings_ms: timings,
400            urls_extracted_count: None,
401            requires_approval: None,
402            approval_timeout_secs: None,
403            approval_fallback: None,
404            approval_rule: None,
405            approval_description: None,
406            escalation_reason: None,
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_info_severity_maps_to_allow() {
417        let findings = vec![Finding {
418            rule_id: RuleId::NonAsciiHostname, // arbitrary rule
419            severity: Severity::Info,
420            title: "test".to_string(),
421            description: "test".to_string(),
422            evidence: vec![],
423            human_view: None,
424            agent_view: None,
425            mitre_id: None,
426            custom_rule_id: None,
427        }];
428        let verdict = Verdict::from_findings(findings, 3, Timings::default());
429        assert_eq!(verdict.action, Action::Allow);
430    }
431
432    #[test]
433    fn test_info_severity_display() {
434        assert_eq!(format!("{}", Severity::Info), "INFO");
435    }
436
437    #[test]
438    fn test_info_severity_ordering() {
439        assert!(Severity::Info < Severity::Low);
440        assert!(Severity::Low < Severity::Medium);
441    }
442
443    #[test]
444    fn test_upgraded_action_from_findings_upgrades_when_findings_are_stronger() {
445        let findings = vec![Finding {
446            rule_id: RuleId::ThreatSuspiciousPackage,
447            severity: Severity::Medium,
448            title: "test".to_string(),
449            description: "test".to_string(),
450            evidence: vec![],
451            human_view: None,
452            agent_view: None,
453            mitre_id: None,
454            custom_rule_id: None,
455        }];
456
457        assert_eq!(
458            upgraded_action_from_findings(&findings, Action::Allow),
459            Action::Warn
460        );
461    }
462
463    #[test]
464    fn test_upgraded_action_from_findings_preserves_stronger_current_action() {
465        let findings = vec![Finding {
466            rule_id: RuleId::ThreatSuspiciousPackage,
467            severity: Severity::Medium,
468            title: "test".to_string(),
469            description: "test".to_string(),
470            evidence: vec![],
471            human_view: None,
472            agent_view: None,
473            mitre_id: None,
474            custom_rule_id: None,
475        }];
476
477        assert_eq!(
478            upgraded_action_from_findings(&findings, Action::Block),
479            Action::Block
480        );
481    }
482
483    #[test]
484    fn test_action_from_findings_empty_returns_allow() {
485        assert_eq!(action_from_findings(&[]), Action::Allow);
486    }
487
488    #[test]
489    fn test_action_from_findings_high_returns_block() {
490        let findings = vec![Finding {
491            rule_id: RuleId::ThreatOsvVulnerable,
492            severity: Severity::High,
493            title: "test".to_string(),
494            description: "test".to_string(),
495            evidence: vec![],
496            human_view: None,
497            agent_view: None,
498            mitre_id: None,
499            custom_rule_id: None,
500        }];
501        assert_eq!(action_from_findings(&findings), Action::Block);
502    }
503
504    #[test]
505    fn test_action_from_findings_critical_returns_block() {
506        let findings = vec![Finding {
507            rule_id: RuleId::ThreatMaliciousPackage,
508            severity: Severity::Critical,
509            title: "test".to_string(),
510            description: "test".to_string(),
511            evidence: vec![],
512            human_view: None,
513            agent_view: None,
514            mitre_id: None,
515            custom_rule_id: None,
516        }];
517        assert_eq!(action_from_findings(&findings), Action::Block);
518    }
519
520    #[test]
521    fn test_action_from_findings_low_returns_warn() {
522        let findings = vec![Finding {
523            rule_id: RuleId::ThreatSuspiciousPackage,
524            severity: Severity::Low,
525            title: "test".to_string(),
526            description: "test".to_string(),
527            evidence: vec![],
528            human_view: None,
529            agent_view: None,
530            mitre_id: None,
531            custom_rule_id: None,
532        }];
533        assert_eq!(action_from_findings(&findings), Action::Warn);
534    }
535
536    #[test]
537    fn test_upgraded_action_preserves_current_on_empty_findings() {
538        assert_eq!(
539            upgraded_action_from_findings(&[], Action::Block),
540            Action::Block
541        );
542    }
543}