Skip to main content

verifyos_cli/rules/
info_plist.rs

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}