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];
12
13pub struct UsageDescriptionsRule;
14
15impl AppStoreRule for UsageDescriptionsRule {
16    fn id(&self) -> &'static str {
17        "RULE_USAGE_DESCRIPTIONS"
18    }
19
20    fn name(&self) -> &'static str {
21        "Missing Usage Description Keys"
22    }
23
24    fn category(&self) -> RuleCategory {
25        RuleCategory::Privacy
26    }
27
28    fn severity(&self) -> Severity {
29        Severity::Warning
30    }
31
32    fn recommendation(&self) -> &'static str {
33        "Add NS*UsageDescription keys required by your app's feature usage."
34    }
35
36    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
37        let Some(plist) = artifact.info_plist else {
38            return Ok(RuleReport {
39                status: RuleStatus::Skip,
40                message: Some("Info.plist not found".to_string()),
41                evidence: None,
42            });
43        };
44
45        let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
46            Ok(scan) => scan,
47            Err(err) => {
48                return Ok(RuleReport {
49                    status: RuleStatus::Skip,
50                    message: Some(format!("Usage scan skipped: {err}")),
51                    evidence: None,
52                });
53            }
54        };
55
56        if scan.required_keys.is_empty() && !scan.requires_location_key {
57            return Ok(RuleReport {
58                status: RuleStatus::Pass,
59                message: Some("No usage APIs detected".to_string()),
60                evidence: None,
61            });
62        }
63
64        let mut missing: Vec<&str> = scan
65            .required_keys
66            .iter()
67            .copied()
68            .filter(|key| !plist.has_key(key))
69            .collect();
70
71        if scan.requires_location_key && !has_any_location_key(plist) {
72            missing.push("NSLocationWhenInUseUsageDescription | NSLocationAlwaysAndWhenInUseUsageDescription | NSLocationAlwaysUsageDescription");
73        }
74
75        if missing.is_empty() {
76            return Ok(RuleReport {
77                status: RuleStatus::Pass,
78                message: None,
79                evidence: None,
80            });
81        }
82
83        Ok(RuleReport {
84            status: RuleStatus::Fail,
85            message: Some("Missing required usage description keys".to_string()),
86            evidence: Some(format!(
87                "Missing keys: {}. Evidence: {}",
88                missing.join(", "),
89                format_evidence(&scan)
90            )),
91        })
92    }
93}
94
95pub struct UsageDescriptionsValueRule;
96
97impl AppStoreRule for UsageDescriptionsValueRule {
98    fn id(&self) -> &'static str {
99        "RULE_USAGE_DESCRIPTIONS_EMPTY"
100    }
101
102    fn name(&self) -> &'static str {
103        "Empty Usage Description Values"
104    }
105
106    fn category(&self) -> RuleCategory {
107        RuleCategory::Privacy
108    }
109
110    fn severity(&self) -> Severity {
111        Severity::Warning
112    }
113
114    fn recommendation(&self) -> &'static str {
115        "Ensure NS*UsageDescription values are non-empty and user-facing."
116    }
117
118    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
119        let Some(plist) = artifact.info_plist else {
120            return Ok(RuleReport {
121                status: RuleStatus::Skip,
122                message: Some("Info.plist not found".to_string()),
123                evidence: None,
124            });
125        };
126
127        let scan = match scan_usage_from_app_bundle(artifact.app_bundle_path) {
128            Ok(scan) => scan,
129            Err(err) => {
130                return Ok(RuleReport {
131                    status: RuleStatus::Skip,
132                    message: Some(format!("Usage scan skipped: {err}")),
133                    evidence: None,
134                });
135            }
136        };
137
138        if scan.required_keys.is_empty() && !scan.requires_location_key {
139            return Ok(RuleReport {
140                status: RuleStatus::Pass,
141                message: Some("No usage APIs detected".to_string()),
142                evidence: None,
143            });
144        }
145
146        let mut empty: Vec<&str> = scan
147            .required_keys
148            .iter()
149            .copied()
150            .filter(|key| is_empty_string(plist, key))
151            .collect();
152
153        if scan.requires_location_key {
154            if let Some(key) = find_empty_location_key(plist) {
155                empty.push(key);
156            }
157        }
158
159        if empty.is_empty() {
160            return Ok(RuleReport {
161                status: RuleStatus::Pass,
162                message: None,
163                evidence: None,
164            });
165        }
166
167        Ok(RuleReport {
168            status: RuleStatus::Fail,
169            message: Some("Usage description values are empty".to_string()),
170            evidence: Some(format!(
171                "Empty keys: {}. Evidence: {}",
172                empty.join(", "),
173                format_evidence(&scan)
174            )),
175        })
176    }
177}
178
179pub struct InfoPlistRequiredKeysRule;
180
181impl AppStoreRule for InfoPlistRequiredKeysRule {
182    fn id(&self) -> &'static str {
183        "RULE_INFO_PLIST_REQUIRED_KEYS"
184    }
185
186    fn name(&self) -> &'static str {
187        "Missing Required Info.plist Keys"
188    }
189
190    fn category(&self) -> RuleCategory {
191        RuleCategory::Metadata
192    }
193
194    fn severity(&self) -> Severity {
195        Severity::Warning
196    }
197
198    fn recommendation(&self) -> &'static str {
199        "Add required Info.plist keys for your app's functionality."
200    }
201
202    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
203        let Some(plist) = artifact.info_plist else {
204            return Ok(RuleReport {
205                status: RuleStatus::Skip,
206                message: Some("Info.plist not found".to_string()),
207                evidence: None,
208            });
209        };
210
211        let mut missing = Vec::new();
212        if !plist.has_key("LSApplicationQueriesSchemes") {
213            missing.push("LSApplicationQueriesSchemes");
214        }
215        if !plist.has_key("UIRequiredDeviceCapabilities") {
216            missing.push("UIRequiredDeviceCapabilities");
217        }
218
219        if missing.is_empty() {
220            return Ok(RuleReport {
221                status: RuleStatus::Pass,
222                message: None,
223                evidence: None,
224            });
225        }
226
227        Ok(RuleReport {
228            status: RuleStatus::Fail,
229            message: Some("Missing required Info.plist keys".to_string()),
230            evidence: Some(format!("Missing keys: {}", missing.join(", "))),
231        })
232    }
233}
234
235pub struct InfoPlistCapabilitiesRule;
236
237impl AppStoreRule for InfoPlistCapabilitiesRule {
238    fn id(&self) -> &'static str {
239        "RULE_INFO_PLIST_CAPABILITIES_EMPTY"
240    }
241
242    fn name(&self) -> &'static str {
243        "Empty Info.plist Capability Lists"
244    }
245
246    fn category(&self) -> RuleCategory {
247        RuleCategory::Metadata
248    }
249
250    fn severity(&self) -> Severity {
251        Severity::Warning
252    }
253
254    fn recommendation(&self) -> &'static str {
255        "Remove empty arrays or populate capability keys with valid values."
256    }
257
258    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
259        let Some(plist) = artifact.info_plist else {
260            return Ok(RuleReport {
261                status: RuleStatus::Skip,
262                message: Some("Info.plist not found".to_string()),
263                evidence: None,
264            });
265        };
266
267        let mut empty = Vec::new();
268
269        if is_empty_array(plist, "LSApplicationQueriesSchemes") {
270            empty.push("LSApplicationQueriesSchemes");
271        }
272
273        if is_empty_array(plist, "UIRequiredDeviceCapabilities") {
274            empty.push("UIRequiredDeviceCapabilities");
275        }
276
277        if empty.is_empty() {
278            return Ok(RuleReport {
279                status: RuleStatus::Pass,
280                message: None,
281                evidence: None,
282            });
283        }
284
285        Ok(RuleReport {
286            status: RuleStatus::Fail,
287            message: Some("Capability keys are present but empty".to_string()),
288            evidence: Some(format!("Empty keys: {}", empty.join(", "))),
289        })
290    }
291}
292
293fn is_empty_string(plist: &InfoPlist, key: &str) -> bool {
294    match plist.get_string(key) {
295        Some(value) => value.trim().is_empty(),
296        None => false,
297    }
298}
299
300fn is_empty_array(plist: &InfoPlist, key: &str) -> bool {
301    match plist.get_value(key) {
302        Some(value) => value.as_array().map(|arr| arr.is_empty()).unwrap_or(false),
303        None => false,
304    }
305}
306
307fn has_any_location_key(plist: &InfoPlist) -> bool {
308    LOCATION_KEYS.iter().any(|key| plist.has_key(key))
309}
310
311fn find_empty_location_key(plist: &InfoPlist) -> Option<&'static str> {
312    for key in LOCATION_KEYS {
313        if plist.has_key(key) && is_empty_string(plist, key) {
314            return Some(*key);
315        }
316    }
317    None
318}
319
320fn format_evidence(scan: &crate::parsers::macho_scanner::UsageScan) -> String {
321    let mut list: Vec<&str> = scan.evidence.iter().copied().collect();
322    list.sort_unstable();
323    list.join(", ")
324}