1use crate::parsers::plist_reader::InfoPlist;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, thiserror::Error)]
6pub enum UsageScanError {
7 #[error("Failed to read executable: {0}")]
8 Io(#[from] std::io::Error),
9 #[error("Unable to resolve app executable")]
10 MissingExecutable,
11}
12
13#[derive(Debug, Default, Clone)]
14pub struct UsageScan {
15 pub required_keys: HashSet<&'static str>,
16 pub privacy_categories: HashSet<&'static str>,
17 pub requires_location_key: bool,
18 pub evidence: HashSet<&'static str>,
19}
20
21#[derive(Debug, Default, Clone)]
22pub struct CapabilityScan {
23 pub detected: HashSet<&'static str>,
24 pub evidence: HashSet<&'static str>,
25}
26
27pub fn scan_usage_from_app_bundle(app_bundle_path: &Path) -> Result<UsageScan, UsageScanError> {
28 let executable =
29 resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
30 scan_usage_from_executable(&executable)
31}
32
33#[derive(Debug, Default, Clone)]
34pub struct PrivateApiScan {
35 pub hits: Vec<&'static str>,
36}
37
38#[derive(Debug, Default, Clone)]
39pub struct SdkScan {
40 pub hits: Vec<&'static str>,
41}
42
43pub fn scan_private_api_from_app_bundle(
44 app_bundle_path: &Path,
45) -> Result<PrivateApiScan, UsageScanError> {
46 let executable =
47 resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
48 scan_private_api_from_executable(&executable)
49}
50
51pub fn scan_sdks_from_app_bundle(app_bundle_path: &Path) -> Result<SdkScan, UsageScanError> {
52 let executable =
53 resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
54 scan_sdks_from_executable(&executable)
55}
56
57pub fn scan_capabilities_from_app_bundle(
58 app_bundle_path: &Path,
59) -> Result<CapabilityScan, UsageScanError> {
60 let executable =
61 resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
62 scan_capabilities_from_executable(&executable)
63}
64
65pub fn scan_instrumentation_from_app_bundle(
66 app_bundle_path: &Path,
67) -> Result<Vec<&'static str>, UsageScanError> {
68 let executable =
69 resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
70 let bytes = std::fs::read(&executable)?;
71 let mut hits = Vec::new();
72 for signature in INSTRUMENTATION_SIGNATURES {
73 if contains_subslice(&bytes, signature.as_bytes()) {
74 hits.push(*signature);
75 }
76 }
77 Ok(hits)
78}
79
80fn resolve_executable_path(app_bundle_path: &Path) -> Option<PathBuf> {
81 let info_plist_path = app_bundle_path.join("Info.plist");
82 if info_plist_path.exists() {
83 if let Ok(info_plist) = InfoPlist::from_file(&info_plist_path) {
84 if let Some(executable) = info_plist.get_string("CFBundleExecutable") {
85 let candidate = app_bundle_path.join(executable);
86 if candidate.exists() {
87 return Some(candidate);
88 }
89 }
90 }
91 }
92
93 let app_name = app_bundle_path
94 .file_name()
95 .and_then(|n| n.to_str())
96 .unwrap_or("")
97 .trim_end_matches(".app");
98
99 if app_name.is_empty() {
100 return None;
101 }
102
103 let fallback = app_bundle_path.join(app_name);
104 fallback.exists().then_some(fallback)
105}
106
107fn scan_usage_from_executable(path: &Path) -> Result<UsageScan, UsageScanError> {
108 let bytes = std::fs::read(path)?;
109 let mut scan = UsageScan::default();
110
111 for (signature, requirement) in SIGNATURES {
112 if contains_subslice(&bytes, signature.as_bytes()) {
113 scan.evidence.insert(*signature);
114 match requirement {
115 Requirement::Key(key) => {
116 scan.required_keys.insert(*key);
117 }
118 Requirement::PrivacyCategory(cat) => {
119 scan.privacy_categories.insert(*cat);
120 }
121 Requirement::AnyLocation => {
122 scan.requires_location_key = true;
123 }
124 }
125 }
126 }
127
128 Ok(scan)
129}
130
131fn scan_private_api_from_executable(path: &Path) -> Result<PrivateApiScan, UsageScanError> {
132 let bytes = std::fs::read(path)?;
133 let mut scan = PrivateApiScan::default();
134
135 for signature in PRIVATE_API_SIGNATURES {
136 if contains_subslice(&bytes, signature.as_bytes()) {
137 scan.hits.push(*signature);
138 }
139 }
140
141 scan.hits.sort_unstable();
142 scan.hits.dedup();
143
144 Ok(scan)
145}
146
147fn scan_sdks_from_executable(path: &Path) -> Result<SdkScan, UsageScanError> {
148 let bytes = std::fs::read(path)?;
149 let mut scan = SdkScan::default();
150
151 for signature in SDK_SIGNATURES {
152 if contains_subslice(&bytes, signature.as_bytes()) {
153 scan.hits.push(*signature);
154 }
155 }
156
157 scan.hits.sort_unstable();
158 scan.hits.dedup();
159
160 Ok(scan)
161}
162
163fn scan_capabilities_from_executable(path: &Path) -> Result<CapabilityScan, UsageScanError> {
164 let bytes = std::fs::read(path)?;
165 let mut scan = CapabilityScan::default();
166
167 for (signature, capability) in CAPABILITY_SIGNATURES {
168 if contains_subslice(&bytes, signature.as_bytes()) {
169 scan.evidence.insert(*signature);
170 scan.detected.insert(*capability);
171 }
172 }
173
174 Ok(scan)
175}
176
177fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
178 haystack
179 .windows(needle.len())
180 .any(|window| window == needle)
181}
182
183#[derive(Clone, Copy)]
184enum Requirement {
185 Key(&'static str),
186 PrivacyCategory(&'static str),
187 AnyLocation,
188}
189
190const SIGNATURES: &[(&str, Requirement)] = &[
191 (
192 "AVCaptureDevice",
193 Requirement::Key("NSCameraUsageDescription"),
194 ),
195 (
196 "AVAudioSession",
197 Requirement::Key("NSMicrophoneUsageDescription"),
198 ),
199 (
200 "AVAudioRecorder",
201 Requirement::Key("NSMicrophoneUsageDescription"),
202 ),
203 (
204 "PHPhotoLibrary",
205 Requirement::Key("NSPhotoLibraryUsageDescription"),
206 ),
207 (
208 "PHPhotoLibraryAddOnly",
209 Requirement::Key("NSPhotoLibraryAddUsageDescription"),
210 ),
211 ("CLLocationManager", Requirement::AnyLocation),
212 (
213 "CBCentralManager",
214 Requirement::Key("NSBluetoothAlwaysUsageDescription"),
215 ),
216 (
217 "CBPeripheralManager",
218 Requirement::Key("NSBluetoothAlwaysUsageDescription"),
219 ),
220 (
221 "CBPeripheral",
222 Requirement::Key("NSBluetoothPeripheralUsageDescription"),
223 ),
224 ("LAContext", Requirement::Key("NSFaceIDUsageDescription")),
225 (
226 "EKEventStore",
227 Requirement::Key("NSCalendarsUsageDescription"),
228 ),
229 (
230 "EKReminder",
231 Requirement::Key("NSRemindersUsageDescription"),
232 ),
233 (
234 "CNContactStore",
235 Requirement::Key("NSContactsUsageDescription"),
236 ),
237 (
238 "SFSpeechRecognizer",
239 Requirement::Key("NSSpeechRecognitionUsageDescription"),
240 ),
241 (
242 "CMMotionManager",
243 Requirement::Key("NSMotionUsageDescription"),
244 ),
245 ("CMPedometer", Requirement::Key("NSMotionUsageDescription")),
246 (
247 "MPMediaLibrary",
248 Requirement::Key("NSAppleMusicUsageDescription"),
249 ),
250 (
251 "HKHealthStore",
252 Requirement::Key("NSHealthShareUsageDescription"),
253 ),
254 (
256 "systemBootTime",
257 Requirement::PrivacyCategory("NSPrivacyAccessedAPICategorySystemBootTime"),
258 ),
259 (
260 "diskSpace",
261 Requirement::PrivacyCategory("NSPrivacyAccessedAPICategoryDiskSpace"),
262 ),
263 (
264 "activeInputModes",
265 Requirement::PrivacyCategory("NSPrivacyAccessedAPICategoryActiveInputModes"),
266 ),
267 (
268 "userDefaults",
269 Requirement::PrivacyCategory("NSPrivacyAccessedAPICategoryUserDefaults"),
270 ),
271 (
272 "fileModificationDate",
273 Requirement::PrivacyCategory("NSPrivacyAccessedAPICategoryFileModificationDate"),
274 ),
275];
276
277const PRIVATE_API_SIGNATURES: &[&str] = &[
278 "LSApplicationWorkspace",
279 "LSApplicationProxy",
280 "LSAppWorkspace",
281 "SBApplication",
282 "SpringBoard",
283 "MobileGestalt",
284 "UICallApplication",
285 "UIGetScreenImage",
286 "_MGCopyAnswer",
287];
288
289const SDK_SIGNATURES: &[&str] = &[
290 "FirebaseApp",
291 "FIRApp",
292 "GADMobileAds",
293 "FBSDKCoreKit",
294 "FBSDKLoginKit",
295 "Amplitude",
296 "Mixpanel",
297 "Segment",
298 "SentrySDK",
299 "AppsFlyerLib",
300 "Adjust",
301];
302
303const CAPABILITY_SIGNATURES: &[(&str, &str)] = &[
304 ("AVCaptureDevice", "camera"),
305 ("CLLocationManager", "location"),
306];
307
308const INSTRUMENTATION_SIGNATURES: &[&str] = &[
309 "__llvm_profile_runtime",
310 "__llvm_prf_data",
311 "__llvm_prf_names",
312 "__llvm_prf_vnds",
313];
314
315#[cfg(test)]
316mod tests {
317 use super::{resolve_executable_path, scan_usage_from_app_bundle};
318 use plist::{Dictionary, Value};
319 use tempfile::tempdir;
320
321 #[test]
322 fn resolves_executable_from_cf_bundle_executable_before_bundle_name() {
323 let dir = tempdir().expect("temp dir");
324 let app_path = dir.path().join("CustomName.app");
325 std::fs::create_dir_all(&app_path).expect("create app dir");
326
327 let mut dict = Dictionary::new();
328 dict.insert(
329 "CFBundleExecutable".to_string(),
330 Value::String("RunnerBinary".to_string()),
331 );
332 Value::Dictionary(dict)
333 .to_file_xml(app_path.join("Info.plist"))
334 .expect("write plist");
335 std::fs::write(app_path.join("RunnerBinary"), b"plain binary").expect("write executable");
336
337 let resolved = resolve_executable_path(&app_path).expect("resolved executable");
338 assert_eq!(resolved, app_path.join("RunnerBinary"));
339 }
340
341 #[test]
342 fn usage_scan_reads_custom_executable_name_from_info_plist() {
343 let dir = tempdir().expect("temp dir");
344 let app_path = dir.path().join("CustomName.app");
345 std::fs::create_dir_all(&app_path).expect("create app dir");
346
347 let mut dict = Dictionary::new();
348 dict.insert(
349 "CFBundleExecutable".to_string(),
350 Value::String("RunnerBinary".to_string()),
351 );
352 Value::Dictionary(dict)
353 .to_file_xml(app_path.join("Info.plist"))
354 .expect("write plist");
355 std::fs::write(app_path.join("RunnerBinary"), b"AVCaptureDevice")
356 .expect("write executable");
357
358 let scan = scan_usage_from_app_bundle(&app_path).expect("usage scan");
359 assert!(scan.required_keys.contains("NSCameraUsageDescription"));
360 assert!(scan.evidence.contains("AVCaptureDevice"));
361 }
362}