Skip to main content

verifyos_cli/rules/
extensions.rs

1use 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}