1use std::io::Write;
2
3use crate::verdict::{Action, Evidence, Finding, 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, warn_only: bool, mut w: impl Write) -> std::io::Result<()> {
55 if verdict.findings.is_empty() {
56 return Ok(());
57 }
58
59 let is_warn_only_block = warn_only && verdict.action == Action::Block;
60 let action_str = match verdict.action {
61 Action::Allow => "INFO",
62 Action::Warn | Action::WarnAck => "WARNING",
63 Action::Block if is_warn_only_block => {
64 "DETECTED (shell hook cannot block in preexec mode — command will still run)"
65 }
66 Action::Block => "BLOCKED",
67 };
68
69 if let Some(ref reason) = verdict.escalation_reason {
70 writeln!(w, "tirith: {action_str} (escalated: {reason})")?;
71 } else {
72 writeln!(w, "tirith: {action_str}")?;
73 }
74
75 for finding in &verdict.findings {
76 let sev = crate::style::severity_label(&finding.severity, crate::style::Stream::Stderr);
77
78 writeln!(w, " {} {} — {}", sev, finding.rule_id, finding.title)?;
79 writeln!(w, " {}", finding.description)?;
80
81 for evidence in &finding.evidence {
83 if let Evidence::HomoglyphAnalysis {
84 raw,
85 escaped,
86 suspicious_chars,
87 } = evidence
88 {
89 writeln!(w)?;
90 let visual = format_visual_with_markers(raw, suspicious_chars);
92 writeln!(w, " Visual: {visual}")?;
93 let esc_styled = if crate::style::use_color_for(crate::style::Stream::Stderr) {
94 format!("\x1b[33m{escaped}\x1b[0m")
95 } else {
96 escaped.to_string()
97 };
98 writeln!(w, " Escaped: {esc_styled}")?;
99
100 if !suspicious_chars.is_empty() {
102 writeln!(w)?;
103 let header =
104 crate::style::bold("Suspicious bytes:", crate::style::Stream::Stderr);
105 writeln!(w, " {header}")?;
106 for sc in suspicious_chars {
107 writeln!(
108 w,
109 " {:08x}: {} {:6} {}",
110 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
111 )?;
112 }
113 }
114 }
115 }
116 }
117
118 if verdict.action == Action::Block && verdict.bypass_available {
119 if is_warn_only_block {
120 writeln!(
121 w,
122 " Safer: use an enter-capable shell (bash 5+/zsh/fish) to actually block this, or prefix with TIRITH=0 to suppress."
123 )?;
124 } else {
125 writeln!(
126 w,
127 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
128 )?;
129 }
130 }
131
132 Ok(())
133}
134
135fn format_visual_with_markers(
138 raw: &str,
139 suspicious_chars: &[crate::verdict::SuspiciousChar],
140) -> String {
141 use std::collections::HashSet;
142
143 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
144 let use_color = crate::style::use_color_for(crate::style::Stream::Stderr);
145
146 let mut result = String::new();
147 let mut byte_offset = 0;
148
149 for ch in raw.chars() {
150 if suspicious_offsets.contains(&byte_offset) {
151 if use_color {
152 result.push_str("\x1b[41m\x1b[97m"); result.push(ch);
154 result.push_str("\x1b[0m");
155 } else {
156 result.push('[');
157 result.push(ch);
158 result.push(']');
159 }
160 } else {
161 result.push(ch);
162 }
163 byte_offset += ch.len_utf8();
164 }
165
166 result
167}
168
169pub fn write_human_auto(verdict: &Verdict, warn_only: bool) -> std::io::Result<()> {
172 if crate::style::use_color_for(crate::style::Stream::Stderr) {
173 write_human(verdict, warn_only, std::io::stderr().lock())
174 } else {
175 write_human_no_color(verdict, warn_only, std::io::stderr().lock())
176 }
177}
178
179fn write_human_no_color(
181 verdict: &Verdict,
182 warn_only: bool,
183 mut w: impl Write,
184) -> std::io::Result<()> {
185 if verdict.findings.is_empty() {
186 return Ok(());
187 }
188
189 let is_warn_only_block = warn_only && verdict.action == Action::Block;
190 let action_str = match verdict.action {
191 Action::Allow => "INFO",
192 Action::Warn | Action::WarnAck => "WARNING",
193 Action::Block if is_warn_only_block => {
194 "DETECTED (shell hook cannot block in preexec mode — command will still run)"
195 }
196 Action::Block => "BLOCKED",
197 };
198
199 if let Some(ref reason) = verdict.escalation_reason {
200 writeln!(w, "tirith: {action_str} (escalated: {reason})")?;
201 } else {
202 writeln!(w, "tirith: {action_str}")?;
203 }
204
205 for finding in &verdict.findings {
206 writeln!(
207 w,
208 " [{}] {} — {}",
209 finding.severity, finding.rule_id, finding.title
210 )?;
211 writeln!(w, " {}", finding.description)?;
212
213 for evidence in &finding.evidence {
215 if let Evidence::HomoglyphAnalysis {
216 raw,
217 escaped,
218 suspicious_chars,
219 } = evidence
220 {
221 writeln!(w)?;
222 let visual = format_visual_with_brackets(raw, suspicious_chars);
224 writeln!(w, " Visual: {visual}")?;
225 writeln!(w, " Escaped: {escaped}")?;
226
227 if !suspicious_chars.is_empty() {
229 writeln!(w)?;
230 writeln!(w, " Suspicious bytes:")?;
231 for sc in suspicious_chars {
232 writeln!(
233 w,
234 " {:08x}: {} {:6} {}",
235 sc.offset, sc.hex_bytes, sc.codepoint, sc.description
236 )?;
237 }
238 }
239 }
240 }
241 }
242
243 if verdict.action == Action::Block && verdict.bypass_available {
244 if is_warn_only_block {
245 writeln!(
246 w,
247 " Safer: use an enter-capable shell (bash 5+/zsh/fish) to actually block this, or prefix with TIRITH=0 to suppress."
248 )?;
249 } else {
250 writeln!(
251 w,
252 " Bypass: prefix your command with TIRITH=0 (applies to that command only)"
253 )?;
254 }
255 }
256
257 Ok(())
258}
259
260fn format_visual_with_brackets(
262 raw: &str,
263 suspicious_chars: &[crate::verdict::SuspiciousChar],
264) -> String {
265 use std::collections::HashSet;
266
267 let suspicious_offsets: HashSet<usize> = suspicious_chars.iter().map(|sc| sc.offset).collect();
268
269 let mut result = String::new();
270 let mut byte_offset = 0;
271
272 for ch in raw.chars() {
273 if suspicious_offsets.contains(&byte_offset) {
274 result.push('[');
275 result.push(ch);
276 result.push(']');
277 } else {
278 result.push(ch);
279 }
280 byte_offset += ch.len_utf8();
281 }
282
283 result
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::verdict::{Action, Evidence, Finding, RuleId, Severity, Timings, Verdict};
290
291 fn block_verdict_with_bypass() -> Verdict {
292 let mut v = Verdict::from_findings(
293 vec![Finding {
294 rule_id: RuleId::PlainHttpToSink,
295 severity: Severity::High,
296 title: "Plain HTTP URL in execution context".to_string(),
297 description: "test".to_string(),
298 evidence: vec![Evidence::Url {
299 raw: "http://evil.com/x.sh".to_string(),
300 }],
301 human_view: None,
302 agent_view: None,
303 mitre_id: None,
304 custom_rule_id: None,
305 }],
306 3,
307 Timings {
308 tier0_ms: 0.0,
309 tier1_ms: 0.0,
310 tier2_ms: None,
311 tier3_ms: None,
312 total_ms: 0.0,
313 },
314 );
315 v.action = Action::Block;
317 v.bypass_available = true;
318 v
319 }
320
321 #[test]
322 fn write_human_no_color_warn_only_renders_detected() {
323 let verdict = block_verdict_with_bypass();
324 let mut buf = Vec::new();
325 write_human_no_color(&verdict, true, &mut buf).unwrap();
326 let out = String::from_utf8(buf).unwrap();
327 assert!(
328 !out.contains("BLOCKED"),
329 "warn-only must not render BLOCKED: {out}"
330 );
331 assert!(
332 out.contains("DETECTED (shell hook cannot block in preexec mode"),
333 "warn-only must render DETECTED with explanation: {out}"
334 );
335 assert!(
336 !out.contains("Bypass:"),
337 "warn-only must replace the Bypass hint: {out}"
338 );
339 assert!(
340 out.contains("Safer:"),
341 "warn-only must render the Safer hint: {out}"
342 );
343 }
344
345 #[test]
346 fn write_human_no_color_plain_renders_blocked() {
347 let verdict = block_verdict_with_bypass();
348 let mut buf = Vec::new();
349 write_human_no_color(&verdict, false, &mut buf).unwrap();
350 let out = String::from_utf8(buf).unwrap();
351 assert!(
352 out.contains("BLOCKED"),
353 "default must still render BLOCKED: {out}"
354 );
355 assert!(
356 !out.contains("DETECTED"),
357 "default must not render DETECTED: {out}"
358 );
359 assert!(
360 out.contains("Bypass:"),
361 "default must render the Bypass hint: {out}"
362 );
363 }
364
365 #[test]
366 fn warn_only_flag_does_not_reach_write_json() {
367 let verdict = block_verdict_with_bypass();
373 let mut buf = Vec::new();
374 write_json(&verdict, &[], &mut buf).unwrap();
375 let json = String::from_utf8(buf).unwrap();
376 assert!(
377 !json.contains("warn_only"),
378 "JSON must not carry warn_only: {json}"
379 );
380 assert!(
381 !json.contains("DETECTED"),
382 "JSON must not carry the DETECTED banner string: {json}"
383 );
384 }
385}