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