1use crate::report::data::{ReportData, SlowRule, TimingMode};
2use crate::rules::core::{ArtifactCacheStats, CacheCounter, RuleStatus, Severity};
3use comfy_table::modifiers::UTF8_ROUND_CORNERS;
4use comfy_table::presets::UTF8_FULL;
5use comfy_table::{Cell, Color, Table};
6use textwrap::wrap;
7
8pub fn render_table(report: &ReportData, timing_mode: TimingMode) -> String {
9 let mut table = Table::new();
10 let mut header = vec![
11 "Rule", "Target", "Category", "Severity", "Status", "Message",
12 ];
13 if timing_mode == TimingMode::Full {
14 header.push("Time");
15 }
16 table
17 .load_preset(UTF8_FULL)
18 .apply_modifier(UTF8_ROUND_CORNERS)
19 .set_header(header);
20
21 for res in &report.results {
22 let severity_cell = match res.severity {
23 Severity::Error => Cell::new("ERROR").fg(Color::Red),
24 Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
25 Severity::Info => Cell::new("INFO").fg(Color::Blue),
26 };
27
28 let status_cell = match res.status {
29 RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
30 RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
31 RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
32 RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
33 };
34
35 let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
36 let wrapped = wrap(&message, 40).join("\n");
37
38 let mut row = vec![
39 Cell::new(res.rule_name.clone()),
40 Cell::new(res.target.clone()).fg(Color::Cyan),
41 Cell::new(format!("{:?}", res.category)),
42 severity_cell,
43 status_cell,
44 Cell::new(wrapped),
45 ];
46 if timing_mode == TimingMode::Full {
47 row.push(Cell::new(format!("{} ms", res.duration_ms)));
48 }
49 table.add_row(row);
50 }
51
52 if timing_mode != TimingMode::Off {
53 let slow_rules = format_slow_rules(report.slow_rules.clone());
54 let cache_summary = format_cache_stats(&report.cache_stats);
55 format!(
56 "{}\nTotal scan time: {} ms{}{}\n",
57 table, report.total_duration_ms, slow_rules, cache_summary
58 )
59 } else {
60 format!("{table}")
61 }
62}
63
64pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
65 serde_json::to_string_pretty(report)
66}
67
68pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
69 let mut rules = Vec::new();
70 let mut results = Vec::new();
71
72 for item in &report.results {
73 rules.push(serde_json::json!({
74 "id": item.rule_id,
75 "name": item.rule_name,
76 "shortDescription": { "text": item.rule_name },
77 "fullDescription": { "text": item.message.clone().unwrap_or_default() },
78 "help": { "text": item.recommendation },
79 "properties": {
80 "category": format!("{:?}", item.category),
81 "severity": format!("{:?}", item.severity),
82 "durationMs": item.duration_ms,
83 }
84 }));
85
86 if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
87 results.push(serde_json::json!({
88 "ruleId": item.rule_id,
89 "level": sarif_level(item.severity),
90 "message": {
91 "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
92 },
93 "properties": {
94 "category": format!("{:?}", item.category),
95 "evidence": item.evidence.clone().unwrap_or_default(),
96 "durationMs": item.duration_ms,
97 }
98 }));
99 }
100 }
101
102 let sarif = serde_json::json!({
103 "version": "2.1.0",
104 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
105 "runs": [
106 {
107 "invocations": [
108 {
109 "executionSuccessful": true,
110 "properties": {
111 "totalDurationMs": report.total_duration_ms,
112 "slowRules": sarif_slow_rules(&report.slow_rules),
113 "cacheStats": report.cache_stats,
114 }
115 }
116 ],
117 "tool": {
118 "driver": {
119 "name": "verifyos-cli",
120 "semanticVersion": report.ruleset_version,
121 "rules": rules
122 }
123 },
124 "properties": {
125 "totalDurationMs": report.total_duration_ms,
126 "slowRules": sarif_slow_rules(&report.slow_rules),
127 "cacheStats": report.cache_stats,
128 },
129 "results": results
130 }
131 ]
132 });
133
134 serde_json::to_string_pretty(&sarif)
135}
136
137pub fn render_markdown(
138 report: &ReportData,
139 suppressed: Option<usize>,
140 timing_mode: TimingMode,
141) -> String {
142 let total = report.results.len();
143 let fail_count = report
144 .results
145 .iter()
146 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
147 .count();
148 let warn_count = report
149 .results
150 .iter()
151 .filter(|r| r.severity == Severity::Warning)
152 .count();
153 let error_count = report
154 .results
155 .iter()
156 .filter(|r| r.severity == Severity::Error)
157 .count();
158
159 let mut out = String::new();
160 out.push_str("# verifyOS-cli Report\n\n");
161 out.push_str(&format!("- Total rules: {total}\n"));
162 out.push_str(&format!("- Failures: {fail_count}\n"));
163 out.push_str(&format!(
164 "- Severity: error={error_count}, warning={warn_count}\n"
165 ));
166 if timing_mode != TimingMode::Off {
167 out.push_str(&format!(
168 "- Total scan time: {} ms\n",
169 report.total_duration_ms
170 ));
171 if !report.slow_rules.is_empty() {
172 out.push_str("- Slowest rules:\n");
173 for item in &report.slow_rules {
174 out.push_str(&format!(
175 " - {} (`{}`): {} ms\n",
176 item.rule_name, item.rule_id, item.duration_ms
177 ));
178 }
179 }
180 let cache_lines = markdown_cache_stats(&report.cache_stats);
181 if !cache_lines.is_empty() {
182 out.push_str("- Cache activity:\n");
183 for line in cache_lines {
184 out.push_str(&format!(" - {}\n", line));
185 }
186 }
187 }
188 if let Some(suppressed) = suppressed {
189 out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
190 }
191 out.push('\n');
192
193 out.push_str("## Findings\n\n");
194 for target in &report.scanned_targets {
195 let target_findings: Vec<_> = report
196 .results
197 .iter()
198 .filter(|r| {
199 &r.target == target && matches!(r.status, RuleStatus::Fail | RuleStatus::Error)
200 })
201 .collect();
202
203 if target_findings.is_empty() {
204 continue;
205 }
206
207 out.push_str(&format!("### Target: `{target}`\n\n"));
208 for item in target_findings {
209 out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
210 out.push_str(&format!(" - Category: `{:?}`\n", item.category));
211 out.push_str(&format!(" - Severity: `{:?}`\n", item.severity));
212 out.push_str(&format!(" - Status: `{:?}`\n", item.status));
213 if let Some(message) = &item.message {
214 out.push_str(&format!(" - Message: {}\n", message));
215 }
216 if let Some(evidence) = &item.evidence {
217 out.push_str(&format!(" - Evidence: {}\n", evidence));
218 }
219 if !item.recommendation.is_empty() {
220 out.push_str(&format!(" - Recommendation: {}\n", item.recommendation));
221 }
222 if timing_mode == TimingMode::Full {
223 out.push_str(&format!(" - Time: {} ms\n", item.duration_ms));
224 }
225 out.push('\n');
226 }
227 }
228
229 out
230}
231
232fn format_slow_rules(items: Vec<SlowRule>) -> String {
233 if items.is_empty() {
234 return String::new();
235 }
236
237 let parts: Vec<String> = items
238 .into_iter()
239 .map(|item| format!("{} ({} ms)", item.rule_id, item.duration_ms))
240 .collect();
241 format!("\nSlowest rules: {}", parts.join(", "))
242}
243
244fn format_cache_stats(stats: &ArtifactCacheStats) -> String {
245 let lines = markdown_cache_stats(stats);
246 if lines.is_empty() {
247 return String::new();
248 }
249
250 format!("\nCache activity: {}", lines.join(", "))
251}
252
253fn markdown_cache_stats(stats: &ArtifactCacheStats) -> Vec<String> {
254 let counters = [
255 ("nested_bundles", stats.nested_bundles),
256 ("usage_scan", stats.usage_scan),
257 ("private_api_scan", stats.private_api_scan),
258 ("sdk_scan", stats.sdk_scan),
259 ("capability_scan", stats.capability_scan),
260 ("signature_summary", stats.signature_summary),
261 ("bundle_plist", stats.bundle_plist),
262 ("entitlements", stats.entitlements),
263 ("provisioning_profile", stats.provisioning_profile),
264 ("bundle_files", stats.bundle_files),
265 ];
266
267 counters
268 .into_iter()
269 .filter(|(_, counter)| counter.hits > 0 || counter.misses > 0)
270 .map(|(name, counter)| format_cache_counter(name, counter))
271 .collect()
272}
273
274fn format_cache_counter(name: &str, counter: CacheCounter) -> String {
275 format!("{name} h/m={}/{}", counter.hits, counter.misses)
276}
277
278fn sarif_slow_rules(items: &[SlowRule]) -> Vec<serde_json::Value> {
279 items
280 .iter()
281 .map(|item| {
282 serde_json::json!({
283 "ruleId": item.rule_id,
284 "ruleName": item.rule_name,
285 "durationMs": item.duration_ms,
286 })
287 })
288 .collect()
289}
290
291fn sarif_level(severity: Severity) -> &'static str {
292 match severity {
293 Severity::Error => "error",
294 Severity::Warning => "warning",
295 Severity::Info => "note",
296 }
297}