1use std::process;
2
3use anyhow::{Context, Result, bail};
4
5use libverify_core::assessment::{
6 AssessmentReport, BatchEntry, BatchReport, SkippedEntry, VerificationResult,
7};
8use libverify_core::evidence::EvidenceState;
9use libverify_core::profile::GateDecision;
10use libverify_core::registry::ControlRegistry;
11use libverify_policy::OpaProfile;
12
13use crate::adapter;
14use crate::client::GitHubClient;
15use crate::dependency;
16use crate::graphql::{self, PrData};
17use crate::types::{CombinedStatusResponse, CommitStatusItem};
18
19pub fn verify_pr(
21 client: &GitHubClient,
22 owner: &str,
23 repo: &str,
24 pr_number: u32,
25 policy: Option<&str>,
26 with_evidence: bool,
27) -> Result<VerificationResult> {
28 let pr_data =
29 graphql::fetch_pr(client, owner, repo, pr_number).context("failed to fetch PR data")?;
30 assess_from_pr_data(
31 client,
32 &pr_data,
33 owner,
34 repo,
35 pr_number,
36 policy,
37 with_evidence,
38 )
39}
40
41fn assess_from_pr_data(
42 client: &GitHubClient,
43 pr_data: &PrData,
44 owner: &str,
45 repo: &str,
46 pr_number: u32,
47 policy: Option<&str>,
48 with_evidence: bool,
49) -> Result<VerificationResult> {
50 let posture =
51 crate::posture::collect_repository_posture(client, owner, repo, &pr_data.metadata.head.sha);
52 assess_from_pr_data_with_posture(
53 client,
54 pr_data,
55 owner,
56 repo,
57 pr_number,
58 policy,
59 with_evidence,
60 posture,
61 )
62}
63
64#[allow(clippy::too_many_arguments)]
65fn assess_from_pr_data_with_posture(
66 client: &GitHubClient,
67 pr_data: &PrData,
68 owner: &str,
69 repo: &str,
70 pr_number: u32,
71 policy: Option<&str>,
72 with_evidence: bool,
73 repository_posture: EvidenceState<libverify_core::evidence::RepositoryPosture>,
74) -> Result<VerificationResult> {
75 let repo_full = format!("{owner}/{repo}");
76 let mut bundle = adapter::build_pull_request_bundle(
77 &repo_full,
78 pr_number,
79 &pr_data.metadata,
80 &pr_data.files,
81 &pr_data.reviews,
82 &pr_data.commits,
83 );
84
85 let combined_status = if pr_data.commit_statuses.is_empty() {
86 None
87 } else {
88 Some(CombinedStatusResponse {
89 state: String::new(),
90 statuses: pr_data
91 .commit_statuses
92 .iter()
93 .map(|s| CommitStatusItem {
94 context: s.context.clone(),
95 state: s.state.clone(),
96 })
97 .collect(),
98 })
99 };
100 let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
101 bundle.check_runs = EvidenceState::complete(evidence);
102
103 if let Some(cr_list) = bundle.check_runs.value() {
104 let build_platforms = adapter::map_build_platform_evidence(cr_list);
105 if !build_platforms.is_empty() {
106 bundle.build_platform = EvidenceState::complete(build_platforms);
107 }
108 }
109
110 bundle.repository_posture = repository_posture;
111
112 let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
114 let dep_sigs = dependency::collect_pr_dependency_signatures(
115 client,
116 owner,
117 repo,
118 &pr_data.metadata.head.sha,
119 &changed_files,
120 );
121 bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
122
123 let report = assess_bundle(&bundle, policy)?;
124 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
125 Ok(VerificationResult::new(report, evidence_bundle))
126}
127
128pub fn verify_pr_batch(
130 client: &GitHubClient,
131 owner: &str,
132 repo: &str,
133 pr_numbers: &[u32],
134 policy: Option<&str>,
135 with_evidence: bool,
136) -> Result<BatchReport> {
137 let mut reports = Vec::new();
138 let mut skipped = Vec::new();
139 let mut total_pass = 0usize;
140 let mut total_review = 0usize;
141 let mut total_fail = 0usize;
142 let total = pr_numbers.len();
143
144 let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
145
146 let head_sha = all_data
148 .iter()
149 .find_map(|(_, r)| r.as_ref().ok().map(|d| d.metadata.head.sha.as_str()))
150 .unwrap_or("HEAD");
151 let posture = crate::posture::collect_repository_posture(client, owner, repo, head_sha);
152
153 for (i, (pr_number, result)) in all_data.into_iter().enumerate() {
154 eprintln!("Verifying PR #{pr_number} ({}/{})", i + 1, total);
155
156 match result.and_then(|pr_data| {
157 assess_from_pr_data_with_posture(
158 client,
159 &pr_data,
160 owner,
161 repo,
162 pr_number,
163 policy,
164 with_evidence,
165 posture.clone(),
166 )
167 }) {
168 Ok(vr) => {
169 for outcome in &vr.report.outcomes {
170 match outcome.decision {
171 GateDecision::Pass => total_pass += 1,
172 GateDecision::Review => total_review += 1,
173 GateDecision::Fail => total_fail += 1,
174 }
175 }
176 reports.push(BatchEntry {
177 subject_id: format!("#{pr_number}"),
178 result: vr,
179 });
180 }
181 Err(e) => {
182 eprintln!("Warning: skipping PR #{pr_number}: {e:#}");
183 skipped.push(SkippedEntry {
184 subject_id: format!("#{pr_number}"),
185 reason: format!("{e:#}"),
186 });
187 }
188 }
189 }
190
191 Ok(BatchReport {
192 reports,
193 total_pass,
194 total_review,
195 total_fail,
196 skipped,
197 })
198}
199
200pub fn verify_release(
205 client: &GitHubClient,
206 owner: &str,
207 repo: &str,
208 base_tag: &str,
209 head_tag: &str,
210 policy: Option<&str>,
211 with_evidence: bool,
212) -> Result<VerificationResult> {
213 let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
214 .context("failed to compare refs")?;
215
216 if commits.is_empty() {
217 bail!("no commits found between {base_tag} and {head_tag}");
218 }
219
220 let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
221 let commit_pr_map =
222 graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
223 eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
224 std::collections::HashMap::new()
225 });
226
227 let commit_prs: Vec<_> = commits
228 .iter()
229 .map(|c| adapter::GitHubCommitPullAssociation {
230 commit_sha: c.sha.clone(),
231 pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
232 })
233 .collect();
234
235 let release_assets = crate::release_api::get_release_assets(client, owner, repo, head_tag)
237 .unwrap_or_else(|err| {
238 eprintln!("Warning: failed to fetch release assets: {err}");
239 vec![]
240 });
241
242 let artifact_attestations =
243 crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets);
244
245 let unique_pr_numbers: Vec<u32> = {
247 let mut seen = std::collections::HashSet::new();
248 commit_pr_map
249 .values()
250 .flatten()
251 .filter(|pr| seen.insert(pr.number))
252 .map(|pr| pr.number)
253 .collect()
254 };
255
256 let repo_full = format!("{owner}/{repo}");
257
258 let mut change_requests = Vec::new();
259 let mut all_check_runs = Vec::new();
260
261 if !unique_pr_numbers.is_empty() {
262 for (pr_number, result) in graphql::fetch_prs(client, owner, repo, &unique_pr_numbers) {
263 match result {
264 Ok(pr_data) => {
265 change_requests.push(adapter::map_pull_request_evidence(
266 &repo_full,
267 pr_number,
268 &pr_data.metadata,
269 &pr_data.files,
270 &pr_data.reviews,
271 &pr_data.commits,
272 ));
273 all_check_runs.extend(pr_data.check_runs);
274 }
275 Err(e) => {
276 eprintln!(
277 "Warning: failed to fetch PR #{pr_number} for release verification: {e:#}"
278 );
279 }
280 }
281 }
282 }
283
284 let mut bundle = adapter::build_release_bundle(
285 &repo_full,
286 base_tag,
287 head_tag,
288 &commits,
289 &commit_prs,
290 artifact_attestations,
291 );
292 bundle.change_requests = change_requests;
293 bundle.repository_posture =
294 crate::posture::collect_repository_posture(client, owner, repo, head_tag);
295
296 let check_run_evidence = adapter::map_check_runs_evidence(&all_check_runs, None);
298 bundle.check_runs = EvidenceState::complete(check_run_evidence);
299 if let Some(cr_list) = bundle.check_runs.value() {
300 let build_platforms = adapter::map_build_platform_evidence(cr_list);
301 if !build_platforms.is_empty() {
302 bundle.build_platform = EvidenceState::complete(build_platforms);
303 }
304 }
305
306 let report = assess_bundle(&bundle, policy)?;
307 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
308 Ok(VerificationResult::new(report, evidence_bundle))
309}
310
311pub fn verify_repo(
319 client: &GitHubClient,
320 owner: &str,
321 repo: &str,
322 reference: &str,
323 policy: Option<&str>,
324 with_evidence: bool,
325) -> Result<VerificationResult> {
326 let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
327
328 let dep_sigs = enrich_npm_attestations(dep_sigs);
330
331 let repository_posture =
332 crate::posture::collect_repository_posture(client, owner, repo, reference);
333
334 let bundle = libverify_core::evidence::EvidenceBundle {
335 dependency_signatures: dep_sigs,
336 repository_posture,
337 check_runs: EvidenceState::not_applicable(),
338 build_platform: EvidenceState::not_applicable(),
339 artifact_attestations: EvidenceState::not_applicable(),
340 ..Default::default()
341 };
342
343 use libverify_core::slsa::SlsaTrack;
345 let policy_str = policy.unwrap_or("default");
346 let dep_level = match policy_str {
347 "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
348 "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
349 "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
350 "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
351 _ => libverify_core::slsa::SlsaLevel::L4, };
353 let dep_controls =
354 libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
355 let mut registry = ControlRegistry::new();
356 for control in dep_controls {
357 registry.register(control);
358 }
359 for control in libverify_core::controls::posture_controls() {
361 registry.register(control);
362 }
363 let profile = OpaProfile::from_preset_or_file(policy_str)?;
364 let report = libverify_core::assessment::assess(&bundle, registry.controls(), &profile);
365 let evidence_bundle = if with_evidence { Some(bundle) } else { None };
366 Ok(VerificationResult::new(report, evidence_bundle))
367}
368
369pub fn assess_bundle(
370 bundle: &libverify_core::evidence::EvidenceBundle,
371 policy: Option<&str>,
372) -> Result<AssessmentReport> {
373 let registry = ControlRegistry::builtin();
374 let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
375 Ok(libverify_core::assessment::assess(
376 bundle,
377 registry.controls(),
378 &profile,
379 ))
380}
381
382pub fn exit_if_assessment_fails(result: &VerificationResult) {
383 if result
384 .report
385 .outcomes
386 .iter()
387 .any(|o| o.decision == GateDecision::Fail)
388 {
389 process::exit(1);
390 }
391}
392
393fn enrich_npm_attestations(
396 state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
397) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
398 use crate::npm_attestation::NpmAttestationClient;
399 use crate::pypi_attestation::PypiAttestationClient;
400
401 fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
402 let has_npm = deps
403 .iter()
404 .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
405 let has_pypi = deps
406 .iter()
407 .any(|d| d.registry.as_deref() == Some("pypi.org"));
408
409 if has_npm && let Ok(client) = NpmAttestationClient::new() {
410 client.enrich_npm_deps(deps);
411 }
412 if has_pypi && let Ok(client) = PypiAttestationClient::new() {
413 client.enrich_pypi_deps(deps);
414 }
415 }
416
417 match state {
418 EvidenceState::Complete { mut value } => {
419 enrich(&mut value);
420 EvidenceState::Complete { value }
421 }
422 EvidenceState::Partial { mut value, gaps } => {
423 enrich(&mut value);
424 EvidenceState::Partial { value, gaps }
425 }
426 other => other,
427 }
428}