Skip to main content

verifyos_cli/rules/
info_plist.rs

1use crate::parsers::macho_scanner::scan_usage_from_app_bundle;
2use crate::parsers::plist_reader::InfoPlist;
3use crate::rules::core::{
4    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
5};
6
7const LOCATION_KEYS: &[&str] = &[
8    "NSLocationWhenInUseUsageDescription",
9    "NSLocationAlwaysAndWhenInUseUsageDescription",
10    "NSLocationAlwaysUsageDescription",
11];
12const LSQUERY_SCHEME_LIMIT: usize = 50;
13const SUSPICIOUS_SCHEMES: &[&str] = &[
14    "app-prefs",
15    "prefs",
16    "settings",
17    "sb",
18    "sbsettings",
19    "sbprefs",
20];
21
22pub struct UsageDescriptionsRule;
23
24impl AppStoreRule for UsageDescriptionsRule {
25    fn id(&self) -> &'static str {
26        "RULE_USAGE_DESCRIPTIONS"
27    }
28
29    fn name(&self) -> &'static str {
30        "Missing Usage Description Keys"
31    }
32
33    fn category(&self) -> RuleCategory {
34        RuleCategory::Privacy
35    }
36
37    fn severity(&self) -> Severity {
38        Severity::Warning
39    }
40
41    fn recommendation(&self) -> &'static str {
42        "Add NS*UsageDescription keys required by your app's feature usage."
43    }
44
45    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
46        let Some(plist) = artifact.info_plist else {
47            return Ok(RuleReport {
48                status: RuleStatus::Skip,
49                message: Some("Info.plist not found".to_string()),
50                evidence: None,
51            });
52        };
53
54        let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
55            Ok(scan) => scan,
56            Err(err) => {
57                return Ok(RuleReport {
58                    status: RuleStatus::Skip,
59                    message: Some(format!("Usage scan skipped: {err}")),
60                    evidence: None,
61                });
62            }
63        };
64
65        if scan.required_keys.is_empty() && !scan.requires_location_key {
66            return Ok(RuleReport {
67                status: RuleStatus::Pass,
68                message: Some("No usage APIs detected".to_string()),
69                evidence: None,
70            });
71        }
72
73        let mut missing: Vec<&str> = scan
74            .required_keys
75            .iter()
76            .copied()
77            .filter(|key| !plist.has_key(key))
78            .collect();
79
80        if scan.requires_location_key && !has_any_location_key(plist) {
81            missing.push("NSLocationWhenInUseUsageDescription | NSLocationAlwaysAndWhenInUseUsageDescription | NSLocationAlwaysUsageDescription");
82        }
83
84        if missing.is_empty() {
85            return Ok(RuleReport {
86                status: RuleStatus::Pass,
87                message: None,
88                evidence: None,
89            });
90        }
91
92        Ok(RuleReport {
93            status: RuleStatus::Fail,
94            message: Some("Missing required usage description keys".to_string()),
95            evidence: Some(format!(
96                "Missing keys: {}. Evidence: {}",
97                missing.join(", "),
98                format_evidence(&scan)
99            )),
100        })
101    }
102}
103
104pub struct UsageDescriptionsValueRule;
105
106impl AppStoreRule for UsageDescriptionsValueRule {
107    fn id(&self) -> &'static str {
108        "RULE_USAGE_DESCRIPTIONS_EMPTY"
109    }
110
111    fn name(&self) -> &'static str {
112        "Empty Usage Description Values"
113    }
114
115    fn category(&self) -> RuleCategory {
116        RuleCategory::Privacy
117    }
118
119    fn severity(&self) -> Severity {
120        Severity::Warning
121    }
122
123    fn recommendation(&self) -> &'static str {
124        "Ensure NS*UsageDescription values are non-empty and user-facing."
125    }
126
127    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
128        let Some(plist) = artifact.info_plist else {
129            return Ok(RuleReport {
130                status: RuleStatus::Skip,
131                message: Some("Info.plist not found".to_string()),
132                evidence: None,
133            });
134        };
135
136        let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
137            Ok(scan) => scan,
138            Err(err) => {
139                return Ok(RuleReport {
140                    status: RuleStatus::Skip,
141                    message: Some(format!("Usage scan skipped: {err}")),
142                    evidence: None,
143                });
144            }
145        };
146
147        if scan.required_keys.is_empty() && !scan.requires_location_key {
148            return Ok(RuleReport {
149                status: RuleStatus::Pass,
150                message: Some("No usage APIs detected".to_string()),
151                evidence: None,
152            });
153        }
154
155        let mut empty: Vec<&str> = scan
156            .required_keys
157            .iter()
158            .copied()
159            .filter(|key| is_empty_string(plist, key))
160            .collect();
161
162        if scan.requires_location_key {
163            if let Some(key) = find_empty_location_key(plist) {
164                empty.push(key);
165            }
166        }
167
168        if empty.is_empty() {
169            return Ok(RuleReport {
170                status: RuleStatus::Pass,
171                message: None,
172                evidence: None,
173            });
174        }
175
176        Ok(RuleReport {
177            status: RuleStatus::Fail,
178            message: Some("Usage description values are empty".to_string()),
179            evidence: Some(format!(
180                "Empty keys: {}. Evidence: {}",
181                empty.join(", "),
182                format_evidence(&scan)
183            )),
184        })
185    }
186}
187
188pub struct InfoPlistRequiredKeysRule;
189
190impl AppStoreRule for InfoPlistRequiredKeysRule {
191    fn id(&self) -> &'static str {
192        "RULE_INFO_PLIST_REQUIRED_KEYS"
193    }
194
195    fn name(&self) -> &'static str {
196        "Missing Required Info.plist Keys"
197    }
198
199    fn category(&self) -> RuleCategory {
200        RuleCategory::Metadata
201    }
202
203    fn severity(&self) -> Severity {
204        Severity::Warning
205    }
206
207    fn recommendation(&self) -> &'static str {
208        "Add required Info.plist keys for your app's functionality."
209    }
210
211    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
212        let Some(plist) = artifact.info_plist else {
213            return Ok(RuleReport {
214                status: RuleStatus::Skip,
215                message: Some("Info.plist not found".to_string()),
216                evidence: None,
217            });
218        };
219
220        let mut missing = Vec::new();
221        if !plist.has_key("LSApplicationQueriesSchemes") {
222            missing.push("LSApplicationQueriesSchemes");
223        }
224        if !plist.has_key("UIRequiredDeviceCapabilities") {
225            missing.push("UIRequiredDeviceCapabilities");
226        }
227
228        if missing.is_empty() {
229            return Ok(RuleReport {
230                status: RuleStatus::Pass,
231                message: None,
232                evidence: None,
233            });
234        }
235
236        Ok(RuleReport {
237            status: RuleStatus::Fail,
238            message: Some("Missing required Info.plist keys".to_string()),
239            evidence: Some(format!("Missing keys: {}", missing.join(", "))),
240        })
241    }
242}
243
244pub struct InfoPlistCapabilitiesRule;
245
246impl AppStoreRule for InfoPlistCapabilitiesRule {
247    fn id(&self) -> &'static str {
248        "RULE_INFO_PLIST_CAPABILITIES_EMPTY"
249    }
250
251    fn name(&self) -> &'static str {
252        "Empty Info.plist Capability Lists"
253    }
254
255    fn category(&self) -> RuleCategory {
256        RuleCategory::Metadata
257    }
258
259    fn severity(&self) -> Severity {
260        Severity::Warning
261    }
262
263    fn recommendation(&self) -> &'static str {
264        "Remove empty arrays or populate capability keys with valid values."
265    }
266
267    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
268        let Some(plist) = artifact.info_plist else {
269            return Ok(RuleReport {
270                status: RuleStatus::Skip,
271                message: Some("Info.plist not found".to_string()),
272                evidence: None,
273            });
274        };
275
276        let mut empty = Vec::new();
277
278        if is_empty_array(plist, "LSApplicationQueriesSchemes") {
279            empty.push("LSApplicationQueriesSchemes");
280        }
281
282        if is_empty_array(plist, "UIRequiredDeviceCapabilities") {
283            empty.push("UIRequiredDeviceCapabilities");
284        }
285
286        if empty.is_empty() {
287            return Ok(RuleReport {
288                status: RuleStatus::Pass,
289                message: None,
290                evidence: None,
291            });
292        }
293
294        Ok(RuleReport {
295            status: RuleStatus::Fail,
296            message: Some("Capability keys are present but empty".to_string()),
297            evidence: Some(format!("Empty keys: {}", empty.join(", "))),
298        })
299    }
300}
301
302pub struct LSApplicationQueriesSchemesAuditRule;
303
304impl AppStoreRule for LSApplicationQueriesSchemesAuditRule {
305    fn id(&self) -> &'static str {
306        "RULE_LSAPPLICATIONQUERIES_SCHEMES_AUDIT"
307    }
308
309    fn name(&self) -> &'static str {
310        "LSApplicationQueriesSchemes Audit"
311    }
312
313    fn category(&self) -> RuleCategory {
314        RuleCategory::Metadata
315    }
316
317    fn severity(&self) -> Severity {
318        Severity::Warning
319    }
320
321    fn recommendation(&self) -> &'static str {
322        "Keep LSApplicationQueriesSchemes minimal, valid, and aligned with actual app usage."
323    }
324
325    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
326        let Some(plist) = artifact.info_plist else {
327            return Ok(RuleReport {
328                status: RuleStatus::Skip,
329                message: Some("Info.plist not found".to_string()),
330                evidence: None,
331            });
332        };
333
334        let Some(value) = plist.get_value("LSApplicationQueriesSchemes") else {
335            return Ok(RuleReport {
336                status: RuleStatus::Skip,
337                message: Some("LSApplicationQueriesSchemes not declared".to_string()),
338                evidence: None,
339            });
340        };
341
342        let Some(entries) = value.as_array() else {
343            return Ok(RuleReport {
344                status: RuleStatus::Fail,
345                message: Some("LSApplicationQueriesSchemes is not an array".to_string()),
346                evidence: None,
347            });
348        };
349
350        if entries.is_empty() {
351            return Ok(RuleReport {
352                status: RuleStatus::Skip,
353                message: Some("LSApplicationQueriesSchemes is empty".to_string()),
354                evidence: None,
355            });
356        }
357
358        let mut invalid = Vec::new();
359        let mut suspicious = Vec::new();
360        let mut normalized = std::collections::HashMap::new();
361
362        for entry in entries {
363            let Some(raw) = entry.as_string() else {
364                invalid.push("<non-string>".to_string());
365                continue;
366            };
367            let trimmed = raw.trim();
368            if trimmed.is_empty() || !is_valid_scheme(trimmed) {
369                invalid.push(raw.to_string());
370                continue;
371            }
372
373            let normalized_key = trimmed.to_ascii_lowercase();
374            *normalized.entry(normalized_key.clone()).or_insert(0usize) += 1;
375
376            if SUSPICIOUS_SCHEMES
377                .iter()
378                .any(|scheme| scheme.eq_ignore_ascii_case(&normalized_key))
379            {
380                suspicious.push(trimmed.to_string());
381            }
382        }
383
384        let mut issues = Vec::new();
385        if entries.len() > LSQUERY_SCHEME_LIMIT {
386            issues.push(format!(
387                "Contains {} schemes (limit {})",
388                entries.len(),
389                LSQUERY_SCHEME_LIMIT
390            ));
391        }
392
393        let mut duplicates: Vec<String> = normalized
394            .iter()
395            .filter_map(|(scheme, count)| {
396                if *count > 1 {
397                    Some(scheme.clone())
398                } else {
399                    None
400                }
401            })
402            .collect();
403        duplicates.sort();
404        if !duplicates.is_empty() {
405            issues.push(format!("Duplicate schemes: {}", duplicates.join(", ")));
406        }
407
408        if !invalid.is_empty() {
409            issues.push(format!("Invalid scheme entries: {}", invalid.join(", ")));
410        }
411
412        if !suspicious.is_empty() {
413            issues.push(format!(
414                "Potentially private schemes: {}",
415                unique_sorted(suspicious).join(", ")
416            ));
417        }
418
419        if issues.is_empty() {
420            return Ok(RuleReport {
421                status: RuleStatus::Pass,
422                message: Some("LSApplicationQueriesSchemes looks sane".to_string()),
423                evidence: None,
424            });
425        }
426
427        Ok(RuleReport {
428            status: RuleStatus::Fail,
429            message: Some("LSApplicationQueriesSchemes audit failed".to_string()),
430            evidence: Some(issues.join(" | ")),
431        })
432    }
433}
434
435fn is_empty_string(plist: &InfoPlist, key: &str) -> bool {
436    match plist.get_string(key) {
437        Some(value) => value.trim().is_empty(),
438        None => false,
439    }
440}
441
442fn is_empty_array(plist: &InfoPlist, key: &str) -> bool {
443    match plist.get_value(key) {
444        Some(value) => value.as_array().map(|arr| arr.is_empty()).unwrap_or(false),
445        None => false,
446    }
447}
448
449fn has_any_location_key(plist: &InfoPlist) -> bool {
450    LOCATION_KEYS.iter().any(|key| plist.has_key(key))
451}
452
453fn find_empty_location_key(plist: &InfoPlist) -> Option<&'static str> {
454    for key in LOCATION_KEYS {
455        if plist.has_key(key) && is_empty_string(plist, key) {
456            return Some(*key);
457        }
458    }
459    None
460}
461
462fn format_evidence(scan: &crate::parsers::macho_scanner::UsageScan) -> String {
463    let mut list: Vec<&str> = scan.evidence.iter().copied().collect();
464    list.sort_unstable();
465    list.join(", ")
466}
467
468fn is_valid_scheme(value: &str) -> bool {
469    let mut chars = value.chars();
470    let Some(first) = chars.next() else {
471        return false;
472    };
473
474    if !first.is_ascii_alphabetic() {
475        return false;
476    }
477
478    for ch in chars {
479        if !(ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.') {
480            return false;
481        }
482    }
483
484    true
485}
486
487fn unique_sorted(mut values: Vec<String>) -> Vec<String> {
488    values.sort();
489    values.dedup();
490    values
491}