1use crate::parsers::macho_scanner::scan_usage_from_app_bundle;
2use crate::parsers::plist_reader::InfoPlist;
3use crate::rules::core::{
4 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
5};
6
7const LOCATION_KEYS: &[&str] = &[
8 "NSLocationWhenInUseUsageDescription",
9 "NSLocationAlwaysAndWhenInUseUsageDescription",
10 "NSLocationAlwaysUsageDescription",
11];
12const LSQUERY_SCHEME_LIMIT: usize = 50;
13const SUSPICIOUS_SCHEMES: &[&str] = &[
14 "app-prefs",
15 "prefs",
16 "settings",
17 "sb",
18 "sbsettings",
19 "sbprefs",
20];
21
22pub struct UsageDescriptionsRule;
23
24impl AppStoreRule for UsageDescriptionsRule {
25 fn id(&self) -> &'static str {
26 "RULE_USAGE_DESCRIPTIONS"
27 }
28
29 fn name(&self) -> &'static str {
30 "Missing Usage Description Keys"
31 }
32
33 fn category(&self) -> RuleCategory {
34 RuleCategory::Privacy
35 }
36
37 fn severity(&self) -> Severity {
38 Severity::Warning
39 }
40
41 fn recommendation(&self) -> &'static str {
42 "Add NS*UsageDescription keys required by your app's feature usage."
43 }
44
45 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
46 let Some(plist) = artifact.info_plist else {
47 return Ok(RuleReport {
48 status: RuleStatus::Skip,
49 message: Some("Info.plist not found".to_string()),
50 evidence: None,
51 });
52 };
53
54 let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
55 Ok(scan) => scan,
56 Err(err) => {
57 return Ok(RuleReport {
58 status: RuleStatus::Skip,
59 message: Some(format!("Usage scan skipped: {err}")),
60 evidence: None,
61 });
62 }
63 };
64
65 if scan.required_keys.is_empty() && !scan.requires_location_key {
66 return Ok(RuleReport {
67 status: RuleStatus::Pass,
68 message: Some("No usage APIs detected".to_string()),
69 evidence: None,
70 });
71 }
72
73 let mut missing: Vec<&str> = scan
74 .required_keys
75 .iter()
76 .copied()
77 .filter(|key| !plist.has_key(key))
78 .collect();
79
80 if scan.requires_location_key && !has_any_location_key(plist) {
81 missing.push("NSLocationWhenInUseUsageDescription | NSLocationAlwaysAndWhenInUseUsageDescription | NSLocationAlwaysUsageDescription");
82 }
83
84 if missing.is_empty() {
85 return Ok(RuleReport {
86 status: RuleStatus::Pass,
87 message: None,
88 evidence: None,
89 });
90 }
91
92 Ok(RuleReport {
93 status: RuleStatus::Fail,
94 message: Some("Missing required usage description keys".to_string()),
95 evidence: Some(format!(
96 "Missing keys: {}. Evidence: {}",
97 missing.join(", "),
98 format_evidence(&scan)
99 )),
100 })
101 }
102}
103
104pub struct UsageDescriptionsValueRule;
105
106impl AppStoreRule for UsageDescriptionsValueRule {
107 fn id(&self) -> &'static str {
108 "RULE_USAGE_DESCRIPTIONS_EMPTY"
109 }
110
111 fn name(&self) -> &'static str {
112 "Empty Usage Description Values"
113 }
114
115 fn category(&self) -> RuleCategory {
116 RuleCategory::Privacy
117 }
118
119 fn severity(&self) -> Severity {
120 Severity::Warning
121 }
122
123 fn recommendation(&self) -> &'static str {
124 "Ensure NS*UsageDescription values are non-empty and user-facing."
125 }
126
127 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
128 let Some(plist) = artifact.info_plist else {
129 return Ok(RuleReport {
130 status: RuleStatus::Skip,
131 message: Some("Info.plist not found".to_string()),
132 evidence: None,
133 });
134 };
135
136 let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
137 Ok(scan) => scan,
138 Err(err) => {
139 return Ok(RuleReport {
140 status: RuleStatus::Skip,
141 message: Some(format!("Usage scan skipped: {err}")),
142 evidence: None,
143 });
144 }
145 };
146
147 if scan.required_keys.is_empty() && !scan.requires_location_key {
148 return Ok(RuleReport {
149 status: RuleStatus::Pass,
150 message: Some("No usage APIs detected".to_string()),
151 evidence: None,
152 });
153 }
154
155 let mut empty: Vec<&str> = scan
156 .required_keys
157 .iter()
158 .copied()
159 .filter(|key| is_empty_string(plist, key))
160 .collect();
161
162 if scan.requires_location_key {
163 if let Some(key) = find_empty_location_key(plist) {
164 empty.push(key);
165 }
166 }
167
168 if empty.is_empty() {
169 return Ok(RuleReport {
170 status: RuleStatus::Pass,
171 message: None,
172 evidence: None,
173 });
174 }
175
176 Ok(RuleReport {
177 status: RuleStatus::Fail,
178 message: Some("Usage description values are empty".to_string()),
179 evidence: Some(format!(
180 "Empty keys: {}. Evidence: {}",
181 empty.join(", "),
182 format_evidence(&scan)
183 )),
184 })
185 }
186}
187
188pub struct InfoPlistRequiredKeysRule;
189
190impl AppStoreRule for InfoPlistRequiredKeysRule {
191 fn id(&self) -> &'static str {
192 "RULE_INFO_PLIST_REQUIRED_KEYS"
193 }
194
195 fn name(&self) -> &'static str {
196 "Missing Required Info.plist Keys"
197 }
198
199 fn category(&self) -> RuleCategory {
200 RuleCategory::Metadata
201 }
202
203 fn severity(&self) -> Severity {
204 Severity::Warning
205 }
206
207 fn recommendation(&self) -> &'static str {
208 "Add required Info.plist keys for your app's functionality."
209 }
210
211 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
212 let Some(plist) = artifact.info_plist else {
213 return Ok(RuleReport {
214 status: RuleStatus::Skip,
215 message: Some("Info.plist not found".to_string()),
216 evidence: None,
217 });
218 };
219
220 let mut missing = Vec::new();
221 if !plist.has_key("LSApplicationQueriesSchemes") {
222 missing.push("LSApplicationQueriesSchemes");
223 }
224 if !plist.has_key("UIRequiredDeviceCapabilities") {
225 missing.push("UIRequiredDeviceCapabilities");
226 }
227
228 if missing.is_empty() {
229 return Ok(RuleReport {
230 status: RuleStatus::Pass,
231 message: None,
232 evidence: None,
233 });
234 }
235
236 Ok(RuleReport {
237 status: RuleStatus::Fail,
238 message: Some("Missing required Info.plist keys".to_string()),
239 evidence: Some(format!("Missing keys: {}", missing.join(", "))),
240 })
241 }
242}
243
244pub struct InfoPlistCapabilitiesRule;
245
246impl AppStoreRule for InfoPlistCapabilitiesRule {
247 fn id(&self) -> &'static str {
248 "RULE_INFO_PLIST_CAPABILITIES_EMPTY"
249 }
250
251 fn name(&self) -> &'static str {
252 "Empty Info.plist Capability Lists"
253 }
254
255 fn category(&self) -> RuleCategory {
256 RuleCategory::Metadata
257 }
258
259 fn severity(&self) -> Severity {
260 Severity::Warning
261 }
262
263 fn recommendation(&self) -> &'static str {
264 "Remove empty arrays or populate capability keys with valid values."
265 }
266
267 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
268 let Some(plist) = artifact.info_plist else {
269 return Ok(RuleReport {
270 status: RuleStatus::Skip,
271 message: Some("Info.plist not found".to_string()),
272 evidence: None,
273 });
274 };
275
276 let mut empty = Vec::new();
277
278 if is_empty_array(plist, "LSApplicationQueriesSchemes") {
279 empty.push("LSApplicationQueriesSchemes");
280 }
281
282 if is_empty_array(plist, "UIRequiredDeviceCapabilities") {
283 empty.push("UIRequiredDeviceCapabilities");
284 }
285
286 if empty.is_empty() {
287 return Ok(RuleReport {
288 status: RuleStatus::Pass,
289 message: None,
290 evidence: None,
291 });
292 }
293
294 Ok(RuleReport {
295 status: RuleStatus::Fail,
296 message: Some("Capability keys are present but empty".to_string()),
297 evidence: Some(format!("Empty keys: {}", empty.join(", "))),
298 })
299 }
300}
301
302pub struct LSApplicationQueriesSchemesAuditRule;
303
304impl AppStoreRule for LSApplicationQueriesSchemesAuditRule {
305 fn id(&self) -> &'static str {
306 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT"
307 }
308
309 fn name(&self) -> &'static str {
310 "LSApplicationQueriesSchemes Audit"
311 }
312
313 fn category(&self) -> RuleCategory {
314 RuleCategory::Metadata
315 }
316
317 fn severity(&self) -> Severity {
318 Severity::Warning
319 }
320
321 fn recommendation(&self) -> &'static str {
322 "Keep LSApplicationQueriesSchemes minimal, valid, and aligned with actual app usage."
323 }
324
325 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
326 let Some(plist) = artifact.info_plist else {
327 return Ok(RuleReport {
328 status: RuleStatus::Skip,
329 message: Some("Info.plist not found".to_string()),
330 evidence: None,
331 });
332 };
333
334 let Some(value) = plist.get_value("LSApplicationQueriesSchemes") else {
335 return Ok(RuleReport {
336 status: RuleStatus::Skip,
337 message: Some("LSApplicationQueriesSchemes not declared".to_string()),
338 evidence: None,
339 });
340 };
341
342 let Some(entries) = value.as_array() else {
343 return Ok(RuleReport {
344 status: RuleStatus::Fail,
345 message: Some("LSApplicationQueriesSchemes is not an array".to_string()),
346 evidence: None,
347 });
348 };
349
350 if entries.is_empty() {
351 return Ok(RuleReport {
352 status: RuleStatus::Skip,
353 message: Some("LSApplicationQueriesSchemes is empty".to_string()),
354 evidence: None,
355 });
356 }
357
358 let mut invalid = Vec::new();
359 let mut suspicious = Vec::new();
360 let mut normalized = std::collections::HashMap::new();
361
362 for entry in entries {
363 let Some(raw) = entry.as_string() else {
364 invalid.push("<non-string>".to_string());
365 continue;
366 };
367 let trimmed = raw.trim();
368 if trimmed.is_empty() || !is_valid_scheme(trimmed) {
369 invalid.push(raw.to_string());
370 continue;
371 }
372
373 let normalized_key = trimmed.to_ascii_lowercase();
374 *normalized.entry(normalized_key.clone()).or_insert(0usize) += 1;
375
376 if SUSPICIOUS_SCHEMES
377 .iter()
378 .any(|scheme| scheme.eq_ignore_ascii_case(&normalized_key))
379 {
380 suspicious.push(trimmed.to_string());
381 }
382 }
383
384 let mut issues = Vec::new();
385 if entries.len() > LSQUERY_SCHEME_LIMIT {
386 issues.push(format!(
387 "Contains {} schemes (limit {})",
388 entries.len(),
389 LSQUERY_SCHEME_LIMIT
390 ));
391 }
392
393 let mut duplicates: Vec<String> = normalized
394 .iter()
395 .filter_map(|(scheme, count)| {
396 if *count > 1 {
397 Some(scheme.clone())
398 } else {
399 None
400 }
401 })
402 .collect();
403 duplicates.sort();
404 if !duplicates.is_empty() {
405 issues.push(format!("Duplicate schemes: {}", duplicates.join(", ")));
406 }
407
408 if !invalid.is_empty() {
409 issues.push(format!("Invalid scheme entries: {}", invalid.join(", ")));
410 }
411
412 if !suspicious.is_empty() {
413 issues.push(format!(
414 "Potentially private schemes: {}",
415 unique_sorted(suspicious).join(", ")
416 ));
417 }
418
419 if issues.is_empty() {
420 return Ok(RuleReport {
421 status: RuleStatus::Pass,
422 message: Some("LSApplicationQueriesSchemes looks sane".to_string()),
423 evidence: None,
424 });
425 }
426
427 Ok(RuleReport {
428 status: RuleStatus::Fail,
429 message: Some("LSApplicationQueriesSchemes audit failed".to_string()),
430 evidence: Some(issues.join(" | ")),
431 })
432 }
433}
434
435fn is_empty_string(plist: &InfoPlist, key: &str) -> bool {
436 match plist.get_string(key) {
437 Some(value) => value.trim().is_empty(),
438 None => false,
439 }
440}
441
442fn is_empty_array(plist: &InfoPlist, key: &str) -> bool {
443 match plist.get_value(key) {
444 Some(value) => value.as_array().map(|arr| arr.is_empty()).unwrap_or(false),
445 None => false,
446 }
447}
448
449fn has_any_location_key(plist: &InfoPlist) -> bool {
450 LOCATION_KEYS.iter().any(|key| plist.has_key(key))
451}
452
453fn find_empty_location_key(plist: &InfoPlist) -> Option<&'static str> {
454 for key in LOCATION_KEYS {
455 if plist.has_key(key) && is_empty_string(plist, key) {
456 return Some(*key);
457 }
458 }
459 None
460}
461
462fn format_evidence(scan: &crate::parsers::macho_scanner::UsageScan) -> String {
463 let mut list: Vec<&str> = scan.evidence.iter().copied().collect();
464 list.sort_unstable();
465 list.join(", ")
466}
467
468fn is_valid_scheme(value: &str) -> bool {
469 let mut chars = value.chars();
470 let Some(first) = chars.next() else {
471 return false;
472 };
473
474 if !first.is_ascii_alphabetic() {
475 return false;
476 }
477
478 for ch in chars {
479 if !(ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.') {
480 return false;
481 }
482 }
483
484 true
485}
486
487fn unique_sorted(mut values: Vec<String>) -> Vec<String> {
488 values.sort();
489 values.dedup();
490 values
491}