Skip to main content

verifyos_cli/rules/
entitlements.rs

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