verifyos_cli/rules/
nested_bundles.rs1use 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}