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
8 changes: 6 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Secure pip bootstrap (eMudhra/emSign advisory)
run: |
python -m pip install "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
python -m pip install --upgrade "certifi>=2026.10.19"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
python -m pip install -e ".[dev]"

- name: Run pytest with coverage
run: pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ pip install -e .
```

---
### Security: eMudhra/emSign CA incident

A [security incident](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576) affecting eMudhra intermediate certificates means older packaging tools can be man-in-the-middled. Use at least:

- pip 26.2.2
- uv 0.13.4
- certifi 2026.10.19 (this SDK now requires it)

Upgrade pip and uv in existing virtual environments, refresh lockfiles (`uv lock --upgrade-package certifi`), and rebuild container images. The official `python:3.11` and `3.12` Docker images keep an older pip, so upgrade it with the hash-pinned command from the advisory.

`nitrostack-py install` upgrades the pip in the project `.venv` to a hash-pinned 26.2.2 before installing anything, and refuses to run `uv` older than 0.13.4.


## Scaffolding a New Project (Recommended)

Expand Down
76 changes: 73 additions & 3 deletions nitrostack/cli/install.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
"""Dependency installation wrapper for `nitrostack-py install`."""

from __future__ import annotations

import os
import re
import shutil
import subprocess
import sys
from typing import List, Optional

from typing import List, Optional, Tuple
from nitrostack.cli._shared import write_text_atomic

_TRUE_TOKENS = frozenset({"1", "true", "yes", "on"})
_PEP503_KEEP = re.compile(r"[^A-Za-z0-9._-]+")
_PEP503_DASH = re.compile(r"[-_.]+")

# eMudhra/emSign CA incident minimums (python.org advisory).
MIN_PIP_VERSION = (26, 2, 2)
MIN_UV_VERSION = (0, 13, 4)
# Hash-pinned wheel taken verbatim from the advisory. Do not edit by hand.
SECURE_PIP_SPEC = (
"pip @ https://files.pythonhosted.org/packages/"
"3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/"
"pip-26.2.2-py3-none-any.whl"
"#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
)


def _read(path: str) -> str:
with open(path, "r", encoding="utf-8") as handle:
Expand Down Expand Up @@ -115,8 +124,68 @@ def ensure_project_venv(root: str) -> str:
return python


def _parse_version(text: str, tool: str) -> Optional[Tuple[int, int, int]]:
"""Parse `pip 24.0 from ...` or `uv 0.13.4 (abc 2026-10-20)` into a tuple."""
match = re.search(rf"\b{tool}\s+(\d+)\.(\d+)(?:\.(\d+))?", text or "")
if not match:
return None
major, minor, patch = match.groups()
return (int(major), int(minor), int(patch or 0))


def _tool_version(cmd: List[str], tool: str) -> Optional[Tuple[int, int, int]]:
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
except OSError:
return None
if result.returncode != 0:
return None
return _parse_version(result.stdout, tool)


def _get_pip_version(python: str) -> Optional[Tuple[int, int, int]]:
return _tool_version([python, "-m", "pip", "--version"], "pip")


def _get_uv_version(uv: str) -> Optional[Tuple[int, int, int]]:
return _tool_version([uv, "--version"], "uv")


def _ensure_secure_pip(python: str, cwd: str) -> None:
"""Make sure the venv's pip is >= 26.2.2 before it downloads anything.

The venv pip is whatever the interpreter bundled, which can trust the
compromised emSign intermediates. The pinned wheel is verified by sha256,
so the bootstrap itself cannot be tampered with.
"""
version = _get_pip_version(python)
if version is not None and version >= MIN_PIP_VERSION:
return
print("Updating pip securely before installing dependencies (eMudhra/emSign advisory).")
result = subprocess.run([python, "-m", "pip", "install", SECURE_PIP_SPEC], cwd=cwd)
if result.returncode != 0:
raise RuntimeError(
"Secure pip upgrade failed. Upgrade pip using the python.org eMudhra/emSign "
"advisory instructions, then retry `nitrostack-py install`."
)
version = _get_pip_version(python)
if version is None or version < MIN_PIP_VERSION:
raise RuntimeError(
"pip is still below 26.2.2 after the secure upgrade. "
"Upgrade pip manually, then retry `nitrostack-py install`."
)

def _check_uv_version(uv: str) -> None:
version = _get_uv_version(uv)
if version is None or version < MIN_UV_VERSION:
raise RuntimeError(
"uv 0.13.4 or later is required (eMudhra/emSign CA incident). "
"Upgrade uv (`uv self update` or reinstall) and retry."
)

def _run_pip(args: List[str], cwd: str) -> None:
python = ensure_project_venv(cwd)
_ensure_secure_pip(python, cwd)
cmd = [python, "-m", "pip", "install", *args]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
Expand All @@ -131,6 +200,7 @@ def _run_uv(args: List[str], cwd: str) -> None:
uv = _uv_bin()
if not uv:
raise RuntimeError("uv is not installed")
_check_uv_version(uv)
cmd = [uv, *args]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"watchfiles>=0.18.0",
"anyio>=4.0.0",
"packaging>=23.0.0",
"certifi>=2026.10.19",
"tomli>=2.0.0; python_version < '3.11'",
]

Expand Down Expand Up @@ -85,4 +86,4 @@ exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
]
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ uvicorn>=0.20.0
watchfiles>=0.18.0
anyio>=4.0.0
packaging>=23.0.0
certifi>=2026.10.19
tomli>=2.0.0; python_version < "3.11"
3 changes: 3 additions & 0 deletions tests/test_cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
def _uv_absent(monkeypatch):
"""Keep the existing pip-path tests off a real uv binary on PATH."""
monkeypatch.setattr("nitrostack.cli.install._uv_bin", lambda: None)
# pip/uv version gates are covered in test_cli_install_security.py
monkeypatch.setattr("nitrostack.cli.install._ensure_secure_pip", lambda *a, **k: None)
monkeypatch.setattr("nitrostack.cli.install._check_uv_version", lambda *a, **k: None)


def test_optional_extra_names_parses_pyproject():
Expand Down
158 changes: 158 additions & 0 deletions tests/test_cli_install_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""eMudhra/emSign remediation: secure pip bootstrap and uv version gate."""
from __future__ import annotations

import os
import sys
from types import SimpleNamespace
from unittest.mock import patch

import pytest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from nitrostack.cli.install import ( # noqa: E402
SECURE_PIP_SPEC,
_check_uv_version,
_ensure_secure_pip,
_get_pip_version,
_parse_version,
_run_pip,
_run_uv,
)

MOD = "nitrostack.cli.install"


def _ok(stdout: str = "") -> SimpleNamespace:
return SimpleNamespace(returncode=0, stdout=stdout, stderr="")


def _fail() -> SimpleNamespace:
return SimpleNamespace(returncode=1, stdout="", stderr="")


# ---- version parsing ------------------------------------------------------

def test_parse_pip_two_and_three_part_versions():
assert _parse_version("pip 24.0 from /x/pip (python 3.12)", "pip") == (24, 0, 0)
assert _parse_version("pip 26.2.2 from /x/pip (python 3.12)", "pip") == (26, 2, 2)


def test_parse_uv_version_with_build_suffix():
assert _parse_version("uv 0.13.4 (abc123 2026-10-20)", "uv") == (0, 13, 4)


def test_parse_garbage_returns_none():
assert _parse_version("command not found", "pip") is None
assert _parse_version("", "uv") is None


def test_get_pip_version_none_when_command_fails():
with patch(f"{MOD}.subprocess.run", return_value=_fail()):
assert _get_pip_version("python") is None


def test_get_pip_version_none_when_python_missing():
with patch(f"{MOD}.subprocess.run", side_effect=OSError("nope")):
assert _get_pip_version("python") is None


# ---- pip bootstrap --------------------------------------------------------

@pytest.mark.parametrize("version", [(26, 2, 2), (26, 3, 0), (27, 0, 0)])
def test_pip_already_secure_skips_bootstrap(version, tmp_path):
with patch(f"{MOD}._get_pip_version", return_value=version):
with patch(f"{MOD}.subprocess.run") as run:
_ensure_secure_pip("python", str(tmp_path))
run.assert_not_called()


def test_outdated_pip_is_upgraded_with_pinned_wheel(tmp_path):
with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), (26, 2, 2)]):
with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run:
_ensure_secure_pip("python", str(tmp_path))
run.assert_called_once()
assert run.call_args.args[0] == ["python", "-m", "pip", "install", SECURE_PIP_SPEC]
assert "#sha256=" in SECURE_PIP_SPEC


def test_unknown_pip_version_triggers_upgrade(tmp_path):
with patch(f"{MOD}._get_pip_version", side_effect=[None, (26, 2, 2)]):
with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run:
_ensure_secure_pip("python", str(tmp_path))
run.assert_called_once()


def test_pip_upgrade_failure_raises_actionable_error(tmp_path):
with patch(f"{MOD}._get_pip_version", return_value=(24, 0, 0)):
with patch(f"{MOD}.subprocess.run", return_value=_fail()):
with pytest.raises(RuntimeError, match="Secure pip upgrade failed"):
_ensure_secure_pip("python", str(tmp_path))


def test_pip_still_old_after_upgrade_raises(tmp_path):
with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), (24, 0, 0)]):
with patch(f"{MOD}.subprocess.run", return_value=_ok()):
with pytest.raises(RuntimeError, match="still below 26.2.2"):
_ensure_secure_pip("python", str(tmp_path))


def test_pip_version_unverifiable_after_upgrade_raises(tmp_path):
with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), None]):
with patch(f"{MOD}.subprocess.run", return_value=_ok()):
with pytest.raises(RuntimeError, match="still below 26.2.2"):
_ensure_secure_pip("python", str(tmp_path))


def test_run_pip_stops_before_installing_when_bootstrap_fails(tmp_path):
with patch(f"{MOD}.ensure_project_venv", return_value="python"):
with patch(f"{MOD}._ensure_secure_pip", side_effect=RuntimeError("boom")):
with patch(f"{MOD}.subprocess.run") as run:
with pytest.raises(RuntimeError, match="boom"):
_run_pip(["-e", "."], cwd=str(tmp_path))
run.assert_not_called()


def test_run_pip_bootstraps_before_installing(tmp_path):
order: list[str] = []
with patch(f"{MOD}.ensure_project_venv", return_value="python"):
with patch(f"{MOD}._ensure_secure_pip", side_effect=lambda *a, **k: order.append("bootstrap")):
with patch(
f"{MOD}.subprocess.run",
side_effect=lambda *a, **k: order.append("install") or _ok(),
):
_run_pip(["-e", "."], cwd=str(tmp_path))
assert order == ["bootstrap", "install"]


# ---- uv gate --------------------------------------------------------------

@pytest.mark.parametrize("version", [(0, 13, 4), (0, 14, 0), (1, 0, 0)])
def test_uv_meeting_minimum_is_allowed(version):
with patch(f"{MOD}._get_uv_version", return_value=version):
_check_uv_version("/usr/bin/uv")


@pytest.mark.parametrize("version", [(0, 13, 3), (0, 9, 0), None])
def test_old_or_unknown_uv_is_blocked(version):
with patch(f"{MOD}._get_uv_version", return_value=version):
with pytest.raises(RuntimeError, match="0.13.4"):
_check_uv_version("/usr/bin/uv")


def test_run_uv_blocked_before_any_download(tmp_path):
with patch(f"{MOD}._uv_bin", return_value="/usr/bin/uv"):
with patch(f"{MOD}._get_uv_version", return_value=(0, 13, 3)):
with patch(f"{MOD}.subprocess.run") as run:
with pytest.raises(RuntimeError, match="uv 0.13.4"):
_run_uv(["sync"], cwd=str(tmp_path))
run.assert_not_called()


def test_run_uv_runs_when_version_ok(tmp_path):
with patch(f"{MOD}._uv_bin", return_value="/usr/bin/uv"):
with patch(f"{MOD}._get_uv_version", return_value=(0, 13, 4)):
with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run:
_run_uv(["sync"], cwd=str(tmp_path))
run.assert_called_once()
assert run.call_args.args[0] == ["/usr/bin/uv", "sync"]
53 changes: 53 additions & 0 deletions tests/test_security_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""eMudhra/emSign remediation: lockfile, dependency floor and CI workflow guards."""
from __future__ import annotations

import os
import sys
from pathlib import Path

import pytest
from packaging.version import Version

try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from nitrostack.cli.install import SECURE_PIP_SPEC # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
MIN_CERTIFI = Version("2026.10.19")


def test_lock_resolves_fixed_certifi():
data = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8"))
versions = [Version(p["version"]) for p in data["package"] if p["name"] == "certifi"]
assert versions, "certifi missing from uv.lock"
assert all(v >= MIN_CERTIFI for v in versions), versions


def test_pyproject_declares_certifi_floor():
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert "certifi>=2026.10.19" in data["project"]["dependencies"]


def test_requirements_declares_certifi_floor():
lines = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
assert "certifi>=2026.10.19" in [line.strip() for line in lines]


@pytest.mark.parametrize(
"name, later_command",
[
("test.yml", 'pip install -e ".[dev]"'),
],
)

def test_workflow_bootstraps_secure_pip_before_other_installs(name, later_command):
text = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8")
assert SECURE_PIP_SPEC in text, "workflow pip spec must match install.SECURE_PIP_SPEC"
assert 'python -m pip install --upgrade "certifi>=2026.10.19"' in text
assert "pip install --upgrade pip" not in text
assert text.index(SECURE_PIP_SPEC) < text.index(later_command)
Loading
Loading