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
90 changes: 90 additions & 0 deletions .github/actions/image-sbom/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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:<sha>
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
with:
# 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
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-<image digest> in the same package lists it. Do not delete those
# tags. `oras discover <image>` reads the index.
echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
digest=$(oras resolve "$IMAGE")
# 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."
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 "Image digest \`$digest\`. Workflow artifact \`$FILE\`; SBOM in GHCR: $attached."
echo
echo '```sh'
echo "oras discover $IMAGE"
echo '```'
echo
echo "See \`docs/sbom.md\` for fetching and querying it."
} >> "$GITHUB_STEP_SUMMARY"
14 changes: 14 additions & 0 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/build-nocache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}
Expand Down
95 changes: 95 additions & 0 deletions docs/sbom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 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. 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-<sha>.cdx.json` and `sbom-worker-<sha>.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-<image manifest digest>` 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

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-<environment>` / `check-performance-data-worker-<environment>`, where
`<environment>` is `development`, `qa`, `preproduction`, `production` or `pr-<n>`):

```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:<digest>`.

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@sha256:<digest from step 1>
oras pull ghcr.io/dfe-digital/check-performance-data@sha256:<digest the discover output lists>
```

The pull writes the SBOM (`sbom-web-<sha of the commit whose build attached it>.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@sha256:<digest>
```

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:<sha>` in place of the `@sha256:`
reference; ORAS resolves the digest itself.

## Checking an advisory against it

Look a package up by name:

```sh
jq '.components[] | select(.name == "Npgsql") | {name, version, purl}' sbom-web-<sha>.cdx.json
```

Or scan the whole SBOM for known vulnerabilities with [grype](https://github.com/anchore/grype):

```sh
grype sbom:./sbom-web-<sha>.cdx.json
```

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.
2 changes: 1 addition & 1 deletion docs/technical-stakeholders-functional-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading