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};
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}