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::cell::RefCell;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
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}
75
76#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
77pub enum RuleCategory {
78    Privacy,
79    Signing,
80    Bundling,
81    Entitlements,
82    Ats,
83    ThirdParty,
84    Permissions,
85    Metadata,
86    Other,
87}
88
89// Stub for now. Will hold the path to the app and the parsed Info.plist
90pub struct ArtifactContext<'a> {
91    pub app_bundle_path: &'a std::path::Path,
92    pub info_plist: Option<&'a crate::parsers::plist_reader::InfoPlist>,
93    nested_bundles_cache: RefCell<Option<Vec<BundleTarget>>>,
94    usage_scan_cache: RefCell<Option<UsageScan>>,
95    private_api_scan_cache: RefCell<Option<PrivateApiScan>>,
96    sdk_scan_cache: RefCell<Option<SdkScan>>,
97    capability_scan_cache: RefCell<Option<CapabilityScan>>,
98    signature_summary_cache: RefCell<HashMap<PathBuf, MachOSignatureSummary>>,
99    bundle_plist_cache: RefCell<HashMap<PathBuf, Option<InfoPlist>>>,
100    entitlements_cache: RefCell<HashMap<PathBuf, Option<InfoPlist>>>,
101    provisioning_profile_cache: RefCell<HashMap<PathBuf, Option<ProvisioningProfile>>>,
102    bundle_file_cache: RefCell<Option<Vec<PathBuf>>>,
103    pub xcode_project: Option<&'a crate::parsers::xcode_parser::XcodeProject>,
104    cache_stats: RefCell<ArtifactCacheStats>,
105}
106
107impl<'a> ArtifactContext<'a> {
108    pub fn new(
109        app_bundle_path: &'a Path,
110        info_plist: Option<&'a crate::parsers::plist_reader::InfoPlist>,
111        xcode_project: Option<&'a crate::parsers::xcode_parser::XcodeProject>,
112    ) -> Self {
113        Self {
114            app_bundle_path,
115            info_plist,
116            nested_bundles_cache: RefCell::new(None),
117            usage_scan_cache: RefCell::new(None),
118            private_api_scan_cache: RefCell::new(None),
119            sdk_scan_cache: RefCell::new(None),
120            capability_scan_cache: RefCell::new(None),
121            signature_summary_cache: RefCell::new(HashMap::new()),
122            bundle_plist_cache: RefCell::new(HashMap::new()),
123            entitlements_cache: RefCell::new(HashMap::new()),
124            provisioning_profile_cache: RefCell::new(HashMap::new()),
125            bundle_file_cache: RefCell::new(None),
126            xcode_project,
127            cache_stats: RefCell::new(ArtifactCacheStats::default()),
128        }
129    }
130
131    pub fn nested_bundles(&self) -> Result<Vec<BundleTarget>, BundleScanError> {
132        if let Some(bundles) = self.nested_bundles_cache.borrow().as_ref() {
133            self.cache_stats.borrow_mut().nested_bundles.hits += 1;
134            return Ok(bundles.clone());
135        }
136
137        self.cache_stats.borrow_mut().nested_bundles.misses += 1;
138        let bundles = find_nested_bundles(self.app_bundle_path)?;
139        *self.nested_bundles_cache.borrow_mut() = Some(bundles.clone());
140        Ok(bundles)
141    }
142
143    pub fn usage_scan(&self) -> Result<UsageScan, UsageScanError> {
144        if let Some(scan) = self.usage_scan_cache.borrow().as_ref() {
145            self.cache_stats.borrow_mut().usage_scan.hits += 1;
146            return Ok(scan.clone());
147        }
148
149        self.cache_stats.borrow_mut().usage_scan.misses += 1;
150        let scan = scan_usage_from_app_bundle(self.app_bundle_path)?;
151        *self.usage_scan_cache.borrow_mut() = Some(scan.clone());
152        Ok(scan)
153    }
154
155    pub fn private_api_scan(&self) -> Result<PrivateApiScan, UsageScanError> {
156        if let Some(scan) = self.private_api_scan_cache.borrow().as_ref() {
157            self.cache_stats.borrow_mut().private_api_scan.hits += 1;
158            return Ok(scan.clone());
159        }
160
161        self.cache_stats.borrow_mut().private_api_scan.misses += 1;
162        let scan = scan_private_api_from_app_bundle(self.app_bundle_path)?;
163        *self.private_api_scan_cache.borrow_mut() = Some(scan.clone());
164        Ok(scan)
165    }
166
167    pub fn sdk_scan(&self) -> Result<SdkScan, UsageScanError> {
168        if let Some(scan) = self.sdk_scan_cache.borrow().as_ref() {
169            self.cache_stats.borrow_mut().sdk_scan.hits += 1;
170            return Ok(scan.clone());
171        }
172
173        self.cache_stats.borrow_mut().sdk_scan.misses += 1;
174        let scan = scan_sdks_from_app_bundle(self.app_bundle_path)?;
175        *self.sdk_scan_cache.borrow_mut() = Some(scan.clone());
176        Ok(scan)
177    }
178
179    pub fn capability_scan(&self) -> Result<CapabilityScan, UsageScanError> {
180        if let Some(scan) = self.capability_scan_cache.borrow().as_ref() {
181            self.cache_stats.borrow_mut().capability_scan.hits += 1;
182            return Ok(scan.clone());
183        }
184
185        self.cache_stats.borrow_mut().capability_scan.misses += 1;
186        let scan = scan_capabilities_from_app_bundle(self.app_bundle_path)?;
187        *self.capability_scan_cache.borrow_mut() = Some(scan.clone());
188        Ok(scan)
189    }
190
191    pub fn signature_summary(
192        &self,
193        executable_path: impl AsRef<Path>,
194    ) -> Result<MachOSignatureSummary, MachOError> {
195        let executable_path = executable_path.as_ref().to_path_buf();
196        if let Some(summary) = self.signature_summary_cache.borrow().get(&executable_path) {
197            self.cache_stats.borrow_mut().signature_summary.hits += 1;
198            return Ok(summary.clone());
199        }
200
201        self.cache_stats.borrow_mut().signature_summary.misses += 1;
202        let summary = read_macho_signature_summary(&executable_path)?;
203        self.signature_summary_cache
204            .borrow_mut()
205            .insert(executable_path, summary.clone());
206        Ok(summary)
207    }
208
209    pub fn executable_path_for_bundle(&self, bundle_path: &Path) -> Option<PathBuf> {
210        if let Ok(Some(plist)) = self.bundle_info_plist(bundle_path) {
211            if let Some(executable) = plist.get_string("CFBundleExecutable") {
212                let candidate = bundle_path.join(executable);
213                if candidate.exists() {
214                    return Some(candidate);
215                }
216            }
217        }
218
219        resolve_bundle_executable_path(bundle_path)
220    }
221
222    pub fn bundle_info_plist(&self, bundle_path: &Path) -> Result<Option<InfoPlist>, PlistError> {
223        if let Some(plist) = self.bundle_plist_cache.borrow().get(bundle_path) {
224            self.cache_stats.borrow_mut().bundle_plist.hits += 1;
225            return Ok(plist.clone());
226        }
227
228        self.cache_stats.borrow_mut().bundle_plist.misses += 1;
229        let plist_path = bundle_path.join("Info.plist");
230        let plist = if plist_path.exists() {
231            Some(InfoPlist::from_file(&plist_path)?)
232        } else {
233            None
234        };
235
236        self.bundle_plist_cache
237            .borrow_mut()
238            .insert(bundle_path.to_path_buf(), plist.clone());
239        Ok(plist)
240    }
241
242    pub fn entitlements_for_bundle(
243        &self,
244        bundle_path: &Path,
245    ) -> Result<Option<InfoPlist>, RuleError> {
246        let executable_path = match self.executable_path_for_bundle(bundle_path) {
247            Some(path) => path,
248            None => return Ok(None),
249        };
250
251        if let Some(entitlements) = self.entitlements_cache.borrow().get(&executable_path) {
252            self.cache_stats.borrow_mut().entitlements.hits += 1;
253            return Ok(entitlements.clone());
254        }
255
256        self.cache_stats.borrow_mut().entitlements.misses += 1;
257        let macho = MachOExecutable::from_file(&executable_path)
258            .map_err(crate::rules::entitlements::EntitlementsError::MachO)
259            .map_err(RuleError::Entitlements)?;
260        let entitlements = match macho.entitlements {
261            Some(entitlements_xml) => {
262                let plist = InfoPlist::from_bytes(entitlements_xml.as_bytes())
263                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
264                Some(plist)
265            }
266            None => None,
267        };
268
269        self.entitlements_cache
270            .borrow_mut()
271            .insert(executable_path, entitlements.clone());
272        Ok(entitlements)
273    }
274
275    pub fn provisioning_profile_for_bundle(
276        &self,
277        bundle_path: &Path,
278    ) -> Result<Option<ProvisioningProfile>, ProvisioningError> {
279        let provisioning_path = bundle_path.join("embedded.mobileprovision");
280        if let Some(profile) = self
281            .provisioning_profile_cache
282            .borrow()
283            .get(&provisioning_path)
284        {
285            self.cache_stats.borrow_mut().provisioning_profile.hits += 1;
286            return Ok(profile.clone());
287        }
288
289        self.cache_stats.borrow_mut().provisioning_profile.misses += 1;
290        let profile = if provisioning_path.exists() {
291            Some(ProvisioningProfile::from_embedded_file(&provisioning_path)?)
292        } else {
293            None
294        };
295
296        self.provisioning_profile_cache
297            .borrow_mut()
298            .insert(provisioning_path, profile.clone());
299        Ok(profile)
300    }
301
302    pub fn bundle_file_paths(&self) -> Vec<PathBuf> {
303        if let Some(paths) = self.bundle_file_cache.borrow().as_ref() {
304            self.cache_stats.borrow_mut().bundle_files.hits += 1;
305            return paths.clone();
306        }
307
308        self.cache_stats.borrow_mut().bundle_files.misses += 1;
309        let mut files = Vec::new();
310        collect_bundle_files(self.app_bundle_path, &mut files);
311        *self.bundle_file_cache.borrow_mut() = Some(files.clone());
312        files
313    }
314
315    pub fn bundle_relative_file(&self, relative_path: &str) -> Option<PathBuf> {
316        self.bundle_file_paths().into_iter().find(|path| {
317            path.strip_prefix(self.app_bundle_path)
318                .ok()
319                .map(|rel| rel == Path::new(relative_path))
320                .unwrap_or(false)
321        })
322    }
323
324    pub fn cache_stats(&self) -> ArtifactCacheStats {
325        self.cache_stats.borrow().clone()
326    }
327}
328
329fn resolve_bundle_executable_path(bundle_path: &Path) -> Option<PathBuf> {
330    let bundle_name = bundle_path
331        .file_name()
332        .and_then(|n| n.to_str())
333        .unwrap_or("")
334        .trim_end_matches(".app")
335        .trim_end_matches(".appex")
336        .trim_end_matches(".framework");
337
338    if bundle_name.is_empty() {
339        return None;
340    }
341
342    let fallback = bundle_path.join(bundle_name);
343    if fallback.exists() {
344        Some(fallback)
345    } else {
346        None
347    }
348}
349
350fn collect_bundle_files(root: &Path, files: &mut Vec<PathBuf>) {
351    let entries = match std::fs::read_dir(root) {
352        Ok(entries) => entries,
353        Err(_) => return,
354    };
355
356    for entry in entries.flatten() {
357        let path = entry.path();
358        if path.is_dir() {
359            collect_bundle_files(&path, files);
360        } else {
361            files.push(path);
362        }
363    }
364}
365
366pub trait AppStoreRule: Send + Sync {
367    fn id(&self) -> &'static str;
368    fn name(&self) -> &'static str;
369    fn category(&self) -> RuleCategory;
370    fn severity(&self) -> Severity;
371    fn recommendation(&self) -> &'static str;
372    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError>;
373}