diff --git a/.github/actions/harden-pip/action.yml b/.github/actions/harden-pip/action.yml index 868c30295f5..f4ccb7b0fb1 100644 --- a/.github/actions/harden-pip/action.yml +++ b/.github/actions/harden-pip/action.yml @@ -9,7 +9,10 @@ description: >- compromised eMudhra/emSign intermediate CAs. The sha256 fragment guarantees the bootstrap download's integrity even if the bootstrapping pip's trust store is compromised. See https://github.com/NVIDIA/cuda-python/issues/3195 and - https://discuss.python.org/t/109576. + https://discuss.python.org/t/109576. Before that, print sys.version and fail + if the interpreter is older than the first CPython release of its minor + series whose ssl module rejects the compromised intermediate keys (3.11.18, + 3.12.16, 3.13.18, 3.14.10, 3.15.2). inputs: python: @@ -19,6 +22,15 @@ inputs: runs: using: composite steps: + - name: Check Python interpreter is patched + shell: bash --noprofile --norc -xeuo pipefail {0} + env: + HARDEN_PIP_PYTHON: ${{ inputs.python }} + # The script lives next to this action so that it matches the action's + # commit even when the action is referenced from another checkout. + run: | + "${HARDEN_PIP_PYTHON}" "${GITHUB_ACTION_PATH}/check_python_patched.py" + - name: Install hash-pinned pip 26.2.2 shell: bash --noprofile --norc -xeuo pipefail {0} env: diff --git a/.github/actions/harden-pip/check_python_patched.py b/.github/actions/harden-pip/check_python_patched.py new file mode 100644 index 00000000000..51c16a6a022 --- /dev/null +++ b/.github/actions/harden-pip/check_python_patched.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Fail when the running CPython is older than the fix for the eMudhra/emSign CA compromise. + +Print ``sys.version`` and compare the interpreter with the first release of its +minor series whose ``ssl`` module rejects the compromised eMudhra intermediate +keys (CVE-2026-115557). Any release at or after that patch release passes. + +Release announcement: +https://discuss.python.org/t/python-3-15-2-3-14-10-3-13-18-3-12-16-and-3-11-18-are-out/109678 + +This file uses only the standard library because it runs on whichever +interpreter the job is about to use, before any package is installed. +""" + +from __future__ import annotations + +import sys + +# Minor series -> first patch release carrying the fix. +FIXED_MICRO = { + (3, 11): 18, + (3, 12): 16, + (3, 13): 18, + (3, 14): 10, + (3, 15): 2, +} + + +def classify(version_info) -> tuple[str, str]: + """Return ``(status, detail)`` where status is ``"ok"``, ``"warn"`` or ``"fail"``.""" + series = (version_info.major, version_info.minor) + found = f"{version_info.major}.{version_info.minor}.{version_info.micro}" + + if series in FIXED_MICRO: + fixed = f"{series[0]}.{series[1]}.{FIXED_MICRO[series]}" + if version_info.releaselevel != "final": + return ( + "fail", + f"Python {found} ({version_info.releaselevel}) is a pre-release; use a final release >= {fixed}.", + ) + if version_info.micro >= FIXED_MICRO[series]: + return "ok", f"Python {found} includes the fix (first fixed release of this series: {fixed})." + return ( + "fail", + f"Python {found} is older than {fixed}, the first release of this series that rejects the compromised eMudhra intermediate CA keys.", + ) + + if series > max(FIXED_MICRO): + # Newer than every series we know a fix for (for example 3.16 pre-releases). + return ( + "warn", + f"Python {found}: no fixed release is known for the {series[0]}.{series[1]} series yet; update FIXED_MICRO once one exists.", + ) + + return ( + "fail", + f"Python {series[0]}.{series[1]} has no release that rejects the compromised eMudhra intermediate CA keys.", + ) + + +def main() -> int: + print(f"sys.version: {sys.version}") + print(f"sys.executable: {sys.executable}") + status, detail = classify(sys.version_info) + if status == "ok": + print(detail) + return 0 + if status == "warn": + print(f"::warning title=Python patch level unverified::{detail}") + return 0 + print(f"::error title=Unpatched Python interpreter::{detail}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/tests/test_check_python_patched.py b/ci/tools/tests/test_check_python_patched.py new file mode 100644 index 00000000000..4494804e626 --- /dev/null +++ b/ci/tools/tests/test_check_python_patched.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the interpreter patch-level gate used by the harden-pip action.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ACTION_DIR = Path(__file__).resolve().parents[3] / ".github" / "actions" / "harden-pip" +SCRIPT_PATH = ACTION_DIR / "check_python_patched.py" + +_spec = importlib.util.spec_from_file_location("check_python_patched", SCRIPT_PATH) +assert _spec is not None and _spec.loader is not None +check_python_patched = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_python_patched) + + +def _version(major: int, minor: int, micro: int, releaselevel: str = "final") -> SimpleNamespace: + return SimpleNamespace(major=major, minor=minor, micro=micro, releaselevel=releaselevel) + + +@pytest.mark.agent_authored(model="claude-sonnet-5.5") +@pytest.mark.parametrize( + ("version", "status"), + [ + # First fixed release and anything later in the same series passes. + ((3, 11, 18), "ok"), + ((3, 11, 19), "ok"), + ((3, 12, 16), "ok"), + ((3, 13, 18), "ok"), + ((3, 14, 10), "ok"), + ((3, 14, 11), "ok"), + ((3, 15, 2), "ok"), + ((3, 15, 3), "ok"), + # The patch release before the fix fails. + ((3, 11, 17), "fail"), + ((3, 12, 15), "fail"), + ((3, 13, 17), "fail"), + ((3, 14, 9), "fail"), + ((3, 15, 1), "fail"), + ((3, 15, 0), "fail"), + # Series without any fixed release. + ((3, 10, 99), "fail"), + ((3, 9, 25), "fail"), + ], +) +def test_classify_final_releases(version: tuple[int, int, int], status: str) -> None: + assert check_python_patched.classify(_version(*version))[0] == status + + +@pytest.mark.agent_authored(model="claude-sonnet-5.5") +def test_classify_prerelease_of_fixed_series_fails() -> None: + assert check_python_patched.classify(_version(3, 15, 2, "candidate"))[0] == "fail" + + +@pytest.mark.agent_authored(model="claude-sonnet-5.5") +def test_classify_newer_series_warns() -> None: + assert check_python_patched.classify(_version(3, 16, 0, "alpha"))[0] == "warn" + + +@pytest.mark.agent_authored(model="claude-sonnet-5.5") +def test_script_prints_sys_version_and_matches_classification() -> None: + result = subprocess.run( # noqa: S603 + [sys.executable, str(SCRIPT_PATH)], capture_output=True, text=True, check=False + ) + assert f"sys.version: {sys.version}" in result.stdout + expected_status, _ = check_python_patched.classify(sys.version_info) + assert (result.returncode == 1) == (expected_status == "fail") + + +@pytest.mark.agent_authored(model="claude-sonnet-5.5") +def test_harden_pip_action_runs_the_check_before_installing_pip() -> None: + text = (ACTION_DIR / "action.yml").read_text(encoding="utf-8") + check = text.index("check_python_patched.py") + install = text.index("pip install") + assert check < install diff --git a/ruff.toml b/ruff.toml index 5a2778cfb80..5b01f55bf09 100644 --- a/ruff.toml +++ b/ruff.toml @@ -158,6 +158,7 @@ inline-quotes = "double" "qa/**" = ["T201"] "toolshed/**" = ["T201"] "ci/**" = ["T201"] +".github/actions/**" = ["T201"] "**/build_hooks.py" = ["T201"] "**/_build_shared.py" = ["T201"] "**/docs/**/conf.py" = ["T201", "ARG001"]