Skip to content

docker-build: the image is pushed (and tagged latest) before Trivy scans it #87

Description

@nkg

docker-build.yml publishes the image and only then gates on it, so a build that fails the CRITICAL/HIGH scan has already moved latest.

The ordering, as of v3.5.0:

- name: Build & push          # tags: type=sha,format=short + type=raw,value=latest
  uses: docker/build-push-action@…
  with:
    push: ${{ inputs.push }}
    tags: ${{ steps.meta.outputs.tags }}

- name: Sign with cosign      # signs the tags at that digest

- name: Trivy scan (fail on findings)
  with:
    image-ref: …@${{ steps.build.outputs.digest }}
    exit-code: '1'

By the time the scan runs, the tags are live. The job goes red, the check reports the failure, and consumers still pull the image that failed it.

Observed

HordiaLabs/extractor-llm run 35353826558: the Trivy step failed on two HIGH findings, and ghcr.io/hordialabs/extractor-llm shows the image it scanned published as sha-095c975 — carrying latest and main until the next successful build replaced them a day later. Anything doing docker compose pull in that window got the image the gate had rejected.

Cosign is a smaller version of the same thing: the signature attests where the image came from, which stays true, but it is issued before the image is known to pass.

Suggested fix

Push by digest first, tag only after the scan passes. docker/build-push-action supports this and it stays multi-arch-safe:

- name: Build & push by digest
  id: build
  with:
    push: ${{ inputs.push }}
    tags: ''                 # no tags yet
    outputs: type=image,name=${{ steps.imageref.outputs.repo }},push-by-digest=true,name-canonical=true,push=${{ inputs.push }}

- name: Trivy scan (fail on findings)
  with:
    image-ref: ${{ steps.imageref.outputs.repo }}@${{ steps.build.outputs.digest }}
    exit-code: '1'

- name: Tag the scanned digest
  if: inputs.push
  run: |
    while IFS= read -r tag; do
      [ -z "$tag" ] && continue
      docker buildx imagetools create --tag "$tag" \
        "${{ steps.imageref.outputs.repo }}@${{ steps.build.outputs.digest }}"
    done <<< "${{ steps.meta.outputs.tags }}"

Cosign then moves below the scan too, signing the digest that passed.

An untagged digest is still reachable by anyone who knows it, and it will sit in the registry until GC — but nothing resolves to it, which is the property that matters: latest never advances to an image that failed the gate.

Two smaller alternatives, both worse here: load: true and scan locally before pushing (breaks multi-arch), or delete the tags in an if: failure() step (racy — the bad latest is live for the length of the scan, and a cleanup step can itself fail).

Blast radius

Every consumer with run-trivy-scan: true. It has been latent because the scan had never actually run until v3.3.0 fixed the mixed-case image reference — the first honest scan is also the first time a red gate has had a published image behind it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions