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