Docker Scanner
A container image carries whatever the Dockerfile put into it. This scanner picks up Dockerfiles and compose files by name, then runs a fixed list of line-anchored regular expressions over the raw text. Root user, unpinned base, database ports, secrets in ENV, remote ADD, weak compose passwords and privileged mode all come from that one pass.
Which files it reads
The Docker scanner is the only one in this group with two kinds of input. Both are picked by file name alone.
1The scanner starts from the repository file index: every path with its text. It does not look at file contents to decide what is a Dockerfile. Only the file name counts.
2Dockerfile names are an exact list: Dockerfile, Dockerfile.dev, .prod, .staging. A path matches when it equals the name or ends with /name, so nested folders are found.
3Compose files use a second list of six names: docker-compose.yml, .yaml, .dev.yml, .prod.yml, compose.yml, compose.yaml. They get their own three checks.
4Anything else is invisible to it. A Dockerfile.local or a compose override file is never read, however dangerous its contents.
5Dev files are scanned, but a path containing the text dev skips the root-user and HEALTHCHECK checks, because dev containers usually run as root on purpose. The other four checks still run.
Walking a Dockerfile
Six rules, anchored to line starts, run over one string. The bar on the left is the text each rule matched; the arrow shows which check it fed.
1One Dockerfile, ten lines. Six checks will run over the text in a fixed order. Nothing is parsed: each check is a regular expression, anchored to the start of a line, run over the whole file.
2Root user. The check looks for any line matching ^\s*USER\s+\S+. There is none, so the container runs as root. This is a missing-line rule, so no single line is to blame.
3Base image. FROM node:latest matches the :latest pattern. A second pass also flags any FROM whose image has no :tag after its last / (scratch is exempt).
4Exposed port. Each EXPOSE number is looked up in a table of nine: 22, 2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017. 3000 is not in it; 5432 is PostgreSQL.
5Secret in ENV. An ENV name containing PASSWORD, SECRET, KEY or TOKEN with a literal value. NODE_ENV does not match, and a value starting with $ or { is skipped. One finding per file at most.
6Remote ADD. ADD followed by http:// or https:// pulls unverified bytes into the image at build time.
7HEALTHCHECK. No line matches ^\s*HEALTHCHECK\s+, so the orchestrator cannot tell a hung container from a healthy one. Another missing-line rule.
8Six findings, two of them from lines that are absent. Line 6, COPY . ., is not reported. The pattern for it exists in the configuration but no check uses it.
Walking a compose file
Compose files get three checks. The same shape: a regular expression over the raw text, one finding per file for the password rule.
1A compose file gets three checks, again as regular expressions over the raw text. The YAML is not loaded, so there is no notion of which service a line belongs to.
2Weak password. PASSWORD or PASSWD followed by : or = and a known default (postgres, admin, changeme, ...) or any value of 8 word characters or fewer. Case is ignored.
3Privileged. The text privileged: true anywhere in the file. The container shares the host kernel's devices and can escape.
4Published port. Lines shaped - "host:container" are read; if the container port is 5432, 3306, 6379, 27017 or 9200, the database is reachable from every network the host is on. Once per port.
What it deliberately skips
Each row is a line the scanner meets. Step through to see the verdict and the reason.
1Variable references are not secrets. The ENV check reads the value after the equals sign and ignores it if it begins with a dollar sign or a brace.
2Boolean-like values are ignored. A name with KEY in it is not enough on its own; the value must look like something other than a flag.
3scratch is exempt. Distroless and static-binary images start from nothing and have no tag to pin.
4Registry ports are handled. The scanner splits on the last slash first, so registry.io:5000/app is correctly seen as untagged and flagged.
5Loopback binds fall outside the pattern. This is a side effect of the regular expression, not a designed exemption. It happens to match what a reader would want.
6The root check only asks whether a USER line exists. It does not read the value, so USER root counts as having a user. Treat that as a known blind spot.
The fix, line by line
Fixing a finding means changing the text so the rule no longer matches, or so the line it wants is present.
1Before: the six findings from the walk. Each step below fixes one line and the matching check goes quiet.
2Add a non-root user after creating it with addgroup and adduser. Any USER <name> line satisfies the check.
3Pin the tag. A version and a variant, so the base only changes when you change it.
4Remove the database port. The app container exposes only its own port. A commented line is not matched because the pattern is anchored to the line start.
5Take the secret out of the image. Pass it at run time or mount it; the image then carries nothing to extract.
6Download and verify. A RUN step with a checksum fails the build if the bytes change.
7Declare a health check. Any line starting with HEALTHCHECK clears the finding. Six fixes, zero findings.
Why it matters
A Dockerfile is a recipe that anyone who can pull the image can read back. A missing USER line means a container escape lands as root. A secret in ENV is stored in the image layers and shows up in docker history. An unpinned tag makes tomorrow's build different from today's. A database port in an application image suggests the database lives next to the app.
Tesla (2018). Attackers found an unsecured Kubernetes dashboard on Tesla's AWS infrastructure, used it to reach AWS credentials, and ran cryptocurrency miners. Alpine Linux, CVE-2019-5021 (2019). Official Alpine Docker images shipped with a blank root password for three years, so anyone with console access to a container could log in as root.
Vulnerable vs fixed code
# BAD: the walk above, line by line
FROM node:latest
ENV DB_PASSWORD=hunter2hunter2
ADD https://example.com/tool.tgz /opt/
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
EXPOSE 5432
CMD ["npm", "start"]
# no USER line, no HEALTHCHECK line: two more findings# GOOD: multi-stage, pinned, non-root, with a health check
FROM node:20.11-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/
FROM node:20.11-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app .
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s CMD wget -q --spider http://localhost:3000/api/health || exit 1
CMD ["node", "dist/index.js"]
# .dockerignore keeps .env, .git and node_modules out of the build contextChecks and limits
Every check, with the severity and confidence the code assigns.
| Check | Fires when | Severity | Conf. |
|---|---|---|---|
| No USER | no line matches ^\s*USER\s+\S+; path without dev | Medium | 0.85 |
| :latest / untagged | FROM x:latest, or a FROM image with no colon after its last slash | Low | 0.70 |
| Sensitive EXPOSE | port in 22, 2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017 | Medium | 0.80 |
| ENV secret | ENV name with PASSWORD, SECRET, KEY or TOKEN and a literal value | High | 0.85 |
| Remote ADD | ADD http(s):// | Medium | 0.80 |
| No HEALTHCHECK | no HEALTHCHECK line; path without dev | Low | 0.60 |
| Compose password | default or short value after a PASSWORD or PASSWD key | Medium | 0.90 |
| Compose privileged | privileged: true | Critical | 0.95 |
| Compose port | container port 5432, 3306, 6379, 27017 or 9200 published | Low | 0.75 |
The code defines a pattern for COPY . . but nothing runs it, so COPY . . and COPY .env produce no finding today. Severity reaches High (ENV secrets) and Critical (compose privileged mode). Findings carry the file path only, with no line number.