Docker security checklist

Docker Image Vulnerability Checklist: 8 Checks (2026)

A container image is a filesystem snapshot with an entry point, and every package inside it carries its own CVE surface. This page documents all 8 checks in the CtrlOps Image Vulnerabilities audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202616 min read
8
checks in this audit
3
rated high severity
16s
automated run time
6
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the docker-security-image-vulnerabilities audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

An image built six months ago has more known vulnerabilities today than it did on the day it shipped, and nothing about it changed. Secrets baked into a layer during the build stay readable even after a later layer deletes them. This page documents the 8 checks in the CtrlOps Image Vulnerabilities audit, in the order the audit runs them.

This audit reads the images stored on the host, not the containers started from them. Every threshold below is transcribed from the audit script itself. Three of these checks depend on tooling being installed, and the audit is explicit about the difference between "clean" and "nothing scanned it", which is the distinction that makes an image audit worth reading.

Key takeaways

Eight checks, and the three that matter most are the three that need a scanner on the host to answer properly.

  • Three are rated HIGH: the CVE scan, the secrets scan and end-of-life base images. Three are MEDIUM and two are LOW.
  • Six need root, or docker group membership, because they query the daemon for the image list. Signature verification and the SBOM check read the environment and PATH instead.
  • The audit uses what is installed rather than installing anything. trivy drives checks 1 and 3, dockle drives check 7, syft drives check 8. Where the tool is missing, the check says so in the finding instead of quietly passing.
  • Scan depth is capped on purpose. Checks 1 and 3 scan the first 5 images, check 4 reads 10, check 7 reads 3 with dockle or 10 without, and check 6 pattern-matches every image tag. A full CVE scan of every image on a busy host is a build-pipeline job, not a 16-second audit.
  • The whole audit takes about 16 seconds when CtrlOps runs it over your existing SSH connection.
#CheckSeverityWhat it catchesRoot needed
1Image vulnerability scanHIGHCritical and high CVEs in local imagesYes
2Floating image tagsMEDIUMImages pinned to latest instead of a versionYes
3Image secrets scanHIGHCredentials embedded in layers or build historyYes
4Image default userMEDIUMImages with no USER instructionYes
5Signature verificationMEDIUMNo Docker Content Trust and no cosignNo
6End-of-life base imageHIGHBase images that no longer receive patchesYes
7Build best practicesLOWdockle fatal findings, or a missing HEALTHCHECKYes
8SBOM generationLOWNo SBOM tooling for future CVE matchingNo
A pass here is a statement about scan time, not about tomorrow. New CVEs are published every day against packages that have not changed. An image that passes check 1 today can fail it next week with no rebuild in between, which is why the finding names how many images were scanned out of how many exist.

Check 1: Do any local images contain critical or high CVEs?

High severityNeeds sudo

A CVE scan compares every package inside an image against the vulnerability databases. Critical and high severity findings are known, published and in many cases have public proof-of-concept code. An image running a critical CVE in production is a door somebody already wrote the instructions for.

The audit takes the first 5 local images, excluding untagged ones, and scans them with trivy for CRITICAL and HIGH findings. If trivy is absent it reports what else is available, because a scan nobody ran is not a pass.

How to check manually

# Scan one image
trivy image --severity CRITICAL,HIGH your-image:tag

# Or the first five local images, the way the audit does
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | head -5 | while read img; do
  echo "=== $img ==="
  trivy image --quiet --severity CRITICAL,HIGH "$img"
done
Result thresholds this check applies
ResultWhenWhat it means
PASStrivy finds no critical or high CVEs in the images it scannedThe finding names how many images were scanned out of how many exist locally.
WARNHigh severity CVEs but no criticals, or no trivy on the hostFour distinct warnings. With high CVEs it asks you to schedule remediation. With grype or docker scout installed it tells you to run that tool manually, because automated parsing is trivy-only. With no scanner at all it tells you to install trivy.
FAILtrivy finds one or more CRITICAL severity CVEsThe critical and high counts across the scanned images are reported. Patch or rebuild those images.
SKIPDocker is not installed, the daemon cannot be queried, there are no local images, or trivy ran and produced no outputFour distinct skips. The last one is the interesting case: trivy is installed but returned nothing, so run it manually to find out why.

The distinction to hold on to is warning versus skip. A warning means the audit knows something and wants action. A skip means the audit could not evaluate, and treating it as a pass is exactly the mistake the branch exists to prevent.

How to fix it

# Pin to a specific, patched version and refresh the OS packages
FROM node:20.15.1-alpine3.20
RUN apk update && apk upgrade --no-cache
# For images you do not build, pull a patched tag and rebuild on top
docker pull nginx:1.27.1-alpine
docker build --no-cache -t your-app:1.4.2 .

Then move the gate into CI so a vulnerable image never reaches a server:

- name: Scan image for CVEs
  run: trivy image --exit-code 1 --severity CRITICAL your-image:${{ github.sha }}
Scanning at build time only leaves a growing blind spot. An image that was clean when it shipped accumulates published CVEs for as long as it runs. Scan on every build, and re-scan what is actually running on a weekly schedule, which is what this audit does when you run it per host.

Check 2: Are any images using a floating latest tag?

Medium severityNeeds sudo

The latest tag is a pointer that moves every time the publisher pushes. Two hosts that pulled it a week apart are running different code with the same name. A deployment on latest can change underneath you on the next pull, with no version in the audit trail to roll back to.

The audit lists local image tags, excluding untagged ones, and reports up to five that end in latest.

How to check manually

docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | grep -E ':latest$'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo local image uses the floating latest tagThe total image count is named. Builds are reproducible from the tags alone.
WARNOne or more images are tagged latestUp to five are listed by name. Pin to a version or a digest so you know what shipped.
SKIPDocker is not installed, the daemon cannot be queried, or there are no local imagesNothing to evaluate.

How to fix it

# Not this
FROM python:latest

# A specific version
FROM python:3.12.4-slim-bookworm

# Or a digest, for builds that must be byte-identical
FROM python@sha256:...
services:
  app:
    image: postgres:16.3-alpine
Bottom line: latest is not a version, it is a moving target with a name. Pinning is what makes a rollback possible, and a rollback is what you want at the exact moment you have no time to work out which build broke things.

Check 3: Are there secrets embedded in image layers?

High severityNeeds sudo

Secrets baked into image layers persist forever, even when a later layer deletes the file. Every COPY, ADD and RUN creates a layer, and layers are additive: an API key added in layer 3 and removed in layer 4 is still readable with docker history --no-trunc or by unpacking the layer tarball.

The audit uses trivy's secret scanner across the first 5 local images when trivy is installed. Without trivy it falls back to grepping build history text for AWS_SECRET, PRIVATE KEY, PASSWORD=, API_KEY and SECRET_KEY, and it is explicit that this is a weaker check.

How to check manually

# With trivy, which reads the layer contents
trivy image --scanners secret your-image:tag

# Without trivy, which only reads the build commands
docker history --no-trunc your-image:tag | grep -iE 'AWS_SECRET|PRIVATE KEY|PASSWORD=|API_KEY|SECRET_KEY'
Result thresholds this check applies
ResultWhenWhat it means
PASStrivy is installed and detects no secrets in the images it scannedThe number of images scanned is named. This is the only branch that reflects a real layer-content scan coming back clean.
WARNNo scanner is installed, or the history fallback matched a secret-like stringTwo distinct warnings. Without trivy the finding says only build history text was checked. With a history match it names the images and asks you to verify manually.
FAILtrivy detects secrets embedded in image layersThe affected images are named. Rebuild without the credential and rotate it, because removing it from a later layer does not remove it from the image.
SKIPDocker is not installed, the daemon cannot be queried, or there are no local imagesNothing to scan.

The two warnings are the point of this check. Without trivy, the audit can only read the text of the build commands, which catches RUN export API_KEY=... and misses a key that was copied in as a file. Reading that warning as "no secrets found" is precisely the wrong conclusion.

How to fix it

Use build secrets so the credential never becomes a layer:

FROM node:20-alpine AS builder
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install

FROM node:20-alpine
COPY --from=builder /app /app
USER node
docker build --secret id=npm_token,src=.npm_token .

If a secret is already in a published image, the order matters:

  1. Rotate the credential first. It is compromised from the moment the image was pushed or shared.
  2. Rebuild from scratch. Do not add a layer that deletes it, because the earlier layer still carries it.
  3. Delete the old image everywhere, locally and in every registry that holds a copy.
docker history --no-trunc shows the full build command for every layer. If a build ever ran RUN echo $API_KEY > config.txt, that key is visible to anyone who can pull the image, including after the file was deleted in the next instruction.

Check 4: Do images define a non-root default user?

Medium severityNeeds sudo

With no USER instruction in the Dockerfile, an image runs as root by default. Every container started from it runs as root unless somebody remembers -u at runtime, which turns a build-time property into a per-deployment discipline problem.

The audit reads Config.User on the first 10 local images and treats empty, root and 0 as root.

How to check manually

docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | head -10 | while read img; do
  user=$(docker inspect -f '{{.Config.User}}' "$img" 2>/dev/null)
  echo "$img -> User=${user:-root (default)}"
done
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery image checked defines a non-root USERContainers from those images start unprivileged without anyone passing a flag.
WARNOne or more images have no USER setThe count and up to five image names are listed. Add a USER instruction, or override with -u at runtime for images you do not build.
SKIPDocker is not installed, the daemon cannot be queried, or there are no local imagesNothing to evaluate.

This is the image-side counterpart to check 3 of the Container Hardening audit. Fixing it here fixes it for every container started from the image, which is why it is worth doing even when the runtime override is already in place.

How to fix it

FROM node:20-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup . /app
WORKDIR /app
USER appuser

CMD ["node", "server.js"]
# For images you do not control
docker run -u 1000:1000 nginx:1.27-alpine
Bottom line: most official images default to root, several of them for good reasons, because they drop privileges themselves after binding a port. Check what the process runs as rather than assuming either way, and set USER before CMD in the Dockerfiles you own.

Check 5: Is image signature verification enabled?

Medium severityNo sudo needed

Without signature verification, Docker pulls and runs images without confirming who published them. A compromised registry, a typosquatted image name or a manipulated mirror all produce a running container with no warning anywhere in the process.

The audit reads DOCKER_CONTENT_TRUST from the environment and checks whether cosign is on the PATH. It needs no daemon access, which is why this is the one image check that does not need root.

This check has drifted, and its PASS branch is the problem. Docker announced the retirement of Content Trust and notary.docker.io in July 2025, with full retirement in June 2026. DOCKER_CONTENT_TRUST=1 now breaks pulls of Docker Official Images rather than verifying them, so a PASS here is a finding of its own rather than a clean result. Verify with Sigstore cosign or Notation instead, and see the known issues page for the other seven checks whose logic has drifted from the software they audit.

How to check manually

echo "DOCKER_CONTENT_TRUST=${DOCKER_CONTENT_TRUST:-not set}"
command -v cosign >/dev/null && echo "cosign available" || echo "cosign not found"
Result thresholds this check applies
ResultWhenWhat it means
PASSDOCKER_CONTENT_TRUST=1 is setPulls and pushes are signature-verified.
WARNcosign is installed but DCT is off, or neither is in placeTwo distinct warnings. With cosign it tells you to verify explicitly or enable DCT. With neither it says images are pulled and run without confirming who published them.
SKIPDocker is not installedNothing to check.

One caveat worth knowing: the check reads the environment of the audit session. A DOCKER_CONTENT_TRUST=1 exported only inside a CI job, or only in one user's shell profile, is not the same as enforcement, and this check will report it accordingly depending on where it runs.

How to fix it

Adopt cosign, and do not set DOCKER_CONTENT_TRUST:

# Verify a signature before the image runs anywhere
cosign verify --key cosign.pub your-registry.com/your-image:tag
# Enforce it in the pipeline rather than on the host
- name: Verify image signature
  run: |
    cosign verify --certificate-identity-regexp '.*' \
      --certificate-oidc-issuer-regexp '.*' \
      your-image:${{ github.sha }}

If the variable is already exported somewhere on the host, unset it. Leaving it set is now a way to break pulls of Docker Official Images, not a way to secure them.

Bottom line: the useful reading of this check is the warning, not the pass. Both warning branches tell you the same true thing: nothing on this host verifies who published an image before running it. Fix that with cosign or Notation, in the pipeline where a failed verification can stop a deployment.

Check 6: Are any images built on end-of-life base images?

High severityNeeds sudo

An end-of-life base image no longer receives security patches. Every future CVE in that distribution is permanent for as long as the image is in use, and the only fix is migrating to a supported base rather than updating packages.

The audit pattern-matches every local image tag against a known EOL list: Ubuntu 14.04, 16.04 and 18.04 including the codenames, Debian 7 to 9 including wheezy, jessie and stretch, CentOS 6 to 8, Alpine 3.9 to 3.12, Node 10, 12 and 14, and Python 2.7, 3.5 and 3.6.

How to check manually

docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | \
  grep -E 'ubuntu:(14|16|18)\.04|debian:(7|8|9)($|-)|centos:(6|7|8)|alpine:3\.(9|10|11|12)|node:(10|12|14)($|-)|python:(2\.7|3\.5|3\.6)'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo image tag matches the known EOL patternsThe image count is named, with the caveat that unusual bases still need their own support window checked.
FAILOne or more image tags match a known EOL baseUp to ten are listed. These no longer receive security patches, so the fix is a rebuild on a supported base.
SKIPDocker is not installed, the daemon cannot be queried, or there are no local imagesNothing to evaluate.

The check reads tags, not layer contents. An image built FROM ubuntu:18.04 and retagged as myapp:2.1 will not match, which is a deliberate trade: pattern-matching tags is instant and has no false positives, while proving the actual base means unpacking every image.

How to fix it

EOL imageReplace with
ubuntu:18.04ubuntu:24.04
debian:stretchdebian:bookworm
centos:7rockylinux:9 or almalinux:9
node:14node:20-alpine
python:3.6python:3.12-slim
alpine:3.12alpine:3.20

Then test properly. EOL to current is usually several releases at once, so expect renamed packages, moved paths and library version bumps rather than a clean swap.

The pattern list is a floor, not a full EOL inventory. It was written into the script rather than queried from a source that tracks end-of-life dates, so bases that have since aged out are not flagged: Alpine 3.13 to 3.20, Node 18 and 20, Python 3.7 to 3.9, Ubuntu 20.04 standard support and Debian 10 all pass this check today. Check your own bases against endoflife.date rather than trusting a pass, and see the known issues page for the rest of the checks whose logic has drifted.

Check 7: Do images follow build best practices?

Low severityNeeds sudo

Build best practices reduce attack surface and make containers operable. A missing HEALTHCHECK means Docker cannot distinguish a healthy container from one that is hung, so a process that stopped responding keeps receiving traffic indefinitely.

The audit prefers dockle, scanning the first 3 images and counting fatal-level findings. Without dockle it falls back to reading Config.Healthcheck on the first 10 images, which is a much narrower question and the finding says so.

How to check manually

# With dockle
dockle your-image:tag

# Without dockle, the fallback the audit uses
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | head -10 | while read img; do
  hc=$(docker inspect -f '{{.Config.Healthcheck}}' "$img" 2>/dev/null)
  echo "$img -> Healthcheck=${hc:-<nil>}"
done
Result thresholds this check applies
ResultWhenWhat it means
PASSdockle reports no fatal findings, or at least one image defines a HEALTHCHECKTwo distinct passes. The heuristic one names how many of the images checked have a healthcheck and still recommends installing dockle for a fuller audit.
WARNdockle found fatal-level findings, or no image defines a HEALTHCHECKTwo distinct warnings. With dockle the fatal count is named and you run dockle for details. Without it, the finding is that none of the images checked define a HEALTHCHECK.
SKIPDocker is not installed, the daemon cannot be queried, or there are no images to checkNothing to evaluate.

The heuristic pass is a low bar by design: one image with a healthcheck out of ten passes it. This check is LOW severity and exists to tell you whether anyone has thought about build hygiene at all, not to grade it.

How to fix it

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1
# Install dockle for the real audit
VERSION=$(curl -s "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/')
curl -sfL "https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz" | sudo tar xz -C /usr/local/bin dockle
dockle your-image:tag
Bottom line: a HEALTHCHECK is one line and gives Docker the ability to notice a container that is running but not working. Without it, orchestration keeps routing traffic to a process that stopped answering.

Check 8: Is an SBOM generator available?

Low severityNo sudo needed

A Software Bill of Materials is a machine-readable inventory of every package and dependency inside an image. When the next widely exploited CVE is published, an SBOM turns "which of our images are affected" from a scanning project into a query.

The audit checks for syft, then the docker sbom plugin, then trivy's SBOM capability. It reports availability rather than whether SBOMs have actually been produced, which is why this is LOW severity.

How to check manually

command -v syft && echo "syft available"
docker sbom --help >/dev/null 2>&1 && echo "docker sbom available"
command -v trivy && echo "trivy can generate SBOMs"
Result thresholds this check applies
ResultWhenWhat it means
PASSsyft is installed, or the docker sbom plugin is availableTwo distinct passes, each naming the command to generate an SBOM per image.
WARNOnly trivy is available, or nothing isTwo distinct warnings. With trivy it points at trivy image --format cyclonedx. With nothing it tells you to install syft so image contents can be matched against future CVEs.

Like the docker group check, this one has no SKIP branch: the absence of every tool is itself the answer, whether or not Docker is installed.

How to fix it

curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# CycloneDX is the most widely consumed format
syft your-image:tag -o cyclonedx-json > sbom.json

# Or with trivy, if that is already in your pipeline
trivy image --format cyclonedx your-image:tag > sbom.json

Store the SBOM alongside the image in your registry and index it, so the question "which images contain this package" is answerable in seconds rather than hours.

Bottom line: the cost is one command per image at build time. The payoff arrives on the day a CVE with a name and a news cycle lands, and the difference between answering in minutes and answering in days is whether that inventory already exists.

What this audit does not cover

This audit reads the images stored on the host. It does not check:

  • Container runtime hardening. Privileged mode, capabilities, the runtime user and read-only filesystems are the Container Hardening audit.
  • Daemon-level controls. Socket exposure, rootless mode and user namespace remapping are the Daemon and Socket audit.
  • Network isolation and running-image hygiene. Port publishing, inter-container connectivity and secrets in environment variables are the Network and Supply Chain audit.
  • Registry-level scanning. Continuous scanning inside Harbor, ECR or GCR happens where the images live, not on the host that pulled them.
  • Application-level vulnerabilities. Logic flaws in the code you built the image around are not visible to a package scanner.

The manual audit problem at scale

Scanning images by hand means installing a scanner, running it per image, reading the output and deciding what each finding means. On one host with five images that is about ten minutes. On ten hosts with different image sets it is an hour, and by the end you have lost track of which host had which finding.

The harder problem is coverage. Manual scans miss images pulled since the last audit, miss hosts that were not on the list, and produce results that live in terminal scrollback rather than in a report anyone compares.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 8 checksAbout 80 minutesAbout 2 minutes
Scanner handlingInstall and invoke per hostDetects trivy, dockle and syft automatically
EOL detectionManual tag comparisonPattern-matched against the known EOL list
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 80 minutesAnother 2 minutes

How CtrlOps runs all 8 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Image Vulnerabilities audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Image Vulnerabilities

Choose it from the Docker category. All 8 checks are listed with their descriptions, severity levels and the estimated run time, about 16 seconds in total. This is the longest Docker audit, because checks 1 and 3 hand work to trivy.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table naming the specific images, CVE counts and EOL tags

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it. Most fixes on this page are rebuilds rather than commands on the host, so the useful output here is often the exact Dockerfile change.

Step 6: Re-run and compare

Run the same audit again after rebuilding and watch the score move. Check 1 is the one to re-run on a schedule rather than after changes, because it can go from pass to fail with nothing on the host changing at all.

Conclusion

Image security is the layer where time works against you. A container hardening flag stays set until somebody changes it. A clean CVE scan degrades on its own, quietly, while the image sits in a registry doing exactly what it did last month.

Three of these eight are HIGH severity, and they describe three different versions of the same problem: something inside the image that you cannot see from docker ps. The CtrlOps Security Audit runs the set in about 16 seconds per server and uses whichever scanner the host already has.

The neighbouring checklists cover the other container layers: container hardening, daemon and socket, volume permissions, runtime and resources and network and supply chain.


Frequently asked questions

Install trivy and run trivy image your-image:tag. It compares every package in the image against the CVE databases and reports findings by severity. grype and Docker Scout are equivalent alternatives. The part that matters more than the tool choice is where it runs: a scan in CI blocks a vulnerable image from shipping, while a scan on the host tells you what is already there.

Because a full CVE scan of every image on a busy host takes minutes per image, and this audit is a 16-second read of the whole host. Checks 1 and 3 take the first five images, check 4 reads ten, check 7 reads three with dockle, and check 6 pattern-matches every tag. The finding always names how many images were scanned out of how many exist, so the coverage is never implied.

Because it is a pointer, not a version. Two pulls a week apart can produce different images with the same name, which breaks reproducibility and makes a rollback impossible: there is no earlier version to go back to. Pin to a version tag, or to a SHA256 digest when the build has to be byte-identical.

You cannot remove them from the existing image. Deleting the file in a later layer leaves the secret in the earlier one, and anyone who can pull the image can read it. Rotate the credential immediately, rebuild the image from scratch without it, and delete the old image from every registry that holds a copy. Use --mount=type=secret in future builds so the credential never becomes a layer.

It uses the scanner on the host. Where trivy is installed, checks 1 and 3 run real CVE and layer-content secret scans. Where it is not, both checks say so in the finding rather than passing: check 1 tells you no scanner is available and check 3 falls back to reading build history text, which it labels as a partial check. The audit never reports "clean" for something it did not scan.

A Software Bill of Materials lists every package, library and dependency inside an image in a machine-readable format. When a new CVE is published, it turns "which images are affected" into a lookup instead of a scanning exercise across your whole estate. Generate one with syft your-image -o cyclonedx-json and store it next to the image in your registry.

On every build in CI, with critical findings blocking the pipeline, and weekly against what is actually running. The second half is the one people skip. New CVEs are published daily against unchanged packages, so an image that passed at build time accumulates known vulnerabilities for as long as it stays deployed.

For two reasons. It matches image tags rather than unpacking layers, so an image retagged as myapp:2.1 hides its base. And its pattern list is hardcoded, so bases that reached end of life after the script was written, Alpine 3.13 to 3.20, Node 18 and 20, Python 3.7 to 3.9, Ubuntu 20.04 and Debian 10, are not flagged at all. Treat it as a floor and check your bases against endoflife.date.

No, and its pass branch is worse than unhelpful. It reads only the DOCKER_CONTENT_TRUST variable in the audit's own shell and whether a cosign binary is on PATH, never an image or a signature. Docker has retired Content Trust, so DOCKER_CONTENT_TRUST=1 now breaks pulls of Docker Official Images. Treat a pass here as a finding, unset the variable, and verify with cosign instead.

When the scanning needs to happen somewhere other than the host: registry-side scanning belongs to your registry, and Kubernetes admission control belongs to a policy engine paired with a scanner. Commercial support and SLAs are another reason teams pick a vendor product. For host-level image auditing over SSH, trivy is the standard and the one this audit parses.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals