SAST scanner · reads the file text

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.

SASTLow to CriticalInfrastructure Misconfiguration9 checksevery scan depth

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.

Path in the repositoryDecisionGoes to Dockerfile name is in the Dockerfile name listDockerfile checks (6) services/api/Dockerfile.prod path ends with /Dockerfile.prodDockerfile checks (6) src/Dockerfile.dev path ends with /Dockerfile.devDockerfile checks (6) scanned, but root and HEALTHCHECK checks are skippedDockerfile checks (4 of 6) docker-compose.yml name is in the compose name listCompose checks (3) deploy/compose.yaml path ends with /compose.yamlCompose checks (3) Dockerfile.local not in the list (only .dev .prod .staging)not scanned docker-compose.override.yml not in the list of six compose namesnot scanned

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.

Dockerfileread as one string, never parsed 1FROM node:latest 2ENV NODE_ENV=production 3ENV DB_PASSWORD=hunter2hunter2 4ADD https://example.com/tool.tgz /opt/ 5WORKDIR /app 6COPY . . 7RUN npm install 8EXPOSE 3000 9EXPOSE 5432 10CMD ["npm", "start"] Checks No USER directiveMedium · conf 0.85 FLAG :latest or untagged FROMLow · conf 0.70 FLAG Sensitive EXPOSE portMedium · conf 0.80 FLAG Secret in ENVHigh · conf 0.85 FLAG ADD with a remote URLMedium · conf 0.80 FLAG No HEALTHCHECKLow · conf 0.60 FLAG Medium, conf 0.85. Absence rules flag the file. The finding stores the file path, not a line number. Low, conf 0.70. The image can change under you between builds. Medium, conf 0.80. A database port in an application image suggests the database runs in the same container. High, conf 0.85. ENV values are baked into every layer and readable with docker history. Medium, conf 0.80. A supply chain risk: nothing checks what the URL serves. Low, conf 0.60. Real output for this file: 6 findings, in the order shown.

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.

docker-compose.ymlread as one string, never parsed 1services: 2 db: 3 image: postgres:16 4 privileged: true 5 environment: 6 POSTGRES_PASSWORD: postgres 7 ports: 8 - "5432:5432" Checks Default or weak passwordMedium · conf 0.90 FLAG Privileged modeCritical · conf 0.95 FLAG Database port on the hostLow · conf 0.75 FLAG Medium, conf 0.90. One finding per file. Critical, conf 0.95. The only Critical in the Docker scanner. Low, conf 0.75.

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.

What the scanner meetsVerdictWhy ENV DB_PASSWORD=${DB_PASSWORD}Dockerfile ENV with a variable referenceSKIP Values starting with $ or { are not literals. The password arrives from the build environment. ENV DEBUG_KEY=trueName contains KEY, value is a boolean-like wordSKIP true, false, 0, 1, none and empty are ignored as common non-secret defaults. FROM scratchAn empty base imageSKIP scratch has no tag by design, so it is exempt from the tag check. FROM registry.io:5000/appRegistry with a port, but the image has no tagFLAG Only the part after the last / is checked for a colon, so the port is not mistaken for a tag. - "127.0.0.1:5432:5432"compose port bound to the loopback addressSKIP The pattern wants digits then a colon right after the dash. 127.0.0.1:... does not match, so it is left alone. USER 1000Any USER line, even a numeric id or rootSKIP The root check is satisfied by the presence of the line. USER root would also pass.

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.

Line the check matched, and the line after the fix findings in this file: 6 findings in this file: 5 findings in this file: 4 findings in this file: 3 findings in this file: 2 findings in this file: 1 findings in this file: 0 No USER directive(no USER line anywhere)USER appFLAGclear :latest or untagged FROMFROM node:latestFROM node:20.11-alpineFLAGclear Sensitive EXPOSE portEXPOSE 5432# database runs in its own container; no EXPOSE 5432FLAGclear Secret in ENVENV DB_PASSWORD=hunter2hunter2# DB_PASSWORD is injected at run time (docker run -e)FLAGclear ADD with a remote URLADD https://example.com/tool.tgz /opt/RUN curl -fsSLO https://example.com/tool.tgz && sha256sum -c tool.sha256FLAGclear No HEALTHCHECK(no HEALTHCHECK line anywhere)HEALTHCHECK --interval=30s CMD wget -q --spider http://localhost:3000/ || exit 1FLAGclear

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

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.

Real-world breaches

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

Checks and limits

Every check, with the severity and confidence the code assigns.

CheckFires whenSeverityConf.
No USERno line matches ^\s*USER\s+\S+; path without devMedium0.85
:latest / untaggedFROM x:latest, or a FROM image with no colon after its last slashLow0.70
Sensitive EXPOSEport in 22, 2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017Medium0.80
ENV secretENV name with PASSWORD, SECRET, KEY or TOKEN and a literal valueHigh0.85
Remote ADDADD http(s)://Medium0.80
No HEALTHCHECKno HEALTHCHECK line; path without devLow0.60
Compose passworddefault or short value after a PASSWORD or PASSWD keyMedium0.90
Compose privilegedprivileged: trueCritical0.95
Compose portcontainer port 5432, 3306, 6379, 27017 or 9200 publishedLow0.75
Docs and code

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.