1use crate::report::data::{AgentFinding, AgentPack, ReportData, ReportItem};
2use crate::rules::core::{RuleCategory, RuleStatus, Severity};
3use std::collections::HashSet;
4
5pub fn build_agent_pack(report: &ReportData) -> AgentPack {
6 let findings: Vec<AgentFinding> = report
7 .results
8 .iter()
9 .filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
10 .map(|item| AgentFinding {
11 rule_id: item.rule_id.clone(),
12 rule_name: item.rule_name.clone(),
13 severity: item.severity,
14 category: item.category,
15 priority: agent_priority(item.severity).to_string(),
16 message: item
17 .message
18 .clone()
19 .unwrap_or_else(|| item.rule_name.clone()),
20 evidence: item.evidence.clone(),
21 recommendation: item.recommendation.clone(),
22 suggested_fix_scope: suggested_fix_scope(item),
23 target_files: target_files(item),
24 patch_hint: patch_hint(item),
25 why_it_fails_review: why_it_fails_review(item),
26 })
27 .collect();
28
29 AgentPack {
30 generated_at_unix: report.generated_at_unix,
31 total_findings: findings.len(),
32 findings,
33 }
34}
35
36pub fn apply_agent_pack_baseline(pack: &mut AgentPack, baseline: &ReportData) {
37 let baseline_keys: HashSet<String> = baseline
38 .results
39 .iter()
40 .filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
41 .map(agent_pack_baseline_key_from_report)
42 .collect();
43
44 pack.findings.retain(|finding| {
45 let key = agent_pack_baseline_key_from_finding(finding);
46 !baseline_keys.contains(&key)
47 });
48 pack.total_findings = pack.findings.len();
49}
50
51pub fn render_agent_pack_markdown(pack: &AgentPack) -> String {
52 let mut out = String::new();
53 out.push_str("# verifyOS Agent Fix Pack\n\n");
54 out.push_str(&format!("- Generated at: `{}`\n", pack.generated_at_unix));
55 out.push_str(&format!("- Total findings: `{}`\n\n", pack.total_findings));
56
57 if pack.findings.is_empty() {
58 out.push_str("## Findings\n\n- No failing findings.\n");
59 return out;
60 }
61
62 let mut findings = pack.findings.clone();
63 findings.sort_by(|a, b| {
64 a.suggested_fix_scope
65 .cmp(&b.suggested_fix_scope)
66 .then_with(|| a.rule_id.cmp(&b.rule_id))
67 });
68
69 out.push_str("## Findings by Fix Scope\n\n");
70
71 let mut current_scope: Option<&str> = None;
72 for finding in &findings {
73 let scope = finding.suggested_fix_scope.as_str();
74 if current_scope != Some(scope) {
75 if current_scope.is_some() {
76 out.push('\n');
77 }
78 out.push_str(&format!("### {}\n\n", scope));
79 current_scope = Some(scope);
80 }
81
82 out.push_str(&format!(
83 "- **{}** (`{}`)\n",
84 finding.rule_name, finding.rule_id
85 ));
86 out.push_str(&format!(" - Priority: `{}`\n", finding.priority));
87 out.push_str(&format!(" - Severity: `{:?}`\n", finding.severity));
88 out.push_str(&format!(" - Category: `{:?}`\n", finding.category));
89 out.push_str(&format!(" - Message: {}\n", finding.message));
90 if let Some(evidence) = &finding.evidence {
91 out.push_str(&format!(" - Evidence: {}\n", evidence));
92 }
93 if !finding.target_files.is_empty() {
94 out.push_str(&format!(
95 " - Target files: {}\n",
96 finding.target_files.join(", ")
97 ));
98 }
99 out.push_str(&format!(
100 " - Why it fails review: {}\n",
101 finding.why_it_fails_review
102 ));
103 out.push_str(&format!(" - Patch hint: {}\n", finding.patch_hint));
104 out.push_str(&format!(" - Recommendation: {}\n", finding.recommendation));
105 }
106
107 out
108}
109
110fn agent_pack_baseline_key_from_report(item: &ReportItem) -> String {
111 format!(
112 "{}|{}",
113 item.rule_id,
114 item.message.clone().unwrap_or_default().trim()
115 )
116}
117
118fn agent_pack_baseline_key_from_finding(item: &AgentFinding) -> String {
119 format!("{}|{}", item.rule_id, item.message.trim())
120}
121
122fn agent_priority(severity: Severity) -> &'static str {
123 match severity {
124 Severity::Error => "high",
125 Severity::Warning => "medium",
126 Severity::Info => "low",
127 }
128}
129
130fn suggested_fix_scope(item: &ReportItem) -> String {
131 match item.category {
132 RuleCategory::Privacy | RuleCategory::Permissions | RuleCategory::Metadata => {
133 "Info.plist".to_string()
134 }
135 RuleCategory::Entitlements | RuleCategory::Signing => "entitlements".to_string(),
136 RuleCategory::Bundling => "bundle-resources".to_string(),
137 RuleCategory::Ats => "ats-config".to_string(),
138 RuleCategory::ThirdParty => "dependencies".to_string(),
139 RuleCategory::Other => "app-bundle".to_string(),
140 }
141}
142
143fn canonical_rule_id(rule_id: &str) -> &str {
144 match rule_id {
145 "RULE_USAGE_DESCRIPTIONS_VALUE" => "RULE_USAGE_DESCRIPTIONS_EMPTY",
146 "RULE_CAMERA_USAGE_DESCRIPTION" => "RULE_CAMERA_USAGE",
147 "RULE_LSAPPLICATIONQUERIESSCHEMES" => "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT",
148 "RULE_UIREQUIREDDEVICECAPABILITIES" => "RULE_DEVICE_CAPABILITIES_AUDIT",
149 "RULE_EXTENSION_ENTITLEMENTS" => "RULE_EXTENSION_ENTITLEMENTS_COMPAT",
150 "RULE_EMBEDDED_SIGNING_CONSISTENCY" => "RULE_EMBEDDED_TEAM_ID_MISMATCH",
151 other => other,
152 }
153}
154
155fn target_files(item: &ReportItem) -> Vec<String> {
156 match canonical_rule_id(&item.rule_id) {
157 "RULE_USAGE_DESCRIPTIONS"
158 | "RULE_USAGE_DESCRIPTIONS_EMPTY"
159 | "RULE_CAMERA_USAGE"
160 | "RULE_INFO_PLIST_VERSIONING" => vec!["Info.plist".to_string()],
161 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT" | "RULE_DEVICE_CAPABILITIES_AUDIT" => {
162 vec!["Info.plist".to_string()]
163 }
164 "RULE_PRIVACY_MANIFEST"
165 | "RULE_PRIVACY_MANIFEST_COMPLETENESS"
166 | "RULE_PRIVACY_SDK_CROSSCHECK" => {
167 vec!["PrivacyInfo.xcprivacy".to_string()]
168 }
169 "RULE_ATS_AUDIT" => vec!["Info.plist (NSAppTransportSecurity)".to_string()],
170 "RULE_BUNDLE_RESOURCE_LEAKAGE" => vec!["App bundle resources".to_string()],
171 "RULE_ENTITLEMENTS_MISMATCH"
172 | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH"
173 | "RULE_EXTENSION_ENTITLEMENTS_COMPAT"
174 | "RULE_NESTED_ENTITLEMENTS_MISMATCH"
175 | "RULE_NESTED_DEBUG_ENTITLEMENT" => vec![
176 "App entitlements plist".to_string(),
177 "embedded.mobileprovision".to_string(),
178 ],
179 "RULE_EMBEDDED_TEAM_ID_MISMATCH" => vec![
180 "Main app executable signature".to_string(),
181 "Embedded frameworks/extensions".to_string(),
182 ],
183 "RULE_PRIVATE_API" => vec!["Linked SDKs or app binary".to_string()],
184 _ => match item.category {
185 RuleCategory::Privacy | RuleCategory::Permissions | RuleCategory::Metadata => {
186 vec!["Info.plist".to_string()]
187 }
188 RuleCategory::Entitlements | RuleCategory::Signing => {
189 vec!["App signing and entitlements".to_string()]
190 }
191 RuleCategory::Bundling => vec!["App bundle resources".to_string()],
192 RuleCategory::Ats => vec!["Info.plist (NSAppTransportSecurity)".to_string()],
193 RuleCategory::ThirdParty => vec!["Embedded SDKs or dependencies".to_string()],
194 RuleCategory::Other => vec!["App bundle".to_string()],
195 },
196 }
197}
198
199fn patch_hint(item: &ReportItem) -> String {
200 match canonical_rule_id(&item.rule_id) {
201 "RULE_USAGE_DESCRIPTIONS"
202 | "RULE_USAGE_DESCRIPTIONS_EMPTY"
203 | "RULE_CAMERA_USAGE" => {
204 "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()
205 }
206 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT" => {
207 "Trim LSApplicationQueriesSchemes to only the schemes the app really probes, remove duplicates, and avoid private or overly broad schemes.".to_string()
208 }
209 "RULE_DEVICE_CAPABILITIES_AUDIT" => {
210 "Align UIRequiredDeviceCapabilities with real binary usage so review devices are not excluded by mistake and unsupported hardware is not declared.".to_string()
211 }
212 "RULE_INFO_PLIST_VERSIONING" => {
213 "Set a valid CFBundleShortVersionString and increment CFBundleVersion before the next submission.".to_string()
214 }
215 "RULE_PRIVACY_MANIFEST" | "RULE_PRIVACY_MANIFEST_COMPLETENESS" => {
216 "Add PrivacyInfo.xcprivacy to the shipped bundle and declare the accessed APIs and collected data used by the app or bundled SDKs.".to_string()
217 }
218 "RULE_PRIVACY_SDK_CROSSCHECK" => {
219 "Review bundled SDKs and extend PrivacyInfo.xcprivacy so their accessed APIs and collected data are explicitly declared.".to_string()
220 }
221 "RULE_ATS_AUDIT" => {
222 "Narrow NSAppTransportSecurity exceptions, remove arbitrary loads when possible, and scope domain exceptions to the smallest set that works.".to_string()
223 }
224 "RULE_BUNDLE_RESOURCE_LEAKAGE" => {
225 "Remove secrets, certificates, provisioning artifacts, debug leftovers, and environment files from the packaged app bundle before archiving.".to_string()
226 }
227 "RULE_ENTITLEMENTS_MISMATCH" | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH" => {
228 "Make the exported entitlements match the provisioning profile and enabled capabilities for APNs, keychain groups, and iCloud.".to_string()
229 }
230 "RULE_EXTENSION_ENTITLEMENTS_COMPAT" | "RULE_NESTED_ENTITLEMENTS_MISMATCH" => {
231 "Make each extension entitlement set a valid subset of the host app and add the extension-specific capabilities it actually needs.".to_string()
232 }
233 "RULE_NESTED_DEBUG_ENTITLEMENT" => {
234 "Strip debug-only entitlements like get-task-allow from release builds and regenerate the final signed archive.".to_string()
235 }
236 "RULE_EMBEDDED_TEAM_ID_MISMATCH" => {
237 "Re-sign embedded frameworks, dylibs, and extensions with the same Team ID and release identity as the host app.".to_string()
238 }
239 "RULE_PRIVATE_API" => {
240 "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()
241 }
242 _ => format!(
243 "Patch the {} scope first, then re-run voc to confirm the finding disappears.",
244 suggested_fix_scope(item)
245 ),
246 }
247}
248
249fn why_it_fails_review(item: &ReportItem) -> String {
250 match canonical_rule_id(&item.rule_id) {
251 "RULE_USAGE_DESCRIPTIONS"
252 | "RULE_USAGE_DESCRIPTIONS_EMPTY"
253 | "RULE_CAMERA_USAGE" => {
254 "App Review rejects binaries that touch protected APIs without clear, user-facing usage descriptions in Info.plist.".to_string()
255 }
256 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT" => {
257 "Overreaching canOpenURL allowlists look like app enumeration and often trigger manual review questions or rejection.".to_string()
258 }
259 "RULE_DEVICE_CAPABILITIES_AUDIT" => {
260 "Incorrect device capability declarations can exclude valid review devices or misrepresent the hardware the app actually requires.".to_string()
261 }
262 "RULE_INFO_PLIST_VERSIONING" => {
263 "Invalid or non-incrementing version metadata blocks submission and confuses App Store release processing.".to_string()
264 }
265 "RULE_PRIVACY_MANIFEST"
266 | "RULE_PRIVACY_MANIFEST_COMPLETENESS"
267 | "RULE_PRIVACY_SDK_CROSSCHECK" => {
268 "Apple now expects accurate privacy manifests for apps and bundled SDKs, and missing declarations can block review.".to_string()
269 }
270 "RULE_ATS_AUDIT" => {
271 "Broad ATS exceptions weaken transport security and are a common reason App Review asks teams to justify or remove insecure settings.".to_string()
272 }
273 "RULE_BUNDLE_RESOURCE_LEAKAGE" => {
274 "Shipping secrets, certificates, or provisioning artifacts in the final bundle is treated as a serious distribution and security issue.".to_string()
275 }
276 "RULE_ENTITLEMENTS_MISMATCH"
277 | "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH"
278 | "RULE_EXTENSION_ENTITLEMENTS_COMPAT"
279 | "RULE_NESTED_ENTITLEMENTS_MISMATCH"
280 | "RULE_NESTED_DEBUG_ENTITLEMENT" => {
281 "Entitlements that do not match the signed capabilities or release profile frequently cause validation failures or manual rejection.".to_string()
282 }
283 "RULE_EMBEDDED_TEAM_ID_MISMATCH" => {
284 "Embedded code signed with a different identity or Team ID can fail notarization-style checks during App Store validation.".to_string()
285 }
286 "RULE_PRIVATE_API" => {
287 "Private API usage is one of the clearest App Store rejection reasons because it relies on unsupported system behavior.".to_string()
288 }
289 _ => format!(
290 "This finding maps to the {} scope and signals metadata, signing, or bundle state that App Review may treat as invalid or risky.",
291 suggested_fix_scope(item)
292 ),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::build_agent_pack;
299 use crate::report::data::{ReportData, ReportItem};
300 use crate::rules::core::{ArtifactCacheStats, RuleCategory, RuleStatus, Severity};
301
302 fn report_item(rule_id: &str) -> ReportItem {
303 ReportItem {
304 rule_id: rule_id.to_string(),
305 rule_name: rule_id.to_string(),
306 category: RuleCategory::Metadata,
307 severity: Severity::Warning,
308 target: "Demo.app".to_string(),
309 status: RuleStatus::Fail,
310 message: Some("example".to_string()),
311 evidence: None,
312 recommendation: "fix it".to_string(),
313 duration_ms: 1,
314 }
315 }
316
317 #[test]
318 fn agent_pack_maps_current_rule_ids() {
319 let report = ReportData {
320 ruleset_version: "test".to_string(),
321 generated_at_unix: 0,
322 total_duration_ms: 0,
323 cache_stats: ArtifactCacheStats::default(),
324 slow_rules: Vec::new(),
325 results: vec![
326 report_item("RULE_USAGE_DESCRIPTIONS_EMPTY"),
327 report_item("RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT"),
328 report_item("RULE_DEVICE_CAPABILITIES_AUDIT"),
329 ],
330 scanned_targets: vec!["Demo.app".to_string()],
331 };
332
333 let pack = build_agent_pack(&report);
334
335 assert_eq!(pack.total_findings, 3);
336 assert!(pack
337 .findings
338 .iter()
339 .all(|finding| finding.target_files == vec!["Info.plist".to_string()]));
340 }
341}