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