From caf0d618572b7fca0b28776ee13dcce2e0da0b99 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 13 May 2026 17:56:29 +0200 Subject: [PATCH] fix(ci): scan multi-arch images per-arch by digest, pin trivy-action (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the intermittent "no child with platform linux/amd64 in index" failure on the merge-backend job — and fixes the same latent bug on merge-frontend before it surfaces. Two compounding root causes per Luca's diagnosis: 1. aquasecurity/trivy-action@master was unpinned, so the action and its bundled Trivy binary float on every CI run. A green build could flip red overnight without a single repo change. 2. Trivy was asked to scan a multi-platform OCI index by tag (the merge-* jobs ran AFTER manifest creation). Its remote resolver cannot reliably pick the right per-arch child out of an index reference — it needs a single-platform reference (digest, or a --platform flag). Fix: - Move the Trivy + upload-sarif steps OUT of merge-backend / merge-frontend and INTO the per-arch build-backend / build-frontend matrix jobs. Each leg scans the image it just pushed by its sha256 digest (`...@${{ steps.build.outputs.digest }}`), which is always single-platform by construction. - Pin aquasecurity/trivy-action@0.28.0 (was @master). - Distinct SARIF category per arch (`backend-vulnerabilities-linux-amd64`, …-arm64) so an amd64-only finding in a base layer doesn't get masked by the arm64 scan in the Security tab. - Move security-events: write down to the build-* jobs (where the scan now runs) and remove it from the merge-* jobs (which only publish the manifest now). Out of scope: flipping `exit-code: '1'` to actually gate CI on findings. Worth doing as a separate follow-up after an audit pass — landing it here would surprise beta with a red build for any pre-existing CRITICAL/HIGH in current images. Inline TODO in the workflow notes the deferral. --- .github/workflows/docker-build.yml | 101 +++++++++++++++++++---------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 400f5404..d55668ef 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -69,6 +69,11 @@ jobs: permissions: contents: read packages: write + # Trivy uploads its SARIF to the Security tab from this job — see + # the "Run Trivy" step below. Scanning per-arch by digest (#476) + # is reliable; scanning the multi-arch index by tag from the + # merge-* job was not. + security-events: write steps: - name: Checkout code @@ -153,13 +158,47 @@ jobs: if-no-files-found: error retention-days: 1 + # Per-arch vulnerability scan (#476). Scanning the multi-arch + # manifest from the merge-* job by tag is unreliable — Trivy's + # remote resolver crashes intermittently with "no child with + # platform linux/amd64 in index". The fix is to scan each leg + # by its single-platform digest right here, where it just landed + # in GHCR. Tag pinned (was @master) so the action + bundled + # Trivy binary don't float between runs. + # + # exit-code is left unset (=0) for now: Trivy reports findings + # to the Security tab but doesn't fail the build. Flipping that + # to '1' to actually gate CI is a deliberate follow-up — needs an + # audit pass first so the next beta build doesn't surprise red. + - name: Run Trivy vulnerability scanner (per-arch, by digest) + if: steps.push-decision.outputs.push == 'true' + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: 'sarif' + output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif' + severity: 'CRITICAL,HIGH' + timeout: '10m' + + - name: Upload Trivy scan results to GitHub Security tab + if: steps.push-decision.outputs.push == 'true' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif' + # Distinct category per arch so the Security tab surfaces + # per-platform findings independently — an amd64-only CVE in + # a base layer doesn't get masked by the arm64 scan. + category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}' + merge-backend: needs: build-backend runs-on: ubuntu-latest + # No security-events permission here — vulnerability scanning moved + # to per-arch build-backend jobs (#476). This job's only job is to + # combine the per-arch digests into a multi-arch manifest. permissions: contents: read packages: write - security-events: write # Only run when at least one digest was pushed (i.e. not on PRs without push intent). if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' @@ -231,23 +270,6 @@ jobs: run: | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }} - - name: Run Trivy vulnerability scanner - if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }} - format: 'sarif' - output: 'trivy-backend.sarif' - severity: 'CRITICAL,HIGH' - timeout: '10m' - - - name: Upload Trivy scan results to GitHub Security tab - if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: 'trivy-backend.sarif' - category: 'backend-vulnerabilities' - # ----------------------------------------------------------------------------- # Frontend: per-arch build, then merge into a multi-arch manifest # ----------------------------------------------------------------------------- @@ -264,6 +286,9 @@ jobs: permissions: contents: read packages: write + # See build-backend for the rationale (#476). Same pattern: per-arch + # vulnerability scan by digest, SARIF uploaded to the Security tab. + security-events: write steps: - name: Checkout code @@ -348,13 +373,34 @@ jobs: if-no-files-found: error retention-days: 1 + # Per-arch vulnerability scan (#476). See build-backend for the + # full rationale; identical pattern here, only the image-ref + + # SARIF filename + category change. + - name: Run Trivy vulnerability scanner (per-arch, by digest) + if: steps.push-decision.outputs.push == 'true' + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: 'sarif' + output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif' + severity: 'CRITICAL,HIGH' + timeout: '10m' + + - name: Upload Trivy scan results to GitHub Security tab + if: steps.push-decision.outputs.push == 'true' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif' + category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}' + merge-frontend: needs: build-frontend runs-on: ubuntu-latest + # See merge-backend — vulnerability scanning moved to the per-arch + # build-frontend matrix (#476). This job only publishes the manifest. permissions: contents: read packages: write - security-events: write if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' steps: @@ -425,23 +471,6 @@ jobs: run: | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }} - - name: Run Trivy vulnerability scanner - if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }} - format: 'sarif' - output: 'trivy-frontend.sarif' - severity: 'CRITICAL,HIGH' - timeout: '10m' - - - name: Upload Trivy scan results to GitHub Security tab - if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: 'trivy-frontend.sarif' - category: 'frontend-vulnerabilities' - summary: needs: [build-backend, merge-backend, build-frontend, merge-frontend] if: always()