Skip to main content

verifyos_cli/rules/
signing.rs

1use 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}