1use crate::core::engine::EngineResult;
2use crate::rules::core::{RuleCategory, RuleStatus, Severity, RULESET_VERSION};
3use comfy_table::modifiers::UTF8_ROUND_CORNERS;
4use comfy_table::presets::UTF8_FULL;
5use comfy_table::{Cell, Color, Table};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::time::{SystemTime, UNIX_EPOCH};
9use textwrap::wrap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ReportData {
13 pub ruleset_version: String,
14 pub generated_at_unix: u64,
15 pub results: Vec<ReportItem>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ReportItem {
20 pub rule_id: String,
21 pub rule_name: String,
22 pub category: RuleCategory,
23 pub severity: Severity,
24 pub status: RuleStatus,
25 pub message: Option<String>,
26 pub evidence: Option<String>,
27 pub recommendation: String,
28}
29
30#[derive(Debug, Clone)]
31pub struct BaselineSummary {
32 pub suppressed: usize,
33}
34
35pub fn build_report(results: Vec<EngineResult>) -> ReportData {
36 let generated_at_unix = SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .unwrap_or_default()
39 .as_secs();
40
41 let mut items = Vec::new();
42
43 for res in results {
44 let (status, message, evidence) = match res.report {
45 Ok(report) => (report.status, report.message, report.evidence),
46 Err(err) => (
47 RuleStatus::Error,
48 Some(err.to_string()),
49 Some("Rule evaluation error".to_string()),
50 ),
51 };
52
53 items.push(ReportItem {
54 rule_id: res.rule_id.to_string(),
55 rule_name: res.rule_name.to_string(),
56 category: res.category,
57 severity: res.severity,
58 status,
59 message,
60 evidence,
61 recommendation: res.recommendation.to_string(),
62 });
63 }
64
65 ReportData {
66 ruleset_version: RULESET_VERSION.to_string(),
67 generated_at_unix,
68 results: items,
69 }
70}
71
72pub fn apply_baseline(report: &mut ReportData, baseline: &ReportData) -> BaselineSummary {
73 let mut suppressed = 0;
74 let baseline_keys: HashSet<String> = baseline
75 .results
76 .iter()
77 .filter(|r| r.status == RuleStatus::Fail)
78 .map(finding_key)
79 .collect();
80
81 report.results.retain(|r| {
82 if r.status != RuleStatus::Fail {
83 return true;
84 }
85 let key = finding_key(r);
86 let keep = !baseline_keys.contains(&key);
87 if !keep {
88 suppressed += 1;
89 }
90 keep
91 });
92
93 BaselineSummary { suppressed }
94}
95
96fn finding_key(item: &ReportItem) -> String {
97 format!(
98 "{}|{}",
99 item.rule_id,
100 item.evidence.clone().unwrap_or_default()
101 )
102}
103
104pub fn render_table(report: &ReportData) -> String {
105 let mut table = Table::new();
106 table
107 .load_preset(UTF8_FULL)
108 .apply_modifier(UTF8_ROUND_CORNERS)
109 .set_header(vec!["Rule", "Category", "Severity", "Status", "Message"]);
110
111 for res in &report.results {
112 let severity_cell = match res.severity {
113 Severity::Error => Cell::new("ERROR").fg(Color::Red),
114 Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
115 Severity::Info => Cell::new("INFO").fg(Color::Blue),
116 };
117
118 let status_cell = match res.status {
119 RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
120 RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
121 RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
122 RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
123 };
124
125 let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
126 let wrapped = wrap(&message, 50).join("\n");
127
128 table.add_row(vec![
129 Cell::new(res.rule_name.clone()),
130 Cell::new(format!("{:?}", res.category)),
131 severity_cell,
132 status_cell,
133 Cell::new(wrapped),
134 ]);
135 }
136
137 format!("{}", table)
138}
139
140pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
141 serde_json::to_string_pretty(report)
142}
143
144pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
145 let mut rules = Vec::new();
146 let mut results = Vec::new();
147
148 for item in &report.results {
149 rules.push(serde_json::json!({
150 "id": item.rule_id,
151 "name": item.rule_name,
152 "shortDescription": { "text": item.rule_name },
153 "fullDescription": { "text": item.message.clone().unwrap_or_default() },
154 "help": { "text": item.recommendation },
155 "properties": {
156 "category": format!("{:?}", item.category),
157 "severity": format!("{:?}", item.severity),
158 }
159 }));
160
161 if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
162 results.push(serde_json::json!({
163 "ruleId": item.rule_id,
164 "level": sarif_level(item.severity),
165 "message": {
166 "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
167 },
168 "properties": {
169 "category": format!("{:?}", item.category),
170 "evidence": item.evidence.clone().unwrap_or_default(),
171 }
172 }));
173 }
174 }
175
176 let sarif = serde_json::json!({
177 "version": "2.1.0",
178 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
179 "runs": [
180 {
181 "tool": {
182 "driver": {
183 "name": "verifyos-cli",
184 "semanticVersion": report.ruleset_version,
185 "rules": rules
186 }
187 },
188 "results": results
189 }
190 ]
191 });
192
193 serde_json::to_string_pretty(&sarif)
194}
195
196fn sarif_level(severity: Severity) -> &'static str {
197 match severity {
198 Severity::Error => "error",
199 Severity::Warning => "warning",
200 Severity::Info => "note",
201 }
202}
203
204pub fn render_markdown(report: &ReportData, suppressed: Option<usize>) -> String {
205 let total = report.results.len();
206 let fail_count = report
207 .results
208 .iter()
209 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
210 .count();
211 let warn_count = report
212 .results
213 .iter()
214 .filter(|r| r.severity == Severity::Warning)
215 .count();
216 let error_count = report
217 .results
218 .iter()
219 .filter(|r| r.severity == Severity::Error)
220 .count();
221
222 let mut out = String::new();
223 out.push_str("# verifyOS-cli Report\n\n");
224 out.push_str(&format!("- Total rules: {total}\n"));
225 out.push_str(&format!("- Failures: {fail_count}\n"));
226 out.push_str(&format!(
227 "- Severity: error={error_count}, warning={warn_count}\n"
228 ));
229 if let Some(suppressed) = suppressed {
230 out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
231 }
232 out.push('\n');
233
234 let mut failures = report
235 .results
236 .iter()
237 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error));
238
239 if failures.next().is_none() {
240 out.push_str("## Findings\n\n- No failing findings.\n");
241 return out;
242 }
243
244 out.push_str("## Findings\n\n");
245 for item in report
246 .results
247 .iter()
248 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
249 {
250 out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
251 out.push_str(&format!(" - Category: `{:?}`\n", item.category));
252 out.push_str(&format!(" - Severity: `{:?}`\n", item.severity));
253 out.push_str(&format!(" - Status: `{:?}`\n", item.status));
254 if let Some(message) = &item.message {
255 out.push_str(&format!(" - Message: {}\n", message));
256 }
257 if let Some(evidence) = &item.evidence {
258 out.push_str(&format!(" - Evidence: {}\n", evidence));
259 }
260 if !item.recommendation.is_empty() {
261 out.push_str(&format!(" - Recommendation: {}\n", item.recommendation));
262 }
263 }
264
265 out
266}