Skip to main content

agentshield/output/
console.rs

1use std::path::Path;
2
3use crate::rules::policy::PolicyVerdict;
4use crate::rules::{AttackCategory, 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    output.push_str(&format!(
53        "  Runtime-risk findings: {}\n",
54        grouped_count(findings, false)
55    ));
56    output.push_str(&format!(
57        "  Supply-chain hygiene: {}\n\n",
58        grouped_count(findings, true)
59    ));
60
61    for finding in &sorted {
62        let severity_tag = if use_color {
63            match finding.severity {
64                Severity::Critical => format!("{RED}{BOLD}[CRITICAL]{RESET}"),
65                Severity::High => format!("{MAGENTA}{BOLD}[HIGH]    {RESET}"),
66                Severity::Medium => format!("{YELLOW}{BOLD}[MEDIUM]  {RESET}"),
67                Severity::Low => format!("{BLUE}[LOW]     {RESET}"),
68                Severity::Info => format!("{DIM}[INFO]    {RESET}"),
69            }
70        } else {
71            match finding.severity {
72                Severity::Critical => "[CRITICAL]".into(),
73                Severity::High => "[HIGH]    ".into(),
74                Severity::Medium => "[MEDIUM]  ".into(),
75                Severity::Low => "[LOW]     ".into(),
76                Severity::Info => "[INFO]    ".into(),
77            }
78        };
79
80        let location = finding
81            .location
82            .as_ref()
83            .map(|l| format!("{}:{}", l.file.display(), l.line))
84            .unwrap_or_else(|| "-".into());
85
86        output.push_str(&format!(
87            "  {} {bold}{}{reset} {}\n",
88            severity_tag,
89            finding.rule_id,
90            finding.message,
91            bold = if use_color { BOLD } else { "" },
92            reset = if use_color { RESET } else { "" },
93        ));
94        output.push_str(&format!(
95            "           {dim}at {}{reset}\n",
96            location,
97            dim = if use_color { DIM } else { "" },
98            reset = if use_color { RESET } else { "" },
99        ));
100        let fp = finding.fingerprint(scan_root);
101        output.push_str(&format!(
102            "           {dim}fp {}{reset}\n",
103            &fp[..12],
104            dim = if use_color { DIM } else { "" },
105            reset = if use_color { RESET } else { "" },
106        ));
107        if let Some(remediation) = &finding.remediation {
108            output.push_str(&format!(
109                "           {cyan}fix: {}{reset}\n",
110                remediation,
111                cyan = if use_color { CYAN } else { "" },
112                reset = if use_color { RESET } else { "" },
113            ));
114        }
115        output.push('\n');
116    }
117
118    // Verdict
119    let (status, status_color) = if verdict.pass {
120        ("PASS", if use_color { GREEN } else { "" })
121    } else {
122        ("FAIL", if use_color { RED } else { "" })
123    };
124    output.push_str(&format!(
125        "  Result: {sc}{bold}{}{reset} (threshold: {}, highest: {})\n\n",
126        status,
127        verdict.fail_threshold,
128        verdict
129            .highest_severity
130            .map(|s| s.to_string())
131            .unwrap_or_else(|| "none".into()),
132        sc = status_color,
133        bold = if use_color { BOLD } else { "" },
134        reset = if use_color { RESET } else { "" },
135    ));
136
137    output
138}
139
140fn grouped_count(findings: &[Finding], supply_chain: bool) -> String {
141    let count = findings
142        .iter()
143        .filter(|finding| (finding.attack_category == AttackCategory::SupplyChain) == supply_chain)
144        .count();
145
146    if count == 0 {
147        "none".into()
148    } else if supply_chain {
149        format!("{count} recommendation(s)")
150    } else {
151        format!("{count} finding(s)")
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::path::PathBuf;
158
159    use crate::ir::SourceLocation;
160    use crate::rules::policy::PolicyVerdict;
161    use crate::rules::{Confidence, Evidence};
162
163    use super::*;
164
165    fn finding(rule_id: &str, category: AttackCategory, severity: Severity) -> Finding {
166        Finding {
167            rule_id: rule_id.into(),
168            rule_name: "Rule".into(),
169            severity,
170            confidence: Confidence::High,
171            attack_category: category,
172            message: "message".into(),
173            location: Some(SourceLocation {
174                file: PathBuf::from("server.py"),
175                line: 1,
176                column: 0,
177                end_line: None,
178                end_column: None,
179            }),
180            evidence: vec![Evidence {
181                description: "evidence".into(),
182                location: None,
183                snippet: None,
184            }],
185            taint_path: None,
186            remediation: None,
187            cwe_id: None,
188        }
189    }
190
191    #[test]
192    fn console_groups_runtime_and_supply_chain_findings() {
193        let output = render(
194            &[
195                finding(
196                    "SHIELD-001",
197                    AttackCategory::CommandInjection,
198                    Severity::Critical,
199                ),
200                finding("SHIELD-009", AttackCategory::SupplyChain, Severity::Medium),
201            ],
202            &PolicyVerdict {
203                pass: false,
204                total_findings: 2,
205                effective_findings: 2,
206                highest_severity: Some(Severity::Critical),
207                fail_threshold: Severity::High,
208            },
209            std::path::Path::new("."),
210        );
211
212        assert!(output.contains("Runtime-risk findings: 1 finding(s)"));
213        assert!(output.contains("Supply-chain hygiene: 1 recommendation(s)"));
214    }
215}