Skip to main content

verifyos_cli/rules/
extensions.rs

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