Skip to main content

libverify_github/
verify.rs

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
19// ---------------------------------------------------------------------------
20// Evidence collection (API calls happen here — expensive, cacheable)
21// ---------------------------------------------------------------------------
22
23/// Collect evidence for a single pull request without evaluating any policy.
24///
25/// Returns an [`EvidenceBundle`] that can be assessed multiple times with
26/// different policies via [`assess_bundle`].
27pub fn collect_pr_evidence(
28    client: &GitHubClient,
29    owner: &str,
30    repo: &str,
31    pr_number: u32,
32) -> Result<libverify_core::evidence::EvidenceBundle> {
33    let pr_data =
34        graphql::fetch_pr(client, owner, repo, pr_number).context("failed to fetch PR data")?;
35    let posture =
36        crate::posture::collect_repository_posture(client, owner, repo, &pr_data.metadata.head.sha);
37    collect_pr_evidence_from_data(client, &pr_data, owner, repo, pr_number, posture)
38}
39
40/// Collect evidence for a batch of PRs.
41///
42/// Returns `(subject_id, Result<EvidenceBundle>)` per PR, preserving order.
43/// Repository posture is collected once and shared across all PRs.
44pub fn collect_pr_batch_evidence(
45    client: &GitHubClient,
46    owner: &str,
47    repo: &str,
48    pr_numbers: &[u32],
49) -> Vec<(String, Result<libverify_core::evidence::EvidenceBundle>)> {
50    let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
51
52    // Collect repository posture once for the entire batch (same repo)
53    let head_sha = all_data
54        .iter()
55        .find_map(|(_, r)| r.as_ref().ok().map(|d| d.metadata.head.sha.as_str()))
56        .unwrap_or("HEAD");
57    let posture = crate::posture::collect_repository_posture(client, owner, repo, head_sha);
58
59    all_data
60        .into_iter()
61        .map(|(pr_number, result)| {
62            let subject_id = format!("#{pr_number}");
63            let bundle = result.and_then(|pr_data| {
64                collect_pr_evidence_from_data(
65                    client,
66                    &pr_data,
67                    owner,
68                    repo,
69                    pr_number,
70                    posture.clone(),
71                )
72            });
73            (subject_id, bundle)
74        })
75        .collect()
76}
77
78/// Collect evidence for a release (tag range).
79pub fn collect_release_evidence(
80    client: &GitHubClient,
81    owner: &str,
82    repo: &str,
83    base_tag: &str,
84    head_tag: &str,
85) -> Result<libverify_core::evidence::EvidenceBundle> {
86    let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
87        .context("failed to compare refs")?;
88
89    if commits.is_empty() {
90        bail!("no commits found between {base_tag} and {head_tag}");
91    }
92
93    let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
94    let commit_pr_map =
95        graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
96            eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
97            std::collections::HashMap::new()
98        });
99
100    let commit_prs: Vec<_> = commits
101        .iter()
102        .map(|c| adapter::GitHubCommitPullAssociation {
103            commit_sha: c.sha.clone(),
104            pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
105        })
106        .collect();
107
108    // Collect build-provenance attestations for release assets
109    let release_assets = crate::release_api::get_release_assets(client, owner, repo, head_tag)
110        .unwrap_or_else(|err| {
111            eprintln!("Warning: failed to fetch release assets: {err}");
112            vec![]
113        });
114
115    let artifact_attestations =
116        crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets);
117
118    // Deduplicate PRs and fetch full evidence for each
119    let unique_pr_numbers: Vec<u32> = {
120        let mut seen = std::collections::HashSet::new();
121        commit_pr_map
122            .values()
123            .flatten()
124            .filter(|pr| seen.insert(pr.number))
125            .map(|pr| pr.number)
126            .collect()
127    };
128
129    let repo_full = format!("{owner}/{repo}");
130
131    let mut change_requests = Vec::new();
132    let mut all_check_runs = Vec::new();
133
134    if !unique_pr_numbers.is_empty() {
135        for (pr_number, result) in graphql::fetch_prs(client, owner, repo, &unique_pr_numbers) {
136            match result {
137                Ok(pr_data) => {
138                    change_requests.push(adapter::map_pull_request_evidence(
139                        &repo_full,
140                        pr_number,
141                        &pr_data.metadata,
142                        &pr_data.files,
143                        &pr_data.reviews,
144                        &pr_data.commits,
145                    ));
146                    all_check_runs.extend(pr_data.check_runs);
147                }
148                Err(e) => {
149                    eprintln!(
150                        "Warning: failed to fetch PR #{pr_number} for release verification: {e:#}"
151                    );
152                }
153            }
154        }
155    }
156
157    let mut bundle = adapter::build_release_bundle(
158        &repo_full,
159        base_tag,
160        head_tag,
161        &commits,
162        &commit_prs,
163        artifact_attestations,
164    );
165    bundle.change_requests = change_requests;
166    bundle.repository_posture =
167        crate::posture::collect_repository_posture(client, owner, repo, head_tag);
168
169    // Aggregate check runs from all PRs for build platform evidence
170    let check_run_evidence = adapter::map_check_runs_evidence(&all_check_runs, None);
171    bundle.check_runs = EvidenceState::complete(check_run_evidence);
172    if let Some(cr_list) = bundle.check_runs.value() {
173        let build_platforms = adapter::map_build_platform_evidence(cr_list);
174        if !build_platforms.is_empty() {
175            bundle.build_platform = EvidenceState::complete(build_platforms);
176        }
177    }
178
179    Ok(bundle)
180}
181
182/// Collect evidence for repository-level posture and dependencies at a given ref.
183pub fn collect_repo_evidence(
184    client: &GitHubClient,
185    owner: &str,
186    repo: &str,
187    reference: &str,
188) -> Result<libverify_core::evidence::EvidenceBundle> {
189    let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
190
191    // Enrich npm dependencies with provenance from the npm attestation API
192    let dep_sigs = enrich_npm_attestations(dep_sigs);
193
194    let repository_posture =
195        crate::posture::collect_repository_posture(client, owner, repo, reference);
196
197    Ok(libverify_core::evidence::EvidenceBundle {
198        dependency_signatures: dep_sigs,
199        repository_posture,
200        check_runs: EvidenceState::not_applicable(),
201        build_platform: EvidenceState::not_applicable(),
202        artifact_attestations: EvidenceState::not_applicable(),
203        ..Default::default()
204    })
205}
206
207// ---------------------------------------------------------------------------
208// Assessment (CPU-only, no API calls — fast, re-runnable with different policies)
209// ---------------------------------------------------------------------------
210
211/// Assess an evidence bundle against all built-in controls using the given policy.
212pub fn assess_bundle(
213    bundle: &libverify_core::evidence::EvidenceBundle,
214    policy: Option<&str>,
215) -> Result<AssessmentReport> {
216    let registry = ControlRegistry::builtin();
217    let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
218    Ok(libverify_core::assessment::assess(
219        bundle,
220        registry.controls(),
221        &profile,
222    ))
223}
224
225/// Assess an evidence bundle using repo-scoped controls (dependencies + posture).
226///
227/// This mirrors the control selection logic of [`verify_repo`] but operates
228/// on a pre-collected bundle.
229pub fn assess_repo_bundle(
230    bundle: &libverify_core::evidence::EvidenceBundle,
231    policy: Option<&str>,
232) -> Result<AssessmentReport> {
233    use libverify_core::slsa::SlsaTrack;
234    let policy_str = policy.unwrap_or("default");
235    let dep_level = match policy_str {
236        "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
237        "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
238        "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
239        "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
240        _ => libverify_core::slsa::SlsaLevel::L4,
241    };
242    let dep_controls =
243        libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
244    let mut registry = ControlRegistry::new();
245    for control in dep_controls {
246        registry.register(control);
247    }
248    for control in libverify_core::controls::posture_controls() {
249        registry.register(control);
250    }
251    let profile = OpaProfile::from_preset_or_file(policy_str)?;
252    Ok(libverify_core::assessment::assess(
253        bundle,
254        registry.controls(),
255        &profile,
256    ))
257}
258
259// ---------------------------------------------------------------------------
260// Convenience wrappers (collect + assess in one call — backward compatible)
261// ---------------------------------------------------------------------------
262
263/// Verify a single pull request and return a verification result.
264pub fn verify_pr(
265    client: &GitHubClient,
266    owner: &str,
267    repo: &str,
268    pr_number: u32,
269    policy: Option<&str>,
270    with_evidence: bool,
271) -> Result<VerificationResult> {
272    let bundle = collect_pr_evidence(client, owner, repo, pr_number)?;
273    let report = assess_bundle(&bundle, policy)?;
274    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
275    Ok(VerificationResult::new(report, evidence_bundle))
276}
277
278/// Verify a batch of PRs and aggregate results.
279pub fn verify_pr_batch(
280    client: &GitHubClient,
281    owner: &str,
282    repo: &str,
283    pr_numbers: &[u32],
284    policy: Option<&str>,
285    with_evidence: bool,
286) -> Result<BatchReport> {
287    let mut reports = Vec::new();
288    let mut skipped = Vec::new();
289    let mut total_pass = 0usize;
290    let mut total_review = 0usize;
291    let mut total_fail = 0usize;
292    let total = pr_numbers.len();
293
294    let evidence_items = collect_pr_batch_evidence(client, owner, repo, pr_numbers);
295
296    for (i, (subject_id, result)) in evidence_items.into_iter().enumerate() {
297        eprintln!("Verifying {subject_id} ({}/{})", i + 1, total);
298
299        match result.and_then(|bundle| {
300            let report = assess_bundle(&bundle, policy)?;
301            let evidence_bundle = if with_evidence { Some(bundle) } else { None };
302            Ok(VerificationResult::new(report, evidence_bundle))
303        }) {
304            Ok(vr) => {
305                for outcome in &vr.report.outcomes {
306                    match outcome.decision {
307                        GateDecision::Pass => total_pass += 1,
308                        GateDecision::Review => total_review += 1,
309                        GateDecision::Fail => total_fail += 1,
310                    }
311                }
312                reports.push(BatchEntry {
313                    subject_id,
314                    result: vr,
315                });
316            }
317            Err(e) => {
318                eprintln!("Warning: skipping {subject_id}: {e:#}");
319                skipped.push(SkippedEntry {
320                    subject_id,
321                    reason: format!("{e:#}"),
322                });
323            }
324        }
325    }
326
327    Ok(BatchReport {
328        reports,
329        total_pass,
330        total_review,
331        total_fail,
332        skipped,
333    })
334}
335
336/// Verify a release (tag range) and return a verification result.
337pub fn verify_release(
338    client: &GitHubClient,
339    owner: &str,
340    repo: &str,
341    base_tag: &str,
342    head_tag: &str,
343    policy: Option<&str>,
344    with_evidence: bool,
345) -> Result<VerificationResult> {
346    let bundle = collect_release_evidence(client, owner, repo, base_tag, head_tag)?;
347    let report = assess_bundle(&bundle, policy)?;
348    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
349    Ok(VerificationResult::new(report, evidence_bundle))
350}
351
352/// Verify repository-level dependency signatures at a given ref.
353pub fn verify_repo(
354    client: &GitHubClient,
355    owner: &str,
356    repo: &str,
357    reference: &str,
358    policy: Option<&str>,
359    with_evidence: bool,
360) -> Result<VerificationResult> {
361    let bundle = collect_repo_evidence(client, owner, repo, reference)?;
362    let report = assess_repo_bundle(&bundle, policy)?;
363    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
364    Ok(VerificationResult::new(report, evidence_bundle))
365}
366
367pub fn exit_if_assessment_fails(result: &VerificationResult) {
368    if result
369        .report
370        .outcomes
371        .iter()
372        .any(|o| o.decision == GateDecision::Fail)
373    {
374        process::exit(1);
375    }
376}
377
378// ---------------------------------------------------------------------------
379// Internal helpers
380// ---------------------------------------------------------------------------
381
382fn collect_pr_evidence_from_data(
383    client: &GitHubClient,
384    pr_data: &PrData,
385    owner: &str,
386    repo: &str,
387    pr_number: u32,
388    repository_posture: EvidenceState<libverify_core::evidence::RepositoryPosture>,
389) -> Result<libverify_core::evidence::EvidenceBundle> {
390    let repo_full = format!("{owner}/{repo}");
391    let mut bundle = adapter::build_pull_request_bundle(
392        &repo_full,
393        pr_number,
394        &pr_data.metadata,
395        &pr_data.files,
396        &pr_data.reviews,
397        &pr_data.commits,
398    );
399
400    let combined_status = if pr_data.commit_statuses.is_empty() {
401        None
402    } else {
403        Some(CombinedStatusResponse {
404            state: String::new(),
405            statuses: pr_data
406                .commit_statuses
407                .iter()
408                .map(|s| CommitStatusItem {
409                    context: s.context.clone(),
410                    state: s.state.clone(),
411                })
412                .collect(),
413        })
414    };
415    let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
416    bundle.check_runs = EvidenceState::complete(evidence);
417
418    if let Some(cr_list) = bundle.check_runs.value() {
419        let build_platforms = adapter::map_build_platform_evidence(cr_list);
420        if !build_platforms.is_empty() {
421            bundle.build_platform = EvidenceState::complete(build_platforms);
422        }
423    }
424
425    bundle.repository_posture = repository_posture;
426
427    // Collect dependency signature evidence from lock files
428    let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
429    let dep_sigs = dependency::collect_pr_dependency_signatures(
430        client,
431        owner,
432        repo,
433        &pr_data.metadata.head.sha,
434        &changed_files,
435    );
436    bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
437
438    Ok(bundle)
439}
440
441/// Enrich dependencies with provenance from registry attestation APIs.
442/// Supports npm (Sigstore) and PyPI (PEP 740).
443fn enrich_npm_attestations(
444    state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
445) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
446    use crate::npm_attestation::NpmAttestationClient;
447    use crate::pypi_attestation::PypiAttestationClient;
448
449    fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
450        let has_npm = deps
451            .iter()
452            .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
453        let has_pypi = deps
454            .iter()
455            .any(|d| d.registry.as_deref() == Some("pypi.org"));
456
457        if has_npm && let Ok(client) = NpmAttestationClient::new() {
458            client.enrich_npm_deps(deps);
459        }
460        if has_pypi && let Ok(client) = PypiAttestationClient::new() {
461            client.enrich_pypi_deps(deps);
462        }
463    }
464
465    match state {
466        EvidenceState::Complete { mut value } => {
467            enrich(&mut value);
468            EvidenceState::Complete { value }
469        }
470        EvidenceState::Partial { mut value, gaps } => {
471            enrich(&mut value);
472            EvidenceState::Partial { value, gaps }
473        }
474        other => other,
475    }
476}