$ BLR//CODE

Home / Notes

10 July 2026 · Technical

A deploy pipeline that refuses to ship known-vulnerable images

Scanning container images in CI is easy. Making the scan a gate without it becoming noise everyone ignores is the actual problem.

Adding a vulnerability scanner to a pipeline takes about ten minutes. Making it useful takes longer, because the default configuration of every scanner produces a wall of findings, and a wall of findings that nobody can act on gets skipped — usually by adding || true to the step, at which point you have a scanner and no security benefit.

The shape that has held up for us:

Fail on CRITICAL, warn on HIGH

- name: Scan images
  run: |
    for image in $(grep -oP '(?<=image: ).*' docker-compose.yml); do
      trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed "$image"
      trivy image --severity HIGH     --exit-code 0 --ignore-unfixed "$image"
    done

Two flags carry most of the weight:

--ignore-unfixed drops findings with no available patch. If upstream hasn’t shipped a fix, failing the build gives you no action to take — it just blocks deploys until someone disables the check. Those findings belong in a report you review, not in a gate.

Severity split. CRITICAL blocks, HIGH informs. If everything blocks, the gate gets bypassed within a fortnight. A gate that is occasionally enforced is worth less than a narrow one that is always enforced.

Suppress with an expiry date and a reason

Sometimes a CRITICAL genuinely doesn’t apply — the classic being a CVE in a binary that ships in the image but is never executed by how you run it. .trivyignore handles it:

# CVE-2023-XXXXX — gosu in the mysql image.
# Only reachable via the entrypoint's user-switching path, which we don't use
# (container runs as a fixed uid). Re-check 2026-10-01.
CVE-2023-XXXXX

The comment is the important part, and it needs three things: why it doesn’t apply, who decided, and when to look again. A suppression file without reasoning becomes permanent by default, and nobody two years later can tell which entries are still justified.

Where suppression isn’t defensible, fix it properly. In one of our images the fix was replacing gosu with su-exec in a custom Postgres build — same function, actively maintained, CVE gone rather than ignored.

Scan the base tag, not just the build

If you build on one architecture and deploy on another — we build arm64 images natively on the target host, while CI runs elsewhere — scanning the locally built artifact isn’t always possible in the same job. Scanning the base image tags referenced by the Dockerfile and Compose file catches the same OS-level CVEs, which is where the large majority of findings come from anyway. It’s a compromise; it’s worth being explicit that it is one.

Order the pipeline so the gate matters

checkout → build/pull → scan → deploy → notify on failure

The scan sits between build and deploy, so a failure stops the release rather than annotating it. And the notification step runs if: failure() only — success is silent.

That last detail matters more than it sounds. A pipeline that messages you on every successful deploy trains you to ignore its messages, and then the one that matters arrives into an already-muted channel. Silence should mean success; a message should mean something needs you.

What this doesn’t do

Image scanning finds known vulnerabilities in packaged software. It says nothing about your application code, your secrets handling, your container running as root, or a misconfigured port binding. It’s one narrow, cheap, automatable control — worth having precisely because it costs nothing per deploy, and worth being clear-eyed about, because “our CI scans images” is not a security posture.

devopssecurityci/cddocker

CTA/ — Follow up

Want this done properly at your place?

If this describes a problem you're currently living with, describe your situation and you'll get a straight answer about what fixing it involves.

Get in touch →