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