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 LSApplicationQueriesSchemesAuditRule;
393
394impl AppStoreRule for LSApplicationQueriesSchemesAuditRule {
395 fn id(&self) -> &'static str {
396 "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT"
397 }
398
399 fn name(&self) -> &'static str {
400 "LSApplicationQueriesSchemes Audit"
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 "Keep LSApplicationQueriesSchemes minimal, valid, and aligned with actual app usage."
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 Some(value) = plist.get_value("LSApplicationQueriesSchemes") else {
425 return Ok(RuleReport {
426 status: RuleStatus::Skip,
427 message: Some("LSApplicationQueriesSchemes not declared".to_string()),
428 evidence: None,
429 });
430 };
431
432 let Some(entries) = value.as_array() else {
433 return Ok(RuleReport {
434 status: RuleStatus::Fail,
435 message: Some("LSApplicationQueriesSchemes is not an array".to_string()),
436 evidence: None,
437 });
438 };
439
440 if entries.is_empty() {
441 return Ok(RuleReport {
442 status: RuleStatus::Skip,
443 message: Some("LSApplicationQueriesSchemes is empty".to_string()),
444 evidence: None,
445 });
446 }
447
448 let mut invalid = Vec::new();
449 let mut suspicious = Vec::new();
450 let mut normalized = std::collections::HashMap::new();
451
452 for entry in entries {
453 let Some(raw) = entry.as_string() else {
454 invalid.push("<non-string>".to_string());
455 continue;
456 };
457 let trimmed = raw.trim();
458 if trimmed.is_empty() || !is_valid_scheme(trimmed) {
459 invalid.push(raw.to_string());
460 continue;
461 }
462
463 let normalized_key = trimmed.to_ascii_lowercase();
464 *normalized.entry(normalized_key.clone()).or_insert(0usize) += 1;
465
466 if SUSPICIOUS_SCHEMES
467 .iter()
468 .any(|scheme| scheme.eq_ignore_ascii_case(&normalized_key))
469 {
470 suspicious.push(trimmed.to_string());
471 }
472 }
473
474 let mut issues = Vec::new();
475 if entries.len() > LSQUERY_SCHEME_LIMIT {
476 issues.push(format!(
477 "Contains {} schemes (limit {})",
478 entries.len(),
479 LSQUERY_SCHEME_LIMIT
480 ));
481 }
482
483 let mut duplicates: Vec<String> = normalized
484 .iter()
485 .filter_map(|(scheme, count)| {
486 if *count > 1 {
487 Some(scheme.clone())
488 } else {
489 None
490 }
491 })
492 .collect();
493 duplicates.sort();
494 if !duplicates.is_empty() {
495 issues.push(format!("Duplicate schemes: {}", duplicates.join(", ")));
496 }
497
498 if !invalid.is_empty() {
499 issues.push(format!("Invalid scheme entries: {}", invalid.join(", ")));
500 }
501
502 if !suspicious.is_empty() {
503 issues.push(format!(
504 "Potentially private schemes: {}",
505 unique_sorted(suspicious).join(", ")
506 ));
507 }
508
509 if issues.is_empty() {
510 return Ok(RuleReport {
511 status: RuleStatus::Pass,
512 message: Some("LSApplicationQueriesSchemes looks sane".to_string()),
513 evidence: None,
514 });
515 }
516
517 Ok(RuleReport {
518 status: RuleStatus::Fail,
519 message: Some("LSApplicationQueriesSchemes audit failed".to_string()),
520 evidence: Some(issues.join(" | ")),
521 })
522 }
523}
524
525fn is_empty_string(plist: &InfoPlist, key: &str) -> bool {
526 match plist.get_string(key) {
527 Some(value) => value.trim().is_empty(),
528 None => false,
529 }
530}
531
532fn is_empty_array(plist: &InfoPlist, key: &str) -> bool {
533 match plist.get_value(key) {
534 Some(value) => value.as_array().map(|arr| arr.is_empty()).unwrap_or(false),
535 None => false,
536 }
537}
538
539fn parse_required_capabilities(plist: &InfoPlist) -> Option<Vec<String>> {
540 let value = plist.get_value("UIRequiredDeviceCapabilities")?;
541
542 if let Some(array) = value.as_array() {
543 let mut out = Vec::new();
544 for item in array {
545 if let Some(value) = item.as_string() {
546 let trimmed = value.trim();
547 if !trimmed.is_empty() {
548 out.push(trimmed.to_string());
549 }
550 }
551 }
552 return Some(out);
553 }
554
555 if let Some(dict) = value.as_dictionary() {
556 let mut out = Vec::new();
557 for (key, value) in dict {
558 if let Some(true) = value.as_boolean() {
559 out.push(key.to_string());
560 }
561 }
562 return Some(out);
563 }
564
565 None
566}
567
568fn capability_group(value: &str) -> Option<&'static str> {
569 match value.trim().to_ascii_lowercase().as_str() {
570 "camera" | "front-facing-camera" | "rear-facing-camera" => Some("camera"),
571 "gps" | "location-services" => Some("location"),
572 _ => None,
573 }
574}
575
576fn has_any_location_key(plist: &InfoPlist) -> bool {
577 LOCATION_KEYS.iter().any(|key| plist.has_key(key))
578}
579
580fn find_empty_location_key(plist: &InfoPlist) -> Option<&'static str> {
581 for key in LOCATION_KEYS {
582 if plist.has_key(key) && is_empty_string(plist, key) {
583 return Some(*key);
584 }
585 }
586 None
587}
588
589fn format_evidence(scan: &crate::parsers::macho_scanner::UsageScan) -> String {
590 let mut list: Vec<&str> = scan.evidence.iter().copied().collect();
591 list.sort_unstable();
592 list.join(", ")
593}
594
595fn is_valid_scheme(value: &str) -> bool {
596 let mut chars = value.chars();
597 let Some(first) = chars.next() else {
598 return false;
599 };
600
601 if !first.is_ascii_alphabetic() {
602 return false;
603 }
604
605 for ch in chars {
606 if !(ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.') {
607 return false;
608 }
609 }
610
611 true
612}
613
614fn unique_sorted(mut values: Vec<String>) -> Vec<String> {
615 values.sort();
616 values.dedup();
617 values
618}