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"