Skip to main content

verifyos_cli/rules/
info_plist.rs

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}