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, Serialize, Deserialize)]
49pub struct AgentPack {
50 pub generated_at_unix: u64,
51 pub total_findings: usize,
52 pub findings: Vec<AgentFinding>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct AgentFinding {
57 pub rule_id: String,
58 pub rule_name: String,
59 pub severity: Severity,
60 pub category: RuleCategory,
61 pub priority: String,
62 pub message: String,
63 pub evidence: Option<String>,
64 pub recommendation: String,
65 pub suggested_fix_scope: String,
66 pub target_files: Vec<String>,
67 pub patch_hint: String,
68 pub why_it_fails_review: String,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum AgentPackFormat {
73 Json,
74 Markdown,
75 Bundle,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum FailOn {
80 Off,
81 Error,
82 Warning,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum TimingMode {
87 Off,
88 Summary,
89 Full,
90}
91
92pub fn build_report(
93 results: Vec<EngineResult>,
94 total_duration_ms: u128,
95 cache_stats: ArtifactCacheStats,
96) -> ReportData {
97 let generated_at_unix = SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .unwrap_or_default()
100 .as_secs();
101
102 let mut items = Vec::new();
103
104 for res in results {
105 let (status, message, evidence) = match res.report {
106 Ok(report) => (report.status, report.message, report.evidence),
107 Err(err) => (
108 RuleStatus::Error,
109 Some(err.to_string()),
110 Some("Rule evaluation error".to_string()),
111 ),
112 };
113
114 items.push(ReportItem {
115 rule_id: res.rule_id.to_string(),
116 rule_name: res.rule_name.to_string(),
117 category: res.category,
118 severity: res.severity,
119 status,
120 message,
121 evidence,
122 recommendation: res.recommendation.to_string(),
123 duration_ms: res.duration_ms,
124 });
125 }
126
127 let report = ReportData {
128 ruleset_version: RULESET_VERSION.to_string(),
129 generated_at_unix,
130 total_duration_ms,
131 cache_stats,
132 slow_rules: Vec::new(),
133 results: items,
134 };
135
136 ReportData {
137 slow_rules: top_slow_rules(&report, 3),
138 ..report
139 }
140}
141
142pub fn apply_baseline(report: &mut ReportData, baseline: &ReportData) -> BaselineSummary {
143 let mut suppressed = 0;
144 let baseline_keys: HashSet<String> = baseline
145 .results
146 .iter()
147 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
148 .map(finding_key)
149 .collect();
150
151 report.results.retain(|r| {
152 if !matches!(r.status, RuleStatus::Fail | RuleStatus::Error) {
153 return true;
154 }
155 let key = finding_key(r);
156 let keep = !baseline_keys.contains(&key);
157 if !keep {
158 suppressed += 1;
159 }
160 keep
161 });
162
163 BaselineSummary { suppressed }
164}
165
166fn finding_key(item: &ReportItem) -> String {
167 format!(
168 "{}|{}",
169 item.rule_id,
170 item.evidence.clone().unwrap_or_default()
171 )
172}
173
174fn agent_pack_baseline_key_from_report(item: &ReportItem) -> String {
175 format!(
176 "{}|{}",
177 item.rule_id,
178 item.message.clone().unwrap_or_default().trim()
179 )
180}
181
182fn agent_pack_baseline_key_from_finding(item: &AgentFinding) -> String {
183 format!("{}|{}", item.rule_id, item.message.trim())
184}
185
186pub fn should_exit_with_failure(report: &ReportData, fail_on: FailOn) -> bool {
187 match fail_on {
188 FailOn::Off => false,
189 FailOn::Error => report.results.iter().any(|item| {
190 matches!(item.status, RuleStatus::Fail | RuleStatus::Error)
191 && matches!(item.severity, Severity::Error)
192 }),
193 FailOn::Warning => report.results.iter().any(|item| {
194 matches!(item.status, RuleStatus::Fail | RuleStatus::Error)
195 && matches!(item.severity, Severity::Error | Severity::Warning)
196 }),
197 }
198}
199
200pub fn build_agent_pack(report: &ReportData) -> AgentPack {
201 let findings: Vec<AgentFinding> = report
202 .results
203 .iter()
204 .filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
205 .map(|item| AgentFinding {
206 rule_id: item.rule_id.clone(),
207 rule_name: item.rule_name.clone(),
208 severity: item.severity,
209 category: item.category,
210 priority: agent_priority(item.severity).to_string(),
211 message: item
212 .message
213 .clone()
214 .unwrap_or_else(|| item.rule_name.clone()),
215 evidence: item.evidence.clone(),
216 recommendation: item.recommendation.clone(),
217 suggested_fix_scope: suggested_fix_scope(item),
218 target_files: target_files(item),
219 patch_hint: patch_hint(item),
220 why_it_fails_review: why_it_fails_review(item),
221 })
222 .collect();
223
224 AgentPack {
225 generated_at_unix: report.generated_at_unix,
226 total_findings: findings.len(),
227 findings,
228 }
229}
230
231pub fn apply_agent_pack_baseline(pack: &mut AgentPack, baseline: &ReportData) {
232 let baseline_keys: HashSet<String> = baseline
233 .results
234 .iter()
235 .filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
236 .map(agent_pack_baseline_key_from_report)
237 .collect();
238
239 pack.findings.retain(|finding| {
240 let key = agent_pack_baseline_key_from_finding(finding);
241 !baseline_keys.contains(&key)
242 });
243 pack.total_findings = pack.findings.len();
244}
245
246pub fn render_agent_pack_markdown(pack: &AgentPack) -> String {
247 let mut out = String::new();
248 out.push_str("# verifyOS Agent Fix Pack\n\n");
249 out.push_str(&format!("- Generated at: `{}`\n", pack.generated_at_unix));
250 out.push_str(&format!("- Total findings: `{}`\n\n", pack.total_findings));
251
252 if pack.findings.is_empty() {
253 out.push_str("## Findings\n\n- No failing findings.\n");
254 return out;
255 }
256
257 let mut findings = pack.findings.clone();
258 findings.sort_by(|a, b| {
259 a.suggested_fix_scope
260 .cmp(&b.suggested_fix_scope)
261 .then_with(|| a.rule_id.cmp(&b.rule_id))
262 });
263
264 out.push_str("## Findings by Fix Scope\n\n");
265
266 let mut current_scope: Option<&str> = None;
267 for finding in &findings {
268 let scope = finding.suggested_fix_scope.as_str();
269 if current_scope != Some(scope) {
270 if current_scope.is_some() {
271 out.push('\n');
272 }
273 out.push_str(&format!("### {}\n\n", scope));
274 current_scope = Some(scope);
275 }
276
277 out.push_str(&format!(
278 "- **{}** (`{}`)\n",
279 finding.rule_name, finding.rule_id
280 ));
281 out.push_str(&format!(" - Priority: `{}`\n", finding.priority));
282 out.push_str(&format!(" - Severity: `{:?}`\n", finding.severity));
283 out.push_str(&format!(" - Category: `{:?}`\n", finding.category));
284 out.push_str(&format!(" - Message: {}\n", finding.message));
285 if let Some(evidence) = &finding.evidence {
286 out.push_str(&format!(" - Evidence: {}\n", evidence));
287 }
288 if !finding.target_files.is_empty() {
289 out.push_str(&format!(
290 " - Target files: {}\n",
291 finding.target_files.join(", ")
292 ));
293 }
294 out.push_str(&format!(
295 " - Why it fails review: {}\n",
296 finding.why_it_fails_review
297 ));
298 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
299 out.push_str(&format!(" - Recommendation: {}\n", finding.recommendation));
300 }
301
302 out
303}
304
305pub fn top_slow_rules(report: &ReportData, limit: usize) -> Vec<SlowRule> {
306 let mut items: Vec<SlowRule> = report
307 .results
308 .iter()
309 .map(|item| SlowRule {
310 rule_id: item.rule_id.clone(),
311 rule_name: item.rule_name.clone(),
312 duration_ms: item.duration_ms,
313 })
314 .collect();
315 items.sort_by(|a, b| {
316 b.duration_ms
317 .cmp(&a.duration_ms)
318 .then_with(|| a.rule_id.cmp(&b.rule_id))
319 });
320 items.truncate(limit);
321 items
322}
323
324pub fn render_table(report: &ReportData, timing_mode: TimingMode) -> String {
325 let mut table = Table::new();
326 let mut header = vec!["Rule", "Category", "Severity", "Status", "Message"];
327 if timing_mode == TimingMode::Full {
328 header.push("Time");
329 }
330 table
331 .load_preset(UTF8_FULL)
332 .apply_modifier(UTF8_ROUND_CORNERS)
333 .set_header(header);
334
335 for res in &report.results {
336 let severity_cell = match res.severity {
337 Severity::Error => Cell::new("ERROR").fg(Color::Red),
338 Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
339 Severity::Info => Cell::new("INFO").fg(Color::Blue),
340 };
341
342 let status_cell = match res.status {
343 RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
344 RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
345 RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
346 RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
347 };
348
349 let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
350 let wrapped = wrap(&message, 50).join("\n");
351
352 let mut row = vec![
353 Cell::new(res.rule_name.clone()),
354 Cell::new(format!("{:?}", res.category)),
355 severity_cell,
356 status_cell,
357 Cell::new(wrapped),
358 ];
359 if timing_mode == TimingMode::Full {
360 row.push(Cell::new(format!("{} ms", res.duration_ms)));
361 }
362 table.add_row(row);
363 }
364
365 if timing_mode != TimingMode::Off {
366 let slow_rules = format_slow_rules(report.slow_rules.clone());
367 let cache_summary = format_cache_stats(&report.cache_stats);
368 format!(
369 "{}\nTotal scan time: {} ms{}{}\n",
370 table, report.total_duration_ms, slow_rules, cache_summary
371 )
372 } else {
373 format!("{}", table)
374 }
375}
376
377pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
378 serde_json::to_string_pretty(report)
379}
380
381pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
382 let mut rules = Vec::new();
383 let mut results = Vec::new();
384
385 for item in &report.results {
386 rules.push(serde_json::json!({
387 "id": item.rule_id,
388 "name": item.rule_name,
389 "shortDescription": { "text": item.rule_name },
390 "fullDescription": { "text": item.message.clone().unwrap_or_default() },
391 "help": { "text": item.recommendation },
392 "properties": {
393 "category": format!("{:?}", item.category),
394 "severity": format!("{:?}", item.severity),
395 "durationMs": item.duration_ms,
396 }
397 }));
398
399 if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
400 results.push(serde_json::json!({
401 "ruleId": item.rule_id,
402 "level": sarif_level(item.severity),
403 "message": {
404 "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
405 },
406 "properties": {
407 "category": format!("{:?}", item.category),
408 "evidence": item.evidence.clone().unwrap_or_default(),
409 "durationMs": item.duration_ms,
410 }
411 }));
412 }
413 }
414
415 let sarif = serde_json::json!({
416 "version": "2.1.0",
417 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
418 "runs": [
419 {
420 "invocations": [
421 {
422 "executionSuccessful": true,
423 "properties": {
424 "totalDurationMs": report.total_duration_ms,
425 "slowRules": sarif_slow_rules(&report.slow_rules),
426 "cacheStats": report.cache_stats,
427 }
428 }
429 ],
430 "tool": {
431 "driver": {
432 "name": "verifyos-cli",
433 "semanticVersion": report.ruleset_version,
434 "rules": rules
435 }
436 },
437 "properties": {
438 "totalDurationMs": report.total_duration_ms,
439 "slowRules": sarif_slow_rules(&report.slow_rules),
440 "cacheStats": report.cache_stats,
441 },
442 "results": results
443 }
444 ]
445 });
446
447 serde_json::to_string_pretty(&sarif)
448}
449
450fn sarif_level(severity: Severity) -> &'static str {
451 match severity {
452 Severity::Error => "error",
453 Severity::Warning => "warning",
454 Severity::Info => "note",
455 }
456}
457
458pub fn render_markdown(
459 report: &ReportData,
460 suppressed: Option<usize>,
461 timing_mode: TimingMode,
462) -> String {
463 let total = report.results.len();
464 let fail_count = report
465 .results
466 .iter()
467 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
468 .count();
469 let warn_count = report
470 .results
471 .iter()
472 .filter(|r| r.severity == Severity::Warning)
473 .count();
474 let error_count = report
475 .results
476 .iter()
477 .filter(|r| r.severity == Severity::Error)
478 .count();
479
480 let mut out = String::new();
481 out.push_str("# verifyOS-cli Report\n\n");
482 out.push_str(&format!("- Total rules: {total}\n"));
483 out.push_str(&format!("- Failures: {fail_count}\n"));
484 out.push_str(&format!(
485 "- Severity: error={error_count}, warning={warn_count}\n"
486 ));
487 if timing_mode != TimingMode::Off {
488 out.push_str(&format!(
489 "- Total scan time: {} ms\n",
490 report.total_duration_ms
491 ));
492 if !report.slow_rules.is_empty() {
493 out.push_str("- Slowest rules:\n");
494 for item in &report.slow_rules {
495 out.push_str(&format!(
496 " - {} (`{}`): {} ms\n",
497 item.rule_name, item.rule_id, item.duration_ms
498 ));
499 }
500 }
501 let cache_lines = markdown_cache_stats(&report.cache_stats);
502 if !cache_lines.is_empty() {
503 out.push_str("- Cache activity:\n");
504 for line in cache_lines {
505 out.push_str(&format!(" - {}\n", line));
506 }
507 }
508 }
509 if let Some(suppressed) = suppressed {
510 out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
511 }
512 out.push('\n');
513
514 let mut failures = report
515 .results
516 .iter()
517 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error));
518
519 if failures.next().is_none() {
520 out.push_str("## Findings\n\n- No failing findings.\n");
521 return out;
522 }
523
524 out.push_str("## Findings\n\n");
525 for item in report
526 .results
527 .iter()
528 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
529 {
530 out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
531 out.push_str(&format!(" - Category: `{:?}`\n", item.category));
532 out.push_str(&format!(" - Severity: `{:?}`\n", item.severity));
533 out.push_str(&format!(" - Status: `{:?}`\n", item.status));
534 if let Some(message) = &item.message {
535 out.push_str(&format!(" - Message: {}\n", message));
536 }
537 if let Some(evidence) = &item.evidence {
538 out.push_str(&format!(" - Evidence: {}\n", evidence));
539 }
540 if !item.recommendation.is_empty() {
541 out.push_str(&format!(" - Recommendation: {}\n", item.recommendation));
542 }
543 if timing_mode == TimingMode::Full {
544 out.push_str(&format!(" - Time: {} ms\n", item.duration_ms));
545 }
546 }
547
548 out
549}
550
551fn format_slow_rules(items: Vec<SlowRule>) -> String {
552 if items.is_empty() {
553 return String::new();
554 }
555
556 let parts: Vec<String> = items
557 .into_iter()
558 .map(|item| format!("{} ({} ms)", item.rule_id, item.duration_ms))
559 .collect();
560 format!("\nSlowest rules: {}", parts.join(", "))
561}
562
563fn format_cache_stats(stats: &ArtifactCacheStats) -> String {
564 let lines = markdown_cache_stats(stats);
565 if lines.is_empty() {
566 return String::new();
567 }
568
569 format!("\nCache activity: {}", lines.join(", "))
570}
571
572fn markdown_cache_stats(stats: &ArtifactCacheStats) -> Vec<String> {
573 let counters = [
574 ("nested_bundles", stats.nested_bundles),
575 ("usage_scan", stats.usage_scan),
576 ("private_api_scan", stats.private_api_scan),
577 ("sdk_scan", stats.sdk_scan),
578 ("capability_scan", stats.capability_scan),
579 ("signature_summary", stats.signature_summary),
580 ("bundle_plist", stats.bundle_plist),
581 ("entitlements", stats.entitlements),
582 ("provisioning_profile", stats.provisioning_profile),
583 ("bundle_files", stats.bundle_files),
584 ];
585
586 counters
587 .into_iter()
588 .filter(|(_, counter)| counter.hits > 0 || counter.misses > 0)
589 .map(|(name, counter)| format_cache_counter(name, counter))
590 .collect()
591}
592
593fn format_cache_counter(name: &str, counter: CacheCounter) -> String {
594 format!("{name} h/m={}/{}", counter.hits, counter.misses)
595}
596
597fn sarif_slow_rules(items: &[SlowRule]) -> Vec<serde_json::Value> {
598 items
599 .iter()
600 .map(|item| {
601 serde_json::json!({
602 "ruleId": item.rule_id,
603 "ruleName": item.rule_name,
604 "durationMs": item.duration_ms,
605 })
606 })
607 .collect()
608}
609
610fn agent_priority(severity: Severity) -> &'static str {
611 match severity {
612 Severity::Error => "high",
613 Severity::Warning => "medium",
614 Severity::Info => "low",
615 }
616}
617
618fn suggested_fix_scope(item: &ReportItem) -> String {
619 match item.category {
620 RuleCategory::Privacy | RuleCategory::Permissions | RuleCategory::Metadata => {
621 "Info.plist".to_string()
622 }
623 RuleCategory::Entitlements | RuleCategory::Signing => "entitlements".to_string(),
624 RuleCategory::Bundling => "bundle-resources".to_string(),
625 RuleCategory::Ats => "ats-config".to_string(),
626 RuleCategory::ThirdParty => "dependencies".to_string(),
627 RuleCategory::Other => "app-bundle".to_string(),
628 }
629}
630
631fn target_files(item: &ReportItem) -> Vec<String> {
632 match item.rule_id.as_str() {
633 "RULE_USAGE_DESCRIPTIONS"
634 | "RULE_USAGE_DESCRIPTIONS_VALUE"
635 | "RULE_CAMERA_USAGE_DESCRIPTION"
636 | "RULE_LSAPPLICATIONQUERIESSCHEMES"
637 | "RULE_UIREQUIREDDEVICECAPABILITIES"
638 | "RULE_INFO_PLIST_VERSIONING" => vec!["Info.plist".to_string()],
639 "RULE_PRIVACY_MANIFEST" | "RULE_PRIVACY_SDK_CROSSCHECK" => {
640 vec!["PrivacyInfo.xcprivacy".to_string()]
641 }
642 "RULE_ATS_AUDIT" => vec!["Info.plist (NSAppTransportSecurity)".to_string()],
643 "RULE_BUNDLE_RESOURCE_LEAKAGE" => vec!["App bundle resources".to_string()],
644 "RULE_ENTITLEMENTS_MISMATCH"
645 | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH"
646 | "RULE_EXTENSION_ENTITLEMENTS"
647 | "RULE_DEBUG_ENTITLEMENTS" => vec![
648 "App entitlements plist".to_string(),
649 "embedded.mobileprovision".to_string(),
650 ],
651 "RULE_EMBEDDED_SIGNING_CONSISTENCY" => vec![
652 "Main app executable signature".to_string(),
653 "Embedded frameworks/extensions".to_string(),
654 ],
655 "RULE_PRIVATE_API" => vec!["Linked SDKs or app binary".to_string()],
656 _ => match item.category {
657 RuleCategory::Privacy | RuleCategory::Permissions | RuleCategory::Metadata => {
658 vec!["Info.plist".to_string()]
659 }
660 RuleCategory::Entitlements | RuleCategory::Signing => {
661 vec!["App signing and entitlements".to_string()]
662 }
663 RuleCategory::Bundling => vec!["App bundle resources".to_string()],
664 RuleCategory::Ats => vec!["Info.plist (NSAppTransportSecurity)".to_string()],
665 RuleCategory::ThirdParty => vec!["Embedded SDKs or dependencies".to_string()],
666 RuleCategory::Other => vec!["App bundle".to_string()],
667 },
668 }
669}
670
671fn patch_hint(item: &ReportItem) -> String {
672 match item.rule_id.as_str() {
673 "RULE_USAGE_DESCRIPTIONS"
674 | "RULE_USAGE_DESCRIPTIONS_VALUE"
675 | "RULE_CAMERA_USAGE_DESCRIPTION" => {
676 "Update Info.plist with the required NS*UsageDescription keys and give each key a user-facing reason that matches the in-app behavior.".to_string()
677 }
678 "RULE_LSAPPLICATIONQUERIESSCHEMES" => {
679 "Trim LSApplicationQueriesSchemes to only the schemes the app really probes, remove duplicates, and avoid private or overly broad schemes.".to_string()
680 }
681 "RULE_UIREQUIREDDEVICECAPABILITIES" => {
682 "Align UIRequiredDeviceCapabilities with real binary usage so review devices are not excluded by mistake and unsupported hardware is not declared.".to_string()
683 }
684 "RULE_INFO_PLIST_VERSIONING" => {
685 "Set a valid CFBundleShortVersionString and increment CFBundleVersion before the next submission.".to_string()
686 }
687 "RULE_PRIVACY_MANIFEST" => {
688 "Add PrivacyInfo.xcprivacy to the shipped bundle and declare the accessed APIs and collected data used by the app or bundled SDKs.".to_string()
689 }
690 "RULE_PRIVACY_SDK_CROSSCHECK" => {
691 "Review bundled SDKs and extend PrivacyInfo.xcprivacy so their accessed APIs and collected data are explicitly declared.".to_string()
692 }
693 "RULE_ATS_AUDIT" => {
694 "Narrow NSAppTransportSecurity exceptions, remove arbitrary loads when possible, and scope domain exceptions to the smallest set that works.".to_string()
695 }
696 "RULE_BUNDLE_RESOURCE_LEAKAGE" => {
697 "Remove secrets, certificates, provisioning artifacts, debug leftovers, and environment files from the packaged app bundle before archiving.".to_string()
698 }
699 "RULE_ENTITLEMENTS_MISMATCH" | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH" => {
700 "Make the exported entitlements match the provisioning profile and enabled capabilities for APNs, keychain groups, and iCloud.".to_string()
701 }
702 "RULE_EXTENSION_ENTITLEMENTS" => {
703 "Make each extension entitlement set a valid subset of the host app and add the extension-specific capabilities it actually needs.".to_string()
704 }
705 "RULE_DEBUG_ENTITLEMENTS" => {
706 "Strip debug-only entitlements like get-task-allow from release builds and regenerate the final signed archive.".to_string()
707 }
708 "RULE_EMBEDDED_SIGNING_CONSISTENCY" => {
709 "Re-sign embedded frameworks, dylibs, and extensions with the same Team ID and release identity as the host app.".to_string()
710 }
711 "RULE_PRIVATE_API" => {
712 "Remove or replace private API references in the app binary or third-party SDKs, then rebuild so the shipped binary no longer exposes them.".to_string()
713 }
714 _ => format!(
715 "Patch the {} scope first, then re-run voc to confirm the finding disappears.",
716 suggested_fix_scope(item)
717 ),
718 }
719}
720
721fn why_it_fails_review(item: &ReportItem) -> String {
722 match item.rule_id.as_str() {
723 "RULE_USAGE_DESCRIPTIONS"
724 | "RULE_USAGE_DESCRIPTIONS_VALUE"
725 | "RULE_CAMERA_USAGE_DESCRIPTION" => {
726 "App Review rejects binaries that touch protected APIs without clear, user-facing usage descriptions in Info.plist.".to_string()
727 }
728 "RULE_LSAPPLICATIONQUERIESSCHEMES" => {
729 "Overreaching canOpenURL allowlists look like app enumeration and often trigger manual review questions or rejection.".to_string()
730 }
731 "RULE_UIREQUIREDDEVICECAPABILITIES" => {
732 "Incorrect device capability declarations can exclude valid review devices or misrepresent the hardware the app actually requires.".to_string()
733 }
734 "RULE_INFO_PLIST_VERSIONING" => {
735 "Invalid or non-incrementing version metadata blocks submission and confuses App Store release processing.".to_string()
736 }
737 "RULE_PRIVACY_MANIFEST" | "RULE_PRIVACY_SDK_CROSSCHECK" => {
738 "Apple now expects accurate privacy manifests for apps and bundled SDKs, and missing declarations can block review.".to_string()
739 }
740 "RULE_ATS_AUDIT" => {
741 "Broad ATS exceptions weaken transport security and are a common reason App Review asks teams to justify or remove insecure settings.".to_string()
742 }
743 "RULE_BUNDLE_RESOURCE_LEAKAGE" => {
744 "Shipping secrets, certificates, or provisioning artifacts in the final bundle is treated as a serious distribution and security issue.".to_string()
745 }
746 "RULE_ENTITLEMENTS_MISMATCH"
747 | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH"
748 | "RULE_EXTENSION_ENTITLEMENTS"
749 | "RULE_DEBUG_ENTITLEMENTS" => {
750 "Entitlements that do not match the signed capabilities or release profile frequently cause validation failures or manual rejection.".to_string()
751 }
752 "RULE_EMBEDDED_SIGNING_CONSISTENCY" => {
753 "Embedded code signed with a different identity or Team ID can fail notarization-style checks during App Store validation.".to_string()
754 }
755 "RULE_PRIVATE_API" => {
756 "Private API usage is one of the clearest App Store rejection reasons because it relies on unsupported system behavior.".to_string()
757 }
758 _ => format!(
759 "This finding maps to the {} scope and signals metadata, signing, or bundle state that App Review may treat as invalid or risky.",
760 suggested_fix_scope(item)
761 ),
762 }
763}