Skip to main content

libverify_github/
attestation.rs

1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::process::Command;
6
7use libverify_core::evidence::{
8    ArtifactAttestation, EvidenceGap, EvidenceState, VerificationOutcome,
9};
10
11use crate::types::ReleaseAsset;
12
13// -- gh CLI attestation types --
14
15/// Raw JSON structure from `gh attestation verify --format json`
16#[derive(Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct GhAttestationOutput {
19    pub verification_result: GhVerificationResult,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct GhVerificationResult {
24    pub statement: Statement,
25    pub signature: Option<SignatureInfo>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct Statement {
30    #[serde(rename = "predicateType")]
31    pub predicate_type: String,
32    /// In-toto statement subjects: artifacts with their digests.
33    #[serde(default)]
34    pub subject: Vec<StatementSubject>,
35}
36
37/// An in-toto statement subject entry.
38#[derive(Debug, Deserialize)]
39pub struct StatementSubject {
40    pub name: String,
41    /// Map of algorithm -> hex digest (e.g. {"sha256": "abcd..."}).
42    #[serde(default)]
43    pub digest: HashMap<String, String>,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct SignatureInfo {
48    pub certificate: Option<CertificateInfo>,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct CertificateInfo {
53    #[serde(rename = "sourceRepositoryURI")]
54    pub source_repository_uri: Option<String>,
55    #[serde(rename = "buildSignerURI")]
56    pub build_signer_uri: Option<String>,
57}
58
59// -- gh CLI verification --
60
61/// Verify an artifact using `gh attestation verify` and return parsed results.
62pub fn verify_artifact(
63    artifact: &str,
64    owner: Option<&str>,
65    repo: Option<&str>,
66) -> Result<Vec<GhAttestationOutput>> {
67    let mut cmd = Command::new("gh");
68    cmd.args(["attestation", "verify", artifact, "--format", "json"]);
69
70    if let Some(r) = repo {
71        cmd.args(["--repo", r]);
72    } else if let Some(o) = owner {
73        cmd.args(["--owner", o]);
74    } else {
75        bail!("either --owner or --repo is required for attestation verification");
76    }
77
78    let output = cmd
79        .output()
80        .context("failed to execute `gh attestation verify`")?;
81
82    if !output.status.success() {
83        let stderr = String::from_utf8_lossy(&output.stderr);
84        bail!("gh attestation verify failed: {stderr}");
85    }
86
87    let stdout = String::from_utf8(output.stdout).context("invalid UTF-8 in gh output")?;
88    let results: Vec<GhAttestationOutput> =
89        serde_json::from_str(&stdout).context("failed to parse gh attestation verify output")?;
90
91    Ok(results)
92}
93
94/// Convert parsed `gh attestation verify` results into core evidence types.
95///
96/// When both a local digest and an attestation-claimed digest are available,
97/// the two are compared. A mismatch overrides the `Verified` outcome with
98/// `SignatureInvalid` (the attestation does not cover the actual artifact).
99pub fn to_artifact_attestations(
100    artifact: &str,
101    results: &[GhAttestationOutput],
102    subject_digest: Option<String>,
103) -> Vec<ArtifactAttestation> {
104    results
105        .iter()
106        .map(|r| {
107            let cert = r
108                .verification_result
109                .signature
110                .as_ref()
111                .and_then(|s| s.certificate.as_ref());
112
113            let claimed_digest = r
114                .verification_result
115                .statement
116                .subject
117                .iter()
118                .find(|s| s.name == artifact)
119                .and_then(|s| s.digest.get("sha256"))
120                .map(|hex| format!("sha256:{hex}"));
121
122            let verification = match (&subject_digest, &claimed_digest) {
123                (Some(local), Some(claimed)) if local != claimed => {
124                    VerificationOutcome::SignatureInvalid {
125                        detail: format!("digest mismatch: local={local}, attestation={claimed}"),
126                    }
127                }
128                _ => VerificationOutcome::Verified,
129            };
130
131            ArtifactAttestation {
132                subject: artifact.to_string(),
133                subject_digest: subject_digest.clone(),
134                predicate_type: r.verification_result.statement.predicate_type.clone(),
135                signer_workflow: cert.and_then(|c| c.build_signer_uri.clone()),
136                source_repo: cert.and_then(|c| c.source_repository_uri.clone()),
137                verification,
138            }
139        })
140        .collect()
141}
142
143// -- Release attestation collection --
144
145/// Compute SHA256 digest of a file, returning the hex string.
146fn sha256_file(path: &std::path::Path) -> Result<String> {
147    let bytes = std::fs::read(path)?;
148    let hash = Sha256::digest(&bytes);
149    Ok(format!("sha256:{hash:x}"))
150}
151
152/// Download release assets to a temporary directory, verify attestations for each,
153/// and return an `EvidenceState` suitable for `EvidenceBundle.artifact_attestations`.
154///
155/// Assets that lack attestations are recorded as unverified rather than causing
156/// an error, so the overall assessment can still proceed.
157pub fn collect_release_attestations(
158    owner: &str,
159    repo: &str,
160    tag: &str,
161    assets: &[ReleaseAsset],
162) -> EvidenceState<Vec<ArtifactAttestation>> {
163    if assets.is_empty() {
164        return EvidenceState::not_applicable();
165    }
166
167    let repo_full = format!("{owner}/{repo}");
168
169    // Check whether `gh` CLI is available before doing any work.
170    if !gh_cli_available() {
171        return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
172            source: "gh-attestation".to_string(),
173            subject: "release-assets".to_string(),
174            detail: "`gh` CLI is not available".to_string(),
175        }]);
176    }
177
178    let tmp_dir = match tempfile::tempdir() {
179        Ok(d) => d,
180        Err(e) => {
181            return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
182                source: "gh-attestation".to_string(),
183                subject: "release-assets".to_string(),
184                detail: format!("failed to create temporary directory: {e}"),
185            }]);
186        }
187    };
188
189    // Download and verify all assets concurrently — each asset writes to a
190    // unique path in the same tmp_dir and spawns independent subprocesses.
191    let tmp_path = tmp_dir.path();
192    let per_asset_results: Vec<(Vec<ArtifactAttestation>, Vec<EvidenceGap>)> =
193        std::thread::scope(|s| {
194            let handles: Vec<_> = assets
195                .iter()
196                .map(|asset| {
197                    s.spawn(|| {
198                        let mut atts = Vec::new();
199                        let mut g = Vec::new();
200                        let asset_path = tmp_path.join(&asset.name);
201
202                        match download_asset(owner, repo, tag, &asset.name, &asset_path) {
203                            Ok(()) => {}
204                            Err(e) => {
205                                g.push(EvidenceGap::CollectionFailed {
206                                    source: "gh-release-download".to_string(),
207                                    subject: asset.name.clone(),
208                                    detail: format!("failed to download asset: {e}"),
209                                });
210                                atts.push(ArtifactAttestation {
211                                    subject: asset.name.clone(),
212                                    subject_digest: None,
213                                    predicate_type: String::new(),
214                                    signer_workflow: None,
215                                    source_repo: None,
216                                    verification: VerificationOutcome::Failed {
217                                        detail: format!("download failed: {e}"),
218                                    },
219                                });
220                                return (atts, g);
221                            }
222                        }
223
224                        let digest = sha256_file(&asset_path).ok();
225                        let path_str = asset_path.to_string_lossy().to_string();
226                        match verify_artifact(&path_str, None, Some(&repo_full)) {
227                            Ok(results) if !results.is_empty() => {
228                                atts.extend(to_artifact_attestations(
229                                    &asset.name, &results, digest,
230                                ));
231                            }
232                            Ok(_) => {
233                                atts.push(ArtifactAttestation {
234                                    subject: asset.name.clone(),
235                                    subject_digest: digest,
236                                    predicate_type: String::new(),
237                                    signer_workflow: None,
238                                    source_repo: None,
239                                    verification: VerificationOutcome::AttestationAbsent {
240                                        detail: "no attestation found".to_string(),
241                                    },
242                                });
243                            }
244                            Err(e) => {
245                                let detail = format!("{e}");
246                                let outcome = classify_verification_error(&detail);
247                                atts.push(ArtifactAttestation {
248                                    subject: asset.name.clone(),
249                                    subject_digest: digest,
250                                    predicate_type: String::new(),
251                                    signer_workflow: None,
252                                    source_repo: None,
253                                    verification: outcome,
254                                });
255                            }
256                        }
257                        (atts, g)
258                    })
259                })
260                .collect();
261            handles.into_iter().map(|h| h.join().unwrap()).collect()
262        });
263
264    let mut attestations: Vec<ArtifactAttestation> = Vec::new();
265    let mut gaps: Vec<EvidenceGap> = Vec::new();
266    for (atts, g) in per_asset_results {
267        attestations.extend(atts);
268        gaps.extend(g);
269    }
270
271    if gaps.is_empty() {
272        EvidenceState::complete(attestations)
273    } else {
274        EvidenceState::partial(attestations, gaps)
275    }
276}
277
278/// Download a single release asset using `gh release download`.
279fn download_asset(
280    owner: &str,
281    repo: &str,
282    tag: &str,
283    asset_name: &str,
284    dest: &std::path::Path,
285) -> Result<()> {
286    let repo_full = format!("{owner}/{repo}");
287    let output = Command::new("gh")
288        .args([
289            "release",
290            "download",
291            tag,
292            "--repo",
293            &repo_full,
294            "--pattern",
295            asset_name,
296            "--dir",
297            &dest.parent().unwrap().to_string_lossy(),
298            "--clobber",
299        ])
300        .output()?;
301
302    if !output.status.success() {
303        let stderr = String::from_utf8_lossy(&output.stderr);
304        anyhow::bail!("{stderr}");
305    }
306
307    if !dest.exists() {
308        anyhow::bail!("asset file not found after download");
309    }
310
311    Ok(())
312}
313
314/// Classify the error message from `gh attestation verify` into a structured outcome.
315fn classify_verification_error(detail: &str) -> VerificationOutcome {
316    let lower = detail.to_lowercase();
317    if lower.contains("no attestation") || lower.contains("not found") {
318        VerificationOutcome::AttestationAbsent {
319            detail: detail.to_string(),
320        }
321    } else if lower.contains("signature") || lower.contains("cosign") {
322        VerificationOutcome::SignatureInvalid {
323            detail: detail.to_string(),
324        }
325    } else if lower.contains("transparency") || lower.contains("rekor") || lower.contains("tlog") {
326        VerificationOutcome::TransparencyLogMissing {
327            detail: detail.to_string(),
328        }
329    } else if lower.contains("signer") || lower.contains("identity") || lower.contains("issuer") {
330        VerificationOutcome::SignerMismatch {
331            detail: detail.to_string(),
332        }
333    } else {
334        VerificationOutcome::Failed {
335            detail: detail.to_string(),
336        }
337    }
338}
339
340/// Check whether the `gh` CLI is available on PATH.
341fn gh_cli_available() -> bool {
342    Command::new("gh")
343        .arg("--version")
344        .output()
345        .map(|o| o.status.success())
346        .unwrap_or(false)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn empty_assets_returns_not_applicable() {
355        let result = collect_release_attestations("owner", "repo", "v1.0.0", &[]);
356        assert!(matches!(result, EvidenceState::NotApplicable));
357    }
358}