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