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 repo_full = format!("{owner}/{repo}");
51    let mut bundle = adapter::build_pull_request_bundle(
52        &repo_full,
53        pr_number,
54        &pr_data.metadata,
55        &pr_data.files,
56        &pr_data.reviews,
57        &pr_data.commits,
58    );
59
60    let combined_status = if pr_data.commit_statuses.is_empty() {
61        None
62    } else {
63        Some(CombinedStatusResponse {
64            state: String::new(),
65            statuses: pr_data
66                .commit_statuses
67                .iter()
68                .map(|s| CommitStatusItem {
69                    context: s.context.clone(),
70                    state: s.state.clone(),
71                })
72                .collect(),
73        })
74    };
75    let evidence = adapter::map_check_runs_evidence(&pr_data.check_runs, combined_status.as_ref());
76    bundle.check_runs = EvidenceState::complete(evidence);
77
78    if let Some(cr_list) = bundle.check_runs.value() {
79        let build_platforms = adapter::map_build_platform_evidence(cr_list);
80        if !build_platforms.is_empty() {
81            bundle.build_platform = EvidenceState::complete(build_platforms);
82        }
83    }
84
85    // Collect dependency signature evidence from lock files
86    let changed_files: Vec<String> = pr_data.files.iter().map(|f| f.filename.clone()).collect();
87    let dep_sigs = dependency::collect_pr_dependency_signatures(
88        client,
89        owner,
90        repo,
91        &pr_data.metadata.head.sha,
92        &changed_files,
93    );
94    bundle.dependency_signatures = enrich_npm_attestations(dep_sigs);
95
96    let report = assess_bundle(&bundle, policy)?;
97    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
98    Ok(VerificationResult::new(report, evidence_bundle))
99}
100
101/// Verify a batch of PRs and aggregate results.
102pub fn verify_pr_batch(
103    client: &GitHubClient,
104    owner: &str,
105    repo: &str,
106    pr_numbers: &[u32],
107    policy: Option<&str>,
108    with_evidence: bool,
109) -> Result<BatchReport> {
110    let mut reports = Vec::new();
111    let mut skipped = Vec::new();
112    let mut total_pass = 0usize;
113    let mut total_review = 0usize;
114    let mut total_fail = 0usize;
115    let total = pr_numbers.len();
116
117    let all_data = graphql::fetch_prs(client, owner, repo, pr_numbers);
118
119    for (i, (pr_number, result)) in all_data.into_iter().enumerate() {
120        eprintln!("Verifying PR #{pr_number} ({}/{})", i + 1, total);
121
122        match result.and_then(|pr_data| {
123            assess_from_pr_data(
124                client,
125                &pr_data,
126                owner,
127                repo,
128                pr_number,
129                policy,
130                with_evidence,
131            )
132        }) {
133            Ok(vr) => {
134                for outcome in &vr.report.outcomes {
135                    match outcome.decision {
136                        GateDecision::Pass => total_pass += 1,
137                        GateDecision::Review => total_review += 1,
138                        GateDecision::Fail => total_fail += 1,
139                    }
140                }
141                reports.push(BatchEntry {
142                    subject_id: format!("#{pr_number}"),
143                    result: vr,
144                });
145            }
146            Err(e) => {
147                eprintln!("Warning: skipping PR #{pr_number}: {e:#}");
148                skipped.push(SkippedEntry {
149                    subject_id: format!("#{pr_number}"),
150                    reason: format!("{e:#}"),
151                });
152            }
153        }
154    }
155
156    Ok(BatchReport {
157        reports,
158        total_pass,
159        total_review,
160        total_fail,
161        skipped,
162    })
163}
164
165/// Verify a release (tag range) and return a verification result.
166///
167/// This encapsulates the full release verification flow:
168/// compare refs, resolve commit PRs, collect attestations, build bundle, assess.
169pub fn verify_release(
170    client: &GitHubClient,
171    owner: &str,
172    repo: &str,
173    base_tag: &str,
174    head_tag: &str,
175    policy: Option<&str>,
176    with_evidence: bool,
177) -> Result<VerificationResult> {
178    let commits = crate::release_api::compare_refs(client, owner, repo, base_tag, head_tag)
179        .context("failed to compare refs")?;
180
181    if commits.is_empty() {
182        bail!("no commits found between {base_tag} and {head_tag}");
183    }
184
185    let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
186    let commit_pr_map =
187        graphql::resolve_commit_prs(client, owner, repo, &shas).unwrap_or_else(|err| {
188            eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
189            std::collections::HashMap::new()
190        });
191
192    let commit_prs: Vec<_> = commits
193        .iter()
194        .map(|c| adapter::GitHubCommitPullAssociation {
195            commit_sha: c.sha.clone(),
196            pull_requests: commit_pr_map.get(&c.sha).cloned().unwrap_or_default(),
197        })
198        .collect();
199
200    // Collect build-provenance attestations for release assets
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    let artifact_attestations =
208        crate::attestation::collect_release_attestations(owner, repo, head_tag, &release_assets);
209
210    let repo_full = format!("{owner}/{repo}");
211    let mut bundle = adapter::build_release_bundle(
212        &repo_full,
213        base_tag,
214        head_tag,
215        &commits,
216        &commit_prs,
217        artifact_attestations,
218    );
219    // Check runs are PR-scoped; not applicable for release verification.
220    bundle.check_runs = EvidenceState::not_applicable();
221
222    let report = assess_bundle(&bundle, policy)?;
223    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
224    Ok(VerificationResult::new(report, evidence_bundle))
225}
226
227/// Verify repository-level dependency signatures at a given ref.
228///
229/// Scans for lock files (Cargo.lock, package-lock.json) at the specified
230/// reference and evaluates dependency signature evidence.
231///
232/// Only evaluates dependency-related controls (not PR or build controls)
233/// to avoid noisy NotApplicable results.
234pub fn verify_repo(
235    client: &GitHubClient,
236    owner: &str,
237    repo: &str,
238    reference: &str,
239    policy: Option<&str>,
240    with_evidence: bool,
241) -> Result<VerificationResult> {
242    let dep_sigs = dependency::collect_repo_dependency_signatures(client, owner, repo, reference);
243
244    // Enrich npm dependencies with provenance from the npm attestation API
245    let dep_sigs = enrich_npm_attestations(dep_sigs);
246
247    let bundle = libverify_core::evidence::EvidenceBundle {
248        dependency_signatures: dep_sigs,
249        check_runs: EvidenceState::not_applicable(),
250        build_platform: EvidenceState::not_applicable(),
251        artifact_attestations: EvidenceState::not_applicable(),
252        ..Default::default()
253    };
254
255    // Use dependency-scoped controls matching the requested policy level
256    use libverify_core::slsa::SlsaTrack;
257    let policy_str = policy.unwrap_or("default");
258    let dep_level = match policy_str {
259        "slsa-l1" => libverify_core::slsa::SlsaLevel::L1,
260        "slsa-l2" => libverify_core::slsa::SlsaLevel::L2,
261        "slsa-l3" => libverify_core::slsa::SlsaLevel::L3,
262        "slsa-l4" => libverify_core::slsa::SlsaLevel::L4,
263        _ => libverify_core::slsa::SlsaLevel::L4, // default/oss/soc2: evaluate all
264    };
265    let dep_controls =
266        libverify_core::controls::slsa_controls_for_level(SlsaTrack::Dependencies, dep_level);
267    let mut registry = ControlRegistry::new();
268    for control in dep_controls {
269        registry.register(control);
270    }
271    let profile = OpaProfile::from_preset_or_file(policy_str)?;
272    let report = libverify_core::assessment::assess(&bundle, registry.controls(), &profile);
273    let evidence_bundle = if with_evidence { Some(bundle) } else { None };
274    Ok(VerificationResult::new(report, evidence_bundle))
275}
276
277pub fn assess_bundle(
278    bundle: &libverify_core::evidence::EvidenceBundle,
279    policy: Option<&str>,
280) -> Result<AssessmentReport> {
281    let registry = ControlRegistry::builtin();
282    let profile = OpaProfile::from_preset_or_file(policy.unwrap_or("default"))?;
283    Ok(libverify_core::assessment::assess(
284        bundle,
285        registry.controls(),
286        &profile,
287    ))
288}
289
290pub fn exit_if_assessment_fails(result: &VerificationResult) {
291    if result
292        .report
293        .outcomes
294        .iter()
295        .any(|o| o.decision == GateDecision::Fail)
296    {
297        process::exit(1);
298    }
299}
300
301/// Enrich dependencies with provenance from registry attestation APIs.
302/// Supports npm (Sigstore) and PyPI (PEP 740).
303fn enrich_npm_attestations(
304    state: EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>>,
305) -> EvidenceState<Vec<libverify_core::evidence::DependencySignatureEvidence>> {
306    use crate::npm_attestation::NpmAttestationClient;
307    use crate::pypi_attestation::PypiAttestationClient;
308
309    fn enrich(deps: &mut [libverify_core::evidence::DependencySignatureEvidence]) {
310        let has_npm = deps
311            .iter()
312            .any(|d| d.registry.as_deref() == Some("registry.npmjs.org"));
313        let has_pypi = deps
314            .iter()
315            .any(|d| d.registry.as_deref() == Some("pypi.org"));
316
317        if has_npm && let Ok(client) = NpmAttestationClient::new() {
318            client.enrich_npm_deps(deps);
319        }
320        if has_pypi && let Ok(client) = PypiAttestationClient::new() {
321            client.enrich_pypi_deps(deps);
322        }
323    }
324
325    match state {
326        EvidenceState::Complete { mut value } => {
327            enrich(&mut value);
328            EvidenceState::Complete { value }
329        }
330        EvidenceState::Partial { mut value, gaps } => {
331            enrich(&mut value);
332            EvidenceState::Partial { value, gaps }
333        }
334        other => other,
335    }
336}