Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/aqua-registry/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ pub struct AquaMinisign {
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaGithubArtifactAttestations {
pub enabled: Option<bool>,
pub predicate_type: Option<String>,
pub signer_workflow: Option<String>,
}

Expand Down Expand Up @@ -1198,6 +1199,9 @@ impl AquaGithubArtifactAttestations {
if let Some(enabled) = other.enabled {
self.enabled = Some(enabled);
}
if let Some(predicate_type) = other.predicate_type {
self.predicate_type = Some(predicate_type);
}
if let Some(signer_workflow) = other.signer_workflow {
self.signer_workflow = Some(signer_workflow);
}
Expand Down Expand Up @@ -1260,6 +1264,30 @@ packages:
assert_eq!(pkg.vars[0].default.as_deref(), Some("true"));
}

#[test]
fn test_github_artifact_attestations_predicate_type() {
let pkg = first_registry_package(
r#"
packages:
- github_artifact_attestations:
enabled: true
predicate_type: https://slsa.dev/provenance/v1
signer_workflow: canonical-workflow.yml
"#,
);

let attestations = pkg.github_artifact_attestations.unwrap();
assert_eq!(attestations.enabled, Some(true));
assert_eq!(
attestations.predicate_type.as_deref(),
Some("https://slsa.dev/provenance/v1")
);
assert_eq!(
attestations.signer_workflow.as_deref(),
Some("canonical-workflow.yml")
);
}

#[test]
fn test_aqua_file_src_gradle() {
// Test the gradle package src template: {{.AssetWithoutExt | trimSuffix "-bin"}}/bin/gradle
Expand Down
35 changes: 32 additions & 3 deletions crates/mise-sigstore/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ impl AttestationClient {
Ok(headers)
}

pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
fn attestations_url(&self, params: &FetchParams) -> Result<reqwest::Url> {
let url = if let Some(repo) = &params.repo {
format!(
"{}/repos/{repo}/attestations/{}",
Expand All @@ -266,8 +266,12 @@ impl AttestationClient {
if let Some(predicate_type) = &params.predicate_type {
query_params.push(("predicate_type", predicate_type.clone()));
}
let url = reqwest::Url::parse_with_params(&url, query_params)
.map_err(|e| AttestationError::Api(format!("Invalid GitHub attestations URL: {e}")))?;
reqwest::Url::parse_with_params(&url, query_params)
.map_err(|e| AttestationError::Api(format!("Invalid GitHub attestations URL: {e}")))
}

pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
let url = self.attestations_url(&params)?;

let response = self
.client
Expand Down Expand Up @@ -1333,6 +1337,31 @@ pub async fn calculate_file_digest(path: &Path) -> Result<String> {
mod tests {
use super::*;

#[test]
fn attestations_url_includes_predicate_type() {
let client = AttestationClient::builder()
.base_url("https://api.github.com")
.build()
.unwrap();
let url = client
.attestations_url(&FetchParams {
owner: "owner".to_string(),
repo: Some("owner/repo".to_string()),
digest: "sha256:abc".to_string(),
limit: 30,
predicate_type: Some("https://slsa.dev/provenance/v1".to_string()),
})
.unwrap();
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();

assert_eq!(url.path(), "/repos/owner/repo/attestations/sha256:abc");
assert_eq!(query.get("per_page").map(String::as_str), Some("30"));
assert_eq!(
query.get("predicate_type").map(String::as_str),
Some("https://slsa.dev/provenance/v1")
);
}

#[test]
fn signer_workflow_requires_identity() {
let err = verify_signer_workflow_identity(None, Some(".github/workflows/release.yml"))
Expand Down
14 changes: 11 additions & 3 deletions src/backend/aqua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ impl AquaBackend {
let settings = Settings::get();

// Check for GitHub artifact attestations (highest priority)
// The registry metadata (enabled flag, signer_workflow) is sufficient for
// The registry metadata (enabled flag, predicate_type, signer_workflow) is sufficient for
// detection at lock-time. Actual cryptographic verification happens at
// install time (always when locked_verify_provenance/paranoid is enabled,
// or on first install when the lockfile doesn't yet have provenance).
Expand Down Expand Up @@ -1066,11 +1066,14 @@ impl AquaBackend {
pkg: &AquaPackage,
digest: &str,
) -> std::result::Result<bool, crate::github::sigstore::DetectError> {
crate::github::sigstore::detect_attestations(
crate::github::sigstore::detect_attestations_with_predicate_type(
&pkg.repo_owner,
&pkg.repo_name,
github::API_URL,
digest,
pkg.github_artifact_attestations
.as_ref()
.and_then(|att| att.predicate_type.as_deref()),
)
.await
}
Expand Down Expand Up @@ -1155,12 +1158,17 @@ impl AquaBackend {
.github_artifact_attestations
.as_ref()
.and_then(|att| att.signer_workflow.as_deref().map(unescape_regex_literal));
let predicate_type = pkg
.github_artifact_attestations
.as_ref()
.and_then(|att| att.predicate_type.as_deref());

match crate::github::sigstore::verify_attestation(
match crate::github::sigstore::verify_attestation_with_predicate_type(
artifact_path,
&pkg.repo_owner,
&pkg.repo_name,
signer_workflow.as_deref(),
predicate_type,
None,
)
.await
Expand Down
84 changes: 81 additions & 3 deletions src/github/sigstore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
use std::path::Path;

use mise_sigstore::sources::github::GitHubSource;
use mise_sigstore::{ArtifactRef, AttestationSource};
use mise_sigstore::{ArtifactRef, AttestationClient, AttestationSource, FetchParams};

pub use mise_sigstore::{AttestationError, SlsaArtifact};

Expand All @@ -56,6 +56,16 @@ fn routed_api_url(api_url: &str) -> String {
}
}

fn attestation_client(api_url: &str) -> AttestationResult<AttestationClient> {
let token = resolve_token_for_wrapper(Some(api_url));
let base_url = routed_api_url(api_url);
let mut builder = AttestationClient::builder().base_url(&base_url);
if let Some(token) = token.as_deref() {
builder = builder.github_token(token);
}
builder.build()
}

/// Verify a GitHub artifact attestation for a file on disk.
///
/// Applies configured URL replacements to the API base URL before dispatching to
Expand Down Expand Up @@ -128,6 +138,41 @@ pub async fn verify_attestation(
}
}

/// Verify a GitHub artifact attestation filtered by predicate type.
///
/// The versions-host cache is keyed by digest only, so predicate-filtered
/// requests go directly to the GitHub attestations API.
pub async fn verify_attestation_with_predicate_type(
artifact_path: &Path,
owner: &str,
repo: &str,
expected_workflow: Option<&str>,
predicate_type: Option<&str>,
api_url: Option<&str>,
) -> AttestationResult<bool> {
let Some(predicate_type) = predicate_type else {
return verify_attestation(artifact_path, owner, repo, expected_workflow, api_url).await;
};

let artifact_digest = mise_sigstore::calculate_file_digest(artifact_path).await?;
let client = attestation_client(api_url.unwrap_or(crate::github::API_URL))?;
let attestations = client
.fetch_attestations(FetchParams {
owner: owner.to_string(),
repo: Some(format!("{owner}/{repo}")),
digest: format!("sha256:{artifact_digest}"),
limit: 30,
predicate_type: Some(predicate_type.to_string()),
})
.await?;
mise_sigstore::verify_github_attestation_with_attestations(
artifact_path,
&attestations,
expected_workflow,
)
.await
}

/// Reason the pre-download attestation probe could not complete.
///
/// Preserved as two variants so callers can log distinct warnings for a misconfigured
Expand All @@ -136,8 +181,7 @@ pub async fn verify_attestation(
/// wrapper keeps that signal instead of flattening both into one error string.
#[derive(Debug)]
pub enum DetectError {
/// `GitHubSource::with_base_url` rejected the (owner, repo, api_url) tuple — usually a
/// malformed base URL.
/// Attestation source/client construction rejected the base URL.
SourceCreation(AttestationError),
/// The attestations endpoint returned an error (403 rate-limit, 5xx, network failure).
Fetch(AttestationError),
Expand Down Expand Up @@ -198,6 +242,40 @@ pub async fn detect_attestations(
Ok(!attestations.is_empty())
}

/// Probe the GitHub attestation API for the given digest and predicate type.
///
/// The versions-host cache is keyed by digest only, so predicate-filtered
/// requests go directly to the GitHub attestations API.
pub async fn detect_attestations_with_predicate_type(
owner: &str,
repo: &str,
api_url: &str,
digest: &str,
predicate_type: Option<&str>,
) -> Result<bool, DetectError> {
let Some(predicate_type) = predicate_type else {
return detect_attestations(owner, repo, api_url, digest).await;
};

let client = attestation_client(api_url).map_err(DetectError::SourceCreation)?;
let digest = if digest.contains(':') {
digest.to_string()
} else {
format!("sha256:{digest}")
};
let attestations = client
.fetch_attestations(FetchParams {
owner: owner.to_string(),
repo: Some(format!("{owner}/{repo}")),
digest,
limit: 30,
predicate_type: Some(predicate_type.to_string()),
})
.await
.map_err(DetectError::Fetch)?;
Ok(!attestations.is_empty())
}

fn use_versions_host_for_attestations(api_url: Option<&str>) -> bool {
let settings = crate::config::Settings::get();
if settings.prefer_offline() || !settings.use_versions_host {
Expand Down
Loading