1use crate::parsers::macho_scanner::{
2 scan_capabilities_from_app_bundle, scan_usage_from_app_bundle,
3};
4use crate::parsers::plist_reader::InfoPlist;
5use crate::rules::core::{
6 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
7};
8
9const LOCATION_KEYS: &[&str] = &[
10 "NSLocationWhenInUseUsageDescription",
11 "NSLocationAlwaysAndWhenInUseUsageDescription",
12 "NSLocationAlwaysUsageDescription",
13];
14const LSQUERY_SCHEME_LIMIT: usize = 50;
15const SUSPICIOUS_SCHEMES: &[&str] = &[
16 "app-prefs",
17 "prefs",
18 "settings",
19 "sb",
20 "sbsettings",
21 "sbprefs",
22];
23
24pub struct UsageDescriptionsRule;
25
26impl AppStoreRule for UsageDescriptionsRule {
27 fn id(&self) -> &'static str {
28 "RULE_USAGE_DESCRIPTIONS"
29 }
30
31 fn name(&self) -> &'static str {
32 "Missing Usage Description Keys"
33 }
34
35 fn category(&self) -> RuleCategory {
36 RuleCategory::Privacy
37 }
38
39 fn severity(&self) -> Severity {
40 Severity::Warning
41 }
42
43 fn recommendation(&self) -> &'static str {
44 "Add NS*UsageDescription keys required by your app's feature usage."
45 }
46
47 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
48 let Some(plist) = artifact.info_plist else {
49 return Ok(RuleReport {
50 status: RuleStatus::Skip,
51 message: Some("Info.plist not found".to_string()),
52 evidence: None,
53 });
54 };
55
56 let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
57 Ok(scan) => scan,
58 Err(err) => {
59 return Ok(RuleReport {
60 status: RuleStatus::Skip,
61 message: Some(format!("Usage scan skipped: {err}")),
62 evidence: None,
63 });
64 }
65 };
66
67 if scan.required_keys.is_empty() && !scan.requires_location_key {
68 return Ok(RuleReport {
69 status: RuleStatus::Pass,
70 message: Some("No usage APIs detected".to_string()),
71 evidence: None,
72 });
73 }
74
75 let mut missing: Vec<&str> = scan
76 .required_keys
77 .iter()
78 .copied()
79 .filter(|key| !plist.has_key(key))
80 .collect();
81
82 if scan.requires_location_key && !has_any_location_key(plist) {
83 missing.push("NSLocationWhenInUseUsageDescription | NSLocationAlwaysAndWhenInUseUsageDescription | NSLocationAlwaysUsageDescription");
84 }
85
86 if missing.is_empty() {
87 return Ok(RuleReport {
88 status: RuleStatus::Pass,
89 message: None,
90 evidence: None,
91 });
92 }
93
94 Ok(RuleReport {
95 status: RuleStatus::Fail,
96 message: Some("Missing required usage description keys".to_string()),
97 evidence: Some(format!(
98 "Missing keys: {}. Evidence: {}",
99 missing.join(", "),
100 format_evidence(&scan)
101 )),
102 })
103 }
104}
105
106pub struct UsageDescriptionsValueRule;
107
108impl AppStoreRule for UsageDescriptionsValueRule {
109 fn id(&self) -> &'static str {
110 "RULE_USAGE_DESCRIPTIONS_EMPTY"
111 }
112
113 fn name(&self) -> &'static str {
114 "Empty Usage Description Values"
115 }
116
117 fn category(&self) -> RuleCategory {
118 RuleCategory::Privacy
119 }
120
121 fn severity(&self) -> Severity {
122 Severity::Warning
123 }
124
125 fn recommendation(&self) -> &'static str {
126 "Ensure NS*UsageDescription values are non-empty and user-facing."
127 }
128
129 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
130 let Some(plist) = artifact.info_plist else {
131 return Ok(RuleReport {
132 status: RuleStatus::Skip,
133 message: Some("Info.plist not found".to_string()),
134 evidence: None,
135 });
136 };
137
138 let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
139 Ok(scan) => scan,
140 Err(err) => {
141 return Ok(RuleReport {
142 status: RuleStatus::Skip,
143 message: Some(format!("Usage scan skipped: {err}")),
144 evidence: None,
145 });
146 }
147 };
148
149 if scan.required_keys.is_empty() && !scan.requires_location_key {
150 return Ok(RuleReport {
151 status: RuleStatus::Pass,
152 message: Some("No usage APIs detected".to_string()),
153 evidence: None,
154 });
155 }
156
157 let mut empty: Vec<&str> = scan
158 .required_keys
159 .iter()
160 .copied()
161 .filter(|key| is_empty_string(plist, key))
162 .collect();
163
164 if scan.requires_location_key {
165 if let Some(key) = find_empty_location_key(plist) {
166 empty.push(key);
167 }
168 }
169
170 if empty.is_empty() {
171 return Ok(RuleReport {
172 status: RuleStatus::Pass,
173 message: None,
174 evidence: None,
175 });
176 }
177
178 Ok(RuleReport {
179 status: RuleStatus::Fail,
180 message: Some("Usage description values are empty".to_string()),
181 evidence: Some(format!(
182 "Empty keys: {}. Evidence: {}",
183 empty.join(", "),
184 format_evidence(&scan)
185 )),
186 })
187 }
188}
189
190pub struct InfoPlistRequiredKeysRule;
191
192impl AppStoreRule for InfoPlistRequiredKeysRule {
193 fn id(&self) -> &'static str {
194 "RULE_INFO_PLIST_REQUIRED_KEYS"
195 }
196
197 fn name(&self) -> &'static str {
198 "Missing Required Info.plist Keys"
199 }
200
201 fn category(&self) -> RuleCategory {
202 RuleCategory::Metadata
203 }
204
205 fn severity(&self) -> Severity {
206 Severity::Warning
207 }
208
209 fn recommendation(&self) -> &'static str {
210 "Add required Info.plist keys for your app's functionality."
211 }
212
213 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
214 let Some(plist) = artifact.info_plist else {
215 return Ok(RuleReport {
216 status: RuleStatus::Skip,
217 message: Some("Info.plist not found".to_string()),
218 evidence: None,
219 });
220 };
221
222 let mut missing = Vec::new();
223 if !plist.has_key("LSApplicationQueriesSchemes") {
224 missing.push("LSApplicationQueriesSchemes");
225 }
226 if !plist.has_key("UIRequiredDeviceCapabilities") {
227 missing.push("UIRequiredDeviceCapabilities");
228 }
229
230 if missing.is_empty() {
231 return Ok(RuleReport {
232 status: RuleStatus::Pass,
233 message: None,
234 evidence: None,
235 });
236 }
237
238 Ok(RuleReport {
239 status: RuleStatus::Fail,
240 message: Some("Missing required Info.plist keys".to_string()),
241 evidence: Some(format!("Missing keys: {}", missing.join(", "))),
242 })
243 }
244}
245
246pub struct InfoPlistCapabilitiesRule;
247
248impl AppStoreRule for InfoPlistCapabilitiesRule {
249 fn id(&self) -> &'static str {
250 "RULE_INFO_PLIST_CAPABILITIES_EMPTY"
251 }
252
253 fn name(&self) -> &'static str {
254 "Empty Info.plist Capability Lists"
255 }
256
257 fn category(&self) -> RuleCategory {
258 RuleCategory::Metadata
259 }
260
261 fn severity(&self) -> Severity {
262 Severity::Warning
263 }
264
265 fn recommendation(&self) -> &'static str {
266 "Remove empty arrays or populate capability keys with valid values."
267 }
268
269 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
270 let Some(plist) = artifact.info_plist else {
271 return Ok(RuleReport {
272 status: RuleStatus::Skip,
273 message: Some("Info.plist not found".to_string()),
274 evidence: None,
275 });
276 };
277
278 let mut empty = Vec::new();
279
280 if is_empty_array(plist, "LSApplicationQueriesSchemes") {
281 empty.push("LSApplicationQueriesSchemes");
282 }
283
284 if is_empty_array(plist, "UIRequiredDeviceCapabilities") {
285 empty.push("UIRequiredDeviceCapabilities");
286 }
287
288 if empty.is_empty() {
289 return Ok(RuleReport {
290 status: RuleStatus::Pass,
291 message: None,
292 evidence: None,
293 });
294 }
295
296 Ok(RuleReport {
297 status: RuleStatus::Fail,
298 message: Some("Capability keys are present but empty".to_string()),
299 evidence: Some(format!("Empty keys: {}", empty.join(", "))),
300 })
301 }
302}
303
304pub struct UIRequiredDeviceCapabilitiesAuditRule;
305
306impl AppStoreRule for UIRequiredDeviceCapabilitiesAuditRule {
307 fn id(&self) -> &'static str {
308 "RULE_DEVICE_CAPABILITIES_AUDIT"
309 }
310
311 fn name(&self) -> &'static str {
312 "UIRequiredDeviceCapabilities Audit"
313 }
314
315 fn category(&self) -> RuleCategory {
316 RuleCategory::Metadata
317 }
318
319 fn severity(&self) -> Severity {
320 Severity::Warning
321 }
322
323 fn recommendation(&self) -> &'static str {
324 "Only declare capabilities that match actual hardware usage in the binary."
325 }
326
327 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
328 let Some(plist) = artifact.info_plist else {
329 return Ok(RuleReport {
330 status: RuleStatus::Skip,
331 message: Some("Info.plist not found".to_string()),
332 evidence: None,
333 });
334 };
335
336 let Some(declared) = parse_required_capabilities(plist) else {
337 return Ok(RuleReport {
338 status: RuleStatus::Skip,
339 message: Some("UIRequiredDeviceCapabilities not declared".to_string()),
340 evidence: None,
341 });
342 };
343
344 if declared.is_empty() {
345 return Ok(RuleReport {
346 status: RuleStatus::Skip,
347 message: Some("UIRequiredDeviceCapabilities is empty".to_string()),
348 evidence: None,
349 });
350 }
351
352 let scan = match scan_capabilities_from_app_bundle(artifact.app_bundle_path) {
353 Ok(scan) => scan,
354 Err(err) => {
355 return Ok(RuleReport {
356 status: RuleStatus::Skip,
357 message: Some(format!("Capability scan skipped: {err}")),
358 evidence: None,
359 });
360 }
361 };
362
363 let mut mismatches = Vec::new();
364 for cap in declared {
365 let Some(group) = capability_group(&cap) else {
366 continue;
367 };
368 if !scan.detected.contains(group) {
369 mismatches.push(format!(
370 "Declared capability '{}' without matching binary usage",
371 cap
372 ));
373 }
374 }
375
376 if mismatches.is_empty() {
377 return Ok(RuleReport {
378 status: RuleStatus::Pass,
379 message: Some("UIRequiredDeviceCapabilities matches binary usage".to_string()),
380 evidence: None,
381 });
382 }
383
384 Ok(RuleReport {
385 status: RuleStatus::Fail,
386 message: Some("Capability list may be overly restrictive".to_string()),
387 evidence: Some(mismatches.join(" | ")),
388 })
389 }
390}
391
392pub struct InfoPlistVersionConsistencyRule;
393
394impl AppStoreRule for InfoPlistVersionConsistencyRule {
395 fn id(&self) -> &'static str {
396 "RULE_INFO_PLIST_VERSIONING"
397 }
398
399 fn name(&self) -> &'static str {
400 "Info.plist Versioning Consistency"
401 }
402
403 fn category(&self) -> RuleCategory {
404 RuleCategory::Metadata
405 }
406
407 fn severity(&self) -> Severity {
408 Severity::Warning
409 }
410
411 fn recommendation(&self) -> &'static str {
412 "Ensure CFBundleShortVersionString is semver-like and CFBundleVersion is a positive integer or dot-separated numeric string."
413 }
414
415 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
416 let Some(plist) = artifact.info_plist else {
417 return Ok(RuleReport {
418 status: RuleStatus::Skip,
419 message: Some("Info.plist not found".to_string()),
420 evidence: None,
421 });
422 };
423
424 let short = plist.get_string("CFBundleShortVersionString");
425 let build = plist.get_string("CFBundleVersion");
426
427 if short.is_none() && build.is_none() {
428 return Ok(RuleReport {
429 status: RuleStatus::Skip,
430 message: Some("Version keys not found".to_string()),
431 evidence: None,
432 });
433 }
434
435 let mut issues = Vec::new();
436
437 match short {
438 Some(value) if is_valid_short_version(value) => {}
439 Some(value) => issues.push(format!("CFBundleShortVersionString invalid: {}", value)),
440 None => issues.push("CFBundleShortVersionString missing".to_string()),
441 }
442
443 match build {
444 Some(value) if is_valid_build_version(value) => {}
445 Some(value) => issues.push(format!("CFBundleVersion invalid: {}", value)),
446 None => issues.push("CFBundleVersion missing".to_string()),
447 }
448
449 if issues.is_empty() {
450 return Ok(RuleReport {
451 status: RuleStatus::Pass,
452 message: Some("Info.plist versioning looks valid".to_string()),
453 evidence: None,
454 });
455 }
456
457 Ok(RuleReport {
458 status: RuleStatus::Fail,
459 message: Some("Info.plist versioning issues".to_string()),
460 evidence: Some(issues.join(" | ")),
461 })
462 }
463}
464
465pub struct LSApplicationQueriesSchemesAuditRule;
466
467impl AppStoreRule for LSApplicationQueriesSchemesAuditRule {
468 fn id(&self) -> &'static str {
469 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT"
470 }
471
472 fn name(&self) -> &'static str {
473 "LSApplicationQueriesSchemes Audit"
474 }
475
476 fn category(&self) -> RuleCategory {
477 RuleCategory::Metadata
478 }
479
480 fn severity(&self) -> Severity {
481 Severity::Warning
482 }
483
484 fn recommendation(&self) -> &'static str {
485 "Keep LSApplicationQueriesSchemes minimal, valid, and aligned with actual app usage."
486 }
487
488 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
489 let Some(plist) = artifact.info_plist else {
490 return Ok(RuleReport {
491 status: RuleStatus::Skip,
492 message: Some("Info.plist not found".to_string()),
493 evidence: None,
494 });
495 };
496
497 let Some(value) = plist.get_value("LSApplicationQueriesSchemes") else {
498 return Ok(RuleReport {
499 status: RuleStatus::Skip,
500 message: Some("LSApplicationQueriesSchemes not declared".to_string()),
501 evidence: None,
502 });
503 };
504
505 let Some(entries) = value.as_array() else {
506 return Ok(RuleReport {
507 status: RuleStatus::Fail,
508 message: Some("LSApplicationQueriesSchemes is not an array".to_string()),
509 evidence: None,
510 });
511 };
512
513 if entries.is_empty() {
514 return Ok(RuleReport {
515 status: RuleStatus::Skip,
516 message: Some("LSApplicationQueriesSchemes is empty".to_string()),
517 evidence: None,
518 });
519 }
520
521 let mut invalid = Vec::new();
522 let mut suspicious = Vec::new();
523 let mut normalized = std::collections::HashMap::new();
524
525 for entry in entries {
526 let Some(raw) = entry.as_string() else {
527 invalid.push("<non-string>".to_string());
528 continue;
529 };
530 let trimmed = raw.trim();
531 if trimmed.is_empty() || !is_valid_scheme(trimmed) {
532 invalid.push(raw.to_string());
533 continue;
534 }
535
536 let normalized_key = trimmed.to_ascii_lowercase();
537 *normalized.entry(normalized_key.clone()).or_insert(0usize) += 1;
538
539 if SUSPICIOUS_SCHEMES
540 .iter()
541 .any(|scheme| scheme.eq_ignore_ascii_case(&normalized_key))
542 {
543 suspicious.push(trimmed.to_string());
544 }
545 }
546
547 let mut issues = Vec::new();
548 if entries.len() > LSQUERY_SCHEME_LIMIT {
549 issues.push(format!(
550 "Contains {} schemes (limit {})",
551 entries.len(),
552 LSQUERY_SCHEME_LIMIT
553 ));
554 }
555
556 let mut duplicates: Vec<String> = normalized
557 .iter()
558 .filter_map(|(scheme, count)| {
559 if *count > 1 {
560 Some(scheme.clone())
561 } else {
562 None
563 }
564 })
565 .collect();
566 duplicates.sort();
567 if !duplicates.is_empty() {
568 issues.push(format!("Duplicate schemes: {}", duplicates.join(", ")));
569 }
570
571 if !invalid.is_empty() {
572 issues.push(format!("Invalid scheme entries: {}", invalid.join(", ")));
573 }
574
575 if !suspicious.is_empty() {
576 issues.push(format!(
577 "Potentially private schemes: {}",
578 unique_sorted(suspicious).join(", ")
579 ));
580 }
581
582 if issues.is_empty() {
583 return Ok(RuleReport {
584 status: RuleStatus::Pass,
585 message: Some("LSApplicationQueriesSchemes looks sane".to_string()),
586 evidence: None,
587 });
588 }
589
590 Ok(RuleReport {
591 status: RuleStatus::Fail,
592 message: Some("LSApplicationQueriesSchemes audit failed".to_string()),
593 evidence: Some(issues.join(" | ")),
594 })
595 }
596}
597
598fn is_empty_string(plist: &InfoPlist, key: &str) -> bool {
599 match plist.get_string(key) {
600 Some(value) => value.trim().is_empty(),
601 None => false,
602 }
603}
604
605fn is_empty_array(plist: &InfoPlist, key: &str) -> bool {
606 match plist.get_value(key) {
607 Some(value) => value.as_array().map(|arr| arr.is_empty()).unwrap_or(false),
608 None => false,
609 }
610}
611
612fn parse_required_capabilities(plist: &InfoPlist) -> Option<Vec<String>> {
613 let value = plist.get_value("UIRequiredDeviceCapabilities")?;
614
615 if let Some(array) = value.as_array() {
616 let mut out = Vec::new();
617 for item in array {
618 if let Some(value) = item.as_string() {
619 let trimmed = value.trim();
620 if !trimmed.is_empty() {
621 out.push(trimmed.to_string());
622 }
623 }
624 }
625 return Some(out);
626 }
627
628 if let Some(dict) = value.as_dictionary() {
629 let mut out = Vec::new();
630 for (key, value) in dict {
631 if let Some(true) = value.as_boolean() {
632 out.push(key.to_string());
633 }
634 }
635 return Some(out);
636 }
637
638 None
639}
640
641fn capability_group(value: &str) -> Option<&'static str> {
642 match value.trim().to_ascii_lowercase().as_str() {
643 "camera" | "front-facing-camera" | "rear-facing-camera" => Some("camera"),
644 "gps" | "location-services" => Some("location"),
645 _ => None,
646 }
647}
648
649fn is_valid_short_version(value: &str) -> bool {
650 let trimmed = value.trim();
651 if trimmed.is_empty() {
652 return false;
653 }
654
655 let parts: Vec<&str> = trimmed.split('.').collect();
656 if parts.is_empty() || parts.len() > 3 {
657 return false;
658 }
659
660 parts.iter().all(|part| is_numeric_component(part))
661}
662
663fn is_valid_build_version(value: &str) -> bool {
664 let trimmed = value.trim();
665 if trimmed.is_empty() {
666 return false;
667 }
668
669 let parts: Vec<&str> = trimmed.split('.').collect();
670 if parts.is_empty() {
671 return false;
672 }
673
674 parts.iter().all(|part| is_numeric_component(part))
675}
676
677fn is_numeric_component(value: &str) -> bool {
678 if value.is_empty() {
679 return false;
680 }
681
682 value.chars().all(|ch| ch.is_ascii_digit())
683}
684
685fn has_any_location_key(plist: &InfoPlist) -> bool {
686 LOCATION_KEYS.iter().any(|key| plist.has_key(key))
687}
688
689fn find_empty_location_key(plist: &InfoPlist) -> Option<&'static str> {
690 for key in LOCATION_KEYS {
691 if plist.has_key(key) && is_empty_string(plist, key) {
692 return Some(*key);
693 }
694 }
695 None
696}
697
698fn format_evidence(scan: &crate::parsers::macho_scanner::UsageScan) -> String {
699 let mut list: Vec<&str> = scan.evidence.iter().copied().collect();
700 list.sort_unstable();
701 list.join(", ")
702}
703
704fn is_valid_scheme(value: &str) -> bool {
705 let mut chars = value.chars();
706 let Some(first) = chars.next() else {
707 return false;
708 };
709
710 if !first.is_ascii_alphabetic() {
711 return false;
712 }
713
714 for ch in chars {
715 if !(ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.') {
716 return false;
717 }
718 }
719
720 true
721}
722
723fn unique_sorted(mut values: Vec<String>) -> Vec<String> {
724 values.sort();
725 values.dedup();
726 values
727}