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 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}