verifyos_cli/rules/
signing.rs1use crate::parsers::bundle_scanner::find_nested_bundles;
2use crate::parsers::macho_parser::read_macho_signature_summary;
3use crate::parsers::plist_reader::InfoPlist;
4use crate::rules::core::{
5 AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
6};
7use std::path::{Path, PathBuf};
8
9pub struct EmbeddedCodeSignatureTeamRule;
10
11impl AppStoreRule for EmbeddedCodeSignatureTeamRule {
12 fn id(&self) -> &'static str {
13 "RULE_EMBEDDED_TEAM_ID_MISMATCH"
14 }
15
16 fn name(&self) -> &'static str {
17 "Embedded Team ID Mismatch"
18 }
19
20 fn category(&self) -> RuleCategory {
21 RuleCategory::Signing
22 }
23
24 fn severity(&self) -> Severity {
25 Severity::Error
26 }
27
28 fn recommendation(&self) -> &'static str {
29 "Ensure all embedded frameworks/extensions are signed with the same Team ID as the app binary."
30 }
31
32 fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
33 let info_plist = match artifact.info_plist {
34 Some(plist) => plist,
35 None => {
36 let plist_path = artifact.app_bundle_path.join("Info.plist");
37 if !plist_path.exists() {
38 return Ok(RuleReport {
39 status: RuleStatus::Skip,
40 message: Some("Info.plist not found".to_string()),
41 evidence: None,
42 });
43 }
44 match InfoPlist::from_file(&plist_path) {
45 Ok(plist) => return evaluate_with_plist(artifact, &plist),
46 Err(err) => {
47 return Ok(RuleReport {
48 status: RuleStatus::Skip,
49 message: Some(format!("Failed to parse Info.plist: {err}")),
50 evidence: Some(plist_path.display().to_string()),
51 })
52 }
53 }
54 }
55 };
56
57 evaluate_with_plist(artifact, info_plist)
58 }
59}
60
61fn evaluate_with_plist(
62 artifact: &ArtifactContext,
63 info_plist: &InfoPlist,
64) -> Result<RuleReport, RuleError> {
65 let Some(app_executable) = info_plist.get_string("CFBundleExecutable") else {
66 return Ok(RuleReport {
67 status: RuleStatus::Skip,
68 message: Some("CFBundleExecutable not found".to_string()),
69 evidence: None,
70 });
71 };
72
73 let app_executable_path = artifact.app_bundle_path.join(app_executable);
74 if !app_executable_path.exists() {
75 return Ok(RuleReport {
76 status: RuleStatus::Skip,
77 message: Some("App executable not found".to_string()),
78 evidence: Some(app_executable_path.display().to_string()),
79 });
80 }
81
82 let app_summary =
83 read_macho_signature_summary(&app_executable_path).map_err(RuleError::MachO)?;
84
85 if app_summary.total_slices == 0 {
86 return Ok(RuleReport {
87 status: RuleStatus::Skip,
88 message: Some("No Mach-O slices found".to_string()),
89 evidence: Some(app_executable_path.display().to_string()),
90 });
91 }
92
93 if app_summary.signed_slices == 0 {
94 return Ok(RuleReport {
95 status: RuleStatus::Fail,
96 message: Some("App executable missing code signature".to_string()),
97 evidence: Some(app_executable_path.display().to_string()),
98 });
99 }
100
101 if app_summary.signed_slices < app_summary.total_slices {
102 return Ok(RuleReport {
103 status: RuleStatus::Fail,
104 message: Some("App executable has unsigned slices".to_string()),
105 evidence: Some(app_executable_path.display().to_string()),
106 });
107 }
108
109 let Some(app_team_id) = app_summary.team_id else {
110 return Ok(RuleReport {
111 status: RuleStatus::Fail,
112 message: Some("App executable missing Team ID".to_string()),
113 evidence: Some(app_executable_path.display().to_string()),
114 });
115 };
116
117 let bundles = find_nested_bundles(artifact.app_bundle_path)
118 .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
119
120 if bundles.is_empty() {
121 return Ok(RuleReport {
122 status: RuleStatus::Pass,
123 message: Some("No embedded bundles found".to_string()),
124 evidence: None,
125 });
126 }
127
128 let mut mismatches = Vec::new();
129
130 for bundle in bundles {
131 let Some(executable_path) = resolve_bundle_executable(&bundle.bundle_path) else {
132 mismatches.push(format!(
133 "{}: Missing CFBundleExecutable",
134 bundle.display_name
135 ));
136 continue;
137 };
138
139 if !executable_path.exists() {
140 mismatches.push(format!(
141 "{}: Executable not found at {}",
142 bundle.display_name,
143 executable_path.display()
144 ));
145 continue;
146 }
147
148 let summary = read_macho_signature_summary(&executable_path).map_err(RuleError::MachO)?;
149
150 if summary.total_slices == 0 {
151 mismatches.push(format!("{}: No Mach-O slices found", bundle.display_name));
152 continue;
153 }
154
155 if summary.signed_slices == 0 {
156 mismatches.push(format!("{}: Missing code signature", bundle.display_name));
157 continue;
158 }
159
160 if summary.signed_slices < summary.total_slices {
161 mismatches.push(format!("{}: Unsigned Mach-O slices", bundle.display_name));
162 continue;
163 }
164
165 let Some(team_id) = summary.team_id else {
166 mismatches.push(format!("{}: Missing Team ID", bundle.display_name));
167 continue;
168 };
169
170 if team_id != app_team_id {
171 mismatches.push(format!(
172 "{}: Team ID mismatch ({} != {})",
173 bundle.display_name, team_id, app_team_id
174 ));
175 }
176 }
177
178 if mismatches.is_empty() {
179 return Ok(RuleReport {
180 status: RuleStatus::Pass,
181 message: Some("Embedded bundles share the same Team ID".to_string()),
182 evidence: None,
183 });
184 }
185
186 Ok(RuleReport {
187 status: RuleStatus::Fail,
188 message: Some("Embedded bundle signing mismatch".to_string()),
189 evidence: Some(mismatches.join(" | ")),
190 })
191}
192
193fn resolve_bundle_executable(bundle_path: &Path) -> Option<PathBuf> {
194 let plist_path = bundle_path.join("Info.plist");
195 if plist_path.exists() {
196 if let Ok(plist) = InfoPlist::from_file(&plist_path) {
197 if let Some(executable) = plist.get_string("CFBundleExecutable") {
198 let candidate = bundle_path.join(executable);
199 if candidate.exists() {
200 return Some(candidate);
201 }
202 }
203 }
204 }
205
206 let bundle_name = bundle_path
207 .file_name()
208 .and_then(|n| n.to_str())
209 .unwrap_or("")
210 .trim_end_matches(".app")
211 .trim_end_matches(".appex")
212 .trim_end_matches(".framework");
213
214 if bundle_name.is_empty() {
215 return None;
216 }
217
218 let fallback = bundle_path.join(bundle_name);
219 if fallback.exists() {
220 Some(fallback)
221 } else {
222 None
223 }
224}