Skip to main content

verifyos_cli/rules/
core.rs

1use crate::parsers::bundle_scanner::{find_nested_bundles, BundleScanError, BundleTarget};
2use crate::parsers::macho_parser::{
3    read_macho_signature_summary, MachOError, MachOExecutable, MachOSignatureSummary,
4};
5use crate::parsers::macho_scanner::{
6    scan_capabilities_from_app_bundle, scan_private_api_from_app_bundle, scan_sdks_from_app_bundle,
7    scan_usage_from_app_bundle, CapabilityScan, PrivateApiScan, SdkScan, UsageScan, UsageScanError,
8};
9use crate::parsers::plist_reader::{InfoPlist, PlistError};
10use crate::parsers::provisioning_profile::{ProvisioningError, ProvisioningProfile};
11use miette::Diagnostic;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Mutex;
16
17pub const RULESET_VERSION: &str = "0.1.0";
18
19#[derive(Debug, thiserror::Error, Diagnostic)]
20pub enum RuleError {
21    #[error(transparent)]
22    #[diagnostic(transparent)]
23    Entitlements(#[from] crate::rules::entitlements::EntitlementsError),
24
25    #[error(transparent)]
26    #[diagnostic(transparent)]
27    Provisioning(#[from] crate::parsers::provisioning_profile::ProvisioningError),
28
29    #[error(transparent)]
30    #[diagnostic(transparent)]
31    MachO(#[from] crate::parsers::macho_parser::MachOError),
32}
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum Severity {
36    Error,
37    Warning,
38    Info,
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42pub enum RuleStatus {
43    Pass,
44    Fail,
45    Error,
46    Skip,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RuleReport {
51    pub status: RuleStatus,
52    pub message: Option<String>,
53    pub evidence: Option<String>,
54}
55
56#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
57pub struct CacheCounter {
58    pub hits: u64,
59    pub misses: u64,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
63pub struct ArtifactCacheStats {
64    pub nested_bundles: CacheCounter,
65    pub usage_scan: CacheCounter,
66    pub private_api_scan: CacheCounter,
67    pub sdk_scan: CacheCounter,
68    pub capability_scan: CacheCounter,
69    pub signature_summary: CacheCounter,
70    pub bundle_plist: CacheCounter,
71    pub entitlements: CacheCounter,
72    pub provisioning_profile: CacheCounter,
73    pub bundle_files: CacheCounter,
74    pub instrumentation_scan: CacheCounter,
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
78pub enum RuleCategory {
79    Privacy,
80    Signing,
81    Bundling,
82    Entitlements,
83    Ats,
84    ThirdParty,
85    Permissions,
86    Metadata,
87    Other,
88}
89
90// Stub for now. Will hold the path to the app and the parsed Info.plist
91pub struct ArtifactContext<'a> {
92    pub app_bundle_path: &'a std::path::Path,
93    pub info_plist: Option<&'a crate::parsers::plist_reader::InfoPlist>,
94    nested_bundles_cache: Mutex<Option<Vec<BundleTarget>>>,
95    usage_scan_cache: Mutex<Option<UsageScan>>,
96    private_api_scan_cache: Mutex<Option<PrivateApiScan>>,
97    sdk_scan_cache: Mutex<Option<SdkScan>>,
98    capability_scan_cache: Mutex<Option<CapabilityScan>>,
99    signature_summary_cache: Mutex<HashMap<PathBuf, MachOSignatureSummary>>,
100    bundle_plist_cache: Mutex<HashMap<PathBuf, Option<InfoPlist>>>,
101    entitlements_cache: Mutex<HashMap<PathBuf, Option<InfoPlist>>>,
102    provisioning_profile_cache: Mutex<HashMap<PathBuf, Option<ProvisioningProfile>>>,
103    bundle_file_cache: Mutex<Option<Vec<PathBuf>>>,
104    instrumentation_scan_cache: Mutex<Option<Vec<&'static str>>>,
105    pub xcode_project: Option<&'a crate::parsers::xcode_parser::XcodeProject>,
106    cache_stats: Mutex<ArtifactCacheStats>,
107}
108
109impl<'a> ArtifactContext<'a> {
110    pub fn new(
111        app_bundle_path: &'a Path,
112        info_plist: Option<&'a crate::parsers::plist_reader::InfoPlist>,
113        xcode_project: Option<&'a crate::parsers::xcode_parser::XcodeProject>,
114    ) -> Self {
115        Self {
116            app_bundle_path,
117            info_plist,
118            nested_bundles_cache: Mutex::new(None),
119            usage_scan_cache: Mutex::new(None),
120            private_api_scan_cache: Mutex::new(None),
121            sdk_scan_cache: Mutex::new(None),
122            capability_scan_cache: Mutex::new(None),
123            signature_summary_cache: Mutex::new(HashMap::new()),
124            bundle_plist_cache: Mutex::new(HashMap::new()),
125            entitlements_cache: Mutex::new(HashMap::new()),
126            provisioning_profile_cache: Mutex::new(HashMap::new()),
127            bundle_file_cache: Mutex::new(None),
128            instrumentation_scan_cache: Mutex::new(None),
129            xcode_project,
130            cache_stats: Mutex::new(ArtifactCacheStats::default()),
131        }
132    }
133
134    pub fn nested_bundles(&self) -> Result<Vec<BundleTarget>, BundleScanError> {
135        if let Some(bundles) = self.nested_bundles_cache.lock().unwrap().as_ref() {
136            self.cache_stats.lock().unwrap().nested_bundles.hits += 1;
137            return Ok(bundles.clone());
138        }
139
140        self.cache_stats.lock().unwrap().nested_bundles.misses += 1;
141        let bundles = find_nested_bundles(self.app_bundle_path)?;
142        *self.nested_bundles_cache.lock().unwrap() = Some(bundles.clone());
143        Ok(bundles)
144    }
145
146    pub fn usage_scan(&self) -> Result<UsageScan, UsageScanError> {
147        if let Some(scan) = self.usage_scan_cache.lock().unwrap().as_ref() {
148            self.cache_stats.lock().unwrap().usage_scan.hits += 1;
149            return Ok(scan.clone());
150        }
151
152        self.cache_stats.lock().unwrap().usage_scan.misses += 1;
153        let scan = scan_usage_from_app_bundle(self.app_bundle_path)?;
154        *self.usage_scan_cache.lock().unwrap() = Some(scan.clone());
155        Ok(scan)
156    }
157
158    pub fn private_api_scan(&self) -> Result<PrivateApiScan, UsageScanError> {
159        if let Some(scan) = self.private_api_scan_cache.lock().unwrap().as_ref() {
160            self.cache_stats.lock().unwrap().private_api_scan.hits += 1;
161            return Ok(scan.clone());
162        }
163
164        self.cache_stats.lock().unwrap().private_api_scan.misses += 1;
165        let scan = scan_private_api_from_app_bundle(self.app_bundle_path)?;
166        *self.private_api_scan_cache.lock().unwrap() = Some(scan.clone());
167        Ok(scan)
168    }
169
170    pub fn sdk_scan(&self) -> Result<SdkScan, UsageScanError> {
171        if let Some(scan) = self.sdk_scan_cache.lock().unwrap().as_ref() {
172            self.cache_stats.lock().unwrap().sdk_scan.hits += 1;
173            return Ok(scan.clone());
174        }
175
176        self.cache_stats.lock().unwrap().sdk_scan.misses += 1;
177        let scan = scan_sdks_from_app_bundle(self.app_bundle_path)?;
178        *self.sdk_scan_cache.lock().unwrap() = Some(scan.clone());
179        Ok(scan)
180    }
181
182    pub fn capability_scan(&self) -> Result<CapabilityScan, UsageScanError> {
183        if let Some(scan) = self.capability_scan_cache.lock().unwrap().as_ref() {
184            self.cache_stats.lock().unwrap().capability_scan.hits += 1;
185            return Ok(scan.clone());
186        }
187
188        self.cache_stats.lock().unwrap().capability_scan.misses += 1;
189        let scan = scan_capabilities_from_app_bundle(self.app_bundle_path)?;
190        *self.capability_scan_cache.lock().unwrap() = Some(scan.clone());
191        Ok(scan)
192    }
193
194    pub fn instrumentation_scan(&self) -> Result<Vec<&'static str>, UsageScanError> {
195        if let Some(hits) = self.instrumentation_scan_cache.lock().unwrap().as_ref() {
196            self.cache_stats.lock().unwrap().instrumentation_scan.hits += 1;
197            return Ok(hits.clone());
198        }
199
200        self.cache_stats.lock().unwrap().instrumentation_scan.misses += 1;
201        let hits = crate::parsers::macho_scanner::scan_instrumentation_from_app_bundle(
202            self.app_bundle_path,
203        )?;
204        *self.instrumentation_scan_cache.lock().unwrap() = Some(hits.clone());
205        Ok(hits)
206    }
207
208    pub fn signature_summary(
209        &self,
210        executable_path: impl AsRef<Path>,
211    ) -> Result<MachOSignatureSummary, MachOError> {
212        let executable_path = executable_path.as_ref().to_path_buf();
213        if let Some(summary) = self
214            .signature_summary_cache
215            .lock()
216            .unwrap()
217            .get(&executable_path)
218        {
219            self.cache_stats.lock().unwrap().signature_summary.hits += 1;
220            return Ok(summary.clone());
221        }
222
223        self.cache_stats.lock().unwrap().signature_summary.misses += 1;
224        let summary = read_macho_signature_summary(&executable_path)?;
225        self.signature_summary_cache
226            .lock()
227            .unwrap()
228            .insert(executable_path, summary.clone());
229        Ok(summary)
230    }
231
232    pub fn executable_path_for_bundle(&self, bundle_path: &Path) -> Option<PathBuf> {
233        if let Ok(Some(plist)) = self.bundle_info_plist(bundle_path) {
234            if let Some(executable) = plist.get_string("CFBundleExecutable") {
235                let candidate = bundle_path.join(executable);
236                if candidate.exists() {
237                    return Some(candidate);
238                }
239            }
240        }
241
242        resolve_bundle_executable_path(bundle_path)
243    }
244
245    pub fn bundle_info_plist(&self, bundle_path: &Path) -> Result<Option<InfoPlist>, PlistError> {
246        if let Some(plist) = self.bundle_plist_cache.lock().unwrap().get(bundle_path) {
247            self.cache_stats.lock().unwrap().bundle_plist.hits += 1;
248            return Ok(plist.clone());
249        }
250
251        self.cache_stats.lock().unwrap().bundle_plist.misses += 1;
252        let plist_path = bundle_path.join("Info.plist");
253        let plist = if plist_path.exists() {
254            Some(InfoPlist::from_file(&plist_path)?)
255        } else {
256            None
257        };
258
259        self.bundle_plist_cache
260            .lock()
261            .unwrap()
262            .insert(bundle_path.to_path_buf(), plist.clone());
263        Ok(plist)
264    }
265
266    pub fn entitlements_for_bundle(
267        &self,
268        bundle_path: &Path,
269    ) -> Result<Option<InfoPlist>, RuleError> {
270        let executable_path = match self.executable_path_for_bundle(bundle_path) {
271            Some(path) => path,
272            None => return Ok(None),
273        };
274
275        if let Some(entitlements) = self
276            .entitlements_cache
277            .lock()
278            .unwrap()
279            .get(&executable_path)
280        {
281            self.cache_stats.lock().unwrap().entitlements.hits += 1;
282            return Ok(entitlements.clone());
283        }
284
285        self.cache_stats.lock().unwrap().entitlements.misses += 1;
286        let macho = MachOExecutable::from_file(&executable_path)
287            .map_err(crate::rules::entitlements::EntitlementsError::MachO)
288            .map_err(RuleError::Entitlements)?;
289        let entitlements = match macho.entitlements {
290            Some(entitlements_xml) => {
291                let plist = InfoPlist::from_bytes(entitlements_xml.as_bytes())
292                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
293                Some(plist)
294            }
295            None => None,
296        };
297
298        self.entitlements_cache
299            .lock()
300            .unwrap()
301            .insert(executable_path, entitlements.clone());
302        Ok(entitlements)
303    }
304
305    pub fn provisioning_profile_for_bundle(
306        &self,
307        bundle_path: &Path,
308    ) -> Result<Option<ProvisioningProfile>, ProvisioningError> {
309        let provisioning_path = bundle_path.join("embedded.mobileprovision");
310        if let Some(profile) = self
311            .provisioning_profile_cache
312            .lock()
313            .unwrap()
314            .get(&provisioning_path)
315        {
316            self.cache_stats.lock().unwrap().provisioning_profile.hits += 1;
317            return Ok(profile.clone());
318        }
319
320        self.cache_stats.lock().unwrap().provisioning_profile.misses += 1;
321        let profile = if provisioning_path.exists() {
322            Some(ProvisioningProfile::from_embedded_file(&provisioning_path)?)
323        } else {
324            None
325        };
326
327        self.provisioning_profile_cache
328            .lock()
329            .unwrap()
330            .insert(provisioning_path, profile.clone());
331        Ok(profile)
332    }
333
334    pub fn bundle_file_paths(&self) -> Vec<PathBuf> {
335        if let Some(paths) = self.bundle_file_cache.lock().unwrap().as_ref() {
336            self.cache_stats.lock().unwrap().bundle_files.hits += 1;
337            return paths.clone();
338        }
339
340        self.cache_stats.lock().unwrap().bundle_files.misses += 1;
341        let mut files = Vec::new();
342        collect_bundle_files(self.app_bundle_path, &mut files);
343        *self.bundle_file_cache.lock().unwrap() = Some(files.clone());
344        files
345    }
346
347    pub fn bundle_relative_file(&self, relative_path: &str) -> Option<PathBuf> {
348        self.bundle_file_paths().into_iter().find(|path| {
349            path.strip_prefix(self.app_bundle_path)
350                .ok()
351                .map(|rel| rel == Path::new(relative_path))
352                .unwrap_or(false)
353        })
354    }
355
356    pub fn cache_stats(&self) -> ArtifactCacheStats {
357        self.cache_stats.lock().unwrap().clone()
358    }
359}
360
361fn resolve_bundle_executable_path(bundle_path: &Path) -> Option<PathBuf> {
362    let bundle_name = bundle_path
363        .file_name()
364        .and_then(|n| n.to_str())
365        .unwrap_or("")
366        .trim_end_matches(".app")
367        .trim_end_matches(".appex")
368        .trim_end_matches(".framework");
369
370    if bundle_name.is_empty() {
371        return None;
372    }
373
374    let fallback = bundle_path.join(bundle_name);
375    if fallback.exists() {
376        Some(fallback)
377    } else {
378        None
379    }
380}
381
382fn collect_bundle_files(root: &Path, files: &mut Vec<PathBuf>) {
383    let entries = match std::fs::read_dir(root) {
384        Ok(entries) => entries,
385        Err(_) => return,
386    };
387
388    for entry in entries.flatten() {
389        let path = entry.path();
390        if path.is_dir() {
391            collect_bundle_files(&path, files);
392        } else {
393            files.push(path);
394        }
395    }
396}
397
398pub trait AppStoreRule: Send + Sync {
399    fn id(&self) -> &'static str;
400    fn name(&self) -> &'static str;
401    fn category(&self) -> RuleCategory;
402    fn severity(&self) -> Severity;
403    fn recommendation(&self) -> &'static str;
404    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError>;
405}