Containers — Dockerfile rules
Container scanning in Vulnetix evaluates the eight VNX-DOCKER-* Rego rules against build-time files — Dockerfile, Containerfile, and the *.dockerfile / *.containerfile variants. The flag --enable-containers turns this on (it’s gated, not on-by-default like SCA / SAST / secrets).
Image-layer scanning (extracting packages from a built OCI image and emitting them as SBOM components) is not part of the current container evaluator. If you need vulnerability findings against a base-image’s apk / deb / rpm packages, run a separate dedicated image scanner — Grype, Trivy, or vulnetix scan against an exported root filesystem after docker export | tar -x. The findings then triage through the standard SCA path.
What container scanning finds
Findings land in .vulnetix/sast.sarif with ruleId: VNX-DOCKER-NNN. The standard SARIF location fields point to the Dockerfile line:
# Every VNX-DOCKER-* finding
jq '.runs[].results[]
| select(.ruleId | startswith("VNX-DOCKER-"))
| {
ruleId,
file: .locations[0].physicalLocation.artifactLocation.uri,
line: .locations[0].physicalLocation.region.startLine,
message: .message.text
}' .vulnetix/sast.sarif
# One rule's hits across all Dockerfiles
jq '.runs[].results[]
| select(.ruleId == "VNX-DOCKER-001")' .vulnetix/sast.sarif
The eight Dockerfile rules
Each rule below has a bad pattern, a good pattern, and a reason. Look the rule up on docs.cli.vulnetix.com (/docs/sast-rules/vnx-docker-NNN/) for the exact detection logic.
VNX-DOCKER-001: missing USER directive
Default Docker behaviour is to run as root. A compromise inside the container then runs as root inside the container’s namespaces — privileged enough to break out of many sandboxes, especially with mounted volumes.
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
FROM python:3.12-slim
RUN useradd --create-home --shell /bin/bash --uid 1000 app
WORKDIR /home/app
COPY --chown=app:app . .
USER app
RUN pip install --user -r requirements.txt
CMD ["python", "app.py"]
FROM python:3.12-slim AS build
WORKDIR /app
COPY . .
RUN pip install --target=/app/deps -r requirements.txt
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=build /app /app
ENV PYTHONPATH=/app/deps
USER nonroot
CMD ["app.py"]
VNX-DOCKER-002: unpinned :latest tag
FROM debian:latest resolves to a different image every time you build. Reproducibility and security both suffer — you can’t rebuild yesterday’s image, and you don’t know what’s in it.
FROM debian:latest
FROM debian:12.5-slim
FROM debian@sha256:5f7e9e09786373f47e3036d9fb6bb47cbf1f6d54f3dc7a3a4e0eddd4d1f04f9f
Digest pins are immutable and survive registry takeovers; tag pins are mutable but human-readable. Renovate or Dependabot can keep digest pins fresh automatically.
VNX-DOCKER-003: missing HEALTHCHECK
A container without a healthcheck is a black box to the orchestrator — Kubernetes / Compose can’t distinguish a hung process from a working one. Failures cascade because nothing replaces a wedged container.
FROM nginx:1.27
COPY site/ /usr/share/nginx/html/
EXPOSE 80
FROM nginx:1.27
COPY site/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost/health || exit 1
Kubernetes uses its own readiness/liveness probes (defined in the manifest, not the Dockerfile), but the HEALTHCHECK directive still serves Compose, docker run, and Swarm.
VNX-DOCKER-004: uncached package manager layer
apt-get update in one layer and apt-get install in another means a future build can install against a stale package index — and the layer reuse misses the security patches in the update.
RUN apt-get update
RUN apt-get install -y curl ca-certificates
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Combine update + install + cleanup into one RUN. --no-install-recommends skips optional packages that bloat the image. rm -rf /var/lib/apt/lists/* discards the package index after install — cuts the image size by ~30 MB.
VNX-DOCKER-005: hardcoded secrets in ENV
ENV values are baked into every layer that uses them — they end up in the image, the registry, and any tarball produced by docker save. A secret in ENV is a leaked secret the moment the image is pushed.
ENV DATABASE_URL="postgres://app:hunter2@db.internal/myapp"
ENV NPM_TOKEN="npm_aBcDeFgHiJkLmNoP1234567890qrstuvwxyzabcd"
RUN npm install
# syntax=docker/dockerfile:1.4
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npm,target=/root/.npmrc \
npm ci
# DATABASE_URL is provided by the orchestrator at runtime,
# not at build time. No ENV declaration in the Dockerfile.
CMD ["node", "server.js"]
docker build --secret id=npm,src=$HOME/.npmrc -t myimage . — the secret is mounted into the build but not persisted in any layer. For runtime secrets, inject via Kubernetes Secret, Docker Compose environment, or --env-file.
VNX-DOCKER-006: privileged port exposure
Ports below 1024 require CAP_NET_BIND_SERVICE (or root). A container exposing EXPOSE 80 typically runs as root specifically to bind it, which compounds the VNX-DOCKER-001 problem.
FROM nginx
USER nginx
EXPOSE 80
FROM nginxinc/nginx-unprivileged:1.27
EXPOSE 8080
FROM nginx:1.27
RUN setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx
USER nginx
EXPOSE 80
setcap grants the binary the specific capability without running as root. nginx-unprivileged is a pre-configured variant from the official image team — usually the cleanest answer.
VNX-DOCKER-007: ADD instead of COPY
ADD does more than COPY — it can fetch URLs, and it auto-extracts local tarballs. Both are footguns: the URL fetch has no integrity check by default, and the tarball extraction has historically had path-traversal bugs.
ADD https://example.com/app.tar.gz /opt/
ADD ./src /app/
COPY ./src /app/
ADD --checksum=sha256:abc123... https://example.com/app.tar.gz /opt/
Modern Docker supports ADD --checksum= (BuildKit 22.10+) for verified remote downloads. For local files always use COPY.
VNX-DOCKER-008: unoptimised RUN
Each RUN produces a layer. Many small layers = larger image, longer push/pull times, more attack surface in the registry.
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y ca-certificates
RUN curl -fsSL https://get.example.com | bash
RUN apt-get clean
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& curl -fsSL https://get.example.com | sh \
&& apt-get purge -y curl \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
Combine. Remove build-time tools (curl after the install) before exiting the RUN. Discard the package index. Avoid apt-get clean standalone — it doesn’t run in one-layer images.
Worked example: hardening a root container that needs build-time root
The build needs apt-get install, which needs root. The runtime doesn’t. Solution: multi-stage.
# syntax=docker/dockerfile:1.4
# Build stage — root is fine here, the result is thrown away
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& pip install --user --no-cache-dir -r requirements.txt \
&& rm -rf /var/lib/apt/lists/*
# Runtime stage — distroless, non-root, minimal attack surface
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /root/.local /home/nonroot/.local
COPY --chown=nonroot:nonroot . .
ENV PATH=/home/nonroot/.local/bin:$PATH
USER nonroot
HEALTHCHECK --interval=30s CMD ["/home/nonroot/.local/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
EXPOSE 8080
CMD ["app.py"]
Verify:
docker build -t myapp:test .
docker run --rm myapp:test id # uid=65532(nonroot)
docker run --rm myapp:test sh # exec failed — distroless has no shell
Producing the VEX
Dockerfile findings (VNX-DOCKER-*) go to OpenVEX — the subject is your image’s tag / digest. If you also run a separate image scanner against the built image and get base-image CVEs, those are SCA findings against pkg:apk/... / pkg:deb/... PURLs and go to CycloneDX VEX (see the SCA page).
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://github.com/yourorg/yourrepo/vex/2026-05-14-docker-001.json",
"author": "developer@example.com",
"timestamp": "2026-05-14T12:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "VNX-DOCKER-001",
"description": "Container runs as root — no USER directive. See https://docs.cli.vulnetix.com/docs/sast-rules/vnx-docker-001/"
},
"products": [{
"@id": "ghcr.io/yourorg/myapp:2.3.0",
"identifiers": { "purl": "pkg:oci/myapp@sha256:abc123...?repository_url=ghcr.io/yourorg" }
}],
"status": "fixed",
"action_statement": "Dockerfile restructured as multi-stage build. Runtime stage uses gcr.io/distroless/python3-debian12:nonroot with USER nonroot. Verified docker run --rm myapp:test id returns uid=65532. See MR !67."
}
]
}
See also
- Capability matrix — Vulnetix containers (unpacked-layer) vs Grype (image-binary).
- Grype’s Class A/B/C/D taxonomy — the finding-class identification before fix-mechanic selection.
- EOL appendix — base-image EOL and migration to RHEL UBI / Chainguard / distroless.
- Glossary.