Skip to main content

tirith_core/
output.rs

1use std::io::Write;
2
3use crate::verdict::{Action, Evidence, Finding, Verdict};
4
5const SCHEMA_VERSION: u32 = 3;
6
7/// JSON output wrapper with schema version.
8#[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
23/// Write verdict as JSON to the given writer.
24pub 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
47/// Write human-readable verdict to stderr.
48///
49/// `warn_only` indicates the caller cannot actually enforce a block (e.g. bash
50/// preexec `DEBUG` trap). In that mode, Block verdicts render as `DETECTED
51/// (shell hook cannot block in preexec mode — command will still run)` instead
52/// of `BLOCKED`, and the bypass hint line is rewritten accordingly. The flag
53/// is human-only — it MUST never reach `write_json`, audit logs, or exit codes.
54pub 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        // Display detailed evidence for homoglyph findings
82        for evidence in &finding.evidence {
83            if let Evidence::HomoglyphAnalysis {
84                raw,
85                escaped,
86                suspicious_chars,
87            } = evidence
88            {
89                writeln!(w)?;
90                // Visual line with markers
91                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                // Suspicious bytes section
101                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
135/// Format a string highlighting suspicious characters — red background when
136/// color is enabled, bracket-wrapped (`[x]`) when color is off.
137fn 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"); // red bg, white fg
153                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
169/// Write human-readable output to stderr, respecting color preferences.
170/// Uses the no-color path when stderr is not a TTY or `NO_COLOR` is set.
171pub 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
179/// Write human-readable output without ANSI colors.
180fn 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        // Display detailed evidence for homoglyph findings (no color)
214        for evidence in &finding.evidence {
215            if let Evidence::HomoglyphAnalysis {
216                raw,
217                escaped,
218                suspicious_chars,
219            } = evidence
220            {
221                writeln!(w)?;
222                // Visual line with markers (using brackets instead of color)
223                let visual = format_visual_with_brackets(raw, suspicious_chars);
224                writeln!(w, "    Visual:  {visual}")?;
225                writeln!(w, "    Escaped: {escaped}")?;
226
227                // Suspicious bytes section
228                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
260/// Format a string with brackets around suspicious characters (for no-color mode)
261fn 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        // from_findings sets action based on severity; ensure it's Block for this test
316        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        // Invariant: `write_json` takes a `Verdict` (no warn_only parameter),
368        // so the flag literally cannot be serialized into machine output.
369        // This test pins down the shape — any refactor that passes warn_only
370        // into write_json would require updating this assertion too, which
371        // is the review bar the plan wants.
372        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}