From 795d2fe7ca5a0a48da9ba1a10d963a3b1ba98949 Mon Sep 17 00:00:00 2001 From: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:04:57 +0800 Subject: [PATCH] fix: confine fetched package caches --- .github/dependabot.yml | 10 ++++++ .github/workflows/ci.yml | 28 +++++++++-------- .github/workflows/release.yml | 32 ++++++++++++++------ CHANGELOG.md | 18 +++++++++++ README.md | 13 +++++++- docs/readme_template.md | 13 +++++++- pyproject.toml | 1 + src/willitbreak/__init__.py | 2 +- src/willitbreak/fetch.py | 32 +++++++++++++++++++- tests/test_fetch.py | 57 ++++++++++++++++++++++++++++++++++- 10 files changed, 178 insertions(+), 28 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c5049e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b780426..79f395f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python: ["3.9", "3.13"] + python: ["3.9", "3.14"] include: - os: ubuntu-latest python: "3.10" @@ -29,11 +29,13 @@ jobs: python: "3.11" - os: ubuntu-latest python: "3.12" - - os: macos-latest + - os: ubuntu-latest python: "3.13" + - os: macos-latest + python: "3.14" steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} @@ -50,8 +52,8 @@ jobs: name: Against a real upgrade runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -98,8 +100,8 @@ jobs: name: README is current runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - run: python -m pip install pillow @@ -109,8 +111,8 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - run: python -m pip install ruff @@ -121,14 +123,14 @@ jobs: name: Build distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - run: python -m pip install build twine - run: python -m build - run: python -m twine check --strict dist/* - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e155e65..ad57eea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,10 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - python: ["3.9", "3.13"] + python: ["3.9", "3.14"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} - run: python -m unittest discover -s tests @@ -44,8 +44,8 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - run: python -m pip install build twine @@ -60,7 +60,7 @@ jobs: echo "tag=$TAG package=$PKG" test "$TAG" = "$PKG" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/ @@ -75,11 +75,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 github-release: name: Attach distributions to the GitHub release @@ -88,12 +88,24 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write + id-token: write + attestations: write steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ - - uses: softprops/action-gh-release@v3 + - name: Generate and verify SHA-256 manifest + shell: bash + run: | + cd dist + sha256sum *.whl *.tar.gz > SHA256SUMS + sha256sum --check SHA256SUMS + - name: Attest release distributions + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: dist/SHA256SUMS + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fa545..cdf9e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.2] - 2026-08-11 + +### Security + +- Validate distribution names against the Python packaging name specification + and prove each cache destination remains inside the selected cache before + deleting or creating it. A crafted project name or version can no longer + remove a same-named directory outside the cache. +- Pin every third-party GitHub Action to an immutable commit and enable weekly + Dependabot checks for Action updates. + +### Changed + +- Test Python 3.14 on Linux, Windows, macOS, and in the release gate. +- Publish a SHA-256 manifest and GitHub build-provenance attestations with each + GitHub release. + ## [0.1.1] - 2026-08-09 ### Security @@ -35,5 +52,6 @@ First release. and colour handling that honours `NO_COLOR`. - Exit code 2 for a breaking upgrade, 1 for the tool itself failing. +[0.1.2]: https://github.com/CAOShurong/willitbreak/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/CAOShurong/willitbreak/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/CAOShurong/willitbreak/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 3cad49a..9264307 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,20 @@ python docs/build_docs.py python docs/build_docs.py --check # what CI runs ``` -CI runs on Ubuntu, Windows and macOS across Python 3.9–3.13, checks this +CI runs on Ubuntu, Windows and macOS across Python 3.9–3.14, checks this README still matches the output, and verifies the exit codes. +## Verify a release + +Starting with v0.1.2, every GitHub release includes a `SHA256SUMS` manifest and +GitHub build-provenance attestations for the wheel and source distribution: + +```bash +sha256sum --check SHA256SUMS +gh attestation verify willitbreak-0.1.2-py3-none-any.whl \ + --repo CAOShurong/willitbreak +``` + ## License MIT. See [LICENSE](LICENSE). diff --git a/docs/readme_template.md b/docs/readme_template.md index 7afb198..3d6965c 100644 --- a/docs/readme_template.md +++ b/docs/readme_template.md @@ -170,9 +170,20 @@ python docs/build_docs.py python docs/build_docs.py --check # what CI runs ``` -CI runs on Ubuntu, Windows and macOS across Python 3.9–3.13, checks this +CI runs on Ubuntu, Windows and macOS across Python 3.9–3.14, checks this README still matches the output, and verifies the exit codes. +## Verify a release + +Starting with v0.1.2, every GitHub release includes a `SHA256SUMS` manifest and +GitHub build-provenance attestations for the wheel and source distribution: + +```bash +sha256sum --check SHA256SUMS +gh attestation verify willitbreak-0.1.2-py3-none-any.whl \ + --repo CAOShurong/willitbreak +``` + ## License MIT. See [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index 62871b7..6b6ae57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Quality Assurance", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", diff --git a/src/willitbreak/__init__.py b/src/willitbreak/__init__.py index d3337b7..dd5fe5e 100644 --- a/src/willitbreak/__init__.py +++ b/src/willitbreak/__init__.py @@ -24,7 +24,7 @@ from .surface import Surface, Symbol, read_surface from .usage import Reference, scan_paths, scan_source -__version__ = "0.1.1" +__version__ = "0.1.2" __all__ = [ "Change", diff --git a/src/willitbreak/fetch.py b/src/willitbreak/fetch.py index 029541a..ea18a41 100644 --- a/src/willitbreak/fetch.py +++ b/src/willitbreak/fetch.py @@ -17,6 +17,7 @@ import json import os import pathlib +import re import shutil import tarfile import urllib.error @@ -35,6 +36,10 @@ PYPI = "https://pypi.org/pypi" USER_AGENT = "willitbreak (+https://github.com/CAOShurong/willitbreak)" TIMEOUT = 30 +PROJECT_NAME = re.compile( + r"(?:[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])\Z", + re.ASCII | re.IGNORECASE, +) class FetchError(Exception): @@ -87,6 +92,7 @@ def _get_json(url: str) -> dict: def latest_version(package: str) -> str: + _validate_project_name(package) data = _get_json(f"{PYPI}/{package}/json") version = data.get("info", {}).get("version") if not version: @@ -107,6 +113,7 @@ def installed_version(package: str) -> str | None: def _release_files(package: str, version: str) -> list[dict]: + _validate_project_name(package) data = _get_json(f"{PYPI}/{package}/{version}/json") files = data.get("urls") or [] if not files: @@ -143,6 +150,29 @@ def _download(url: str) -> bytes: raise FetchError(f"download failed: {exc}") from exc +def _validate_project_name(package: str) -> None: + """Reject names that cannot identify a project on a Python index.""" + if PROJECT_NAME.fullmatch(package) is None: + raise FetchError( + f"invalid project name {package!r}; expected letters, numbers, '.', '-', " + "or '_' with an alphanumeric first and last character" + ) + + +def _cache_destination(cache: pathlib.Path, package: str, version: str) -> pathlib.Path: + """Return a cache path that is provably contained by ``cache``.""" + _validate_project_name(package) + try: + root = cache.resolve() + destination = (cache / f"{package}-{version}").resolve() + destination.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise FetchError( + f"cache destination for {package} {version} resolves outside the cache" + ) from exc + return destination + + def _archive_target(destination: pathlib.Path, member: str) -> pathlib.Path: """Resolve one archive member inside the exact destination directory. @@ -266,7 +296,7 @@ def fetch_version( ) -> Fetched: """Download and unpack one version, reusing the cache when possible.""" cache = cache or cache_root() - destination = cache / f"{package}-{version}" + destination = _cache_destination(cache, package, version) marker = destination / ".willitbreak-complete" if not marker.is_file(): diff --git a/tests/test_fetch.py b/tests/test_fetch.py index 08700ca..60ada45 100644 --- a/tests/test_fetch.py +++ b/tests/test_fetch.py @@ -8,8 +8,63 @@ import tempfile import unittest import zipfile +from unittest import mock -from willitbreak.fetch import FetchError, _safe_extract_tar, _safe_extract_zip +from willitbreak.fetch import ( + FetchError, + _safe_extract_tar, + _safe_extract_zip, + _validate_project_name, + fetch_version, +) + + +class CacheBoundaryTests(unittest.TestCase): + def setUp(self) -> None: + self._temp = tempfile.TemporaryDirectory() + self.addCleanup(self._temp.cleanup) + self.root = pathlib.Path(self._temp.name) + self.cache = self.root / "cache" + self.cache.mkdir() + + def test_valid_python_project_names_are_accepted(self) -> None: + for package in ("a", "requests2", "zope.interface", "google_cloud-storage"): + with self.subTest(package=package): + _validate_project_name(package) + + def test_unicode_casefold_lookalike_is_not_an_ascii_project_name(self) -> None: + with self.assertRaisesRegex(FetchError, "invalid project name"): + _validate_project_name("\N{KELVIN SIGN}") + + def test_project_name_cannot_delete_a_cache_sibling(self) -> None: + victim = self.root / "victim-1.0" + victim.mkdir() + sentinel = victim / "KEEP.txt" + sentinel.write_text("keep", encoding="utf-8") + + release_files = mock.patch( + "willitbreak.fetch._release_files", + side_effect=FetchError("network must not be reached"), + ) + with release_files, self.assertRaisesRegex(FetchError, "invalid project name"): + fetch_version("../victim", "1.0", cache=self.cache) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + + def test_version_cannot_resolve_outside_the_cache(self) -> None: + victim = self.root / "victim" + victim.mkdir() + sentinel = victim / "KEEP.txt" + sentinel.write_text("keep", encoding="utf-8") + + release_files = mock.patch( + "willitbreak.fetch._release_files", + side_effect=FetchError("network must not be reached"), + ) + with release_files, self.assertRaisesRegex(FetchError, "outside the cache"): + fetch_version("demo", "../../../victim", cache=self.cache) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") class SafeExtractionTests(unittest.TestCase):