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}