Skip to main content

verifyos_cli/report/
mod.rs

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!("\n{}", 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}