diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 52e49bb..5005a79 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,8 +5,8 @@ on: branches: ["main"] tags: ["v*"] # Manual rebuild: dispatched on main, runs the whole chain (build, push, - # sign, `notify-ops`, deploy) without a code change, e.g. to redeploy - # during an incident. + # sign, attest the SBOM, `notify-ops`, deploy) without a code change, e.g. + # to redeploy during an incident. workflow_dispatch: env: @@ -29,6 +29,14 @@ jobs: - target: office suffix: "-office" description: "Office image (adds LibreOffice for high-fidelity docx→pdf)" + # Each variant's exact digest, for `attest-sbom`. The legs of a matrix + # share one set of job outputs, so every variant writes a name of its + # own (a name both legs set would keep whichever leg finished last) and + # leaves the other empty: the runner does not write an empty output, so + # it cannot overwrite the other leg's digest. + outputs: + digest-base: ${{ matrix.target == 'base' && steps.build.outputs.digest || '' }} + digest-office: ${{ matrix.target == 'office' && steps.build.outputs.digest || '' }} permissions: contents: read packages: write @@ -118,3 +126,116 @@ jobs: for tag in $TAGS; do cosign sign --yes "${tag}@${DIGEST}" done + + # The SBOM that `attest-sbom` binds to the images, generated in a job that + # can only read: installing the image's dependency set and the generator + # runs over a hundred third-party packages, and none of that code runs next + # to the tokens that push, sign and attest. The three SBOM steps are + # sbom.yml's, verbatim — tests/test_supply_chain_hygiene.py fails if they + # differ — so dispatching sbom.yml on a branch tries them; this workflow + # pushes images and cannot be tried that way. + sbom: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + # Must match the Dockerfile's base image — the app is only ever + # tested on the version it ships on. scripts/check_python_version.py + # gates this; see its docstring for the drift it exists to prevent. + python-version: "3.14" + # No `cache: "pip"`: code running on main can write it, and + # --require-hashes does not cover every cached file (see sbom.yml). + + - name: Install the image's dependency set + run: | + python -m venv "$RUNNER_TEMP/image-env" + "$RUNNER_TEMP/image-env/bin/pip" install --require-hashes -r requirements.lock + + - name: Install CycloneDX generator + run: | + pip install --require-hashes --only-binary :all: -r requirements-sbom.lock + + - name: Generate CycloneDX SBOM (JSON) + env: + VERSION: ${{ github.sha }} + run: | + cyclonedx-py environment "$RUNNER_TEMP/image-env/bin/python" \ + --PEP-639 \ + --output-file "filemorph-${VERSION}.cdx.json" \ + --output-format JSON + + - name: Hand the SBOM to the attestation job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: filemorph-${{ github.sha }}.cdx.json + if-no-files-found: error + # A week, so that a failed attestation can be re-run ("Re-run failed + # jobs") after a weekend without building the images again. + retention-days: 7 + + # Binds the SBOM to each image's digest as a signed attestation: signed + # keyless through Sigstore like the cosign signature, stored with the + # repository's attestations and pushed to GHCR next to the image. Every + # build gets one — tags, main and manual rebuilds — so `:latest` and + # `:office` carry one too. docs/release-signing.md shows how to verify it. + # + # What the SBOM covers: the Python packages the image installs from + # requirements.lock, at the locked versions, plus the pip of the venv it + # is generated from, whose version can differ from the image's. It is + # generated from the lockfile, not by scanning the image, so the Python + # interpreter and the Debian packages of the base image and of the apt + # layers (ffmpeg, Ghostscript, the Cairo/Pango stack, LibreOffice in the + # office image) are not in it. + # + # This job can sign and push, so it checks nothing out and installs + # nothing: it downloads the SBOM, logs in with the docker CLI and runs + # GitHub's attest action. + attest-sbom: + needs: [build-and-push, sbom] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [base, office] + permissions: + attestations: write # store the attestation with the repository + id-token: write # OIDC token for the Sigstore signing certificate + packages: write # push the attestation to GHCR + steps: + # Into a directory of its own: the artifact comes from a job that ran + # third-party code. + - name: Download the SBOM + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: sbom + + # The docker CLI rather than docker/login-action, so that only GitHub's + # own actions run next to this job's tokens. The attest action reads + # the login from the docker config file this writes. + - name: Log in to GitHub Container Registry + env: + ACTOR: ${{ github.actor }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$TOKEN" | docker login "$REGISTRY" --username "$ACTOR" --password-stdin + + - name: Attest the SBOM to the ${{ matrix.target }} image + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + # No tag: the digest identifies the image. The action lowercases + # the name, as registry paths must be. + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ needs.build-and-push.outputs[format('digest-{0}', matrix.target)] }} + sbom-path: sbom/filemorph-${{ github.sha }}.cdx.json + push-to-registry: true + # Storage records exist for organisation-owned repositories only, + # and would need `artifact-metadata: write`. + create-storage-record: false diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 2b96d7e..b7b3878 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -22,9 +22,9 @@ name: SBOM # # The generator itself installs from requirements-sbom.lock with # --require-hashes, like the image's dependencies: an unpinned install would -# run whatever release PyPI served that day. release.yml runs the same three -# SBOM steps verbatim and cannot be tried on a PR, so this workflow is its -# rehearsal: dispatch it on a branch that changes them. +# run whatever release PyPI served that day. release.yml and docker.yml run +# the same three SBOM steps verbatim and cannot be tried on a PR, so this +# workflow is their rehearsal: dispatch it on a branch that changes them. # tests/test_supply_chain_hygiene.py keeps the steps identical. # # Why "environment" and not "requirements": `cyclonedx-py environment` reads diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dd320..d8a53b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — the Docker images carry a signed SBOM attestation + +`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and +office, on release tags, on every build of `main` and on manual rebuilds — so +`:latest` and `:office` carry one too. Until now the SBOM was a file attached +to the release, tied to the image by nothing but its name: `release.yml` finds +the image digest only by tag, best effort, a minute after the tag push. + +- **What it covers.** The Python packages the image installs from + `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv + the SBOM is generated from. It comes from a clean install of the lockfile, + not from scanning the image, so the Python interpreter and the Debian + packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the + Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says + so next to the verification command. Images built before this change, + v1.1.0 among them, have no attestation. +- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with + read access only. `attest-sbom` then signs the SBOM for each image variant + with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — + bound to the digest the build step reported, stores it with the repository's + attestations and pushes it to GHCR. That job holds `attestations: write`, + `id-token: write` and `packages: write`, checks nothing out, installs + nothing, logs in with the docker CLI and runs only GitHub's own actions. + `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps + `actions/attest`, so the latter is pinned directly. +- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows + it. `--predicate-type https://cyclonedx.org/bom` is required: without it, + `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, + `--source-ref` and `--source-digest` pin the workflow, the tag and the + commit whose signed tag was verified; `--owner` alone would accept an + attestation from any repository of the account. +- **What it costs.** The images are attested once both are built, so a deploy + from `main` starts a little later. `notify-ops` deploys only after the whole + Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation + (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual + redeploy included, until "Re-run failed jobs" gets through; the SBOM + artifact is kept for a week so that the re-run needs no new build. If one + image fails to build, neither is attested; the other stays pushed and signed. +- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM + steps identical to `sbom.yml`'s, and fails if an image variant loses its + attestation or its own digest output, if the attestation stops naming the + build's digest or stops going to GHCR, if either job gets an `if:` or + `continue-on-error` (on the job or a step), if the attesting job gains an + action, a permission or any command besides the registry login, if the SBOM + job gains write access, a secret, a cache or kept credentials, if the two + jobs stop agreeing on the SBOM's file name, or if the documented command + drops `--predicate-type` or names a workflow that does not attest. Each of + 42 simulated regressions fails at least one guard. + ### Fixed — compliance templates describe what the code does The DPA template and its TOM annex, the records-of-processing template, the diff --git a/docs/release-signing.md b/docs/release-signing.md index 593f4a5..c8782da 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -13,6 +13,11 @@ Both claims are independent: a forged tag would fail (1); a forged image at the published digest would fail (2). A consumer who needs end-to-end provenance verifies both. +The images also carry a signed **SBOM attestation** listing the Python +packages they ship. [Verifying the SBOM +attestation](#verifying-the-sbom-attestation) says what it covers and +how to check it. + ## Why this matters for the Compliance edition EVB-IT contracts (March 2026 update) require the procurer to be able @@ -60,6 +65,55 @@ A successful verification prints the signing certificate (issuer `https://github.com/MrChengLen/FileMorph/.github/workflows/docker.yml@refs/tags/...`), plus a Rekor transparency-log inclusion proof. +## Verifying the SBOM attestation + +[`docker.yml`](../.github/workflows/docker.yml) attests the CycloneDX +SBOM to every image it pushes: the slim and the office image, for each +release tag and each build of `main`. The attestation binds the SBOM to +the image digest and is signed through Sigstore keyless OIDC, like the +image. It is stored with the repository's attestations and pushed to +GHCR next to the image. Images built before this was introduced, v1.1.0 +among them, have none: `gh` then finds no attestation to verify. + +**What the SBOM covers:** the Python packages the image installs from +`requirements.lock`, at the locked versions, plus the `pip` of the +virtualenv it is generated from, whose version can differ from the +image's. It is generated from a clean install of the lockfile, not by +scanning the image, so it does not list the Python interpreter, the +Debian packages of the `python:3.14-slim` base image, or those the +Dockerfile adds with `apt` (FFmpeg, Ghostscript, the Cairo/Pango stack, +LibreOffice in the office image). Scan the image itself for those. For a +release, the attested SBOM comes from the same lockfile, by the same +steps, as the `filemorph-vX.Y.Z.cdx.json` attached to the release. + +Verify with the [GitHub CLI](https://cli.github.com/), logged in to any +GitHub account (`gh auth login`): + +```bash +gh attestation verify oci://ghcr.io/mrchenglen/filemorph:1.2.3 \ + --owner MrChengLen \ + --signer-workflow MrChengLen/FileMorph/.github/workflows/docker.yml \ + --source-ref refs/tags/v1.2.3 \ + --predicate-type https://cyclonedx.org/bom +``` + +- `--predicate-type` is required: by default `gh` accepts only SLSA + build-provenance attestations, and the check fails. +- `--signer-workflow` and `--source-ref` accept only an attestation that + `docker.yml` made for a push of that tag. After [verifying the + tag](#verifying-a-release-tag), also add + `--source-digest "$(git rev-parse 'v1.2.3^{commit}')"` in that clone: + it ties the attestation to the commit the tag's signature covers, so an + image built after the tag was moved to another commit fails. +- For the office image, verify `filemorph:1.2.3-office`. `:latest` and + `:office` move with every build of `main` and every release; verify + them with the ref of the build that pushed them: `--source-ref + refs/heads/main`, or `refs/tags/vX.Y.Z` right after a release. +- `--bundle-from-oci` reads the attestation from GHCR instead of the + GitHub API. +- To print the attested SBOM, add + `--format json --jq '.[0].verificationResult.statement.predicate'`. + ## First-time setup — generating the maintainer signing key The recurring flow in the next section assumes you already have a GPG @@ -129,7 +183,7 @@ git push origin vX.Y.Z The push triggers two parallel workflows: - [`docker.yml`](../.github/workflows/docker.yml) builds and - cosign-signs the container image. + cosign-signs the container image, and attests its SBOM to it. - [`release.yml`](../.github/workflows/release.yml) verifies the tag against the keys below, builds the source tarball, and publishes the GitHub release with an `IMAGE_DIGEST.txt` diff --git a/requirements-sbom.txt b/requirements-sbom.txt index e8c4a4b..46c3a15 100644 --- a/requirements-sbom.txt +++ b/requirements-sbom.txt @@ -1,5 +1,6 @@ -# The CycloneDX SBOM generator that sbom.yml and release.yml run. CI tooling -# only: it is not part of the image, and the SBOM it writes does not list it. +# The CycloneDX SBOM generator that sbom.yml, release.yml and docker.yml run. +# CI tooling only: it is not part of the image, and the SBOM it writes does +# not list it. # # The workflows install requirements-sbom.lock, compiled from this file with # hashes, and never this file: with --require-hashes pip refuses anything @@ -13,7 +14,7 @@ # leave the major version alone (.github/dependabot.yml): a major bump can # change the SBOM's CycloneDX spec version and its licence data, so take one # deliberately. 7.x also has no --PEP-639 flag: the bump has to drop it from -# sbom.yml and release.yml (tests/test_supply_chain_hygiene.py checks), and -# should dispatch sbom.yml on its branch before merging, since release.yml -# cannot be tried on a PR. +# sbom.yml, release.yml and docker.yml (tests/test_supply_chain_hygiene.py +# checks), and should dispatch sbom.yml on its branch before merging, since +# neither release.yml nor docker.yml can be tried on a PR. cyclonedx-bom>=5,<6 diff --git a/tests/test_supply_chain_hygiene.py b/tests/test_supply_chain_hygiene.py index ddcf0d2..517377b 100644 --- a/tests/test_supply_chain_hygiene.py +++ b/tests/test_supply_chain_hygiene.py @@ -30,7 +30,12 @@ * no job holding a write token or a secret restores or saves an Actions cache, docker.yml builds the image with ``no-cache: true``, and no buildx step anywhere uses a cache (no ``cache-from``/``cache-to``, - ``cache-binary: false``). + ``cache-binary: false``); + * docker.yml attests the SBOM to every image variant it pushes, by the + digest its build step reports: the SBOM comes from sbom.yml's steps in a + job that can only read, and the job that signs the attestation checks + nothing out, installs nothing and runs only GitHub's own actions; + docs/release-signing.md verifies it the way it is made. This is a tripwire, not a substitute for the server-side Scorecard run / review: the per-job permissions check here is a heuristic (it asserts a @@ -68,7 +73,7 @@ "uv_pin=$(grep -oE '^uv==[0-9][0-9A-Za-z.!+-]*' requirements-dev.txt)", 'pip install --only-binary :all: "$uv_pin"', ) -# The steps release.yml copies from sbom.yml, which is the one that can be run. +# The steps release.yml and docker.yml copy from sbom.yml, the one that can be run. _SBOM_STEPS = ( "Install the image's dependency set", "Install CycloneDX generator", @@ -92,6 +97,11 @@ "pull_request_review_comment", "pull_request_target", } +# The only actions that run in docker.yml's job holding `attestations: write`, +# and the one command it runs: the registry login the attest action reads. +_ATTEST_ACTIONS = ("actions/download-artifact@", "actions/attest@") +_ATTEST_LOGIN = 'echo "$TOKEN" | docker login "$REGISTRY" --username "$ACTOR" --password-stdin' +_RELEASE_SIGNING_DOC = _REPO_ROOT / "docs" / "release-signing.md" _SHA40_RE = re.compile(r"^[0-9a-f]{40}$") # `uses: owner/repo@ref` or `uses: owner/repo/path@ref`, tolerating a @@ -412,7 +422,7 @@ def test_deps_latest_mirrors_lint_and_test() -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "verapdf.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml", "verapdf.yml"]) def test_workflow_installs_what_the_image_ships(workflow: str) -> None: """The SBOM lists, and veraPDF validates, the image's dependency set. @@ -430,7 +440,7 @@ def test_workflow_installs_what_the_image_ships(workflow: str) -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml"]) def test_sbom_describes_the_lockfile_venv_only(workflow: str) -> None: """``cyclonedx-py environment`` reads the lockfile venv, not its own. @@ -450,7 +460,7 @@ def test_sbom_describes_the_lockfile_venv_only(workflow: str) -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml"]) def test_sbom_generator_installs_from_its_hashed_lockfile(workflow: str) -> None: """Every pip install in the SBOM workflows is hash-checked, and the generator comes from requirements-sbom.lock, as wheels only. @@ -487,8 +497,9 @@ def test_sbom_generator_lockfile_is_hash_pinned() -> None: the lockfile test above), because the SBOM jobs run the generator on it. """ assert _SBOM_LOCKFILE.is_file(), ( - "requirements-sbom.lock missing, but sbom.yml and release.yml install from it — " - "recompile it with the command in requirements-sbom.txt, or run the deps-lock workflow" + "requirements-sbom.lock missing, but sbom.yml, release.yml and docker.yml install from " + "it — recompile it with the command in requirements-sbom.txt, or run the deps-lock " + "workflow" ) for entry in _lock_entries(_SBOM_LOCKFILE): assert re.fullmatch(r"[A-Za-z0-9._-]+==\S+( --hash=sha256:[0-9a-f]{64})+", entry), ( @@ -798,26 +809,28 @@ def test_buildx_actions_restore_no_cache(workflow: Path) -> None: assert inputs.get("cache-binary") is False, f"{label}: set `cache-binary: false`" -def test_release_sbom_steps_match_sbom_workflow() -> None: - """release.yml runs sbom.yml's SBOM steps verbatim, with the flags the - locked generator accepts. +@pytest.mark.parametrize("workflow", ["release.yml", "docker.yml"]) +def test_sbom_steps_match_sbom_workflow(workflow: str) -> None: + """release.yml and docker.yml run sbom.yml's SBOM steps verbatim, with the + flags the locked generator accepts. - release.yml only runs on a signed tag, so a mistake there would first show - in a release. sbom.yml runs the same steps on every push to main and can - be dispatched on a branch; keeping the two identical makes that run the - test of the release path. + Neither can be tried before it counts: release.yml runs on a signed tag, + and docker.yml pushes the images it builds, so a mistake would first show + in a release or on main. sbom.yml runs the same steps on every push to + main and can be dispatched on a branch; keeping them identical makes that + run the test of both paths. """ def named_steps(name: str) -> dict[str, dict]: jobs = _workflow(_WORKFLOW_DIR / name)["jobs"].values() return {step["name"]: step for job in jobs for step in _steps(job) if "name" in step} - sbom, release = named_steps("sbom.yml"), named_steps("release.yml") + sbom, copy = named_steps("sbom.yml"), named_steps(workflow) for name in _SBOM_STEPS: assert name in sbom, f"sbom.yml has no step {name!r} — update this guard" - assert name in release, f"release.yml has no step {name!r}" - assert release[name].get("run") == sbom[name].get("run"), ( - f"release.yml and sbom.yml differ in step {name!r} — keep them identical" + assert name in copy, f"{workflow} has no step {name!r}" + assert copy[name].get("run") == sbom[name].get("run"), ( + f"{workflow} and sbom.yml differ in step {name!r} — keep them identical" ) generate = sbom["Generate CycloneDX SBOM (JSON)"]["run"] generator = Version(_lock_pins(_SBOM_LOCKFILE)[canonicalize_name("cyclonedx-bom")]) @@ -829,11 +842,209 @@ def named_steps(name: str) -> dict[str, dict]: ) else: assert "--PEP-639" not in generate, ( - f"cyclonedx-bom {generator} no longer accepts --PEP-639 — drop it from sbom.yml " - f"and release.yml, or both SBOM jobs fail" + f"cyclonedx-bom {generator} no longer accepts --PEP-639 — drop it from sbom.yml, " + f"release.yml and docker.yml, or every SBOM job fails" ) +def test_docker_attests_the_sbom_to_every_image_it_pushes() -> None: + """docker.yml binds the SBOM to each image variant by the digest it pushed. + + release.yml can only look an image up by tag, best effort, a minute after + the tag push; an attestation has to name exactly what was built, pushed + and signed, which is the digest the build step reports. The legs of a + matrix share one set of job outputs, so each variant needs an output of + its own: one name for both would hand both attestations whichever digest + was written last. The attestation also goes to GHCR, and it runs on every + build, main as well as tags (a decision of 2026-09-28), so ``:latest`` + carries one too: no ``if:`` may skip either job or a step of it, and no + ``continue-on-error`` may let the run, and with it the deploy, succeed + without an attestation. The SBOM crosses over by file name, and none of + this can be tried before it runs on main, so the names have to line up + here. + """ + jobs = _workflow(_WORKFLOW_DIR / "docker.yml")["jobs"] + build = jobs["build-and-push"] + targets = [leg["target"] for leg in build["strategy"]["matrix"]["include"]] + outputs = build.get("outputs") or {} + for target in targets: + value = str(outputs.get(f"digest-{target}", "")) + assert "steps.build.outputs.digest" in value and f"'{target}'" in value, ( + f"docker.yml: build-and-push must output `digest-{target}`, the build step's " + f"digest set by the {target} leg only" + ) + + attesting = [ + name + for name, job in jobs.items() + if any(str(step.get("uses", "")).startswith("actions/attest@") for step in _steps(job)) + ] + assert attesting == ["attest-sbom"], ( + f"docker.yml: expected one job running actions/attest, `attest-sbom`; found {attesting}" + ) + sbom_jobs = [ + name + for name, job in jobs.items() + if any("cyclonedx-py" in (step.get("run") or "") for step in _steps(job)) + ] + assert len(sbom_jobs) == 1, ( + f"docker.yml: expected one job generating the SBOM, found {sbom_jobs}" + ) + job, sbom = jobs["attest-sbom"], jobs[sbom_jobs[0]] + needs = job.get("needs") or [] + needs = [needs] if isinstance(needs, str) else needs + assert {"build-and-push", sbom_jobs[0]} <= set(needs), ( + f"docker.yml: `attest-sbom` must need build-and-push and {sbom_jobs[0]}, it needs {needs}" + ) + for name in ("attest-sbom", sbom_jobs[0]): + for part in (jobs[name], *_steps(jobs[name])): + assert "if" not in part and not part.get("continue-on-error"), ( + f"docker.yml job `{name}`: an `if:` or `continue-on-error` lets a build push " + f"images without an attestation — it runs on every build, main as well as tags" + ) + attested = (job.get("strategy") or {}).get("matrix", {}).get("target") or [] + assert sorted(attested) == sorted(targets), ( + f"docker.yml: `attest-sbom` must cover every image variant built, {targets}" + ) + + attest = next(s for s in _steps(job) if str(s.get("uses", "")).startswith("actions/attest@")) + inputs = attest.get("with") or {} + assert inputs.get("subject-digest") == ( + "${{ needs.build-and-push.outputs[format('digest-{0}', matrix.target)] }}" + ), "docker.yml: attest each variant's digest as build-and-push reported it" + assert inputs.get("subject-name") == "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}", ( + "docker.yml: the attestation's subject is the image name the build pushed, without a tag" + ) + assert inputs.get("push-to-registry") is True, ( + "docker.yml: push the attestation to GHCR (`push-to-registry: true`)" + ) + + generate = next(s for s in _steps(sbom) if s.get("name") == "Generate CycloneDX SBOM (JSON)") + output = re.search(r'--output-file "([^"]+)"', generate.get("run") or "") + assert output, 'docker.yml: the SBOM step no longer writes `--output-file "…"`' + version = str((generate.get("env") or {}).get("VERSION")) + file_name = output.group(1).replace("${VERSION}", version) + upload = next( + s for s in _steps(sbom) if str(s.get("uses", "")).startswith("actions/upload-artifact@") + ) + download = next( + s for s in _steps(job) if str(s.get("uses", "")).startswith("actions/download-artifact@") + ) + handed = upload.get("with") or {} + received = download.get("with") or {} + assert handed.get("path") == file_name and handed.get("if-no-files-found") == "error", ( + f"docker.yml: upload exactly `{file_name}`, and fail if it is missing" + ) + assert received.get("name") == handed.get("name") and received.get("path") == "sbom", ( + "docker.yml: download the SBOM artifact by its name into `sbom/`, a directory of its own" + ) + assert inputs.get("sbom-path") == f"sbom/{file_name}", ( + f"docker.yml: attest `sbom/{file_name}`, the file the SBOM job generated" + ) + + +def test_docker_attestation_runs_apart_from_third_party_code() -> None: + """docker.yml's attesting job installs nothing; the SBOM job can only read. + + A signed attestation vouches for the SBOM inside it. Generating that SBOM + installs the image's dependency set and the generator — over a hundred + packages — so that job holds read access only and no secret, restores no + cache and keeps no credentials. The job that can sign and push checks + nothing out, uses only ``_ATTEST_ACTIONS``, like release.yml's publish + job, and runs one command, the registry login: a pattern of forbidden + commands would miss ``apt-get`` or ``docker run``. Its permissions are + exactly what attesting to GHCR needs, and no other job may attest. + """ + workflow = _workflow(_WORKFLOW_DIR / "docker.yml") + jobs = workflow["jobs"] + can_attest = [ + name + for name, job in jobs.items() + if (job.get("permissions") or {}).get("attestations") == "write" + ] + assert can_attest == ["attest-sbom"], ( + f"docker.yml: only `attest-sbom` may hold `attestations: write`, found {can_attest}" + ) + job = jobs["attest-sbom"] + assert job["permissions"] == { + "attestations": "write", + "id-token": "write", + "packages": "write", + }, f"docker.yml: `attest-sbom` holds {job['permissions']}" + runs = [step["run"].strip() for step in _steps(job) if "run" in step] + assert runs == [_ATTEST_LOGIN], ( + f"docker.yml job `attest-sbom` can sign and push, so it runs only `{_ATTEST_LOGIN}`; " + f"found {runs}" + ) + for step in _steps(job): + uses = step.get("uses") + assert not uses or str(uses).startswith(_ATTEST_ACTIONS), ( + f"docker.yml job `attest-sbom` runs `{uses}` — only " + f"{', '.join(_ATTEST_ACTIONS)} run next to its tokens" + ) + + for name, sbom in jobs.items(): + if not any("cyclonedx-py" in (step.get("run") or "") for step in _steps(sbom)): + continue + assert sbom.get("permissions") == {"contents": "read"}, ( + f"docker.yml job `{name}` runs third-party code, so it may only read" + ) + assert not _privileged(sbom, workflow), ( + f"docker.yml job `{name}` runs third-party code, so it may hold no secret" + ) + for step in _steps(sbom): + uses, inputs = str(step.get("uses", "")), step.get("with") or {} + assert not uses.startswith("actions/cache") and not any("cache" in k for k in inputs), ( + f"docker.yml job `{name}`, step {step.get('name') or uses!r}: restores a cache, " + f"and the SBOM it generates gets attested" + ) + if uses.startswith("actions/checkout@"): + assert inputs.get("persist-credentials") is False, ( + f"docker.yml job `{name}`: checkout without `persist-credentials: false`" + ) + + +def test_docs_verify_the_sbom_attestation_as_docker_yml_makes_it() -> None: + """docs/release-signing.md verifies the attestation the way it is made, and + says what the SBOM leaves out. + + ``gh attestation verify`` looks for SLSA provenance unless told otherwise, + so without ``--predicate-type https://cyclonedx.org/bom`` the documented + command fails on every image. The workflow it names as signer has to be + the one that attests. And the SBOM lists the Python packages from + requirements.lock only: without saying that the image's Debian packages + are not in it, the attestation reads as a complete inventory. + """ + text = _RELEASE_SIGNING_DOC.read_text(encoding="utf-8") + section = re.search(r"^## Verifying the SBOM attestation\n(.*?)(?=^## |\Z)", text, re.M | re.S) + assert section, "docs/release-signing.md has no `## Verifying the SBOM attestation` section" + body = section.group(1) + blocks = "\n".join(re.findall(r"```bash\n(.*?)```", body, re.S)) + commands = [ + " ".join(match.replace("\\\n", " ").split()) + for match in re.findall(r"gh attestation verify(?:[^\n]*\\\n)*[^\n]*", blocks) + ] + assert commands, "docs/release-signing.md shows no `gh attestation verify` command" + for command in commands: + assert "--predicate-type https://cyclonedx.org/bom" in command, ( + f"`{command}` fails: without `--predicate-type https://cyclonedx.org/bom`, gh looks " + f"for SLSA provenance, which docker.yml does not attest" + ) + signer = re.search( + r"--signer-workflow MrChengLen/FileMorph/\.github/workflows/(\S+)", command + ) + assert signer and (_WORKFLOW_DIR / signer.group(1)).is_file(), ( + f"`{command}` names no workflow of this repository as `--signer-workflow`" + ) + assert "actions/attest@" in _workflow_code(signer.group(1)), ( + f"`{command}`: {signer.group(1)} does not attest — name the workflow that does" + ) + assert "requirements.lock" in body and "Debian" in body, ( + "docs/release-signing.md: say that the SBOM covers requirements.lock's Python packages " + "only, not the image's Debian packages" + ) + + def test_verapdf_image_is_digest_pinned() -> None: """The veraPDF gate runs a validator image pinned by digest.