verifyos_cli/rules/
extensions.rs1use crate::parsers::plist_reader::InfoPlist;
2use crate::rules::core::{
3 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
4};
5use crate::rules::entitlements::{APS_ENVIRONMENT_KEY, EXTENSION_SUBSET_ENTITLEMENT_KEYS};
6
7pub struct ExtensionEntitlementsCompatibilityRule;
8
9impl AppStoreRule for ExtensionEntitlementsCompatibilityRule {
10 fn id(&self) -> &'static str {
11 "RULE_EXTENSION_ENTITLEMENTS_COMPAT"
12 }
13
14 fn name(&self) -> &'static str {
15 "Extension Entitlements Compatibility"
16 }
17
18 fn category(&self) -> RuleCategory {
19 RuleCategory::Entitlements
20 }
21
22 fn severity(&self) -> Severity {
23 Severity::Warning
24 }
25
26 fn recommendation(&self) -> &'static str {
27 "Ensure extension entitlements are a subset of the host app and required keys exist for the extension type."
28 }
29
30 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
31 let bundles = artifact
32 .nested_bundles()
33 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
34
35 let extensions: Vec<_> = bundles
36 .into_iter()
37 .filter(|bundle| {
38 bundle
39 .bundle_path
40 .extension()
41 .and_then(|e| e.to_str())
42 .map(|ext| ext == "appex")
43 .unwrap_or(false)
44 })
45 .collect();
46
47 if extensions.is_empty() {
48 return Ok(RuleReport {
49 status: RuleStatus::Pass,
50 message: Some("No extensions found".to_string()),
51 evidence: None,
52 });
53 }
54
55 let Some(app_entitlements) = artifact.entitlements_for_bundle(artifact.app_bundle_path)?
56 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 = match artifact.bundle_info_plist(&extension.bundle_path) {
68 Ok(Some(plist)) => plist,
69 Ok(None) | Err(_) => continue,
70 };
71
72 let extension_point = plist
73 .get_dictionary("NSExtension")
74 .and_then(|dict| dict.get("NSExtensionPointIdentifier"))
75 .and_then(|value| value.as_string())
76 .unwrap_or("unknown")
77 .to_string();
78
79 let Some(ext_entitlements) =
80 artifact.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 compare_entitlements(app: &InfoPlist, ext: &InfoPlist) -> Vec<String> {
130 let mut issues = Vec::new();
131
132 for key in EXTENSION_SUBSET_ENTITLEMENT_KEYS {
133 if !ext.has_key(key) {
134 continue;
135 }
136
137 if !app.has_key(key) {
138 issues.push(format!("entitlement {key} not present in host app"));
139 continue;
140 }
141
142 if let Some(values) = ext.get_array_strings(key) {
143 let app_values = app.get_array_strings(key).unwrap_or_default();
144 let missing: Vec<String> = values
145 .into_iter()
146 .filter(|value| !app_values.iter().any(|app_value| app_value == value))
147 .collect();
148 if !missing.is_empty() {
149 issues.push(format!(
150 "entitlement {key} values not in host app: {}",
151 missing.join(", ")
152 ));
153 }
154 continue;
155 }
156
157 if let Some(value) = ext.get_string(key) {
158 if app.get_string(key) != Some(value) {
159 issues.push(format!("entitlement {key} mismatch"));
160 }
161 continue;
162 }
163
164 if let Some(value) = ext.get_bool(key) {
165 if app.get_bool(key) != Some(value) {
166 issues.push(format!("entitlement {key} mismatch"));
167 }
168 }
169 }
170
171 issues
172}
173
174fn required_entitlements_for_extension(extension_point: &str) -> &'static [&'static str] {
175 match extension_point {
176 "com.apple.widgetkit-extension" => &["com.apple.security.application-groups"],
177 "com.apple.usernotifications.service" => &[APS_ENVIRONMENT_KEY],
178 _ => &[],
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::compare_entitlements;
185 use crate::parsers::plist_reader::InfoPlist;
186 use crate::rules::entitlements::{
187 ICLOUD_CONTAINER_IDENTIFIERS_KEY, KEYCHAIN_ACCESS_GROUPS_KEY,
188 };
189 use plist::{Dictionary, Value};
190
191 #[test]
192 fn compare_entitlements_uses_shared_subset_keys() {
193 let mut app = Dictionary::new();
194 app.insert(
195 KEYCHAIN_ACCESS_GROUPS_KEY.to_string(),
196 Value::Array(vec![Value::String("group.shared".to_string())]),
197 );
198 app.insert(
199 ICLOUD_CONTAINER_IDENTIFIERS_KEY.to_string(),
200 Value::Array(vec![Value::String("iCloud.shared".to_string())]),
201 );
202
203 let mut ext = Dictionary::new();
204 ext.insert(
205 KEYCHAIN_ACCESS_GROUPS_KEY.to_string(),
206 Value::Array(vec![
207 Value::String("group.shared".to_string()),
208 Value::String("group.extra".to_string()),
209 ]),
210 );
211
212 let issues = compare_entitlements(
213 &InfoPlist::from_dictionary(app),
214 &InfoPlist::from_dictionary(ext),
215 );
216
217 assert_eq!(
218 issues,
219 vec!["entitlement keychain-access-groups values not in host app: group.extra"]
220 );
221 }
222}