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}