From c58597b2f6ff951b41124ca669ff2eff5b83ae2b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 13:05:52 +0100 Subject: [PATCH 1/7] #614 Add a composite action that generates a CycloneDX SBOM for an image and attaches it in GHCR syft (anchore/sbom-action) reads the image the shared build-docker-image action loaded into the runner's daemon; the SBOM is kept as a workflow artifact and attached to the image with ORAS, using the referrers tag schema because GHCR has no referrers API. Co-Authored-By: Claude Fable 5.1 --- .github/actions/image-sbom/action.yml | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/actions/image-sbom/action.yml diff --git a/.github/actions/image-sbom/action.yml b/.github/actions/image-sbom/action.yml new file mode 100644 index 000000000..b422c10f5 --- /dev/null +++ b/.github/actions/image-sbom/action.yml @@ -0,0 +1,74 @@ +name: Image SBOM +description: >- + Generate a CycloneDX software bill of materials for an image the job has just built and + pushed, keep it as a workflow artifact, and attach it to the image in GHCR so it can be found + from the image tag (the commit SHA) for as long as the image exists (OWASP A03, #614). + +inputs: + image: + description: Full reference the build step pushed, e.g. ghcr.io/dfe-digital/check-performance-data: + required: true + name: + description: Short name for the artifact file, e.g. web or worker + required: true + github-token: + description: Token with packages:write for the image's GHCR package (secrets.GITHUB_TOKEN) + required: true + +runs: + using: composite + steps: + - name: Name the SBOM after the image tag + id: names + shell: bash + env: + IMAGE: ${{ inputs.image }} + NAME: ${{ inputs.name }} + run: | + # The tag is the commit sha, so the artifact can be found from the sha alone. + tag="${IMAGE##*:}" + echo "file=sbom-${NAME}-${tag}.cdx.json" >> "$GITHUB_OUTPUT" + + - name: Generate the SBOM + uses: anchore/sbom-action@v0 + with: + # Read from the runner's Docker daemon, where build-docker-image loaded the image + # under this same tag; syft would pull the (public) tag from GHCR if it were missing. + image: ${{ inputs.image }} + format: cyclonedx-json + output-file: ${{ runner.temp }}/${{ steps.names.outputs.file }} + artifact-name: ${{ steps.names.outputs.file }} + upload-artifact: true + upload-release-assets: false + dependency-snapshot: false + + - name: Set up ORAS + uses: oras-project/setup-oras@v2 + + - name: Attach the SBOM to the image in GHCR + shell: bash + working-directory: ${{ runner.temp }} + env: + IMAGE: ${{ inputs.image }} + FILE: ${{ steps.names.outputs.file }} + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + # Workflow artifacts expire (90 days on a public repository); the copy attached to the + # image lives as long as the image. GHCR has no OCI referrers API, so ORAS falls back to + # the referrers tag schema: the SBOM manifest's subject is the image manifest, and an + # index tagged sha256- in the same package lists it. Do not delete those + # tags. `oras discover ` reads the index. + echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + oras attach --artifact-type application/vnd.cyclonedx+json "$IMAGE" "$FILE:application/vnd.cyclonedx+json" + oras discover "$IMAGE" + { + echo "### SBOM for \`$IMAGE\`" + echo + echo "Workflow artifact \`$FILE\`; also attached to the image in GHCR:" + echo + echo '```sh' + echo "oras discover $IMAGE" + echo '```' + echo + echo "See \`docs/sbom.md\` for fetching and querying it." + } >> "$GITHUB_STEP_SUMMARY" From ad67d321675f6cefc8c6bb80116ce14dc8e8802b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 13:06:17 +0100 Subject: [PATCH 2/7] #614 Generate and attach an SBOM after every web and worker image build Both builds in Build and Deploy and the weekly no-cache rebuild of the web image, which pushes a fresh image under the same sha tag. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-and-deploy.yml | 14 ++++++++++++++ .github/workflows/build-nocache.yml | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 7be158626..c6e149771 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -109,6 +109,13 @@ jobs: reuse-cache: true snyk-token: ${{ secrets.SNYK_TOKEN }} + - name: SBOM for the web image + uses: ./.github/actions/image-sbom + with: + image: ${{ steps.build-image.outputs.image }} + name: web + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push worker docker image id: build-worker-image uses: DFE-Digital/github-actions/build-docker-image@master @@ -122,6 +129,13 @@ jobs: reuse-cache: true snyk-token: ${{ secrets.SNYK_TOKEN }} + - name: SBOM for the worker image + uses: ./.github/actions/image-sbom + with: + image: ${{ steps.build-worker-image.outputs.image }} + name: worker + github-token: ${{ secrets.GITHUB_TOKEN }} + e2e: name: E2E smoke tests # The smoke subset only (Category=Smoke): a few minutes to show that the review app is up diff --git a/.github/workflows/build-nocache.yml b/.github/workflows/build-nocache.yml index 5594d3b9f..13abc94b6 100644 --- a/.github/workflows/build-nocache.yml +++ b/.github/workflows/build-nocache.yml @@ -34,6 +34,14 @@ jobs: reuse-cache: false snyk-token: ${{ secrets.SNYK_TOKEN }} + - name: SBOM for the web image + # The weekly rebuild pushes a fresh image under the same sha tag, so it needs its own SBOM. + uses: ./.github/actions/image-sbom + with: + image: ${{ steps.build-image.outputs.image }} + name: web + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Notify teams channel on job failure uses: DFE-Digital/github-actions/send-to-teams-channel@master if: ${{ failure() }} From 2defa314b06682b755476a297d8711068b4756f2 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 13:06:37 +0100 Subject: [PATCH 3/7] #614 Document where each image's SBOM is and how to check an advisory against it Co-Authored-By: Claude Fable 5.1 --- docs/sbom.md | 72 +++++++++++++++++++ ...hnical-stakeholders-functional-overview.md | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 docs/sbom.md diff --git a/docs/sbom.md b/docs/sbom.md new file mode 100644 index 000000000..eb3ba9fb4 --- /dev/null +++ b/docs/sbom.md @@ -0,0 +1,72 @@ +# Software bill of materials (SBOM) + +Every web and worker image that CI pushes to GHCR has a CycloneDX SBOM: a list of what is +inside the image, so that a new security advisory can be checked against what is deployed +without rebuilding anything (OWASP Top 10 2025, A03 software supply chain failures; #614). + +## What it contains + +[syft](https://github.com/anchore/syft) scans the pushed image and writes CycloneDX JSON with: + +- the NuGet packages the published application carries, read from `/app/*.deps.json` + (direct and transitive, with versions — about 90 for the web image), including the + application itself (`DfE.CheckPerformanceData.Web`, `DfE.CheckPerformanceData.RulesEngineWorker`) +- the Ubuntu packages in the runtime image (about 100, as left by the base image plus the + `apt-get upgrade` in the Dockerfile) and the operating system version +- the files those Ubuntu packages own + +It does not contain the SDK or the build tools (they are in the build stage, not the image), +and it does not contain the test projects' packages (SixLabors.ImageSharp, Playwright, xUnit). + +## Where it is + +The image tag is the commit sha, and so is the SBOM's name. + +1. **Workflow artifact** on the `Build and Deploy` (or `Build No Cache`) run: + `sbom-web-.cdx.json` and `sbom-worker-.cdx.json`. Kept 90 days. +2. **Attached to the image in GHCR** as an OCI referrer, kept as long as the image. GHCR has no + referrers API, so ORAS uses the referrers tag schema: the package gains a tag named + `sha256-` that points at an index listing the SBOM. Those tags are the + SBOMs; do not delete them. + +The action that does this is `.github/actions/image-sbom`, called after each image build in +`.github/workflows/build-and-deploy.yml` and `.github/workflows/build-nocache.yml`. + +## Finding the SBOM for a deployed environment + +1. Find the sha the environment runs: the `Build and Deploy` run that deployed it (the run for + the merge commit on `main`), or the image tag on the AKS deployment. +2. List and fetch the SBOM with [ORAS](https://oras.land) (the packages are public; no login): + + ```sh + oras discover ghcr.io/dfe-digital/check-performance-data: + oras pull ghcr.io/dfe-digital/check-performance-data@sha256: + ``` + + The pull writes `sbom-web-.cdx.json` into the current directory. The worker image is + `ghcr.io/dfe-digital/check-performance-data-worker:`. + + Without ORAS installed, run it from its image (Linux/macOS; on Windows Git Bash prefix the + command with `MSYS_NO_PATHCONV=1`): + + ```sh + docker run --rm -v "$PWD:/work" -w /work ghcr.io/oras-project/oras:v1.3.5 discover ghcr.io/dfe-digital/check-performance-data: + ``` + +## Checking an advisory against it + +Look a package up by name: + +```sh +jq '.components[] | select(.name == "Npgsql") | {name, version, purl}' sbom-web-.cdx.json +``` + +Or scan the whole SBOM for known vulnerabilities with [grype](https://github.com/anchore/grype): + +```sh +grype sbom:./sbom-web-.cdx.json +``` + +The weekly `Build No Cache` run rebuilds the web image under the same sha tag with current +Ubuntu packages and attaches a new SBOM to the new image; `oras discover` by tag always shows +the SBOM of the image the tag points at now. diff --git a/docs/technical-stakeholders-functional-overview.md b/docs/technical-stakeholders-functional-overview.md index 33d38c25a..c2f50775e 100644 --- a/docs/technical-stakeholders-functional-overview.md +++ b/docs/technical-stakeholders-functional-overview.md @@ -314,7 +314,7 @@ Producer/consumer split so requests never wait on Notify: `RequestNotificationSe - **Docker**: multi-stage Dockerfiles for Web and Worker (sdk:10.0 → aspnet:10.0, port 8080); both runtime images run as the aspnet image's unprivileged `app` user (uid 1654, `USER $APP_UID`) and the AKS modules enforce `run_as_non_root` (#609); both build stages restore once with `--locked-mode` from the committed lock files and build/publish `--no-restore`, and `src/.dockerignore` keeps host `bin/`/`obj/` out of the `./src` context (#613); pinned Playwright image for E2E; nginx maintenance page (`maintenance_page/`). - **docker-compose**: profiled services — `web`, `rules_engine`, `db` (Postgres 18.1-alpine), `azurite` (+ one-shot `azurite_init` rules seeder), `pgadmin`; `docker-compose.e2e.yml` adds the one-shot `e2e-tests` runner (kept separate so the VS `docker-compose.dcproj` orchestration ignores it). Never add a `docker-compose.override.yml` (breaks `make test-e2e`). -- **CI/CD** (`.github/workflows/build-and-deploy.yml`): build + push both images to GHCR + Snyk scan → PR review apps (`deploy` label) → E2E (non-visual) against the review app → sequential deploy matrix **development → qa → preproduction → production** on push to `main`, via shared `DFE-Digital/github-actions` actions. Also: DB backup/PTR/restore workflows, maintenance toggle, Terraform validation, review-app teardown. +- **CI/CD** (`.github/workflows/build-and-deploy.yml`): build + push both images to GHCR + Snyk scan + CycloneDX SBOM per image (workflow artifact and OCI referrer in GHCR; `docs/sbom.md`, #614) → PR review apps (`deploy` label) → E2E (non-visual) against the review app → sequential deploy matrix **development → qa → preproduction → production** on push to `main`, via shared `DFE-Digital/github-actions` actions. Also: DB backup/PTR/restore workflows, maintenance toggle, Terraform validation, review-app teardown. - **Terraform** (`terraform/application/`): AKS web app + worker (separate replica counts, exec health probes), Azure Postgres **Flexible Server v17** (note: local compose runs 18.1), two storage accounts (app: `files`, `rules-config`; private LDS ingress account), two Key Vaults, StatusCake, GCP WIF for analytics; `terraform/domains/` for DNS + Front Door. Environments: development, qa, review (per-PR), preproduction, production (`config/*.tfvars.json`). ## 17. Testing From 296fb6c5826cb4f8ffd40a74e4704f47f75cee05 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 14:03:04 +0100 Subject: [PATCH 4/7] #614 Attach the SBOM once per image digest and name the digest in the step summary Identical sources build an identical image, so a docs-only pull request, a job re-run and the merge build all resolve to a digest that already carries an SBOM; attaching again would rewrite the referrers index on GHCR. ORAS pinned to 1.3.5, the version the behaviour was checked with. Co-Authored-By: Claude Fable 5.1 --- .github/actions/image-sbom/action.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/actions/image-sbom/action.yml b/.github/actions/image-sbom/action.yml index b422c10f5..8383abd6e 100644 --- a/.github/actions/image-sbom/action.yml +++ b/.github/actions/image-sbom/action.yml @@ -44,6 +44,9 @@ runs: - name: Set up ORAS uses: oras-project/setup-oras@v2 + with: + # The version every check of the attach and discover behaviour on GHCR was run with. + version: 1.3.5 - name: Attach the SBOM to the image in GHCR shell: bash @@ -59,12 +62,23 @@ runs: # index tagged sha256- in the same package lists it. Do not delete those # tags. `oras discover ` reads the index. echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - oras attach --artifact-type application/vnd.cyclonedx+json "$IMAGE" "$FILE:application/vnd.cyclonedx+json" + digest=$(oras resolve "$IMAGE") + # Identical sources build an identical image: a docs-only pull request, a re-run and the + # merge build all resolve to the digest main already deployed. The SBOM describes the + # digest, so it is attached once; a second attach would rewrite the referrers index. + existing=$(oras discover "$IMAGE" --artifact-type application/vnd.cyclonedx+json --format json | jq '.referrers | length') + if [ "$existing" -gt 0 ]; then + echo "$IMAGE ($digest) already has an SBOM attached; not attaching another." + attached="already attached by an earlier build of this digest" + else + oras attach --artifact-type application/vnd.cyclonedx+json "$IMAGE" "$FILE:application/vnd.cyclonedx+json" + attached="attached by this run" + fi oras discover "$IMAGE" { echo "### SBOM for \`$IMAGE\`" echo - echo "Workflow artifact \`$FILE\`; also attached to the image in GHCR:" + echo "Image digest \`$digest\`. Workflow artifact \`$FILE\`; SBOM in GHCR: $attached." echo echo '```sh' echo "oras discover $IMAGE" From 67589e6e98f641af0c5468a11d958c828a0893f8 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 14:03:28 +0100 Subject: [PATCH 5/7] #614 Find a deployed image's SBOM from the digest its pods run, not from the sha tag The weekly no-cache rebuild moves the tag without redeploying, and the SBOM file is named after the commit that first built the image. Co-Authored-By: Claude Fable 5.1 --- docs/sbom.md | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/sbom.md b/docs/sbom.md index eb3ba9fb4..f9d9874e8 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -20,7 +20,9 @@ and it does not contain the test projects' packages (SixLabors.ImageSharp, Playw ## Where it is -The image tag is the commit sha, and so is the SBOM's name. +The image tag is the commit sha. The SBOM file is named after the commit whose build first +attached it: commits with the same `./src` build the same image, and that image is described +once, so the file on an image can carry an earlier commit's sha than the tag you looked up. 1. **Workflow artifact** on the `Build and Deploy` (or `Build No Cache`) run: `sbom-web-.cdx.json` and `sbom-worker-.cdx.json`. Kept 90 days. @@ -34,25 +36,44 @@ The action that does this is `.github/actions/image-sbom`, called after each ima ## Finding the SBOM for a deployed environment -1. Find the sha the environment runs: the `Build and Deploy` run that deployed it (the run for - the merge commit on `main`), or the image tag on the AKS deployment. +Go by the image **digest** the running pods report, not by the sha tag: the weekly +`Build No Cache` run rebuilds the web image and moves the sha tag to the new image without +redeploying anything, so a running environment can be on an earlier digest than its tag. + +1. Read the digest from the pods (the namespace and deployment names are + `schoolstandards-development` / `schoolstandards-test` / `schoolstandards-production` and + `check-performance-data-` / `check-performance-data-worker-`, where + `` is `development`, `qa`, `preproduction`, `production` or `pr-`): + + ```sh + kubectl -n schoolstandards-production get pods -l app=check-performance-data-production \ + -o jsonpath='{.items[*].status.containerStatuses[*].imageID}' + ``` + + Each value is `ghcr.io/dfe-digital/check-performance-data@sha256:`. + 2. List and fetch the SBOM with [ORAS](https://oras.land) (the packages are public; no login): ```sh - oras discover ghcr.io/dfe-digital/check-performance-data: + oras discover ghcr.io/dfe-digital/check-performance-data@sha256: oras pull ghcr.io/dfe-digital/check-performance-data@sha256: ``` - The pull writes `sbom-web-.cdx.json` into the current directory. The worker image is - `ghcr.io/dfe-digital/check-performance-data-worker:`. + The pull writes the SBOM (`sbom-web-.cdx.json`) + into the current directory. The worker image is + `ghcr.io/dfe-digital/check-performance-data-worker`. Without ORAS installed, run it from its image (Linux/macOS; on Windows Git Bash prefix the command with `MSYS_NO_PATHCONV=1`): ```sh - docker run --rm -v "$PWD:/work" -w /work ghcr.io/oras-project/oras:v1.3.5 discover ghcr.io/dfe-digital/check-performance-data: + docker run --rm -v "$PWD:/work" -w /work ghcr.io/oras-project/oras:v1.3.5 discover ghcr.io/dfe-digital/check-performance-data@sha256: ``` + To look at the image a tag points to *now* (for example the `Build and Deploy` run of a merge + commit), use `ghcr.io/dfe-digital/check-performance-data:` in place of the `@sha256:` + reference; ORAS resolves the digest itself. + ## Checking an advisory against it Look a package up by name: @@ -67,6 +88,7 @@ Or scan the whole SBOM for known vulnerabilities with [grype](https://github.com grype sbom:./sbom-web-.cdx.json ``` -The weekly `Build No Cache` run rebuilds the web image under the same sha tag with current -Ubuntu packages and attaches a new SBOM to the new image; `oras discover` by tag always shows -the SBOM of the image the tag points at now. +The weekly `Build No Cache` run rebuilds the web image with current Ubuntu packages, pushes it +under the same sha tag and attaches a new SBOM to the new image; the earlier image keeps its own. +Running pods stay on the digest they started with until the next deployment, which is why the +steps above start from the pod's digest rather than the tag. From 43b27cb81337df585953ce11d69f84549615f6c6 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 14:07:35 +0100 Subject: [PATCH 6/7] #614 Pin ORAS to 1.3.1, which setup-oras@v2 can install setup-oras@v2 installs only the versions in its bundled release list, which stops at 1.3.4, so the 1.3.5 pin failed the build. 1.3.1 is the action's default and was also checked against GHCR. Co-Authored-By: Claude Fable 5.1 --- .github/actions/image-sbom/action.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/image-sbom/action.yml b/.github/actions/image-sbom/action.yml index 8383abd6e..7dc5472ac 100644 --- a/.github/actions/image-sbom/action.yml +++ b/.github/actions/image-sbom/action.yml @@ -45,8 +45,9 @@ runs: - name: Set up ORAS uses: oras-project/setup-oras@v2 with: - # The version every check of the attach and discover behaviour on GHCR was run with. - version: 1.3.5 + # A version the attach and discover behaviour on GHCR was checked with. 1.3.5 was too, + # but setup-oras@v2 does not offer it yet. + version: 1.3.1 - name: Attach the SBOM to the image in GHCR shell: bash From e4ee960ed563620eb58e7bbd79ff4de61c4bf02b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Oct 2026 14:24:47 +0100 Subject: [PATCH 7/7] #614 Say that an image keeps its digest only when the layer cache is hit A re-run of the same merge commit rebuilt the web image's publish layer and produced a new digest, which took its own SBOM. Each digest still has exactly one; the comment and the docs no longer promise that identical sources always give an identical image. Co-Authored-By: Claude Fable 5.1 --- .github/actions/image-sbom/action.yml | 7 ++++--- docs/sbom.md | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/actions/image-sbom/action.yml b/.github/actions/image-sbom/action.yml index 7dc5472ac..97ec94ba9 100644 --- a/.github/actions/image-sbom/action.yml +++ b/.github/actions/image-sbom/action.yml @@ -64,9 +64,10 @@ runs: # tags. `oras discover ` reads the index. echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin digest=$(oras resolve "$IMAGE") - # Identical sources build an identical image: a docs-only pull request, a re-run and the - # merge build all resolve to the digest main already deployed. The SBOM describes the - # digest, so it is attached once; a second attach would rewrite the referrers index. + # The SBOM describes a digest, so it is attached once per digest; a second attach would + # rewrite the referrers index. When the layer cache is hit, a docs-only pull request, a + # re-run and the merge build resolve to a digest an earlier build already described; + # otherwise the image gets a new digest and its own SBOM. existing=$(oras discover "$IMAGE" --artifact-type application/vnd.cyclonedx+json --format json | jq '.referrers | length') if [ "$existing" -gt 0 ]; then echo "$IMAGE ($digest) already has an SBOM attached; not attaching another." diff --git a/docs/sbom.md b/docs/sbom.md index f9d9874e8..1246da618 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -20,9 +20,10 @@ and it does not contain the test projects' packages (SixLabors.ImageSharp, Playw ## Where it is -The image tag is the commit sha. The SBOM file is named after the commit whose build first -attached it: commits with the same `./src` build the same image, and that image is described -once, so the file on an image can carry an earlier commit's sha than the tag you looked up. +The image tag is the commit sha. Each image digest has one SBOM, named after the commit whose +build first attached it. When the layer cache is hit, commits with the same `./src` build the +same image, so the file on an image can carry an earlier commit's sha than the tag you looked +up; otherwise the image gets a new digest and its own SBOM. 1. **Workflow artifact** on the `Build and Deploy` (or `Build No Cache`) run: `sbom-web-.cdx.json` and `sbom-worker-.cdx.json`. Kept 90 days. @@ -59,7 +60,7 @@ redeploying anything, so a running environment can be on an earlier digest than oras pull ghcr.io/dfe-digital/check-performance-data@sha256: ``` - The pull writes the SBOM (`sbom-web-.cdx.json`) + The pull writes the SBOM (`sbom-web-.cdx.json`) into the current directory. The worker image is `ghcr.io/dfe-digital/check-performance-data-worker`.