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