Skip to main content

libverify_github/
verify.rs

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