From e72d3e8cc9440e4bcbcd8020234e8ee9d27125a8 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:57:02 -0700 Subject: [PATCH] security: add verifiable release metadata and attestations --- .github/workflows/package.yml | 61 ++++++++++- CHANGELOG.md | 2 + docs/releasing.md | 34 +++++- scripts/generate_release_metadata.py | 150 +++++++++++++++++++++++++++ scripts/validate_release_metadata.py | 69 ++++++++++++ tests/validate.sh | 2 + 6 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 scripts/generate_release_metadata.py create mode 100644 scripts/validate_release_metadata.py diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 38c0b2f..55b0bdb 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -12,6 +12,8 @@ on: - "scripts/validate_package_artifact.py" - "scripts/validate_installed_package.py" - "scripts/validate_examples.py" + - "scripts/generate_release_metadata.py" + - "scripts/validate_release_metadata.py" - "examples/**" - "compatibility/**" - "docs/releasing.md" @@ -95,14 +97,37 @@ jobs: - name: Validate package indexes run: python -m twine check dist/* + - name: Generate release checksums and SPDX SBOM + env: + SOURCE_REVISION: ${{ github.sha }} + SOURCE_DATE_EPOCH: ${{ github.event.head_commit.timestamp || '0' }} + run: python scripts/generate_release_metadata.py dist + + - name: Validate release checksums and SPDX SBOM + env: + SOURCE_REVISION: ${{ github.sha }} + run: python scripts/validate_release_metadata.py dist + - name: Upload reviewed distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: base-cli-dist-${{ github.run_id }} - path: dist/* + path: | + dist/*.whl + dist/*.tar.gz if-no-files-found: error retention-days: 14 + - name: Upload release metadata + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: base-cli-release-metadata-${{ github.run_id }} + path: | + dist/SBOM.spdx.json + dist/SHA256SUMS + if-no-files-found: error + retention-days: 90 + smoke: name: Install smoke test (Python ${{ matrix.python-version }}) needs: build @@ -184,3 +209,37 @@ jobs: uses: pypa/gh-action-pypi-publish@4bb033805d9e19112d8c697528791ff53f6c2f74 with: packages-dir: dist + + attest: + name: Attest reviewed release + needs: [build, smoke] + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download reviewed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist + + - name: Download release metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: base-cli-release-metadata-${{ github.run_id }} + path: dist + + - name: Attest artifact provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-checksums: dist/SHA256SUMS + + - name: Attest SPDX SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-checksums: dist/SHA256SUMS + sbom-path: dist/SBOM.spdx.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 35a1a93..86fa707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and versions are tracked in the repo-root `VERSION` file. - Add a framework choice guide, five-minute evaluation path, and clearer production-lifecycle positioning for Click and Typer adopters. +- Add deterministic SPDX SBOMs, artifact checksums, and OIDC-backed GitHub + attestations to protected release workflows. ### Changed diff --git a/docs/releasing.md b/docs/releasing.md index 8cc9e1c..d3bca27 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -29,7 +29,39 @@ redaction, protocol framing, persistence, concurrency, retention, and signal cleanup. The publish job downloads that same reviewed artifact; it does not rebuild -during publication. +during publication. The build also emits a deterministic `SHA256SUMS` file and +an SPDX 2.3 `SBOM.spdx.json` release artifact. On tag and protected dispatch +runs, GitHub's OIDC-backed `actions/attest` job records both build provenance +and an SBOM attestation for the exact artifact digests; no PyPI token or other +long-lived publish secret is used. + +## Independent verification + +Download the release metadata artifact from the successful Package workflow +run (the artifact is named `base-cli-release-metadata-`), alongside +the wheel or sdist you downloaded from PyPI: + +```bash +gh run download \ + --repo basefoundry/base-cli \ + --name base-cli-release-metadata- \ + --dir release-metadata +sha256sum -c release-metadata/SHA256SUMS +``` + +The SPDX document's namespace and comment include the source revision used by +the workflow. For a tagged release, verify the matching GitHub attestations +with the GitHub CLI: + +```bash +gh attestation verify base_cli--py3-none-any.whl \ + --repo basefoundry/base-cli +``` + +The same command can verify the sdist. A clean-room verifier should compare +the downloaded artifact's digest with `SHA256SUMS`, confirm the SBOM namespace +contains the expected tag commit, and inspect the attestation's workflow and +repository identity before installation. ## Documentation site diff --git a/scripts/generate_release_metadata.py b/scripts/generate_release_metadata.py new file mode 100644 index 0000000..760c2b1 --- /dev/null +++ b/scripts/generate_release_metadata.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Create deterministic release checksums and an SPDX 2.3 dependency SBOM.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import tomllib # type: ignore[import-untyped] + +PACKAGE_NAME = "base-cli" +SBOM_NAME = "SBOM.spdx.json" +CHECKSUMS_NAME = "SHA256SUMS" + + +def _root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _revision(root: Path) -> str: + value = os.environ.get("SOURCE_REVISION") or os.environ.get("GITHUB_SHA") + if value: + return value + try: + return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def _created_at() -> str: + try: + epoch = int(os.environ.get("SOURCE_DATE_EPOCH", "0")) + except ValueError: + epoch = 0 + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _spdx_id(value: str) -> str: + return "SPDXRef-" + "".join(character if character.isalnum() else "-" for character in value) + + +def _dependency_packages(project: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + packages: list[dict[str, Any]] = [] + relationships: list[dict[str, str]] = [] + source_id = _spdx_id(PACKAGE_NAME) + dependencies: list[tuple[str, str]] = [] + dependencies.extend(("runtime", value) for value in project.get("project", {}).get("dependencies", [])) + for extra, values in project.get("project", {}).get("optional-dependencies", {}).items(): + dependencies.extend((extra, value) for value in values) + for extra, requirement in dependencies: + name = requirement.split(";", 1)[0].split("[", 1)[0].strip() + for delimiter in ("<", ">", "=", "!", "~", " "): + name = name.split(delimiter, 1)[0].strip() + dependency_id = _spdx_id(f"{extra}-{name}") + packages.append( + { + "SPDXID": dependency_id, + "name": name, + "versionInfo": requirement, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + } + ) + relationships.append( + { + "spdxElementId": source_id, + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": dependency_id, + } + ) + return packages, relationships + + +def generate(dist: Path, root: Path) -> None: + artifacts = sorted((*dist.glob("*.whl"), *dist.glob("*.tar.gz"))) + if len(artifacts) != 2: + raise SystemExit(f"expected one wheel and one sdist in {dist}, found {len(artifacts)}") + version = (root / "VERSION").read_text(encoding="utf-8").splitlines()[0].strip() + revision = _revision(root) + project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + source_id = _spdx_id(PACKAGE_NAME) + dependency_packages, relationships = _dependency_packages(project) + (dist / CHECKSUMS_NAME).write_text( + "\n".join(f"{_sha256(path)} {path.name}" for path in artifacts) + "\n", encoding="utf-8" + ) + sbom: dict[str, Any] = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": f"{PACKAGE_NAME}-{version}", + "documentNamespace": f"https://basefoundry.github.io/base-cli/sbom/{version}/{revision}", + "creationInfo": { + "created": _created_at(), + "creators": ["Tool: base-cli release metadata generator"], + "comment": f"Source revision: {revision}", + }, + "documentComment": f"Source revision: {revision}; artifacts are listed in SHA256SUMS.", + "packages": [ + { + "SPDXID": source_id, + "name": PACKAGE_NAME, + "versionInfo": version, + "downloadLocation": "https://pypi.org/project/base-cli/", + "filesAnalyzed": False, + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "copyrightText": "NOASSERTION", + }, + *dependency_packages, + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": source_id, + }, + *relationships, + ], + } + (dist / SBOM_NAME).write_text(json.dumps(sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"Generated {SBOM_NAME} and {CHECKSUMS_NAME} for {PACKAGE_NAME} {version} at {revision}.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dist", type=Path, help="directory containing the wheel and sdist") + args = parser.parse_args() + if not args.dist.is_dir(): + raise SystemExit(f"distribution directory does not exist: {args.dist}") + generate(args.dist, _root()) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release_metadata.py b/scripts/validate_release_metadata.py new file mode 100644 index 0000000..faa8c52 --- /dev/null +++ b/scripts/validate_release_metadata.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Validate release checksums, SPDX metadata, and source revision binding.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +from typing import Any + +SBOM_NAME = "SBOM.spdx.json" +CHECKSUMS_NAME = "SHA256SUMS" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _fail(message: str) -> None: + raise SystemExit(f"release metadata validation failed: {message}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dist", type=Path) + args = parser.parse_args() + checksums_path = args.dist / CHECKSUMS_NAME + sbom_path = args.dist / SBOM_NAME + if not checksums_path.is_file() or not sbom_path.is_file(): + _fail(f"{SBOM_NAME} and {CHECKSUMS_NAME} are required") + rows: dict[str, str] = {} + for line in checksums_path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) != 2 or len(parts[0]) != 64: + _fail(f"invalid checksum row: {line!r}") + rows[parts[1]] = parts[0] + artifacts = sorted((*args.dist.glob("*.whl"), *args.dist.glob("*.tar.gz"))) + if set(rows) != {path.name for path in artifacts} or len(artifacts) != 2: + _fail("SHA256SUMS must cover exactly one wheel and one sdist") + for path in artifacts: + if _sha256(path) != rows[path.name]: + _fail(f"checksum mismatch for {path.name}") + try: + sbom: dict[str, Any] = json.loads(sbom_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _fail(f"invalid SPDX JSON: {exc}") + if sbom.get("spdxVersion") != "SPDX-2.3": + _fail("SBOM must use SPDX-2.3") + if sbom.get("dataLicense") != "CC0-1.0": + _fail("SBOM data license must be CC0-1.0") + expected_revision = os.environ.get("SOURCE_REVISION") or os.environ.get("GITHUB_SHA") + if expected_revision and expected_revision not in str(sbom.get("documentNamespace")): + _fail("SBOM namespace is not bound to SOURCE_REVISION") + if expected_revision and expected_revision not in str(sbom.get("documentComment")): + _fail("SBOM comment is not bound to SOURCE_REVISION") + packages = sbom.get("packages") + if not isinstance(packages, list) or not any(package.get("name") == "base-cli" for package in packages): + _fail("SBOM does not describe base-cli") + print(f"Validated {len(artifacts)} artifact hashes and SPDX SBOM {sbom_path}.") + + +if __name__ == "__main__": + main() diff --git a/tests/validate.sh b/tests/validate.sh index 96fcbf7..61f5a20 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -35,6 +35,8 @@ required_files=( scripts/validate_docs.py scripts/validate_examples.py scripts/validate_consumers.py + scripts/generate_release_metadata.py + scripts/validate_release_metadata.py scripts/benchmark_runtime.py tests/conftest.py compatibility/README.md