Skip to content
Merged
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
5 changes: 5 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,11 @@ docs = """
Enable/disable GitHub Artifact Attestations verification for github backend tools.
When enabled, mise will verify the authenticity and integrity of downloaded tools
using GitHub's artifact attestation system.

Attestations are only verified when the tool resolves to the public GitHub API
(`https://api.github.com`). Tools that set a custom `api_url` (e.g. GitHub
Enterprise Server) skip attestation verification automatically since GHE Server
does not implement the attestations endpoint.
"""
env = "MISE_GITHUB_GITHUB_ATTESTATIONS"
type = "Bool"
Expand Down
61 changes: 53 additions & 8 deletions src/backend/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ const DEFAULT_GITHUB_API_BASE_URL: &str = "https://api.github.com";
const DEFAULT_GITLAB_API_BASE_URL: &str = "https://gitlab.com/api/v4";
const DEFAULT_FORGEJO_API_BASE_URL: &str = "https://codeberg.org/api/v1";

/// GitHub artifact attestations are only served by https://api.github.com. GHE
/// Server doesn't implement the attestations endpoint, so any verification
/// attempt against a custom api_url will fail. Callers gate on this so users
/// don't have to disable `MISE_GITHUB_ATTESTATIONS` globally for GHE tools.
fn attestations_supported(api_url: &str) -> bool {
api_url.trim_end_matches('/') == DEFAULT_GITHUB_API_BASE_URL
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// Status returned from verification attempts
enum VerificationStatus {
/// No attestations or provenance found (not an error, tool may not have them)
Expand Down Expand Up @@ -484,6 +492,7 @@ impl UnifiedGitBackend {
// Uses the asset digest from the GitHub API to query attestations without downloading
if settings.github_attestations
&& settings.github.github_attestations
&& attestations_supported(api_url)
&& let Some(digest) = asset_digest
{
let parts: Vec<&str> = repo.split('/').collect();
Expand Down Expand Up @@ -574,7 +583,10 @@ impl UnifiedGitBackend {
let settings = Settings::get();

// Try GitHub artifact attestations first (highest priority)
if settings.github_attestations && settings.github.github_attestations {
if settings.github_attestations
&& settings.github.github_attestations
&& attestations_supported(api_url)
{
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() == 2 {
let (owner, repo_name) = (parts[0], parts[1]);
Expand Down Expand Up @@ -1465,11 +1477,30 @@ impl UnifiedGitBackend {
.is_some_and(|l| !l.is_github_attestations());
let skip_slsa = locked_provenance.as_ref().is_some_and(|l| !l.is_slsa());

// If the lockfile expects github-attestations but the configured api_url
// doesn't support them (e.g. GHE Server), surface a clear, actionable
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The api_url is calculated here and then again inside try_verify_github_attestations and try_verify_slsa. While not a bug, it would be cleaner to calculate it once in verify_attestations_or_slsa and pass it down to the helper methods to avoid redundant option lookups and string allocations.

// error rather than falling through to the generic "downgrade attack"
// path below.
let api_url = self.get_api_url(&tv.request.options());
if !attestations_supported(&api_url)
&& let Some(ref expected) = locked_provenance
&& expected.is_github_attestations()
{
return Err(eyre::eyre!(
"Lockfile requires github-attestations provenance for {tv} but the \
configured api_url ({api_url}) does not serve attestations. \
Re-run `mise lock` to refresh the lockfile, or remove the custom api_url."
));
}

// Try GitHub artifact attestations first (if enabled globally and for github backend)
if !skip_attestations && settings.github_attestations && settings.github.github_attestations
if !skip_attestations
&& settings.github_attestations
&& settings.github.github_attestations
&& attestations_supported(&api_url)
{
match self
.try_verify_github_attestations(ctx, tv, file_path)
.try_verify_github_attestations(ctx, tv, file_path, &api_url)
.await
{
Ok(true) => {
Expand Down Expand Up @@ -1505,7 +1536,7 @@ impl UnifiedGitBackend {

// Fall back to SLSA provenance (if enabled globally and for github backend)
if !skip_slsa && settings.slsa && settings.github.slsa {
match self.try_verify_slsa(ctx, tv, file_path).await {
match self.try_verify_slsa(ctx, tv, file_path, &api_url).await {
Ok((true, provenance_url)) => {
// Defense-in-depth: verify the result matches the lockfile expectation
if let Some(ref expected) = locked_provenance
Expand Down Expand Up @@ -1557,6 +1588,7 @@ impl UnifiedGitBackend {
ctx: &InstallContext,
tv: &ToolVersion,
file_path: &std::path::Path,
api_url: &str,
) -> std::result::Result<bool, VerificationStatus> {
ctx.pr
.set_message("verify GitHub artifact attestations".to_string());
Expand All @@ -1570,14 +1602,13 @@ impl UnifiedGitBackend {
)));
}
let (owner, repo_name) = (parts[0], parts[1]);
let api_url = self.get_api_url(&tv.request.options());

match crate::github::sigstore::verify_attestation(
file_path,
owner,
repo_name,
None, // We don't know the expected workflow
Some(&api_url),
Some(api_url),
)
.await
{
Expand Down Expand Up @@ -1606,6 +1637,7 @@ impl UnifiedGitBackend {
ctx: &InstallContext,
tv: &ToolVersion,
file_path: &std::path::Path,
api_url: &str,
) -> std::result::Result<(bool, Option<String>), VerificationStatus> {
if self.is_gitlab() || self.is_forgejo() {
return Err(VerificationStatus::NoAttestations);
Expand All @@ -1616,14 +1648,13 @@ impl UnifiedGitBackend {
// Get the release to find provenance assets
let repo = self.repo();
let opts = tv.request.options();
let api_url = self.get_api_url(&opts);
let version = &tv.version;

// Try to get the release (with version prefix support)
let version_prefix = opts.get("version_prefix");
let release =
match try_with_v_prefix_and_repo(version, version_prefix, Some(&repo), |candidate| {
let api_url = api_url.clone();
let api_url = api_url.to_string();
let repo = repo.clone();
async move { github::get_release_for_url(&api_url, &repo, &candidate).await }
})
Expand Down Expand Up @@ -1977,4 +2008,18 @@ mod tests {
let err = crate::github::sigstore::AttestationError::Api("connection refused".to_string());
assert!(!is_slsa_format_issue(&err));
}

#[test]
fn test_attestations_supported_default_api() {
assert!(attestations_supported("https://api.github.com"));
// Trailing slashes are common when users hand-write api_url
assert!(attestations_supported("https://api.github.com/"));
}

#[test]
fn test_attestations_supported_custom_api_url() {
assert!(!attestations_supported("https://ghe.example.com/api/v3"));
assert!(!attestations_supported("https://gitlab.com/api/v4"));
assert!(!attestations_supported("https://codeberg.org/api/v1"));
}
}
Loading