verifyos_cli/rules/
extensions.rs1use crate::parsers::bundle_scanner::find_nested_bundles;
2use crate::parsers::macho_parser::MachOExecutable;
3use crate::parsers::plist_reader::InfoPlist;
4use crate::rules::core::{
5 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
6};
7use std::path::Path;
8
9pub struct ExtensionEntitlementsCompatibilityRule;
10
11impl AppStoreRule for ExtensionEntitlementsCompatibilityRule {
12 fn id(&self) -> &'static str {
13 "RULE_EXTENSION_ENTITLEMENTS_COMPAT"
14 }
15
16 fn name(&self) -> &'static str {
17 "Extension Entitlements Compatibility"
18 }
19
20 fn category(&self) -> RuleCategory {
21 RuleCategory::Entitlements
22 }
23
24 fn severity(&self) -> Severity {
25 Severity::Warning
26 }
27
28 fn recommendation(&self) -> &'static str {
29 "Ensure extension entitlements are a subset of the host app and required keys exist for the extension type."
30 }
31
32 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
33 let bundles = find_nested_bundles(artifact.app_bundle_path)
34 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
35
36 let extensions: Vec<_> = bundles
37 .into_iter()
38 .filter(|bundle| {
39 bundle
40 .bundle_path
41 .extension()
42 .and_then(|e| e.to_str())
43 .map(|ext| ext == "appex")
44 .unwrap_or(false)
45 })
46 .collect();
47
48 if extensions.is_empty() {
49 return Ok(RuleReport {
50 status: RuleStatus::Pass,
51 message: Some("No extensions found".to_string()),
52 evidence: None,
53 });
54 }
55
56 let Some(app_entitlements) = load_entitlements_plist(artifact.app_bundle_path)? else {
57 return Ok(RuleReport {
58 status: RuleStatus::Skip,
59 message: Some("Host app entitlements not found".to_string()),
60 evidence: None,
61 });
62 };
63
64 let mut issues = Vec::new();
65
66 for extension in extensions {
67 let plist_path = extension.bundle_path.join("Info.plist");
68 let plist = match InfoPlist::from_file(&plist_path) {
69 Ok(plist) => plist,
70 Err(_) => continue,
71 };
72
73 let extension_point = plist
74 .get_dictionary("NSExtension")
75 .and_then(|dict| dict.get("NSExtensionPointIdentifier"))
76 .and_then(|value| value.as_string())
77 .unwrap_or("unknown")
78 .to_string();
79
80 let Some(ext_entitlements) = load_entitlements_for_bundle(&extension.bundle_path)?
81 else {
82 continue;
83 };
84
85 let subset_issues = compare_entitlements(&app_entitlements, &ext_entitlements);
86 for issue in subset_issues {
87 issues.push(format!("{}: {}", extension.display_name, issue));
88 }
89
90 let required = required_entitlements_for_extension(&extension_point);
91 for requirement in required {
92 if !ext_entitlements.has_key(requirement) {
93 issues.push(format!(
94 "{}: missing entitlement {} for {}",
95 extension.display_name, requirement, extension_point
96 ));
97 continue;
98 }
99 if *requirement == "com.apple.security.application-groups" {
100 let groups = ext_entitlements
101 .get_array_strings(requirement)
102 .unwrap_or_default();
103 if groups.is_empty() {
104 issues.push(format!(
105 "{}: empty entitlement {} for {}",
106 extension.display_name, requirement, extension_point
107 ));
108 }
109 }
110 }
111 }
112
113 if issues.is_empty() {
114 return Ok(RuleReport {
115 status: RuleStatus::Pass,
116 message: Some("Extension entitlements are compatible".to_string()),
117 evidence: None,
118 });
119 }
120
121 Ok(RuleReport {
122 status: RuleStatus::Fail,
123 message: Some("Extension entitlements mismatch".to_string()),
124 evidence: Some(issues.join(" | ")),
125 })
126 }
127}
128
129fn load_entitlements_plist(app_bundle_path: &Path) -> Result<Option<InfoPlist>, RuleError> {
130 let app_name = app_bundle_path
131 .file_name()
132 .and_then(|n| n.to_str())
133 .unwrap_or("")
134 .trim_end_matches(".app");
135
136 if app_name.is_empty() {
137 return Ok(None);
138 }
139
140 let executable_path = app_bundle_path.join(app_name);
141 if !executable_path.exists() {
142 return Ok(None);
143 }
144
145 let macho = MachOExecutable::from_file(&executable_path)
146 .map_err(crate::rules::entitlements::EntitlementsError::MachO)
147 .map_err(RuleError::Entitlements)?;
148 let Some(entitlements_xml) = macho.entitlements else {
149 return Ok(None);
150 };
151
152 let plist = InfoPlist::from_bytes(entitlements_xml.as_bytes())
153 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
154
155 Ok(Some(plist))
156}
157
158fn load_entitlements_for_bundle(bundle_path: &Path) -> Result<Option<InfoPlist>, RuleError> {
159 let bundle_name = bundle_path
160 .file_name()
161 .and_then(|n| n.to_str())
162 .unwrap_or("")
163 .trim_end_matches(".appex")
164 .trim_end_matches(".app");
165
166 if bundle_name.is_empty() {
167 return Ok(None);
168 }
169
170 let executable_path = bundle_path.join(bundle_name);
171 if !executable_path.exists() {
172 return Ok(None);
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 return Ok(None);
180 };
181
182 let plist = InfoPlist::from_bytes(entitlements_xml.as_bytes())
183 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
184
185 Ok(Some(plist))
186}
187
188fn compare_entitlements(app: &InfoPlist, ext: &InfoPlist) -> Vec<String> {
189 let mut issues = Vec::new();
190
191 for key in [
192 "aps-environment",
193 "keychain-access-groups",
194 "com.apple.security.application-groups",
195 "com.apple.developer.icloud-container-identifiers",
196 "com.apple.developer.icloud-services",
197 "com.apple.developer.associated-domains",
198 "com.apple.developer.in-app-payments",
199 "com.apple.developer.ubiquity-kvstore-identifier",
200 "com.apple.developer.ubiquity-container-identifiers",
201 "com.apple.developer.networking.wifi-info",
202 ] {
203 if !ext.has_key(key) {
204 continue;
205 }
206
207 if !app.has_key(key) {
208 issues.push(format!("entitlement {key} not present in host app"));
209 continue;
210 }
211
212 if let Some(values) = ext.get_array_strings(key) {
213 let app_values = app.get_array_strings(key).unwrap_or_default();
214 let missing: Vec<String> = values
215 .into_iter()
216 .filter(|value| !app_values.contains(value))
217 .collect();
218 if !missing.is_empty() {
219 issues.push(format!(
220 "entitlement {key} values not in host app: {}",
221 missing.join(", ")
222 ));
223 }
224 continue;
225 }
226
227 if let Some(value) = ext.get_string(key) {
228 if app.get_string(key) != Some(value) {
229 issues.push(format!("entitlement {key} mismatch"));
230 }
231 continue;
232 }
233
234 if let Some(value) = ext.get_bool(key) {
235 if app.get_bool(key) != Some(value) {
236 issues.push(format!("entitlement {key} mismatch"));
237 }
238 }
239 }
240
241 issues
242}
243
244fn required_entitlements_for_extension(extension_point: &str) -> &'static [&'static str] {
245 match extension_point {
246 "com.apple.widgetkit-extension" => &["com.apple.security.application-groups"],
247 "com.apple.usernotifications.service" => &["aps-environment"],
248 _ => &[],
249 }
250}