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    let mut attestations: Vec<ArtifactAttestation> = Vec::new();
190    let mut gaps: Vec<EvidenceGap> = Vec::new();
191
192    for asset in assets {
193        let asset_path = tmp_dir.path().join(&asset.name);
194
195        match download_asset(owner, repo, tag, &asset.name, &asset_path) {
196            Ok(()) => {}
197            Err(e) => {
198                gaps.push(EvidenceGap::CollectionFailed {
199                    source: "gh-release-download".to_string(),
200                    subject: asset.name.clone(),
201                    detail: format!("failed to download asset: {e}"),
202                });
203                attestations.push(ArtifactAttestation {
204                    subject: asset.name.clone(),
205                    subject_digest: None,
206                    predicate_type: String::new(),
207                    signer_workflow: None,
208                    source_repo: None,
209                    verification: VerificationOutcome::Failed {
210                        detail: format!("download failed: {e}"),
211                    },
212                });
213                continue;
214            }
215        }
216
217        let digest = sha256_file(&asset_path).ok();
218
219        let path_str = asset_path.to_string_lossy().to_string();
220        match verify_artifact(&path_str, None, Some(&repo_full)) {
221            Ok(results) if !results.is_empty() => {
222                attestations.extend(to_artifact_attestations(&asset.name, &results, digest));
223            }
224            Ok(_) => {
225                attestations.push(ArtifactAttestation {
226                    subject: asset.name.clone(),
227                    subject_digest: digest,
228                    predicate_type: String::new(),
229                    signer_workflow: None,
230                    source_repo: None,
231                    verification: VerificationOutcome::AttestationAbsent {
232                        detail: "no attestation found".to_string(),
233                    },
234                });
235            }
236            Err(e) => {
237                let detail = format!("{e}");
238                let outcome = classify_verification_error(&detail);
239                attestations.push(ArtifactAttestation {
240                    subject: asset.name.clone(),
241                    subject_digest: digest,
242                    predicate_type: String::new(),
243                    signer_workflow: None,
244                    source_repo: None,
245                    verification: outcome,
246                });
247            }
248        }
249    }
250
251    if gaps.is_empty() {
252        EvidenceState::complete(attestations)
253    } else {
254        EvidenceState::partial(attestations, gaps)
255    }
256}
257
258/// Download a single release asset using `gh release download`.
259fn download_asset(
260    owner: &str,
261    repo: &str,
262    tag: &str,
263    asset_name: &str,
264    dest: &std::path::Path,
265) -> Result<()> {
266    let repo_full = format!("{owner}/{repo}");
267    let output = Command::new("gh")
268        .args([
269            "release",
270            "download",
271            tag,
272            "--repo",
273            &repo_full,
274            "--pattern",
275            asset_name,
276            "--dir",
277            &dest.parent().unwrap().to_string_lossy(),
278            "--clobber",
279        ])
280        .output()?;
281
282    if !output.status.success() {
283        let stderr = String::from_utf8_lossy(&output.stderr);
284        anyhow::bail!("{stderr}");
285    }
286
287    if !dest.exists() {
288        anyhow::bail!("asset file not found after download");
289    }
290
291    Ok(())
292}
293
294/// Classify the error message from `gh attestation verify` into a structured outcome.
295fn classify_verification_error(detail: &str) -> VerificationOutcome {
296    let lower = detail.to_lowercase();
297    if lower.contains("no attestation") || lower.contains("not found") {
298        VerificationOutcome::AttestationAbsent {
299            detail: detail.to_string(),
300        }
301    } else if lower.contains("signature") || lower.contains("cosign") {
302        VerificationOutcome::SignatureInvalid {
303            detail: detail.to_string(),
304        }
305    } else if lower.contains("transparency") || lower.contains("rekor") || lower.contains("tlog") {
306        VerificationOutcome::TransparencyLogMissing {
307            detail: detail.to_string(),
308        }
309    } else if lower.contains("signer") || lower.contains("identity") || lower.contains("issuer") {
310        VerificationOutcome::SignerMismatch {
311            detail: detail.to_string(),
312        }
313    } else {
314        VerificationOutcome::Failed {
315            detail: detail.to_string(),
316        }
317    }
318}
319
320/// Check whether the `gh` CLI is available on PATH.
321fn gh_cli_available() -> bool {
322    Command::new("gh")
323        .arg("--version")
324        .output()
325        .map(|o| o.status.success())
326        .unwrap_or(false)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn empty_assets_returns_not_applicable() {
335        let result = collect_release_attestations("owner", "repo", "v1.0.0", &[]);
336        assert!(matches!(result, EvidenceState::NotApplicable));
337    }
338}