verifyos_cli/rules/
entitlements.rs1use 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 let plist = crate::parsers::plist_reader::InfoPlist::from_bytes(
69 entitlements_xml.as_bytes(),
70 )
71 .map_err(|_| EntitlementsError::ParseFailure)?;
72
73 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}