1use std::io::Write;
2
3use crate::verdict::{Action, Evidence, Finding, Severity, Verdict};
4
5const SCHEMA_VERSION: u32 = 3;
6
7#[derive(serde::Serialize)]
9pub struct JsonOutput<'a> {
10 pub schema_version: u32,
11 pub action: Action,
12 pub findings: &'a [Finding],
13 pub tier_reached: u8,
14 pub bypass_requested: bool,
15 pub bypass_honored: bool,
16 pub interactive_detected: bool,
17 pub policy_path_used: &'a Option<String>,
18 pub timings_ms: &'a crate::verdict::Timings,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub urls_extracted_count: Option<usize>,
21}
22
23pub fn write_json(
25 verdict: &Verdict,
26 custom_patterns: &[String],
27 mut w: impl Write,
28) -> std::io::Result<()> {
29 let redacted_findings = crate::redact::redacted_findings(&verdict.findings, custom_patterns);
30 let output = JsonOutput {
31 schema_version: SCHEMA_VERSION,
32 action: verdict.action,
33 findings: &redacted_findings,
34 tier_reached: verdict.tier_reached,
35 bypass_requested: verdict.bypass_requested,
36 bypass_honored: verdict.bypass_honored,
37 interactive_detected: verdict.interactive_detected,
38 policy_path_used: &verdict.policy_path_used,
39 timings_ms: &verdict.timings_ms,
40 urls_extracted_count: verdict.urls_extracted_count,
41 };
42 serde_json::to_writer(&mut w, &output)?;
43 writeln!(w)?;
44 Ok(())
45}
46
47pub fn write_human(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
49 if verdict.findings.is_empty() {
50 return Ok(());
51 }
52
53 let action_str = match verdict.action {
54 Action::Allow => "INFO",
55 Action::Warn => "WARNING",
56 Action::Block => "BLOCKED",
57 };
58
59 writeln!(w, "tirith: {action_str}")?;
60
61 for finding in &verdict.findings {
62 let severity_color = match finding.severity {
63 Severity::Critical => "\x1b[91m", Severity::High => "\x1b[31m", Severity::Medium => "\x1b[33m", Severity::Low => "\x1b[36m", Severity::Info => "\x1b[90m", };
69 let reset = "\x1b[0m";
70
71 writeln!(
72 w,
73 " {}[{}]{} {} — {}",
74 severity_color, finding.severity, reset, finding.rule_id, finding.title
75 )?;
76 writeln!(w, " {}", finding.description)?;
77
78 for evidence in &finding.evidence {
80 if let Evidence::HomoglyphAnalysis {
81 raw,
82 escaped,
83 suspicious_chars,
84 } = evidence
85 {
86 writeln!(w)?;
87 let visual = format_visual_with_markers(raw, suspicious_chars);
89 writeln!(w, " Visual: {visual}")?;
90 writeln!(w, " Escaped: \x1b[33m{escaped}\x1b[0m")?;
91
92 if !suspicious_chars.is_empty() {
94 writeln!(w)?;
95 writeln!(w, " \x1b[33mSuspicious bytes:\x1b[0m")?;
96 for sc in suspicious_chars {
97 writeln!(
98 w,
99 " {:08x}: {} {:6} {}",
100 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
101 )?;
102 }
103 }
104 }
105 }
106 }
107
108 if verdict.action == Action::Block {
109 writeln!(
110 w,
111 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
112 )?;
113 }
114
115 Ok(())
116}
117
118fn format_visual_with_markers(
120 raw: &str,
121 suspicious_chars: &[crate::verdict::SuspiciousChar],
122) -> String {
123 use std::collections::HashSet;
124
125 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
127
128 let mut result = String::new();
129 let mut byte_offset = 0;
130
131 for ch in raw.chars() {
132 if suspicious_offsets.contains(&byte_offset) {
133 result.push_str("\x1b[41m\x1b[97m"); result.push(ch);
136 result.push_str("\x1b[0m"); } else {
138 result.push(ch);
139 }
140 byte_offset += ch.len_utf8();
141 }
142
143 result
144}
145
146pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
149 let stderr = std::io::stderr();
150 let is_tty = is_terminal::is_terminal(&stderr);
151
152 if is_tty {
153 write_human(verdict, stderr.lock())
154 } else {
155 write_human_no_color(verdict, stderr.lock())
156 }
157}
158
159fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
161 if verdict.findings.is_empty() {
162 return Ok(());
163 }
164
165 let action_str = match verdict.action {
166 Action::Allow => "INFO",
167 Action::Warn => "WARNING",
168 Action::Block => "BLOCKED",
169 };
170
171 writeln!(w, "tirith: {action_str}")?;
172
173 for finding in &verdict.findings {
174 writeln!(
175 w,
176 " [{}] {} — {}",
177 finding.severity, finding.rule_id, finding.title
178 )?;
179 writeln!(w, " {}", finding.description)?;
180
181 for evidence in &finding.evidence {
183 if let Evidence::HomoglyphAnalysis {
184 raw,
185 escaped,
186 suspicious_chars,
187 } = evidence
188 {
189 writeln!(w)?;
190 let visual = format_visual_with_brackets(raw, suspicious_chars);
192 writeln!(w, " Visual: {visual}")?;
193 writeln!(w, " Escaped: {escaped}")?;
194
195 if !suspicious_chars.is_empty() {
197 writeln!(w)?;
198 writeln!(w, " Suspicious bytes:")?;
199 for sc in suspicious_chars {
200 writeln!(
201 w,
202 " {:08x}: {} {:6} {}",
203 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
204 )?;
205 }
206 }
207 }
208 }
209 }
210
211 if verdict.action == Action::Block {
212 writeln!(
213 w,
214 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
215 )?;
216 }
217
218 Ok(())
219}
220
221fn format_visual_with_brackets(
223 raw: &str,
224 suspicious_chars: &[crate::verdict::SuspiciousChar],
225) -> String {
226 use std::collections::HashSet;
227
228 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
229
230 let mut result = String::new();
231 let mut byte_offset = 0;
232
233 for ch in raw.chars() {
234 if suspicious_offsets.contains(&byte_offset) {
235 result.push('[');
236 result.push(ch);
237 result.push(']');
238 } else {
239 result.push(ch);
240 }
241 byte_offset += ch.len_utf8();
242 }
243
244 result
245}