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(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 => "INFO",
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", Severity::Info => "\x1b[90m", };
64 let reset = "\x1b[0m";
65
66 writeln!(
67 w,
68 " {}[{}]{} {} — {}",
69 severity_color, finding.severity, reset, finding.rule_id, finding.title
70 )?;
71 writeln!(w, " {}", finding.description)?;
72
73 for evidence in &finding.evidence {
75 if let Evidence::HomoglyphAnalysis {
76 raw,
77 escaped,
78 suspicious_chars,
79 } = evidence
80 {
81 writeln!(w)?;
82 let visual = format_visual_with_markers(raw, suspicious_chars);
84 writeln!(w, " Visual: {visual}")?;
85 writeln!(w, " Escaped: \x1b[33m{escaped}\x1b[0m")?;
86
87 if !suspicious_chars.is_empty() {
89 writeln!(w)?;
90 writeln!(w, " \x1b[33mSuspicious bytes:\x1b[0m")?;
91 for sc in suspicious_chars {
92 writeln!(
93 w,
94 " {:08x}: {} {:6} {}",
95 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
96 )?;
97 }
98 }
99 }
100 }
101 }
102
103 if verdict.action == Action::Block {
104 writeln!(
105 w,
106 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
107 )?;
108 }
109
110 Ok(())
111}
112
113fn format_visual_with_markers(
115 raw: &str,
116 suspicious_chars: &[crate::verdict::SuspiciousChar],
117) -> String {
118 use std::collections::HashSet;
119
120 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
122
123 let mut result = String::new();
124 let mut byte_offset = 0;
125
126 for ch in raw.chars() {
127 if suspicious_offsets.contains(&byte_offset) {
128 result.push_str("\x1b[41m\x1b[97m"); result.push(ch);
131 result.push_str("\x1b[0m"); } else {
133 result.push(ch);
134 }
135 byte_offset += ch.len_utf8();
136 }
137
138 result
139}
140
141pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
144 let stderr = std::io::stderr();
145 let is_tty = is_terminal::is_terminal(&stderr);
146
147 if is_tty {
148 write_human(verdict, stderr.lock())
149 } else {
150 write_human_no_color(verdict, stderr.lock())
151 }
152}
153
154fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
156 if verdict.findings.is_empty() {
157 return Ok(());
158 }
159
160 let action_str = match verdict.action {
161 Action::Allow => "INFO",
162 Action::Warn => "WARNING",
163 Action::Block => "BLOCKED",
164 };
165
166 writeln!(w, "tirith: {action_str}")?;
167
168 for finding in &verdict.findings {
169 writeln!(
170 w,
171 " [{}] {} — {}",
172 finding.severity, finding.rule_id, finding.title
173 )?;
174 writeln!(w, " {}", finding.description)?;
175
176 for evidence in &finding.evidence {
178 if let Evidence::HomoglyphAnalysis {
179 raw,
180 escaped,
181 suspicious_chars,
182 } = evidence
183 {
184 writeln!(w)?;
185 let visual = format_visual_with_brackets(raw, suspicious_chars);
187 writeln!(w, " Visual: {visual}")?;
188 writeln!(w, " Escaped: {escaped}")?;
189
190 if !suspicious_chars.is_empty() {
192 writeln!(w)?;
193 writeln!(w, " Suspicious bytes:")?;
194 for sc in suspicious_chars {
195 writeln!(
196 w,
197 " {:08x}: {} {:6} {}",
198 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
199 )?;
200 }
201 }
202 }
203 }
204 }
205
206 if verdict.action == Action::Block {
207 writeln!(
208 w,
209 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
210 )?;
211 }
212
213 Ok(())
214}
215
216fn format_visual_with_brackets(
218 raw: &str,
219 suspicious_chars: &[crate::verdict::SuspiciousChar],
220) -> String {
221 use std::collections::HashSet;
222
223 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
224
225 let mut result = String::new();
226 let mut byte_offset = 0;
227
228 for ch in raw.chars() {
229 if suspicious_offsets.contains(&byte_offset) {
230 result.push('[');
231 result.push(ch);
232 result.push(']');
233 } else {
234 result.push(ch);
235 }
236 byte_offset += ch.len_utf8();
237 }
238
239 result
240}