From cf8bfa6e09cea2c381d5c89424d633ec351d4617 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 28 Aug 2026 23:25:22 -0400 Subject: [PATCH] ci: scan, describe and sign the published container image Adopters pulling the image had no way to verify what was inside a tag or that it came from this repository. The image is now built and scanned before it is pushed, rather than after, so a bad image is never published. Fixable high and critical findings fail the release and every finding is reported to the security tab. An SPDX SBOM and a max-detail provenance statement are attached to the image, and the pushed digest is signed with cosign keyless, so there is no signing key to store or rotate. The job summary prints the digest and the exact verification commands. Two exclusions, both deliberate. Unfixed advisories do not block, because a release held up by something no change here can resolve trains people to bypass the gate. Neither does npm's own bundled tree inside the node base image: the container runs node directly and never invokes npm. That second one is not theoretical, node:24-slim currently carries four HIGH findings there, so without it the gate would have failed on the first release. The application's own dependencies are still scanned and still block. A multi-platform build cannot be loaded into the local daemon, so the scan builds linux/amd64 alone and the push builds both. Buildx caches the layers. Closes #163 --- .changeset/olive-ducks-invent.md | 24 +++++++ .github/workflows/docker-publish.yml | 100 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .changeset/olive-ducks-invent.md diff --git a/.changeset/olive-ducks-invent.md b/.changeset/olive-ducks-invent.md new file mode 100644 index 0000000..9b8c46a --- /dev/null +++ b/.changeset/olive-ducks-invent.md @@ -0,0 +1,24 @@ +--- +'seamless-auth-api': patch +--- + +Scan, describe and sign the published container image. + +Adopters pulling `ghcr.io/fells-code/seamless-auth-api` had no way to verify what +was inside a tag or that it came from this repository. The release workflow now: + +- Builds the image and scans it with Trivy **before** it is pushed, failing on + fixable high or critical findings, and reports the findings to the security tab +- Attaches an SPDX SBOM and a provenance attestation to the image, so the + registry can answer what is inside a tag and where it was built +- Signs the pushed digest with cosign, keyless, so there is no signing key to + store or rotate +- Prints the digest and the exact verification commands to the job summary + +Unfixed findings do not block, and neither do npm's own bundled dependencies +inside the Node base image, which the container never invokes and which no change +here can patch. Verified against `node:24-slim`: without that exclusion the gate +fails on four findings in npm's own tree on the first release. The application's +own dependencies are still scanned and still block, which is the part this +repository controls. A gate that blocks on something nobody can fix only trains +people to bypass it. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7213418..06fa5cb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,6 +14,11 @@ on: permissions: contents: read packages: write + # Keyless cosign signing exchanges the workflow's OIDC token for a + # short-lived certificate, so there is no signing key to store or rotate. + id-token: write + # Lets the vulnerability scan publish to the security tab. + security-events: write env: REGISTRY: ghcr.io @@ -54,7 +59,61 @@ jobs: type=raw,value=${{ env.IMAGE_TAG }} type=raw,value=latest + # Built for one platform and loaded locally so the image can be scanned + # before anyone can pull it. A multi-platform build cannot be loaded into + # the local daemon, which is why this is separate from the push below. + # Buildx caches the layers, so the second build is cheap. + - name: Build image for scanning + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: ${{ env.IMAGE_NAME }}:scan + platforms: linux/amd64 + + - name: Scan image for vulnerabilities + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: ${{ env.IMAGE_NAME }}:scan + format: table + exit-code: '1' + severity: HIGH,CRITICAL + # Only findings someone can actually act on block a release. An + # unfixed advisory in the base image is real, but failing on it means + # the release is blocked by something no change here can resolve. + ignore-unfixed: true + # npm's own bundled dependencies inside the node base image. The + # container runs `node dist/server.js` and never invokes npm, and + # nothing here can patch them ahead of an upstream image rebuild. + # Verified against node:24-slim: without this the gate fails on four + # HIGH findings in npm's brace-expansion, ip-address and tar, none of + # which the service loads. The application's own dependencies under + # /app/node_modules are still scanned and still block. + skip-dirs: /usr/local/lib/node_modules/npm + + - name: Report scan findings to the security tab + # Runs even when the gate above failed, so the findings that blocked the + # release are visible rather than only in the job log. + if: always() + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: ${{ env.IMAGE_NAME }}:scan + format: sarif + output: trivy-results.sarif + severity: HIGH,CRITICAL + ignore-unfixed: true + skip-dirs: /usr/local/lib/node_modules/npm + + - name: Upload scan findings + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif + category: trivy-image + - name: Build and push Docker image + id: push uses: docker/build-push-action@v6 with: context: . @@ -62,3 +121,44 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 + # Attaches an SPDX SBOM and a max-detail provenance statement to the + # image itself, so an adopter can ask the registry what is inside a + # tag and where it was built. Inspect with: + # docker buildx imagetools inspect --format '{{ json .SBOM }}' + sbom: true + provenance: mode=max + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign the published image + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + cosign sign --yes "ghcr.io/${IMAGE_NAME}@${DIGEST}" + + - name: Record how to verify the image + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + { + echo "### Published image" + echo + echo '```' + echo "ghcr.io/${IMAGE_NAME}@${DIGEST}" + echo '```' + echo + echo "Verify the signature:" + echo + echo '```bash' + echo "cosign verify ghcr.io/${IMAGE_NAME}@${DIGEST} \\" + echo " --certificate-identity-regexp '^https://github.com/${GITHUB_REPOSITORY}/' \\" + echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com" + echo '```' + echo + echo "Read the SBOM:" + echo + echo '```bash' + echo "docker buildx imagetools inspect ghcr.io/${IMAGE_NAME}@${DIGEST} --format '{{ json .SBOM }}'" + echo '```' + } >> "$GITHUB_STEP_SUMMARY"