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