Skip to main content

verifyos_cli/report/
mod.rs

1use crate::core::engine::EngineResult;
2use crate::rules::core::{
3    ArtifactCacheStats, CacheCounter, RuleCategory, RuleStatus, Severity, RULESET_VERSION,
4};
5use comfy_table::modifiers::UTF8_ROUND_CORNERS;
6use comfy_table::presets::UTF8_FULL;
7use comfy_table::{Cell, Color, Table};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10use std::time::{SystemTime, UNIX_EPOCH};
11use textwrap::wrap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ReportData {
15    pub ruleset_version: String,
16    pub generated_at_unix: u64,
17    pub total_duration_ms: u128,
18    pub cache_stats: ArtifactCacheStats,
19    pub slow_rules: Vec<SlowRule>,
20    pub results: Vec<ReportItem>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ReportItem {
25    pub rule_id: String,
26    pub rule_name: String,
27    pub category: RuleCategory,
28    pub severity: Severity,
29    pub status: RuleStatus,
30    pub message: Option<String>,
31    pub evidence: Option<String>,
32    pub recommendation: String,
33    pub duration_ms: u128,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct SlowRule {
38    pub rule_id: String,
39    pub rule_name: String,
40    pub duration_ms: u128,
41}
42
43#[derive(Debug, Clone)]
44pub struct BaselineSummary {
45    pub suppressed: usize,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum FailOn {
50    Off,
51    Error,
52    Warning,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TimingMode {
57    Off,
58    Summary,
59    Full,
60}
61
62pub fn build_report(
63    results: Vec<EngineResult>,
64    total_duration_ms: u128,
65    cache_stats: ArtifactCacheStats,
66) -> ReportData {
67    let generated_at_unix = SystemTime::now()
68        .duration_since(UNIX_EPOCH)
69        .unwrap_or_default()
70        .as_secs();
71
72    let mut items = Vec::new();
73
74    for res in results {
75        let (status, message, evidence) = match res.report {
76            Ok(report) => (report.status, report.message, report.evidence),
77            Err(err) => (
78                RuleStatus::Error,
79                Some(err.to_string()),
80                Some("Rule evaluation error".to_string()),
81            ),
82        };
83
84        items.push(ReportItem {
85            rule_id: res.rule_id.to_string(),
86            rule_name: res.rule_name.to_string(),
87            category: res.category,
88            severity: res.severity,
89            status,
90            message,
91            evidence,
92            recommendation: res.recommendation.to_string(),
93            duration_ms: res.duration_ms,
94        });
95    }
96
97    let report = ReportData {
98        ruleset_version: RULESET_VERSION.to_string(),
99        generated_at_unix,
100        total_duration_ms,
101        cache_stats,
102        slow_rules: Vec::new(),
103        results: items,
104    };
105
106    ReportData {
107        slow_rules: top_slow_rules(&report, 3),
108        ..report
109    }
110}
111
112pub fn apply_baseline(report: &mut ReportData, baseline: &ReportData) -> BaselineSummary {
113    let mut suppressed = 0;
114    let baseline_keys: HashSet<String> = baseline
115        .results
116        .iter()
117        .filter(|r| r.status == RuleStatus::Fail)
118        .map(finding_key)
119        .collect();
120
121    report.results.retain(|r| {
122        if r.status != RuleStatus::Fail {
123            return true;
124        }
125        let key = finding_key(r);
126        let keep = !baseline_keys.contains(&key);
127        if !keep {
128            suppressed += 1;
129        }
130        keep
131    });
132
133    BaselineSummary { suppressed }
134}
135
136fn finding_key(item: &ReportItem) -> String {
137    format!(
138        "{}|{}",
139        item.rule_id,
140        item.evidence.clone().unwrap_or_default()
141    )
142}
143
144pub fn should_exit_with_failure(report: &ReportData, fail_on: FailOn) -> bool {
145    match fail_on {
146        FailOn::Off => false,
147        FailOn::Error => report.results.iter().any(|item| {
148            matches!(item.status, RuleStatus::Fail | RuleStatus::Error)
149                && matches!(item.severity, Severity::Error)
150        }),
151        FailOn::Warning => report.results.iter().any(|item| {
152            matches!(item.status, RuleStatus::Fail | RuleStatus::Error)
153                && matches!(item.severity, Severity::Error | Severity::Warning)
154        }),
155    }
156}
157
158pub fn top_slow_rules(report: &ReportData, limit: usize) -> Vec<SlowRule> {
159    let mut items: Vec<SlowRule> = report
160        .results
161        .iter()
162        .map(|item| SlowRule {
163            rule_id: item.rule_id.clone(),
164            rule_name: item.rule_name.clone(),
165            duration_ms: item.duration_ms,
166        })
167        .collect();
168    items.sort_by(|a, b| {
169        b.duration_ms
170            .cmp(&a.duration_ms)
171            .then_with(|| a.rule_id.cmp(&b.rule_id))
172    });
173    items.truncate(limit);
174    items
175}
176
177pub fn render_table(report: &ReportData, timing_mode: TimingMode) -> String {
178    let mut table = Table::new();
179    let mut header = vec!["Rule", "Category", "Severity", "Status", "Message"];
180    if timing_mode == TimingMode::Full {
181        header.push("Time");
182    }
183    table
184        .load_preset(UTF8_FULL)
185        .apply_modifier(UTF8_ROUND_CORNERS)
186        .set_header(header);
187
188    for res in &report.results {
189        let severity_cell = match res.severity {
190            Severity::Error => Cell::new("ERROR").fg(Color::Red),
191            Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
192            Severity::Info => Cell::new("INFO").fg(Color::Blue),
193        };
194
195        let status_cell = match res.status {
196            RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
197            RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
198            RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
199            RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
200        };
201
202        let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
203        let wrapped = wrap(&message, 50).join("\n");
204
205        let mut row = vec![
206            Cell::new(res.rule_name.clone()),
207            Cell::new(format!("{:?}", res.category)),
208            severity_cell,
209            status_cell,
210            Cell::new(wrapped),
211        ];
212        if timing_mode == TimingMode::Full {
213            row.push(Cell::new(format!("{} ms", res.duration_ms)));
214        }
215        table.add_row(row);
216    }
217
218    if timing_mode != TimingMode::Off {
219        let slow_rules = format_slow_rules(report.slow_rules.clone());
220        let cache_summary = format_cache_stats(&report.cache_stats);
221        format!(
222            "{}\nTotal scan time: {} ms{}{}\n",
223            table, report.total_duration_ms, slow_rules, cache_summary
224        )
225    } else {
226        format!("{}", table)
227    }
228}
229
230pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
231    serde_json::to_string_pretty(report)
232}
233
234pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
235    let mut rules = Vec::new();
236    let mut results = Vec::new();
237
238    for item in &report.results {
239        rules.push(serde_json::json!({
240        "id": item.rule_id,
241        "name": item.rule_name,
242        "shortDescription": { "text": item.rule_name },
243        "fullDescription": { "text": item.message.clone().unwrap_or_default() },
244        "help": { "text": item.recommendation },
245            "properties": {
246                "category": format!("{:?}", item.category),
247                "severity": format!("{:?}", item.severity),
248                "durationMs": item.duration_ms,
249            }
250        }));
251
252        if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
253            results.push(serde_json::json!({
254                "ruleId": item.rule_id,
255                "level": sarif_level(item.severity),
256                "message": {
257                    "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
258                },
259                "properties": {
260                    "category": format!("{:?}", item.category),
261                    "evidence": item.evidence.clone().unwrap_or_default(),
262                    "durationMs": item.duration_ms,
263                }
264            }));
265        }
266    }
267
268    let sarif = serde_json::json!({
269        "version": "2.1.0",
270        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
271        "runs": [
272            {
273                "invocations": [
274                    {
275                        "executionSuccessful": true,
276                        "properties": {
277                            "totalDurationMs": report.total_duration_ms,
278                            "slowRules": sarif_slow_rules(&report.slow_rules),
279                            "cacheStats": report.cache_stats,
280                        }
281                    }
282                ],
283                "tool": {
284                    "driver": {
285                        "name": "verifyos-cli",
286                        "semanticVersion": report.ruleset_version,
287                        "rules": rules
288                    }
289                },
290                "properties": {
291                    "totalDurationMs": report.total_duration_ms,
292                    "slowRules": sarif_slow_rules(&report.slow_rules),
293                    "cacheStats": report.cache_stats,
294                },
295                "results": results
296            }
297        ]
298    });
299
300    serde_json::to_string_pretty(&sarif)
301}
302
303fn sarif_level(severity: Severity) -> &'static str {
304    match severity {
305        Severity::Error => "error",
306        Severity::Warning => "warning",
307        Severity::Info => "note",
308    }
309}
310
311pub fn render_markdown(
312    report: &ReportData,
313    suppressed: Option<usize>,
314    timing_mode: TimingMode,
315) -> String {
316    let total = report.results.len();
317    let fail_count = report
318        .results
319        .iter()
320        .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
321        .count();
322    let warn_count = report
323        .results
324        .iter()
325        .filter(|r| r.severity == Severity::Warning)
326        .count();
327    let error_count = report
328        .results
329        .iter()
330        .filter(|r| r.severity == Severity::Error)
331        .count();
332
333    let mut out = String::new();
334    out.push_str("# verifyOS-cli Report\n\n");
335    out.push_str(&format!("- Total rules: {total}\n"));
336    out.push_str(&format!("- Failures: {fail_count}\n"));
337    out.push_str(&format!(
338        "- Severity: error={error_count}, warning={warn_count}\n"
339    ));
340    if timing_mode != TimingMode::Off {
341        out.push_str(&format!(
342            "- Total scan time: {} ms\n",
343            report.total_duration_ms
344        ));
345        if !report.slow_rules.is_empty() {
346            out.push_str("- Slowest rules:\n");
347            for item in &report.slow_rules {
348                out.push_str(&format!(
349                    "  - {} (`{}`): {} ms\n",
350                    item.rule_name, item.rule_id, item.duration_ms
351                ));
352            }
353        }
354        let cache_lines = markdown_cache_stats(&report.cache_stats);
355        if !cache_lines.is_empty() {
356            out.push_str("- Cache activity:\n");
357            for line in cache_lines {
358                out.push_str(&format!("  - {}\n", line));
359            }
360        }
361    }
362    if let Some(suppressed) = suppressed {
363        out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
364    }
365    out.push('\n');
366
367    let mut failures = report
368        .results
369        .iter()
370        .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error));
371
372    if failures.next().is_none() {
373        out.push_str("## Findings\n\n- No failing findings.\n");
374        return out;
375    }
376
377    out.push_str("## Findings\n\n");
378    for item in report
379        .results
380        .iter()
381        .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
382    {
383        out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
384        out.push_str(&format!("  - Category: `{:?}`\n", item.category));
385        out.push_str(&format!("  - Severity: `{:?}`\n", item.severity));
386        out.push_str(&format!("  - Status: `{:?}`\n", item.status));
387        if let Some(message) = &item.message {
388            out.push_str(&format!("  - Message: {}\n", message));
389        }
390        if let Some(evidence) = &item.evidence {
391            out.push_str(&format!("  - Evidence: {}\n", evidence));
392        }
393        if !item.recommendation.is_empty() {
394            out.push_str(&format!("  - Recommendation: {}\n", item.recommendation));
395        }
396        if timing_mode == TimingMode::Full {
397            out.push_str(&format!("  - Time: {} ms\n", item.duration_ms));
398        }
399    }
400
401    out
402}
403
404fn format_slow_rules(items: Vec<SlowRule>) -> String {
405    if items.is_empty() {
406        return String::new();
407    }
408
409    let parts: Vec<String> = items
410        .into_iter()
411        .map(|item| format!("{} ({} ms)", item.rule_id, item.duration_ms))
412        .collect();
413    format!("\nSlowest rules: {}", parts.join(", "))
414}
415
416fn format_cache_stats(stats: &ArtifactCacheStats) -> String {
417    let lines = markdown_cache_stats(stats);
418    if lines.is_empty() {
419        return String::new();
420    }
421
422    format!("\nCache activity: {}", lines.join(", "))
423}
424
425fn markdown_cache_stats(stats: &ArtifactCacheStats) -> Vec<String> {
426    let counters = [
427        ("nested_bundles", stats.nested_bundles),
428        ("usage_scan", stats.usage_scan),
429        ("private_api_scan", stats.private_api_scan),
430        ("sdk_scan", stats.sdk_scan),
431        ("capability_scan", stats.capability_scan),
432        ("signature_summary", stats.signature_summary),
433        ("bundle_plist", stats.bundle_plist),
434        ("entitlements", stats.entitlements),
435        ("provisioning_profile", stats.provisioning_profile),
436        ("bundle_files", stats.bundle_files),
437    ];
438
439    counters
440        .into_iter()
441        .filter(|(_, counter)| counter.hits > 0 || counter.misses > 0)
442        .map(|(name, counter)| format_cache_counter(name, counter))
443        .collect()
444}
445
446fn format_cache_counter(name: &str, counter: CacheCounter) -> String {
447    format!("{name} h/m={}/{}", counter.hits, counter.misses)
448}
449
450fn sarif_slow_rules(items: &[SlowRule]) -> Vec<serde_json::Value> {
451    items
452        .iter()
453        .map(|item| {
454            serde_json::json!({
455                "ruleId": item.rule_id,
456                "ruleName": item.rule_name,
457                "durationMs": item.duration_ms,
458            })
459        })
460        .collect()
461}