Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/sbom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion docs/release-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
11 changes: 6 additions & 5 deletions requirements-sbom.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Loading
Loading