Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
74 changes: 74 additions & 0 deletions e2e/lockfile/test_lockfile_python
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,77 @@ assert_fail "mise install python -f" "Checksum mismatch"
rm -f mise.lock mise.toml

echo "Python lockfile URL test passed!"

echo "=== Testing provenance recorded in lockfile when enabled ==="
export MISE_PYTHON_GITHUB_ATTESTATIONS=1

cat <<EOF >mise.toml
[tools]
python = "3.13.5"
EOF

mise lock --platform "$MISE_PLATFORM"
assert "test -f mise.lock"
assert_contains "cat mise.lock" 'provenance = "github-attestations"'

echo "Lockfile with provenance:"
cat mise.lock

rm -f mise.lock mise.toml
unset MISE_PYTHON_GITHUB_ATTESTATIONS

echo "Python provenance lockfile test passed!"

echo "=== Testing provenance NOT recorded when disabled ==="
export MISE_PYTHON_GITHUB_ATTESTATIONS=0
export MISE_GITHUB_ATTESTATIONS=0

cat <<EOF >mise.toml
[tools]
python = "3.13.5"
EOF

mise lock --platform "$MISE_PLATFORM"
assert "test -f mise.lock"
# provenance should not appear in lockfile when disabled
assert_fail "grep -q 'provenance' mise.lock"

rm -f mise.lock mise.toml
unset MISE_PYTHON_GITHUB_ATTESTATIONS
unset MISE_GITHUB_ATTESTATIONS

echo "Python provenance disabled test passed!"

echo "=== Testing provenance downgrade attack detection ==="
cat <<EOF >mise.toml
[tools]
python = "3.13.5"
EOF

# Generate lockfile with all platforms (so the current platform is included)
mise lock
assert "test -f mise.lock"

# Inject provenance into ALL platform sections (simulating a previously-verified install)
awk '
/^provenance/ && in_section { next }
{ print }
/^\[tools\.python\."platforms\./ { in_section=1; print "provenance = \"github-attestations\"" }
/^\[/ && !/^\[tools\.python\."platforms\./ { in_section=0 }
' mise.lock >mise.lock.tmp && mv mise.lock.tmp mise.lock
assert_contains "cat mise.lock" 'provenance = "github-attestations"'

# Attempt install with provenance verification disabled.
# The lockfile says provenance was verified, but settings are off,
# so mise should refuse to install (downgrade/stripping attack).
rm -rf "$MISE_DATA_DIR/installs/python"
export MISE_PYTHON_GITHUB_ATTESTATIONS=0
export MISE_GITHUB_ATTESTATIONS=0
assert_fail_contains "mise install 2>&1" "downgrade attack"

echo "=== Cleanup ==="
unset MISE_PYTHON_GITHUB_ATTESTATIONS
unset MISE_GITHUB_ATTESTATIONS
rm -f mise.lock mise.toml

echo "Python provenance downgrade attack test passed!"
4 changes: 4 additions & 0 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,10 @@
"description": "Path to a file containing default python packages to install when installing a python version.",
"type": "string"
},
"github_attestations": {
"description": "Enable GitHub Artifact Attestations verification for precompiled Python binaries.",
"type": "boolean"
},
"patch_url": {
"description": "URL to fetch python patches from to pass to python-build.",
"type": "string"
Expand Down
12 changes: 12 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,18 @@ env = "MISE_PYTHON_DEFAULT_PACKAGES_FILE"
optional = true
type = "Path"

[python.github_attestations]
description = "Enable GitHub Artifact Attestations verification for precompiled Python binaries."
docs = """
Override the global `github_attestations` setting for Python precompiled binaries.
When enabled, mise will verify the authenticity of precompiled Python binaries from astral-sh/python-build-standalone.

Defaults to the global `github_attestations` setting if not specified.
"""
env = "MISE_PYTHON_GITHUB_ATTESTATIONS"
optional = true
type = "Bool"

[python.patch_url]
description = "URL to fetch python patches from to pass to python-build."
env = "MISE_PYTHON_PATCH_URL"
Expand Down
125 changes: 115 additions & 10 deletions src/plugins/core/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ use crate::file::{TarFormat, TarOptions, display_path};
use crate::git::{CloneOptions, Git};
use crate::http::{HTTP, HTTP_FETCH};
use crate::install_context::InstallContext;
use crate::lockfile::PlatformInfo;
use crate::lockfile::{PlatformInfo, ProvenanceType};
use crate::toolset::{ToolRequest, ToolVersion, Toolset};
use crate::ui::progress_report::SingleReport;
use crate::{Result, lock_file::LockFile};
use crate::{dirs, file, plugins, sysconfig};
use crate::{dirs, env, file, plugins, sysconfig};
use async_trait::async_trait;
use eyre::{bail, eyre};
use flate2::read::GzDecoder;
Expand All @@ -28,6 +28,9 @@ use tokio::sync::Mutex;
use versions::Versioning;
use xx::regex;

const ATTESTATION_HELP: &str = "To disable attestation verification, set MISE_PYTHON_GITHUB_ATTESTATIONS=false\n\
or add `python.github_attestations = false` to your mise config";

#[derive(Debug)]
pub struct PythonPlugin {
ba: Arc<BackendArg>,
Expand Down Expand Up @@ -287,13 +290,51 @@ impl PythonPlugin {
HTTP.download_file(&url, &tarball_path, Some(ctx.pr.as_ref()))
.await?;

{
let platform_key = self.get_platform_key();
let pi = tv.lock_platforms.entry(platform_key).or_default();
pi.url = Some(url.clone());
}
// Record the URL in lock_platforms so verify_checksum can find it
let platform_key = self.get_platform_key();
tv.lock_platforms
.entry(platform_key.clone())
.or_default()
.url = Some(url.to_string());

self.verify_checksum(ctx, tv, &tarball_path)?;

// Check lockfile provenance expectation before verification
let locked_provenance = tv
.lock_platforms
.get_mut(&platform_key)
.and_then(|pi| pi.provenance.take());

// Verify GitHub artifact attestations for precompiled binaries
// Returns Ok(true) if verified, Ok(false) if skipped, Err if failed
let verified = self
.verify_github_artifact_attestations(ctx, &tarball_path, &tv.version)
.await?;

// Record provenance only if verification actually succeeded (not skipped)
if verified {
let pi = tv.lock_platforms.entry(platform_key.clone()).or_default();
pi.provenance = Some(ProvenanceType::GithubAttestations);
}

// Enforce lockfile provenance
if let Some(ref expected) = locked_provenance {
let got = tv
.lock_platforms
.get(&platform_key)
.and_then(|pi| pi.provenance.as_ref());
if !got.is_some_and(|g| std::mem::discriminant(g) == std::mem::discriminant(expected)) {
let got_str = got
.map(|g| g.to_string())
.unwrap_or_else(|| "no verification".to_string());
return Err(eyre!(
"Lockfile requires {expected} provenance for {tv} but {got_str} was used. \
This may indicate a downgrade attack. Enable the corresponding verification setting \
or update the lockfile."
));
}
}

file::remove_all(&install)?;
file::untar(
&tarball_path,
Expand Down Expand Up @@ -542,6 +583,69 @@ impl PythonPlugin {
.map(|(_, tag, filename)| (tag, filename));
Ok(result)
}

fn detect_precompiled_provenance(&self) -> Option<ProvenanceType> {
let settings = Settings::get();
let enabled = settings
.python
.github_attestations
.unwrap_or(settings.github_attestations);
if !enabled {
return None;
}
Some(ProvenanceType::GithubAttestations)
}

async fn verify_github_artifact_attestations(
&self,
ctx: &InstallContext,
tarball_path: &std::path::Path,
version: &str,
) -> Result<bool> {
let settings = Settings::get();

// Check Python-specific setting, fall back to global
let enabled = settings
.python
.github_attestations
.unwrap_or(settings.github_attestations);
Comment thread
malept marked this conversation as resolved.
Outdated
if !enabled {
debug!("GitHub artifact attestations verification disabled for Python");
return Ok(false);
}

ctx.pr
.set_message("verify GitHub artifact attestations".to_string());

match sigstore_verification::verify_github_attestation(
tarball_path,
"astral-sh",
"python-build-standalone",
env::GITHUB_TOKEN.as_deref(),
None, // Accept any workflow from repo
)
.await
{
Ok(true) => {
ctx.pr
.set_message("✓ GitHub artifact attestations verified".to_string());
debug!(
"GitHub artifact attestations verified successfully for python@{}",
version
);
Ok(true)
}
Ok(false) => Err(eyre!(
"GitHub artifact attestations verification failed for python@{version}\n{ATTESTATION_HELP}"
)),
Err(sigstore_verification::AttestationError::NoAttestations) => Err(eyre!(
"No GitHub artifact attestations found for python@{version}\n{ATTESTATION_HELP}"
)),
Err(e) => Err(eyre!(
"GitHub artifact attestations verification failed for python@{version}: {e}\n{ATTESTATION_HELP}"
)),
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

#[async_trait]
Expand Down Expand Up @@ -724,12 +828,13 @@ impl Backend for PythonPlugin {
);
let checksum = fetch_checksum_from_shasums(&shasums_url, &filename).await;

// Detect provenance for precompiled binaries
let provenance = self.detect_precompiled_provenance();

Ok(PlatformInfo {
url: Some(url),
checksum,
size: None,
url_api: None,
conda_deps: None,
provenance,
..Default::default()
})
}
Expand Down
Loading