1use std::path::Path;
2
3use crate::rules::policy::PolicyVerdict;
4use crate::rules::{AttackCategory, Finding, Severity};
5
6const 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
17pub 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 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 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}