Skip to main content

agentshield/output/
console.rs

1use std::path::Path;
2
3use crate::rules::policy::PolicyVerdict;
4use crate::rules::{Finding, Severity};
5
6// ANSI color codes
7const RESET: &str = "\x1b[0m";
8const BOLD: &str = "\x1b[1m";
9const DIM: &str = "\x1b[2m";
10const RED: &str = "\x1b[31m";
11const GREEN: &str = "\x1b[32m";
12const YELLOW: &str = "\x1b[33m";
13const BLUE: &str = "\x1b[34m";
14const MAGENTA: &str = "\x1b[35m";
15const CYAN: &str = "\x1b[36m";
16
17/// Render findings as colored console output, grouped by severity then file path.
18///
19/// Each finding includes a truncated fingerprint (first 12 hex chars) for quick
20/// cross-referencing with JSON/SARIF/HTML outputs.
21pub fn render(findings: &[Finding], verdict: &PolicyVerdict, scan_root: &Path) -> String {
22    let use_color = std::env::var("NO_COLOR").is_err();
23    let mut output = String::new();
24
25    if findings.is_empty() {
26        if use_color {
27            output.push_str(&format!(
28                "\n  {GREEN}{BOLD}No security findings detected.{RESET}\n\n"
29            ));
30        } else {
31            output.push_str("\n  No security findings detected.\n\n");
32        }
33        return output;
34    }
35
36    // Sort by severity (critical first), then by file path
37    let mut sorted: Vec<&Finding> = findings.iter().collect();
38    sorted.sort_by(|a, b| {
39        b.severity.cmp(&a.severity).then_with(|| {
40            let a_file = a.location.as_ref().map(|l| &l.file);
41            let b_file = b.location.as_ref().map(|l| &l.file);
42            a_file.cmp(&b_file)
43        })
44    });
45
46    output.push_str(&format!(
47        "\n  {bold}{} finding(s) detected:{reset}\n\n",
48        findings.len(),
49        bold = if use_color { BOLD } else { "" },
50        reset = if use_color { RESET } else { "" },
51    ));
52
53    for finding in &sorted {
54        let severity_tag = if use_color {
55            match finding.severity {
56                Severity::Critical => format!("{RED}{BOLD}[CRITICAL]{RESET}"),
57                Severity::High => format!("{MAGENTA}{BOLD}[HIGH]    {RESET}"),
58                Severity::Medium => format!("{YELLOW}{BOLD}[MEDIUM]  {RESET}"),
59                Severity::Low => format!("{BLUE}[LOW]     {RESET}"),
60                Severity::Info => format!("{DIM}[INFO]    {RESET}"),
61            }
62        } else {
63            match finding.severity {
64                Severity::Critical => "[CRITICAL]".into(),
65                Severity::High => "[HIGH]    ".into(),
66                Severity::Medium => "[MEDIUM]  ".into(),
67                Severity::Low => "[LOW]     ".into(),
68                Severity::Info => "[INFO]    ".into(),
69            }
70        };
71
72        let location = finding
73            .location
74            .as_ref()
75            .map(|l| format!("{}:{}", l.file.display(), l.line))
76            .unwrap_or_else(|| "-".into());
77
78        output.push_str(&format!(
79            "  {} {bold}{}{reset} {}\n",
80            severity_tag,
81            finding.rule_id,
82            finding.message,
83            bold = if use_color { BOLD } else { "" },
84            reset = if use_color { RESET } else { "" },
85        ));
86        output.push_str(&format!(
87            "           {dim}at {}{reset}\n",
88            location,
89            dim = if use_color { DIM } else { "" },
90            reset = if use_color { RESET } else { "" },
91        ));
92        let fp = finding.fingerprint(scan_root);
93        output.push_str(&format!(
94            "           {dim}fp {}{reset}\n",
95            &fp[..12],
96            dim = if use_color { DIM } else { "" },
97            reset = if use_color { RESET } else { "" },
98        ));
99        if let Some(remediation) = &finding.remediation {
100            output.push_str(&format!(
101                "           {cyan}fix: {}{reset}\n",
102                remediation,
103                cyan = if use_color { CYAN } else { "" },
104                reset = if use_color { RESET } else { "" },
105            ));
106        }
107        output.push('\n');
108    }
109
110    // Verdict
111    let (status, status_color) = if verdict.pass {
112        ("PASS", if use_color { GREEN } else { "" })
113    } else {
114        ("FAIL", if use_color { RED } else { "" })
115    };
116    output.push_str(&format!(
117        "  Result: {sc}{bold}{}{reset} (threshold: {}, highest: {})\n\n",
118        status,
119        verdict.fail_threshold,
120        verdict
121            .highest_severity
122            .map(|s| s.to_string())
123            .unwrap_or_else(|| "none".into()),
124        sc = status_color,
125        bold = if use_color { BOLD } else { "" },
126        reset = if use_color { RESET } else { "" },
127    ));
128
129    output
130}