Skip to main content

verifyos_cli/rules/
signing.rs

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