What this check reads
Which branch runs depends on whether dockle is on PATH. If it is, the check runs dockle --exit-code 0 --format json <img> against the first 3 tagged local images and counts occurrences of "level":"FATAL" in the JSON: one or more is the WARN, a zero count is the PASS, and findings below fatal are not counted at all. If it is not, the check falls back to reading docker inspect -f '{{.Config.Healthcheck}}' on up to 10 images, where a literal <nil> means no HEALTHCHECK is defined. That fallback threshold is asymmetric: it WARNs only when every sampled image comes back <nil>, so a single image with a HEALTHCHECK passes the whole host.
The dockle branch does not work as intended today. Dockle pretty-prints its JSON with a space after the colon ("level": "FATAL"), so the compact pattern never matches and the count stays at zero. With dockle installed the check currently returns PASS whatever dockle found.
When it applies
Needs the docker binary and daemon access; otherwise SKIP. Which branch runs - and how much it actually tells you - depends on whether dockle is installed:
- With dockle - up to 3 images are audited against dockle's full checkpoint list, counting
FATAL-level findings only. Lower-severity findings are ignored. - Without dockle - up to 10 images are inspected for a single thing: whether
.Config.Healthcheckis defined. The bar is very low, and asymmetric: WARN only if none of the images define a HEALTHCHECK, PASS if even one does. A host where 9 of 10 images lack a healthcheck therefore passes.
Note a HEALTHCHECK is an availability feature rather than a security control; this branch is a stand-in for a real best-practices audit, which is why the severity is LOW.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | Docker not installed, daemon unreachable, or no images to check | Nothing was audited. Neither branch ran, so no claim was made about how your images are built. |
| WARN | Dockle found FATAL findings | Dockle flagged at least one fatal-level rule across the three images it checked. This branch cannot be reached today, because the count never matches the JSON dockle actually prints. |
| PASS | Dockle clean | With dockle installed this is the only verdict the check can reach today, so it does not show the images are clean. Run dockle yourself and read its findings. |
| WARN | No Dockle and **none** of the images define a HEALTHCHECK | Dockle is not installed, so HEALTHCHECK presence was the only thing tested, and not one of the sampled images defines one. |
| PASS | No Dockle and at least one image has a HEALTHCHECK | One image with a HEALTHCHECK was enough to pass the host. Nothing else about build hygiene was examined. |
Why it matters
Dockle implements the CIS Docker Benchmark section 4 (image) checks: no root user, no ADD for remote URLs, no credentials in ENV, no setuid files, HEALTHCHECK present, apt-get caches cleaned, etc. HEALTHCHECK (CIS Docker 4.6 / 5.26) lets the orchestrator detect hung services and avoid routing traffic to them.
Why it fails, and when it is wrong
- The dockle count never matches (known issue). The script greps for
"level":"FATAL"with no space, and dockle writes"level": "FATAL". Fatal findings are counted as zero and the result is PASS. - Even with the pattern corrected, only FATAL findings count. Dockle rates a missing non-root user (
CIS-DI-0001) as WARN and a missing HEALTHCHECK (CIS-DI-0006) as INFO, so neither would move this result. - The fallback passes if any image has a healthcheck; it is a weak proxy.
- Compose
healthcheck:blocks set the check on the container, not the image; the fallback inspects images and misses them. - Dockle FATAL levels include items that are hard to satisfy on some base images; review its output rather than treating the count as absolute.
How to fix it
Install dockle and run it on every image
Dockle publishes a .deb and an .rpm with each release; the repository in Sources has the install commands. Then run it across everything, not the three images the check samples.
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
echo "== $i"; dockle "$i"
doneClear the FATAL findings first
The ones that come up most are CIS-DI-0010 (a credential in ENV or a file), CIS-DI-0009 (ADD where COPY would do), DKL-DI-0005 (apt-get caches left in the layer) and DKL-DI-0001 (sudo in the image). The apt-get one is fixed in the same RUN that installs:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*Add a HEALTHCHECK where it helps
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8080/healthz || exit 1The command runs inside the container, so the binary it uses has to exist there.
Rebuild and run dockle again
docker build -t app:1.4.3 . && dockle app:1.4.3
# expected: no FATAL findingsVerify the fix
# Run a real best-practices audit across every image, not three:
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
echo "== $i"; dockle --exit-code 0 "$i"
done
# expected: no FATAL findings
# The fallback's single test, per image:
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | \
xargs -I{} sh -c 'printf "%s hc=%s\n" {} "$(docker image inspect -f "{{.Config.Healthcheck}}" {})"'
# <nil> means no HEALTHCHECK
# Confirm a healthcheck actually reports:
docker inspect -f '{{.State.Health.Status}}' <container> # expected: healthyDebugging
Three different cases share this status. 'Docker not installed' is correct on a host with no Docker. 'Cannot query Docker daemon' means the audit account could not reach the daemon: check systemctl is-active docker, then docker ps as that user and sudo docker ps, and run the audit as root or with sudo if only the second works. 'No local images to check' means the daemon held no tagged images, which is normal on a host that pulls at deploy time; check with docker images -a. It is also what you get when the audit has root but the daemon is down, because an empty answer and an empty image list look the same to the script. The Docker Inventory check covers the unreachable case in more detail.
The known issue above: the check counts fatal findings with a pattern that never matches dockle's JSON, so the count is always zero. Run dockle <image> yourself and go by what it prints.
Nearly meaningless: one image with a HEALTHCHECK is enough to pass the whole host. Install dockle, or use the per-image loop above, before treating this as a real result.
Accurate but narrow. Add HEALTHCHECK CMD instructions where they make sense, and remember this branch is not auditing anything else.
Run dockle <image> directly. Each finding carries a checkpoint ID, for example CIS-DI-0010 (secrets stored in the Dockerfile), CIS-DI-0009 (ADD used where COPY would do) or DKL-DI-0005 (apt-get caches not cleared), and the checkpoint list in Sources explains each one.
A deliberate cost cap, since dockle is slow. The message says how many were checked; use the loop for the rest.
The command runs inside the container, so it must exist there (curl often does not) and must exit 0. Test with docker exec <c> <your healthcheck command>; echo $?.
Sources
- Dockle
- Dockle checkpoints (the codes it reports)
- Docker: Dockerfile HEALTHCHECK
- Docker: Building best practices
- Docker Compose: healthcheck
- hadolint (Dockerfile linter, complements Dockle)
How the script reads this
Next
Re-run the Image Vulnerabilities audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 8 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 8 Image Vulnerabilities fixes