Skip to content

security: verify published PyPI artifact bytes - #497

Open
codeforester wants to merge 3 commits into
mainfrom
security/489-20261028-pypi-byte-verification
Open

codeforester wants to merge 3 commits into
mainfrom
security/489-20261028-pypi-byte-verification

Conversation

@codeforester

Copy link
Copy Markdown
Contributor

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.

@codeforester
codeforester force-pushed the security/489-20261028-pypi-byte-verification branch from 4aed2bf to a83d688 Compare October 27, 2026 18:53
python scripts/verify_pypi_metadata.py \
--version "${{ needs.build.outputs.version }}" \
--artifacts-dir dist \
--evidence-output dist/PYPI-BYTE-VERIFICATION.json

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: the new "Upload PyPI byte verification evidence" step has no if: always(), so Actions skips it whenever the preceding verification step fails — meaning the evidence artifact is never produced on exactly the failure path (a PyPI byte/hash mismatch) it exists to document. When scripts/verify_pypi_metadata.py detects a mismatch, it writes the evidence file and then raises SystemExit(1); that marks the step failed, and the next step (no if: condition) is skipped by default — losing the forensic evidence, contradicting this PR's stated goal, and inconsistent with this repo's own convention in .github/workflows/tests.yml (~lines 121-127, 194-200), which pairs if: always() with if-no-files-found: ignore for exactly this "keep evidence even on failure" case. Naively adding just if: always() isn't quite right either — if the script crashes before writing any evidence file (e.g. _fetch() exhausts retries), the upload step's current if-no-files-found: error would then surface a misleading second "no files found" failure masking the real root cause; pair it with if-no-files-found: ignore like the existing convention does.

Comment thread scripts/verify_pypi_metadata.py Outdated
continue
filename = entry.get("filename")
digest = (entry.get("digests") or {}).get("sha256") if isinstance(entry.get("digests"), dict) else None
if not isinstance(filename, str) or not isinstance(digest, str):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse: this reimplements SHA-256 file hashing inline (hashlib.sha256(path.read_bytes()).hexdigest()) instead of reusing release_metadata_helpers.sha256_file(), which every sibling release-integrity script already shares (via the same try/except-ImportError pattern used in generate_release_metadata.py, validate_release_metadata.py, verify_release_assets.py). The shared helper streams in 1MB chunks; this loads the whole artifact into memory — duplicate logic that would need a second fix if hashing ever changes, with strictly worse memory behavior for no benefit.

checksums: dict[str, str] = {}
if not checksums_path.is_file():
errors.append("reviewed SHA256SUMS is missing")
else:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarity bug: this error message swaps the semantic labels — it prints the locally-reviewed hash set as "expected" and PyPI's actual response as "observed", backwards from how a reader would interpret those words during a CI failure (expected {sorted(observed)!r}, observed {sorted(published)!r}, where observed actually holds the locally-built/reviewed artifact hashes and published holds PyPI's response). A maintainer debugging a genuine mismatch could misattribute which side is the trusted baseline.

Comment thread scripts/verify_pypi_metadata.py Outdated
digest = (entry.get("digests") or {}).get("sha256") if isinstance(entry.get("digests"), dict) else None
if not isinstance(filename, str) or not isinstance(digest, str):
errors.append("PyPI file URL entry has no filename and SHA-256 digest")
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness: when PyPI's JSON response has a malformed/missing urls field, this discards the already-computed local artifact hashes and returns an empty evidence dict — losing forensic detail in exactly the case most likely to indicate something's wrong with the PyPI response. The local (reviewed) hashes were already computed before this check; the written evidence file ends up {} instead of recording them for later audit.

@codeforester

Copy link
Copy Markdown
Contributor Author

Implemented the review fix in commit ee0f4989.

  • The PyPI byte-verification evidence upload now uses if: ${{ always() }}, so the forensic JSON is retained when the byte comparison fails, which is the failure path it documents.

@codeforester

Copy link
Copy Markdown
Contributor Author

Follow-up after the latest push (ee0f4989):

  • The if: always() fix landed, but only half of what I suggested: if-no-files-found is still error. If verify_pypi_metadata.py crashes before writing any evidence file (e.g. _fetch() exhausts its retries), this step now runs but finds nothing at dist/PYPI-BYTE-VERIFICATION.json, and if-no-files-found: error will raise a misleading "no files found" failure that masks the real root cause instead of surfacing it. Pairing with if-no-files-found: ignore (as tests.yml already does) would close this.
  • The other three findings in scripts/verify_pypi_metadata.py — the inline SHA-256 reimplementation instead of release_metadata_helpers.sha256_file(), the swapped expected/observed labels in the mismatch message, and the malformed/missing urls field discarding already-computed local hashes — are all still present in the current diff; none were addressed by this push.

Leaving these threads open since the underlying code is unchanged for 3 of 4, and the 4th is only partially fixed.

@codeforester

Copy link
Copy Markdown
Contributor Author

Addressed the remaining evidence/hash-validation feedback in commit f409ced7.

  • Evidence upload now uses if: always() with if-no-files-found: ignore, preserving the primary failure when no evidence file can be produced.
  • Reused the streaming sha256_file() release helper.
  • Renamed local/published hash sets for unambiguous diagnostics.
  • Preserved reviewed artifact hashes and recorded validation errors when PyPI returns malformed urls data.
  • Added regression coverage for malformed PyPI metadata.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant