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