Skip to main content

verifyos_cli/rules/
nested_bundles.rs

1use crate::parsers::bundle_scanner::find_nested_bundles;
2use crate::parsers::macho_parser::MachOExecutable;
3use crate::parsers::provisioning_profile::ProvisioningProfile;
4use crate::rules::core::{
5    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
6};
7
8pub struct NestedBundleEntitlementsRule;
9
10impl AppStoreRule for NestedBundleEntitlementsRule {
11    fn id(&self) -> &'static str {
12        "RULE_NESTED_ENTITLEMENTS_MISMATCH"
13    }
14
15    fn name(&self) -> &'static str {
16        "Nested Bundle Entitlements Mismatch"
17    }
18
19    fn category(&self) -> RuleCategory {
20        RuleCategory::Entitlements
21    }
22
23    fn severity(&self) -> Severity {
24        Severity::Error
25    }
26
27    fn recommendation(&self) -> &'static str {
28        "Ensure embedded bundles have entitlements matching their embedded provisioning profiles."
29    }
30
31    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
32        let bundles = find_nested_bundles(artifact.app_bundle_path)
33            .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
34
35        if bundles.is_empty() {
36            return Ok(RuleReport {
37                status: RuleStatus::Pass,
38                message: Some("No nested bundles found".to_string()),
39                evidence: None,
40            });
41        }
42
43        let mut mismatches = Vec::new();
44
45        for bundle in bundles {
46            let executable_path = resolve_executable_path(&bundle.bundle_path);
47            let Some(executable_path) = executable_path else {
48                continue;
49            };
50
51            let provisioning_path = bundle.bundle_path.join("embedded.mobileprovision");
52            if !provisioning_path.exists() {
53                continue;
54            }
55
56            let macho = MachOExecutable::from_file(&executable_path)
57                .map_err(crate::rules::entitlements::EntitlementsError::MachO)
58                .map_err(RuleError::Entitlements)?;
59            let Some(entitlements_xml) = macho.entitlements else {
60                continue;
61            };
62
63            let entitlements =
64                crate::parsers::plist_reader::InfoPlist::from_bytes(entitlements_xml.as_bytes())
65                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
66
67            let profile = ProvisioningProfile::from_embedded_file(&provisioning_path)
68                .map_err(RuleError::Provisioning)?;
69
70            let mut local_mismatches = Vec::new();
71
72            if let Some(app_aps) = entitlements.get_string("aps-environment") {
73                match profile.entitlements.get_string("aps-environment") {
74                    Some(prov_aps) if prov_aps != app_aps => local_mismatches.push(format!(
75                        "aps-environment app={} profile={}",
76                        app_aps, prov_aps
77                    )),
78                    None => local_mismatches.push("aps-environment missing in profile".to_string()),
79                    _ => {}
80                }
81            }
82
83            let keychain_diff = diff_string_array(
84                &entitlements,
85                &profile.entitlements,
86                "keychain-access-groups",
87            );
88            if !keychain_diff.is_empty() {
89                local_mismatches.push(format!(
90                    "keychain-access-groups missing in profile: {}",
91                    keychain_diff.join(", ")
92                ));
93            }
94
95            let icloud_diff = diff_string_array(
96                &entitlements,
97                &profile.entitlements,
98                "com.apple.developer.icloud-container-identifiers",
99            );
100            if !icloud_diff.is_empty() {
101                local_mismatches.push(format!(
102                    "iCloud containers missing in profile: {}",
103                    icloud_diff.join(", ")
104                ));
105            }
106
107            if !local_mismatches.is_empty() {
108                mismatches.push(format!(
109                    "{}: {}",
110                    bundle.display_name,
111                    local_mismatches.join("; ")
112                ));
113            }
114        }
115
116        if mismatches.is_empty() {
117            return Ok(RuleReport {
118                status: RuleStatus::Pass,
119                message: Some("Nested bundle entitlements match profiles".to_string()),
120                evidence: None,
121            });
122        }
123
124        Ok(RuleReport {
125            status: RuleStatus::Fail,
126            message: Some("Nested bundle entitlements mismatch".to_string()),
127            evidence: Some(mismatches.join(" | ")),
128        })
129    }
130}
131
132pub struct NestedBundleDebugEntitlementRule;
133
134impl AppStoreRule for NestedBundleDebugEntitlementRule {
135    fn id(&self) -> &'static str {
136        "RULE_NESTED_DEBUG_ENTITLEMENT"
137    }
138
139    fn name(&self) -> &'static str {
140        "Nested Bundle Debug Entitlement"
141    }
142
143    fn category(&self) -> RuleCategory {
144        RuleCategory::Entitlements
145    }
146
147    fn severity(&self) -> Severity {
148        Severity::Error
149    }
150
151    fn recommendation(&self) -> &'static str {
152        "Remove get-task-allow from embedded frameworks/extensions."
153    }
154
155    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
156        let bundles = find_nested_bundles(artifact.app_bundle_path)
157            .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
158
159        if bundles.is_empty() {
160            return Ok(RuleReport {
161                status: RuleStatus::Pass,
162                message: Some("No nested bundles found".to_string()),
163                evidence: None,
164            });
165        }
166
167        let mut offenders = Vec::new();
168
169        for bundle in bundles {
170            let executable_path = resolve_executable_path(&bundle.bundle_path);
171            let Some(executable_path) = executable_path else {
172                continue;
173            };
174
175            let macho = MachOExecutable::from_file(&executable_path)
176                .map_err(crate::rules::entitlements::EntitlementsError::MachO)
177                .map_err(RuleError::Entitlements)?;
178            let Some(entitlements_xml) = macho.entitlements else {
179                continue;
180            };
181
182            let entitlements =
183                crate::parsers::plist_reader::InfoPlist::from_bytes(entitlements_xml.as_bytes())
184                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
185
186            if let Some(true) = entitlements.get_bool("get-task-allow") {
187                offenders.push(bundle.display_name);
188            }
189        }
190
191        if offenders.is_empty() {
192            return Ok(RuleReport {
193                status: RuleStatus::Pass,
194                message: Some("No debug entitlements in nested bundles".to_string()),
195                evidence: None,
196            });
197        }
198
199        Ok(RuleReport {
200            status: RuleStatus::Fail,
201            message: Some("Debug entitlements found in nested bundles".to_string()),
202            evidence: Some(offenders.join(", ")),
203        })
204    }
205}
206
207fn resolve_executable_path(bundle_path: &std::path::Path) -> Option<std::path::PathBuf> {
208    let bundle_name = bundle_path
209        .file_name()
210        .and_then(|n| n.to_str())
211        .unwrap_or("")
212        .trim_end_matches(".app")
213        .trim_end_matches(".appex")
214        .trim_end_matches(".framework");
215
216    if bundle_name.is_empty() {
217        return None;
218    }
219
220    let executable_path = bundle_path.join(bundle_name);
221    if executable_path.exists() {
222        Some(executable_path)
223    } else {
224        None
225    }
226}
227
228fn diff_string_array(
229    entitlements: &crate::parsers::plist_reader::InfoPlist,
230    profile: &crate::parsers::plist_reader::InfoPlist,
231    key: &str,
232) -> Vec<String> {
233    let app_values = entitlements.get_array_strings(key).unwrap_or_default();
234    let profile_values = profile.get_array_strings(key).unwrap_or_default();
235
236    app_values
237        .into_iter()
238        .filter(|value| !profile_values.contains(value))
239        .collect()
240}