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
PATHinstead. - 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.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Image vulnerability scan | HIGH | Critical and high CVEs in local images | Yes |
| 2 | Floating image tags | MEDIUM | Images pinned to latest instead of a version | Yes |
| 3 | Image secrets scan | HIGH | Credentials embedded in layers or build history | Yes |
| 4 | Image default user | MEDIUM | Images with no USER instruction | Yes |
| 5 | Signature verification | MEDIUM | No Docker Content Trust and no cosign | No |
| 6 | End-of-life base image | HIGH | Base images that no longer receive patches | Yes |
| 7 | Build best practices | LOW | dockle fatal findings, or a missing HEALTHCHECK | Yes |
| 8 | SBOM generation | LOW | No SBOM tooling for future CVE matching | No |
Check 1: Do any local images contain critical or high CVEs?
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 | When | What it means |
|---|---|---|
| PASS | trivy finds no critical or high CVEs in the images it scanned | The finding names how many images were scanned out of how many exist locally. |
| WARN | High severity CVEs but no criticals, or no trivy on the host | Four 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. |
| FAIL | trivy finds one or more CRITICAL severity CVEs | The critical and high counts across the scanned images are reported. Patch or rebuild those images. |
| SKIP | Docker is not installed, the daemon cannot be queried, there are no local images, or trivy ran and produced no output | Four 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 }}Check 2: Are any images using a floating latest tag?
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 | When | What it means |
|---|---|---|
| PASS | No local image uses the floating latest tag | The total image count is named. Builds are reproducible from the tags alone. |
| WARN | One or more images are tagged latest | Up to five are listed by name. Pin to a version or a digest so you know what shipped. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no local images | Nothing 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-alpinelatest 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?
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 | When | What it means |
|---|---|---|
| PASS | trivy is installed and detects no secrets in the images it scanned | The number of images scanned is named. This is the only branch that reflects a real layer-content scan coming back clean. |
| WARN | No scanner is installed, or the history fallback matched a secret-like string | Two 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. |
| FAIL | trivy detects secrets embedded in image layers | The 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. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no local images | Nothing 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 nodedocker build --secret id=npm_token,src=.npm_token .If a secret is already in a published image, the order matters:
- Rotate the credential first. It is compromised from the moment the image was pushed or shared.
- Rebuild from scratch. Do not add a layer that deletes it, because the earlier layer still carries it.
- 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?
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 | When | What it means |
|---|---|---|
| PASS | Every image checked defines a non-root USER | Containers from those images start unprivileged without anyone passing a flag. |
| WARN | One or more images have no USER set | The 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. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no local images | Nothing 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-alpineUSER before CMD in the Dockerfiles you own.Check 5: Is image signature verification enabled?
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.
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 | When | What it means |
|---|---|---|
| PASS | DOCKER_CONTENT_TRUST=1 is set | Pulls and pushes are signature-verified. |
| WARN | cosign is installed but DCT is off, or neither is in place | Two 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. |
| SKIP | Docker is not installed | Nothing 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.
Check 6: Are any images built on end-of-life base images?
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 | When | What it means |
|---|---|---|
| PASS | No image tag matches the known EOL patterns | The image count is named, with the caveat that unusual bases still need their own support window checked. |
| FAIL | One or more image tags match a known EOL base | Up to ten are listed. These no longer receive security patches, so the fix is a rebuild on a supported base. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no local images | Nothing 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 image | Replace with |
|---|---|
ubuntu:18.04 | ubuntu:24.04 |
debian:stretch | debian:bookworm |
centos:7 | rockylinux:9 or almalinux:9 |
node:14 | node:20-alpine |
python:3.6 | python:3.12-slim |
alpine:3.12 | alpine: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.
Check 7: Do images follow build best practices?
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 | When | What it means |
|---|---|---|
| PASS | dockle reports no fatal findings, or at least one image defines a HEALTHCHECK | Two distinct passes. The heuristic one names how many of the images checked have a healthcheck and still recommends installing dockle for a fuller audit. |
| WARN | dockle found fatal-level findings, or no image defines a HEALTHCHECK | Two 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. |
| SKIP | Docker is not installed, the daemon cannot be queried, or there are no images to check | Nothing 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:tagHEALTHCHECK 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?
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 | When | What it means |
|---|---|---|
| PASS | syft is installed, or the docker sbom plugin is available | Two distinct passes, each naming the command to generate an SBOM per image. |
| WARN | Only trivy is available, or nothing is | Two 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.jsonStore 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.
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.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 8 checks | About 80 minutes | About 2 minutes |
| Scanner handling | Install and invoke per host | Detects trivy, dockle and syft automatically |
| EOL detection | Manual tag comparison | Pattern-matched against the known EOL list |
| Get a scored report | Not available | Automatic |
| Re-audit after fixing | Another 80 minutes | Another 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.