1use std::io::Write;
2
3use crate::verdict::{Action, Evidence, Finding, Severity, Verdict};
4
5const SCHEMA_VERSION: u32 = 2;
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(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
25 let output = JsonOutput {
26 schema_version: SCHEMA_VERSION,
27 action: verdict.action,
28 findings: &verdict.findings,
29 tier_reached: verdict.tier_reached,
30 bypass_requested: verdict.bypass_requested,
31 bypass_honored: verdict.bypass_honored,
32 interactive_detected: verdict.interactive_detected,
33 policy_path_used: &verdict.policy_path_used,
34 timings_ms: &verdict.timings_ms,
35 urls_extracted_count: verdict.urls_extracted_count,
36 };
37 serde_json::to_writer(&mut w, &output)?;
38 writeln!(w)?;
39 Ok(())
40}
41
42pub fn write_human(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
44 if verdict.findings.is_empty() {
45 return Ok(());
46 }
47
48 let action_str = match verdict.action {
49 Action::Allow => return Ok(()),
50 Action::Warn => "WARNING",
51 Action::Block => "BLOCKED",
52 };
53
54 writeln!(w, "tirith: {action_str}")?;
55
56 for finding in &verdict.findings {
57 let severity_color = match finding.severity {
58 Severity::Critical => "\x1b[91m", Severity::High => "\x1b[31m", Severity::Medium => "\x1b[33m", Severity::Low => "\x1b[36m", };
63 let reset = "\x1b[0m";
64
65 writeln!(
66 w,
67 " {}[{}]{} {} — {}",
68 severity_color, finding.severity, reset, finding.rule_id, finding.title
69 )?;
70 writeln!(w, " {}", finding.description)?;
71
72 for evidence in &finding.evidence {
74 if let Evidence::HomoglyphAnalysis {
75 raw,
76 escaped,
77 suspicious_chars,
78 } = evidence
79 {
80 writeln!(w)?;
81 let visual = format_visual_with_markers(raw, suspicious_chars);
83 writeln!(w, " Visual: {visual}")?;
84 writeln!(w, " Escaped: \x1b[33m{escaped}\x1b[0m")?;
85
86 if !suspicious_chars.is_empty() {
88 writeln!(w)?;
89 writeln!(w, " \x1b[33mSuspicious bytes:\x1b[0m")?;
90 for sc in suspicious_chars {
91 writeln!(
92 w,
93 " {:08x}: {} {:6} {}",
94 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
95 )?;
96 }
97 }
98 }
99 }
100 }
101
102 if verdict.action == Action::Block {
103 writeln!(
104 w,
105 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
106 )?;
107 }
108
109 Ok(())
110}
111
112fn format_visual_with_markers(
114 raw: &str,
115 suspicious_chars: &[crate::verdict::SuspiciousChar],
116) -> String {
117 use std::collections::HashSet;
118
119 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
121
122 let mut result = String::new();
123 let mut byte_offset = 0;
124
125 for ch in raw.chars() {
126 if suspicious_offsets.contains(&byte_offset) {
127 result.push_str("\x1b[41m\x1b[97m"); result.push(ch);
130 result.push_str("\x1b[0m"); } else {
132 result.push(ch);
133 }
134 byte_offset += ch.len_utf8();
135 }
136
137 result
138}
139
140pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
143 let stderr = std::io::stderr();
144 let is_tty = is_terminal::is_terminal(&stderr);
145
146 if is_tty {
147 write_human(verdict, stderr.lock())
148 } else {
149 write_human_no_color(verdict, stderr.lock())
150 }
151}
152
153fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
155 if verdict.findings.is_empty() {
156 return Ok(());
157 }
158
159 let action_str = match verdict.action {
160 Action::Allow => return Ok(()),
161 Action::Warn => "WARNING",
162 Action::Block => "BLOCKED",
163 };
164
165 writeln!(w, "tirith: {action_str}")?;
166
167 for finding in &verdict.findings {
168 writeln!(
169 w,
170 " [{}] {} — {}",
171 finding.severity, finding.rule_id, finding.title
172 )?;
173 writeln!(w, " {}", finding.description)?;
174
175 for evidence in &finding.evidence {
177 if let Evidence::HomoglyphAnalysis {
178 raw,
179 escaped,
180 suspicious_chars,
181 } = evidence
182 {
183 writeln!(w)?;
184 let visual = format_visual_with_brackets(raw, suspicious_chars);
186 writeln!(w, " Visual: {visual}")?;
187 writeln!(w, " Escaped: {escaped}")?;
188
189 if !suspicious_chars.is_empty() {
191 writeln!(w)?;
192 writeln!(w, " Suspicious bytes:")?;
193 for sc in suspicious_chars {
194 writeln!(
195 w,
196 " {:08x}: {} {:6} {}",
197 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
198 )?;
199 }
200 }
201 }
202 }
203 }
204
205 if verdict.action == Action::Block {
206 writeln!(
207 w,
208 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
209 )?;
210 }
211
212 Ok(())
213}
214
215fn format_visual_with_brackets(
217 raw: &str,
218 suspicious_chars: &[crate::verdict::SuspiciousChar],
219) -> String {
220 use std::collections::HashSet;
221
222 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
223
224 let mut result = String::new();
225 let mut byte_offset = 0;
226
227 for ch in raw.chars() {
228 if suspicious_offsets.contains(&byte_offset) {
229 result.push('[');
230 result.push(ch);
231 result.push(']');
232 } else {
233 result.push(ch);
234 }
235 byte_offset += ch.len_utf8();
236 }
237
238 result
239}