diff --git a/.github/actions/image-sbom/action.yml b/.github/actions/image-sbom/action.yml new file mode 100644 index 000000000..97ec94ba9 --- /dev/null +++ b/.github/actions/image-sbom/action.yml @@ -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: + 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- 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 + 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" 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() }} diff --git a/docs/sbom.md b/docs/sbom.md new file mode 100644 index 000000000..1246da618 --- /dev/null +++ b/docs/sbom.md @@ -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-.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 + +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@sha256: + oras pull ghcr.io/dfe-digital/check-performance-data@sha256: + ``` + + 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@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: + +```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 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. 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