diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edb0df7f..92ec282a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: DATABASE_URL: postgresql://cortex:cortex@localhost:5432/cortex steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} @@ -67,7 +67,7 @@ jobs: PGPASSWORD=cortex psql -h localhost -U cortex -d cortex -c "SELECT version();" - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} @@ -75,7 +75,7 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Cache HuggingFace models - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -116,7 +116,7 @@ jobs: - name: Upload coverage if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: coverage-report path: coverage.xml @@ -135,15 +135,15 @@ jobs: CORTEX_MEMORY_STORE_BACKEND: sqlite steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-3.12-sqlite-${{ hashFiles('pyproject.toml') }} @@ -151,7 +151,7 @@ jobs: ${{ runner.os }}-pip-3.12-sqlite- - name: Cache HuggingFace models - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -196,15 +196,15 @@ jobs: CORTEX_MEMORY_STORE_BACKEND: sqlite steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Cache HuggingFace models - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -280,10 +280,10 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -303,10 +303,10 @@ jobs: name: Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" @@ -342,10 +342,10 @@ jobs: name: Build Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -356,7 +356,7 @@ jobs: run: python -m build - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist path: dist/ @@ -373,10 +373,10 @@ jobs: # the production image; nothing else runs the image with psycopg absent # and no DATABASE_URL. continue-on-error is intentionally NOT set. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # GitHub Actions cache (type=gha) persists Docker layer cache across # workflow runs, scoped to this repo. Without it, every run repeats the @@ -388,7 +388,7 @@ jobs: # (https://github.com/docker/build-push-action#cache-backend-api), # type=gha is the documented zero-config option for GHA runners. - name: Build image (buildx, GHA layer cache) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ./Dockerfile diff --git a/.github/workflows/marketplace-pins.yml b/.github/workflows/marketplace-pins.yml index f909b3cb..6b307931 100644 --- a/.github/workflows/marketplace-pins.yml +++ b/.github/workflows/marketplace-pins.yml @@ -31,7 +31,7 @@ jobs: name: pins current vs latest releases runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Check marketplace pins env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-ccplugins.yml b/.github/workflows/publish-ccplugins.yml index 919cc6e0..242175f3 100644 --- a/.github/workflows/publish-ccplugins.yml +++ b/.github/workflows/publish-ccplugins.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Cortex if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: path: cortex fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: - name: Checkout fork of ccplugins if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: ${{ env.FORK }} token: ${{ secrets.CCPLUGINS_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c33ed77e..b1e72465 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,14 +16,34 @@ name: Release # This workflow runs the tests on tag push, creates a GitHub Release with # auto-generated notes, and (best-effort) publishes to the deprecated # PyPI channel. +# +# Supply-chain hardening (issue #178). Cortex reads and persists every +# session transcript and installs session hooks with the user's full +# filesystem access; a substituted wheel is a keylogger on the user's +# work, and until now the wheel shipped with nothing a user could verify. +# This workflow now: +# * attests build provenance for the wheel, the sdist and the SBOM +# (Sigstore-backed; verify with `gh attestation verify +# --repo cdeust/Cortex`); +# * publishes a CycloneDX SBOM generated from uv.lock, so the ML stack +# (torch/transformers/scipy/scikit-learn) — the bulk of the attack +# surface — is enumerable rather than opaque; +# * publishes a `.sha256` companion for every GitHub Release +# asset, verified on download by scripts/verify_release_artifact.py; +# * pins every third-party action to a commit SHA (a floating tag in the +# job that builds and signs releases is a live supply-chain hole). +# PyPI keeps PEP 740 attestations (native to Trusted Publishing) in +# addition to the GitHub attestations above. on: push: tags: - "v*" +# Least privilege at the top level; jobs that mint OIDC tokens widen it +# locally (issue #178). `contents: write` is the release-upload scope. permissions: - contents: write + contents: read jobs: test: @@ -34,10 +54,10 @@ jobs: DATABASE_URL: postgresql://cortex:cortex@localhost:5432/cortex steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -67,7 +87,7 @@ jobs: PGPASSWORD=cortex psql -h localhost -U cortex -d cortex -c "SELECT version();" - name: Cache HuggingFace models - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/huggingface key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 @@ -86,8 +106,10 @@ jobs: name: GitHub Release needs: test runs-on: ubuntu-latest + permissions: + contents: write # create the release + attach auto-generated notes steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 @@ -105,7 +127,7 @@ jobs: echo "$CHANGELOG" > changelog.txt - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: body_path: changelog.txt generate_release_notes: true @@ -116,14 +138,18 @@ jobs: # on a non-vulnerable version. Decoupled from github-release so a PyPI # failure never blocks the supported channel. build: - name: Build sdist + wheel (deprecated PyPI channel) + name: Build sdist + wheel + attest (deprecated PyPI channel) needs: test runs-on: ubuntu-latest + permissions: + contents: write # upload attested wheel/sdist + checksums to release + id-token: write # OIDC token Sigstore exchanges for a signing cert + attestations: write # record the build-provenance statement on the repo steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -133,11 +159,109 @@ jobs: - name: Build sdist and wheel run: python -m build - - name: Upload build artifacts - uses: actions/upload-artifact@v4 + # Checksums for every artifact BEFORE attestation and upload, so the + # `.sha256` a user verifies with scripts/verify_release_artifact.py + # describes exactly the bytes that were attested (issue #178 criterion 4). + - name: Compute SHA-256 checksums + run: | + set -euxo pipefail + cd dist + for f in *; do + sha256sum "$f" | tee "$f.sha256" + done + ls -la + + # Provenance BEFORE upload: the attestation binds each artifact digest to + # this workflow, commit and runner. Verify with + # gh attestation verify --repo cdeust/Cortex + - name: Attest build provenance (wheel + sdist) + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + + - name: Upload build artifacts (for the PyPI job) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist - path: dist/ + # Only the distributions go to PyPI; the .sha256 companions are + # GitHub-release assets, excluded here so twine sees a clean dist/. + path: | + dist/*.whl + dist/*.tar.gz + + - name: Attach attested distributions + checksums to the GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ github.ref_name }} + files: | + dist/*.whl + dist/*.tar.gz + dist/*.sha256 + fail_on_unmatched_files: true + + # CycloneDX SBOM for the FULL dependency graph (issue #178 criterion 3). + # Generated from uv.lock via `uv export`, so it lists the exact versions the + # release resolves to — including torch/transformers/scipy/scikit-learn, + # which arrive transitively through sentence-transformers and are the bulk + # of the attack surface. Deriving it from the lock (not a live install) + # keeps the job bounded and reproducible: "what is in this distribution" is + # answered from the same lock the wheel is built against, and the several- + # hundred-MB torch download is not on the critical path. + sbom: + name: SBOM (CycloneDX, from uv.lock) + needs: test + runs-on: ubuntu-latest + permissions: + contents: write # upload SBOM + checksum to the release + id-token: write # OIDC for attestation + attestations: write # record the provenance statement + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install uv (pinned) + cyclonedx + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Export the locked graph and build the SBOM + run: | + set -euxo pipefail + # Full transitive graph, all extras, pinned to uv.lock. --no-emit-project + # drops the local package's own editable line so cyclonedx sees only + # third-party components. --no-hashes because cyclonedx-py records the + # component set, not the wheel hashes (those live in the .sha256 files). + uv export --frozen --all-extras --no-dev --no-emit-project --no-hashes \ + --format requirements-txt > sbom-requirements.txt + wc -l sbom-requirements.txt + # cyclonedx-py ships in the cyclonedx-bom distribution. + uv tool run --from cyclonedx-bom cyclonedx-py requirements \ + sbom-requirements.txt --output-format JSON --output-file hypermnesia-mcp.cdx.json + # Fail loudly if the ML stack is somehow absent — criterion 3 names it. + for pkg in torch transformers scipy scikit-learn; do + grep -q "\"name\": \"$pkg\"" hypermnesia-mcp.cdx.json \ + || { echo "SBOM missing expected component: $pkg" >&2; exit 1; } + done + sha256sum hypermnesia-mcp.cdx.json | tee hypermnesia-mcp.cdx.json.sha256 + ls -la hypermnesia-mcp.cdx.json* + + - name: Attest build provenance (SBOM) + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: hypermnesia-mcp.cdx.json + + - name: Upload SBOM + checksum to the GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ github.ref_name }} + files: | + hypermnesia-mcp.cdx.json + hypermnesia-mcp.cdx.json.sha256 + fail_on_unmatched_files: true publish-pypi: name: Publish to PyPI (deprecated channel) @@ -153,18 +277,22 @@ jobs: id-token: write steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist path: dist/ - name: Publish to PyPI (best-effort — must not fail the release) - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 # Deprecated channel: a rejected upload (already-exists, or the # Trusted Publisher entry was removed) must NOT red-X the release. continue-on-error: true with: verbose: true + # PEP 740 attestations are ON by default here (id-token: write is + # present); the package page shows a verified build alongside the + # GitHub attestations minted in the build job. + # # skip-existing so a retag against an already-published version # is a no-op instead of a hard failure — this was the exact # failure mode that motivated removing PyPI in a2dc7e3. diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..071cf914 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,33 @@ +name: OpenSSF Scorecard + +# Issue #178 criterion 6. Cortex adopts the ecosystem's single Scorecard +# definition as a CALL SITE, not a copy: the reusable workflow lives in +# cdeust/automatised-pipeline (AP #66/#77) so the pinned action SHAs and the +# report-only policy are maintained in one place. Scorecard grades repo-level +# posture (branch protection, signing, review counts) and is language- +# agnostic, which is exactly the part of the supply-chain story that +# generalises — SBOM and build provenance stay per-repo in release.yml because +# their toolchains differ (uv/pip here, cargo there, pnpm in prd-gen). +# +# The first score is a BASELINE recorded as-is: a low number is a valid +# measurement (§8), not a failure, and this deliberately does not gate a PR — +# a contributor cannot fix repo-level policy inside their diff. + +on: + schedule: + - cron: "0 7 * * 2" # Tuesdays 07:00 UTC — posture drifts by inaction + branch_protection_rule: + push: + branches: [main] + workflow_dispatch: + +permissions: read-all + +jobs: + scorecard: + uses: cdeust/automatised-pipeline/.github/workflows/scorecard.yml@main + permissions: + security-events: write # upload SARIF to code scanning + id-token: write # publish signed results to the OpenSSF API + contents: read + actions: read diff --git a/.github/workflows/sync-ccplugins-fork.yml b/.github/workflows/sync-ccplugins-fork.yml index 6809b93a..4f9da00d 100644 --- a/.github/workflows/sync-ccplugins-fork.yml +++ b/.github/workflows/sync-ccplugins-fork.yml @@ -46,7 +46,7 @@ jobs: - name: Checkout fork if: steps.guard.outputs.has_pat == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: ${{ env.FORK }} token: ${{ secrets.CCPLUGINS_PAT }} diff --git a/SECURITY.md b/SECURITY.md index d6e7fc0e..7df5ea05 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,74 @@ # Security Policy +## What Cortex accesses + +Cortex runs with your user's full permissions and, by design, reaches the +most sensitive parts of your engineering environment. Stated plainly so the +assurance below can be judged against the actual exposure: + +- **Every Claude Code session transcript** under `~/.claude/` — it reads and + persists conversation history (`projects/*/**.jsonl`), memory notes and + session logs to build your memory store (see `PRIVACY.md`). +- **Session hooks** — it installs `SessionStart` / `SessionEnd` / + `PostToolUse` hooks (`hooks/`, `scripts/install_hooks.py`) that execute + automatically on Claude Code lifecycle events. +- **Your filesystem**, at your user's permission level — it reads source + trees you point it at and writes its store and agent-config files. + +A substituted or tampered Cortex distribution is therefore a keylogger on +your engineering work. That is precisely why the artifact integrity story +below exists: the local-only privacy guarantee in `PRIVACY.md` is only as +strong as the guarantee that the code you ran is the code we published. + +## Supply-chain assurance + +As of issue #178, every Cortex release ships with verifiable provenance +(`.github/workflows/release.yml`): + +- **Build provenance** — the wheel, the sdist and the SBOM each carry a + Sigstore-backed [build-provenance attestation](https://docs.github.com/actions/security-guides/using-artifact-attestations). + Verify a downloaded asset binds to this repository and workflow: + + ```bash + gh attestation verify hypermnesia_mcp--py3-none-any.whl \ + --repo cdeust/Cortex + ``` + +- **PyPI provenance** — the deprecated PyPI channel publishes via PEP 740 + Trusted Publishing with attestations, so the package page shows a verified + build. (`pip` verifies distributions against PyPI's recorded hashes; this + adds *who built it and from which commit* on top.) + +- **SBOM** — a CycloneDX SBOM (`hypermnesia-mcp.cdx.json`) generated from + `uv.lock` accompanies every release, enumerating the full transitive + dependency graph including the ML stack (torch / transformers / scipy / + scikit-learn) that is the bulk of the attack surface. + +- **Checksums** — every GitHub Release asset has a `.sha256` + companion. Verify a download before trusting it: + + ```bash + python scripts/verify_release_artifact.py --checksum-file .sha256 + ``` + + A mismatch exits non-zero and prints both digests; this is the consumer + side of the checksums, and it is what closes the risk — an attestation + nobody checks changes nothing. + +- **Continuous analysis** — CodeQL runs on a schedule via GitHub's default + code-scanning setup (Python, JavaScript/TypeScript and Actions), and OpenSSF + Scorecard via `scorecard.yml`. The Scorecard number is a recorded baseline, + not a badge. + +**What this does NOT claim.** Provenance proves *who built the artifact and +from which commit*, not that the source is free of defects; and it is worth +nothing to a user who does not run the verification commands above. The +supported marketplace install path consumes the git tree directly +(ADR-0050), so its integrity is the tagged commit plus its attestation, not +a downloaded checksum. The container image built in CI is a bare-container +*smoke test* (`ci.yml`, `docker-smoke`) — it is never pushed to a registry, +so there is no published image to attest. + ## Reporting a Vulnerability If you discover a security issue in this project, **do not** open a public diff --git a/scripts/verify_release_artifact.py b/scripts/verify_release_artifact.py new file mode 100644 index 00000000..ec069c07 --- /dev/null +++ b/scripts/verify_release_artifact.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Verify a downloaded release artifact against its published SHA-256. + +Issue #178, acceptance criterion 4. Cortex reads and persists every session +transcript and installs session hooks with the user's full filesystem access; +a substituted wheel or bundle is a keylogger on the user's engineering work. +The release workflow now publishes a ``.sha256`` companion for every +GitHub Release asset (wheel, sdist, SBOM). This module is the *consumer* side: +an install path that downloads a release asset can verify it before trusting +it, and a user can run it by hand. + +Scope, stated honestly (§8, no theater): + * The MARKETPLACE install path consumes the git tree directly + (``${CLAUDE_PLUGIN_ROOT}``, ADR-0050); its integrity is the git commit + plus the tag's build-provenance attestation, not a downloaded checksum. + * The PYPI path installs through ``pip``, which verifies distributions + against PyPI's own recorded hashes (and PEP 740 attestations when the + installer supports them). This module does NOT duplicate that. + * What this module covers is the third path: fetching a raw asset from the + GitHub Release (e.g. the SBOM, or a wheel pulled outside pip). That is the + one download with no built-in hash check, so it is the one that gets one. + +Standard library only: this may run before the plugin's own dependencies exist +on ``sys.path`` (same constraint as the ``launcher_deps_*`` modules). +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +from pathlib import Path + +# source: FIPS 180-4 (SHA-256 digest is 32 bytes -> 64 lowercase hex chars). +_SHA256_HEX_LEN = 64 +# Read the artifact in fixed-size chunks so a multi-hundred-MB wheel (the ML +# stack) is hashed with bounded memory rather than a single read into RAM. +# source: measured — 1 MiB balances syscall count against resident memory. +_CHUNK_BYTES = 1024 * 1024 + + +class ArtifactIntegrityError(Exception): + """Raised when an artifact's actual SHA-256 does not match the expected one. + + Carries both digests so the caller's error message can name the exact + mismatch (§F1: an integrity failure must be actionable, not just "bad"). + """ + + def __init__(self, artifact: Path, expected: str, actual: str) -> None: + self.artifact = artifact + self.expected = expected + self.actual = actual + super().__init__( + f"integrity check FAILED for {artifact}: " + f"expected sha256={expected}, computed sha256={actual}" + ) + + +def sha256_of(artifact: Path) -> str: + """Return the lowercase hex SHA-256 of the file at ``artifact``. + + Precondition: ``artifact`` is a readable regular file. + Postcondition: returns a 64-char lowercase hex string that is the SHA-256 + of the file's exact bytes; the file is read in bounded chunks. + """ + digest = hashlib.sha256() + with artifact.open("rb") as handle: + for chunk in iter(lambda: handle.read(_CHUNK_BYTES), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _normalize_expected(expected: str) -> str: + """Return the bare hex digest from a raw or ``sha256sum``-formatted value. + + Precondition: ``expected`` contains a 64-char hex digest, optionally + followed by whitespace and a filename (the ``sha256sum`` / ``shasum -a 256`` + line format `` ``). + Postcondition: returns the lowercase 64-char hex digest. + Raises ``ValueError`` if no valid digest is present — a malformed checksum + file must fail loudly, never silently pass verification. + """ + token = expected.strip().split()[0] if expected.strip() else "" + token = token.lower() + if len(token) != _SHA256_HEX_LEN or any(c not in "0123456789abcdef" for c in token): + raise ValueError( + f"expected a {_SHA256_HEX_LEN}-char hex SHA-256, got: {expected!r}" + ) + return token + + +def verify_artifact(artifact: Path, expected: str) -> None: + """Verify ``artifact`` has SHA-256 ``expected``; raise on any mismatch. + + Precondition: ``artifact`` is a readable file; ``expected`` is a hex digest + (optionally in ``sha256sum`` line format). + Postcondition: returns ``None`` iff the file's digest equals ``expected``. + Raises ``ArtifactIntegrityError`` on mismatch and ``ValueError`` if + ``expected`` is not a well-formed digest. There is no return value that + means "probably fine": the function either returns cleanly or raises. + """ + want = _normalize_expected(expected) + got = sha256_of(artifact) + if got != want: + raise ArtifactIntegrityError(artifact, want, got) + + +def verify_from_checksum_file(artifact: Path, checksum_file: Path) -> None: + """Verify ``artifact`` against the digest recorded in ``checksum_file``. + + Precondition: ``checksum_file`` is the ``.sha256`` companion the + release workflow publishes, whose first line is `` ``. + Postcondition: returns ``None`` iff ``artifact`` matches; otherwise raises + ``ArtifactIntegrityError`` (mismatch) or ``ValueError`` (unreadable/empty + checksum file). + """ + contents = checksum_file.read_text(encoding="utf-8").strip() + if not contents: + raise ValueError(f"checksum file is empty: {checksum_file}") + verify_artifact(artifact, contents) + + +def main(argv: list[str] | None = None) -> int: + """CLI: ``verify_release_artifact.py ( | --checksum-file FILE)``. + + Returns 0 on a verified match, 1 on integrity failure, 2 on usage/IO error. + Exit codes are the contract callers (install scripts) branch on. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path, help="path to the downloaded asset") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("expected", nargs="?", help="expected SHA-256 hex digest") + group.add_argument( + "--checksum-file", + type=Path, + help="path to the published .sha256 companion", + ) + args = parser.parse_args(argv) + + try: + if args.checksum_file is not None: + verify_from_checksum_file(args.artifact, args.checksum_file) + else: + verify_artifact(args.artifact, args.expected) + except ArtifactIntegrityError as exc: + print(str(exc), file=sys.stderr) + return 1 + except (OSError, ValueError) as exc: + print(f"verification could not run: {exc}", file=sys.stderr) + return 2 + + print(f"OK: {args.artifact} matches the expected SHA-256") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_py/scripts/test_verify_release_artifact.py b/tests_py/scripts/test_verify_release_artifact.py new file mode 100644 index 00000000..92a12e2e --- /dev/null +++ b/tests_py/scripts/test_verify_release_artifact.py @@ -0,0 +1,116 @@ +"""Tests for scripts/verify_release_artifact.py — release-asset integrity (#178). + +Issue #178 criterion 4 (with §13 A3, G4): the point of a checksum is that a +TAMPERED artifact is REJECTED. Asserting only that a good file passes would +miss the entire reason the code exists, so the tamper-rejection cases are the +load-bearing ones here. + +unittest-style to match the sibling scripts tests; Cortex's pytest collects +unittest classes natively. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +_spec = importlib.util.spec_from_file_location( + "verify_release_artifact", + Path(__file__).resolve().parents[2] / "scripts" / "verify_release_artifact.py", +) +assert _spec is not None and _spec.loader is not None +verify = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(verify) + + +def _write(path: Path, data: bytes) -> str: + path.write_bytes(data) + return hashlib.sha256(data).hexdigest() + + +class VerifyArtifactTest(unittest.TestCase): + def test_matching_digest_passes(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + good = _write(art, b"authentic release bytes") + # No exception == verified. verify_artifact returns None on success. + self.assertIsNone(verify.verify_artifact(art, good)) + + def test_tampered_artifact_is_rejected(self) -> None: + """The core case: bytes changed after the digest was published.""" + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + published = _write(art, b"authentic release bytes") + # Attacker swaps the file contents; the published digest is stale. + art.write_bytes(b"authentic release bytes + keylogger") + with self.assertRaises(verify.ArtifactIntegrityError) as ctx: + verify.verify_artifact(art, published) + # The error must name both digests so the failure is actionable. + self.assertEqual(ctx.exception.expected, published) + self.assertNotEqual(ctx.exception.actual, published) + + def test_single_bit_flip_is_rejected(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "sdist.tar.gz" + good = _write(art, b"\x00" * 4096) + art.write_bytes(b"\x01" + b"\x00" * 4095) + with self.assertRaises(verify.ArtifactIntegrityError): + verify.verify_artifact(art, good) + + def test_checksum_file_line_format_accepted(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + good = _write(art, b"payload") + checksum = Path(tmp) / "wheel.whl.sha256" + # sha256sum / shasum -a 256 line format: " ". + checksum.write_text(f"{good} wheel.whl\n", encoding="utf-8") + self.assertIsNone(verify.verify_from_checksum_file(art, checksum)) + + def test_checksum_file_tamper_is_rejected(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + good = _write(art, b"payload") + art.write_bytes(b"payload-modified") + checksum = Path(tmp) / "wheel.whl.sha256" + checksum.write_text(f"{good} wheel.whl\n", encoding="utf-8") + with self.assertRaises(verify.ArtifactIntegrityError): + verify.verify_from_checksum_file(art, checksum) + + def test_malformed_digest_raises_value_error(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + _write(art, b"payload") + # A too-short / non-hex value must fail loudly, never silently pass. + with self.assertRaises(ValueError): + verify.verify_artifact(art, "not-a-real-digest") + + def test_empty_checksum_file_raises(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + _write(art, b"payload") + checksum = Path(tmp) / "wheel.whl.sha256" + checksum.write_text("", encoding="utf-8") + with self.assertRaises(ValueError): + verify.verify_from_checksum_file(art, checksum) + + def test_cli_returns_1_on_tamper(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + published = _write(art, b"authentic") + art.write_bytes(b"tampered") + rc = verify.main([str(art), published]) + self.assertEqual(rc, 1) + + def test_cli_returns_0_on_match(self) -> None: + with TemporaryDirectory() as tmp: + art = Path(tmp) / "wheel.whl" + good = _write(art, b"authentic") + rc = verify.main([str(art), good]) + self.assertEqual(rc, 0) + + +if __name__ == "__main__": + unittest.main()