Skip to main content

verifyos_cli/rules/
entitlements.rs

1use crate::parsers::plist_reader::InfoPlist;
2use crate::rules::core::{
3    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
4};
5
6pub(crate) const APS_ENVIRONMENT_KEY: &str = "aps-environment";
7pub(crate) const KEYCHAIN_ACCESS_GROUPS_KEY: &str = "keychain-access-groups";
8pub(crate) const ICLOUD_CONTAINER_IDENTIFIERS_KEY: &str =
9    "com.apple.developer.icloud-container-identifiers";
10pub(crate) const EXTENSION_SUBSET_ENTITLEMENT_KEYS: &[&str] = &[
11    APS_ENVIRONMENT_KEY,
12    KEYCHAIN_ACCESS_GROUPS_KEY,
13    "com.apple.security.application-groups",
14    ICLOUD_CONTAINER_IDENTIFIERS_KEY,
15    "com.apple.developer.icloud-services",
16    "com.apple.developer.associated-domains",
17    "com.apple.developer.in-app-payments",
18    "com.apple.developer.ubiquity-kvstore-identifier",
19    "com.apple.developer.ubiquity-container-identifiers",
20    "com.apple.developer.networking.wifi-info",
21];
22
23#[derive(Debug, thiserror::Error, miette::Diagnostic)]
24pub enum EntitlementsError {
25    #[error("Failed to parse Mach-O executable for entitlements")]
26    #[diagnostic(
27        code(verifyos::entitlements::parse_failure),
28        help("The executable could not be parsed as a valid Mach-O binary.")
29    )]
30    ParseFailure,
31
32    #[error("App contains `get-task-allow` entitlement")]
33    #[diagnostic(
34        code(verifyos::entitlements::debug_build),
35        help("The `get-task-allow` entitlement is present and set to true. This indicates a debug build which will be rejected by the App Store.")
36    )]
37    DebugEntitlement,
38
39    #[error("Mach-O Parsing Error: {0}")]
40    #[diagnostic(code(verifyos::entitlements::macho_error))]
41    MachO(#[from] crate::parsers::macho_parser::MachOError),
42}
43
44pub struct EntitlementsMismatchRule;
45
46impl AppStoreRule for EntitlementsMismatchRule {
47    fn id(&self) -> &'static str {
48        "RULE_ENTITLEMENTS_MISMATCH"
49    }
50
51    fn name(&self) -> &'static str {
52        "Debug Entitlements Present"
53    }
54
55    fn category(&self) -> RuleCategory {
56        RuleCategory::Entitlements
57    }
58
59    fn severity(&self) -> Severity {
60        Severity::Error
61    }
62
63    fn recommendation(&self) -> &'static str {
64        "Remove the get-task-allow entitlement for App Store builds."
65    }
66
67    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
68        if let Some(plist) = artifact.entitlements_for_bundle(artifact.app_bundle_path)? {
69            if let Some(true) = plist.get_bool("get-task-allow") {
70                return Ok(RuleReport {
71                    status: RuleStatus::Fail,
72                    message: Some("get-task-allow entitlement is true".to_string()),
73                    evidence: Some("Entitlements plist has get-task-allow=true".to_string()),
74                });
75            }
76        }
77
78        Ok(RuleReport {
79            status: RuleStatus::Pass,
80            message: None,
81            evidence: None,
82        })
83    }
84}
85
86pub struct EntitlementsProvisioningMismatchRule;
87
88impl AppStoreRule for EntitlementsProvisioningMismatchRule {
89    fn id(&self) -> &'static str {
90        "RULE_ENTITLEMENTS_PROVISIONING_MISMATCH"
91    }
92
93    fn name(&self) -> &'static str {
94        "Entitlements vs Provisioning Mismatch"
95    }
96
97    fn category(&self) -> RuleCategory {
98        RuleCategory::Entitlements
99    }
100
101    fn severity(&self) -> Severity {
102        Severity::Error
103    }
104
105    fn recommendation(&self) -> &'static str {
106        "Ensure entitlements in the app match the embedded provisioning profile."
107    }
108
109    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
110        let Some(entitlements) = load_entitlements_plist(artifact)? else {
111            return Ok(RuleReport {
112                status: RuleStatus::Skip,
113                message: Some("No entitlements found".to_string()),
114                evidence: None,
115            });
116        };
117
118        let Some(profile) = artifact
119            .provisioning_profile_for_bundle(artifact.app_bundle_path)
120            .map_err(RuleError::Provisioning)?
121        else {
122            let provisioning_path = artifact.app_bundle_path.join("embedded.mobileprovision");
123            return Ok(RuleReport {
124                status: RuleStatus::Skip,
125                message: Some("embedded.mobileprovision not found".to_string()),
126                evidence: Some(provisioning_path.display().to_string()),
127            });
128        };
129        let provisioning_entitlements = profile.entitlements;
130
131        let mut mismatches = Vec::new();
132
133        if let Some(app_aps) = entitlements.get_string(APS_ENVIRONMENT_KEY) {
134            match provisioning_entitlements.get_string(APS_ENVIRONMENT_KEY) {
135                Some(prov_aps) if prov_aps != app_aps => mismatches.push(format!(
136                    "aps-environment: app={} profile={}",
137                    app_aps, prov_aps
138                )),
139                None => mismatches.push("aps-environment missing in profile".to_string()),
140                _ => {}
141            }
142        }
143
144        let keychain_diff = diff_string_array(
145            &entitlements,
146            &provisioning_entitlements,
147            KEYCHAIN_ACCESS_GROUPS_KEY,
148        );
149        if !keychain_diff.is_empty() {
150            mismatches.push(format!(
151                "keychain-access-groups missing in profile: {}",
152                keychain_diff.join(", ")
153            ));
154        }
155
156        let icloud_diff = diff_string_array(
157            &entitlements,
158            &provisioning_entitlements,
159            ICLOUD_CONTAINER_IDENTIFIERS_KEY,
160        );
161        if !icloud_diff.is_empty() {
162            mismatches.push(format!(
163                "iCloud containers missing in profile: {}",
164                icloud_diff.join(", ")
165            ));
166        }
167
168        if mismatches.is_empty() {
169            return Ok(RuleReport {
170                status: RuleStatus::Pass,
171                message: None,
172                evidence: None,
173            });
174        }
175
176        Ok(RuleReport {
177            status: RuleStatus::Fail,
178            message: Some("Provisioning profile mismatch".to_string()),
179            evidence: Some(mismatches.join("; ")),
180        })
181    }
182}
183
184fn load_entitlements_plist(artifact: &ArtifactContext) -> Result<Option<InfoPlist>, RuleError> {
185    artifact.entitlements_for_bundle(artifact.app_bundle_path)
186}
187
188pub(crate) fn diff_string_array(
189    entitlements: &InfoPlist,
190    profile: &InfoPlist,
191    key: &str,
192) -> Vec<String> {
193    let app_values = entitlements.get_array_strings(key).unwrap_or_default();
194    let profile_values = profile.get_array_strings(key).unwrap_or_default();
195
196    app_values
197        .into_iter()
198        .filter(|value| !profile_values.contains(value))
199        .collect()
200}