verifyos_cli/rules/
extensions.rs1use crate::parsers::plist_reader::InfoPlist;
2use crate::rules::core::{
3 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
4};
5
6pub struct ExtensionEntitlementsCompatibilityRule;
7
8impl AppStoreRule for ExtensionEntitlementsCompatibilityRule {
9 fn id(&self) -> &'static str {
10 "RULE_EXTENSION_ENTITLEMENTS_COMPAT"
11 }
12
13 fn name(&self) -> &'static str {
14 "Extension Entitlements Compatibility"
15 }
16
17 fn category(&self) -> RuleCategory {
18 RuleCategory::Entitlements
19 }
20
21 fn severity(&self) -> Severity {
22 Severity::Warning
23 }
24
25 fn recommendation(&self) -> &'static str {
26 "Ensure extension entitlements are a subset of the host app and required keys exist for the extension type."
27 }
28
29 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
30 let bundles = artifact
31 .nested_bundles()
32 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
33
34 let extensions: Vec<_> = bundles
35 .into_iter()
36 .filter(|bundle| {
37 bundle
38 .bundle_path
39 .extension()
40 .and_then(|e| e.to_str())
41 .map(|ext| ext == "appex")
42 .unwrap_or(false)
43 })
44 .collect();
45
46 if extensions.is_empty() {
47 return Ok(RuleReport {
48 status: RuleStatus::Pass,
49 message: Some("No extensions found".to_string()),
50 evidence: None,
51 });
52 }
53
54 let Some(app_entitlements) = artifact.entitlements_for_bundle(artifact.app_bundle_path)?
55 else {
56 return Ok(RuleReport {
57 status: RuleStatus::Skip,
58 message: Some("Host app entitlements not found".to_string()),
59 evidence: None,
60 });
61 };
62
63 let mut issues = Vec::new();
64
65 for extension in extensions {
66 let plist = match artifact.bundle_info_plist(&extension.bundle_path) {
67 Ok(Some(plist)) => plist,
68 Ok(None) | Err(_) => continue,
69 };
70
71 let extension_point = plist
72 .get_dictionary("NSExtension")
73 .and_then(|dict| dict.get("NSExtensionPointIdentifier"))
74 .and_then(|value| value.as_string())
75 .unwrap_or("unknown")
76 .to_string();
77
78 let Some(ext_entitlements) =
79 artifact.entitlements_for_bundle(&extension.bundle_path)?
80 else {
81 continue;
82 };
83
84 let subset_issues = compare_entitlements(&app_entitlements, &ext_entitlements);
85 for issue in subset_issues {
86 issues.push(format!("{}: {}", extension.display_name, issue));
87 }
88
89 let required = required_entitlements_for_extension(&extension_point);
90 for requirement in required {
91 if !ext_entitlements.has_key(requirement) {
92 issues.push(format!(
93 "{}: missing entitlement {} for {}",
94 extension.display_name, requirement, extension_point
95 ));
96 continue;
97 }
98 if *requirement == "com.apple.security.application-groups" {
99 let groups = ext_entitlements
100 .get_array_strings(requirement)
101 .unwrap_or_default();
102 if groups.is_empty() {
103 issues.push(format!(
104 "{}: empty entitlement {} for {}",
105 extension.display_name, requirement, extension_point
106 ));
107 }
108 }
109 }
110 }
111
112 if issues.is_empty() {
113 return Ok(RuleReport {
114 status: RuleStatus::Pass,
115 message: Some("Extension entitlements are compatible".to_string()),
116 evidence: None,
117 });
118 }
119
120 Ok(RuleReport {
121 status: RuleStatus::Fail,
122 message: Some("Extension entitlements mismatch".to_string()),
123 evidence: Some(issues.join(" | ")),
124 })
125 }
126}
127
128fn compare_entitlements(app: &InfoPlist, ext: &InfoPlist) -> Vec<String> {
129 let mut issues = Vec::new();
130
131 for key in [
132 "aps-environment",
133 "keychain-access-groups",
134 "com.apple.security.application-groups",
135 "com.apple.developer.icloud-container-identifiers",
136 "com.apple.developer.icloud-services",
137 "com.apple.developer.associated-domains",
138 "com.apple.developer.in-app-payments",
139 "com.apple.developer.ubiquity-kvstore-identifier",
140 "com.apple.developer.ubiquity-container-identifiers",
141 "com.apple.developer.networking.wifi-info",
142 ] {
143 if !ext.has_key(key) {
144 continue;
145 }
146
147 if !app.has_key(key) {
148 issues.push(format!("entitlement {key} not present in host app"));
149 continue;
150 }
151
152 if let Some(values) = ext.get_array_strings(key) {
153 let app_values = app.get_array_strings(key).unwrap_or_default();
154 let missing: Vec<String> = values
155 .into_iter()
156 .filter(|value| !app_values.iter().any(|app_value| app_value == value))
157 .collect();
158 if !missing.is_empty() {
159 issues.push(format!(
160 "entitlement {key} values not in host app: {}",
161 missing.join(", ")
162 ));
163 }
164 continue;
165 }
166
167 if let Some(value) = ext.get_string(key) {
168 if app.get_string(key) != Some(value) {
169 issues.push(format!("entitlement {key} mismatch"));
170 }
171 continue;
172 }
173
174 if let Some(value) = ext.get_bool(key) {
175 if app.get_bool(key) != Some(value) {
176 issues.push(format!("entitlement {key} mismatch"));
177 }
178 }
179 }
180
181 issues
182}
183
184fn required_entitlements_for_extension(extension_point: &str) -> &'static [&'static str] {
185 match extension_point {
186 "com.apple.widgetkit-extension" => &["com.apple.security.application-groups"],
187 "com.apple.usernotifications.service" => &["aps-environment"],
188 _ => &[],
189 }
190}