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