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