verifyos_cli/rules/
entitlements.rs1use 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}