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, 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}