Skip to content

Commit 92f5eb1

Browse files
authored
security: verify published PyPI artifact bytes (#497)
## Summary - compare PyPI file names and SHA-256 digests with the reviewed wheel/sdist - validate the reviewed `SHA256SUMS` file in the same gate - retain machine-readable `PYPI-BYTE-VERIFICATION.json` evidence as a workflow artifact - keep the existing GitHub Release byte-for-byte verification as the companion release-assets check ## Security rationale Version and README checks alone do not prove that users received the reviewed bytes. The PyPI verification job now fails on missing, extra, renamed, or hash-mismatched files and records the result. ## Validation - `UV_CACHE_DIR=/private/tmp/base-cli-uv-cache uv run --extra dev pytest -q tests/test_verify_pypi_metadata.py tests/test_package_workflow.py` - Ruff and `git diff --check` pass Relates to #489.
1 parent be62cfc commit 92f5eb1

4 files changed

Lines changed: 155 additions & 3 deletions

File tree

‎.github/workflows/package.yml‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,32 @@ jobs:
400400
with:
401401
python-version: "3.13"
402402

403-
- name: Verify PyPI description and version
404-
run: python scripts/verify_pypi_metadata.py --version "${{ needs.build.outputs.version }}"
403+
- name: Download reviewed distributions
404+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
405+
with:
406+
name: base-cli-dist-${{ github.run_id }}
407+
path: dist
408+
409+
- name: Download release metadata
410+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
411+
with:
412+
name: base-cli-release-metadata-${{ github.run_id }}
413+
path: dist
414+
415+
- name: Verify PyPI metadata and published bytes
416+
run: |
417+
python scripts/verify_pypi_metadata.py \
418+
--version "${{ needs.build.outputs.version }}" \
419+
--artifacts-dir dist \
420+
--evidence-output dist/PYPI-BYTE-VERIFICATION.json
421+
422+
- name: Upload PyPI byte verification evidence
423+
if: ${{ always() }}
424+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
425+
with:
426+
name: base-cli-pypi-byte-evidence-${{ github.run_id }}
427+
path: dist/PYPI-BYTE-VERIFICATION.json
428+
if-no-files-found: ignore
405429

406430
release:
407431
name: Create GitHub Release

‎scripts/verify_pypi_metadata.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
from pathlib import Path
1212
from typing import Any
1313

14+
try:
15+
from .release_metadata_helpers import sha256_file
16+
except ImportError: # pragma: no cover - direct script execution
17+
from release_metadata_helpers import sha256_file
18+
1419

1520
def _normalized(value: str) -> str:
1621
return value.replace("\r\n", "\n").strip()
@@ -40,6 +45,61 @@ def validate_metadata(metadata: dict[str, Any], version: str, readme: str) -> li
4045
return errors
4146

4247

48+
def validate_artifact_hashes(metadata: dict[str, Any], artifacts_dir: Path) -> tuple[list[str], dict[str, Any]]:
49+
"""Compare PyPI file hashes with the reviewed distribution and SHA256SUMS."""
50+
51+
errors: list[str] = []
52+
artifacts = sorted((*artifacts_dir.glob("*.whl"), *artifacts_dir.glob("*.tar.gz")))
53+
reviewed = {path.name: sha256_file(path) for path in artifacts}
54+
urls = metadata.get("urls")
55+
published: dict[str, str] = {}
56+
if not isinstance(urls, list):
57+
errors.append("PyPI response has no file URL list")
58+
else:
59+
for entry in urls:
60+
if not isinstance(entry, dict):
61+
errors.append("PyPI file URL entry is not an object")
62+
continue
63+
filename = entry.get("filename")
64+
digest = (entry.get("digests") or {}).get("sha256") if isinstance(entry.get("digests"), dict) else None
65+
if not isinstance(filename, str) or not isinstance(digest, str):
66+
errors.append("PyPI file URL entry has no filename and SHA-256 digest")
67+
continue
68+
published[filename] = digest
69+
if set(published) != set(reviewed):
70+
errors.append(
71+
f"PyPI files differ from reviewed artifacts: expected {sorted(reviewed)!r}, observed {sorted(published)!r}"
72+
)
73+
for filename, digest in reviewed.items():
74+
if published.get(filename) != digest:
75+
errors.append(f"PyPI SHA-256 for {filename} does not match the reviewed artifact")
76+
77+
checksums_path = artifacts_dir / "SHA256SUMS"
78+
checksums: dict[str, str] = {}
79+
if not checksums_path.is_file():
80+
errors.append("reviewed SHA256SUMS is missing")
81+
else:
82+
for line in checksums_path.read_text(encoding="utf-8").splitlines():
83+
fields = line.split(maxsplit=1)
84+
if len(fields) == 2:
85+
checksums[fields[1]] = fields[0]
86+
if checksums != reviewed:
87+
errors.append("reviewed SHA256SUMS does not match the reviewed artifacts")
88+
89+
evidence = {
90+
"package": "base-cli",
91+
"version": metadata.get("info", {}).get("version") if isinstance(metadata.get("info"), dict) else None,
92+
"files": [
93+
{"filename": filename, "sha256": reviewed[filename], "pypi_sha256": published.get(filename)}
94+
for filename in sorted(reviewed)
95+
],
96+
"sha256sums_verified": checksums == reviewed,
97+
"result": not errors,
98+
"errors": errors,
99+
}
100+
return errors, evidence
101+
102+
43103
def _fetch(url: str, attempts: int, delay: float) -> dict[str, Any]:
44104
last_error: Exception | None = None
45105
for attempt in range(1, attempts + 1):
@@ -62,9 +122,17 @@ def main() -> None:
62122
parser.add_argument("--readme", type=Path, default=Path("README.md"))
63123
parser.add_argument("--attempts", type=int, default=12)
64124
parser.add_argument("--delay", type=float, default=5.0)
125+
parser.add_argument("--artifacts-dir", type=Path)
126+
parser.add_argument("--evidence-output", type=Path)
65127
args = parser.parse_args()
66128
metadata = _fetch(f"https://pypi.org/pypi/base-cli/{args.version}/json", args.attempts, args.delay)
67129
errors = validate_metadata(metadata, args.version, args.readme.read_text(encoding="utf-8"))
130+
evidence: dict[str, Any] | None = None
131+
if args.artifacts_dir is not None:
132+
artifact_errors, evidence = validate_artifact_hashes(metadata, args.artifacts_dir)
133+
errors.extend(artifact_errors)
134+
if args.evidence_output is not None and evidence is not None:
135+
args.evidence_output.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8")
68136
if errors:
69137
for error in errors:
70138
print(f"PyPI metadata validation failed: {error}")

‎tests/test_package_workflow.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def test_package_workflow_gates_tag_publication_on_release_documentation() -> No
6363
assert "python scripts/validate_release_docs.py" in workflow
6464
assert "scripts/verify_pypi_metadata.py" in workflow
6565
assert "name: Verify published PyPI metadata" in workflow
66+
assert "--artifacts-dir dist" in workflow
67+
assert "PYPI-BYTE-VERIFICATION.json" in workflow
68+
assert "if: ${{ always() }}" in workflow
6669
assert "needs: [build, smoke, provenance, publish, attest, verify_pypi]" in workflow
6770

6871

‎tests/test_verify_pypi_metadata.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import sys
45
import unittest
56
from pathlib import Path
7+
from tempfile import TemporaryDirectory
68

79
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8-
from scripts.verify_pypi_metadata import validate_metadata
10+
from scripts.verify_pypi_metadata import validate_artifact_hashes, validate_metadata
911

1012
README = """\
1113
# base-cli
@@ -29,6 +31,61 @@ def test_rejects_prepublication_description(self) -> None:
2931
errors = validate_metadata(metadata, "1.2.3", README)
3032
self.assertTrue(any("pre-publication wording" in error for error in errors))
3133

34+
def test_verifies_pypi_file_hashes_against_reviewed_artifacts_and_checksums(self) -> None:
35+
with TemporaryDirectory() as directory:
36+
root = Path(directory)
37+
wheel = root / "base_cli-1.2.3-py3-none-any.whl"
38+
sdist = root / "base_cli-1.2.3.tar.gz"
39+
wheel.write_bytes(b"wheel")
40+
sdist.write_bytes(b"sdist")
41+
checksums = "\n".join(
42+
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}" for path in (wheel, sdist)
43+
)
44+
(root / "SHA256SUMS").write_text(checksums + "\n", encoding="utf-8")
45+
metadata = {
46+
"info": {"version": "1.2.3"},
47+
"urls": [
48+
{"filename": path.name, "digests": {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}}
49+
for path in (wheel, sdist)
50+
],
51+
}
52+
errors, evidence = validate_artifact_hashes(metadata, root)
53+
54+
self.assertEqual(errors, [])
55+
self.assertTrue(evidence["result"])
56+
57+
def test_rejects_pypi_hash_mismatch(self) -> None:
58+
with TemporaryDirectory() as directory:
59+
root = Path(directory)
60+
wheel = root / "base_cli-1.2.3-py3-none-any.whl"
61+
wheel.write_bytes(b"wheel")
62+
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
63+
(root / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n", encoding="utf-8")
64+
metadata = {
65+
"info": {"version": "1.2.3"},
66+
"urls": [{"filename": wheel.name, "digests": {"sha256": "0" * 64}}],
67+
}
68+
69+
errors, evidence = validate_artifact_hashes(metadata, root)
70+
71+
self.assertTrue(any("does not match" in error for error in errors))
72+
self.assertFalse(evidence["result"])
73+
74+
def test_preserves_reviewed_hashes_when_pypi_urls_are_malformed(self) -> None:
75+
with TemporaryDirectory() as directory:
76+
root = Path(directory)
77+
wheel = root / "base_cli-1.2.3-py3-none-any.whl"
78+
wheel.write_bytes(b"wheel")
79+
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
80+
(root / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n", encoding="utf-8")
81+
82+
errors, evidence = validate_artifact_hashes({"info": {"version": "1.2.3"}, "urls": {}}, root)
83+
84+
self.assertTrue(any("no file URL list" in error for error in errors))
85+
self.assertEqual(evidence["files"], [{"filename": wheel.name, "pypi_sha256": None, "sha256": digest}])
86+
self.assertEqual(evidence["errors"], errors)
87+
self.assertFalse(evidence["result"])
88+
3289

3390
if __name__ == "__main__":
3491
unittest.main()

0 commit comments

Comments
 (0)