kodebeat / papers / security

Turning container hardening findings into the fixed file

Hadofix, a fixer that applies what hadolint reports as a diff.

Where hadolint emits warnings for a human to act on, hadofix emits a diff: --no-install-recommends, apt lists cleaned inside the layer that created them, a non-root USER, exec-form CMD and ENTRYPOINT, and pins only where the file already contains the answer. Every hunk carries a comment saying which rule it fixes.

The problem

hadolint runs on a repository with eleven Dockerfiles and reports 143 findings, every one of them correct, and none of them gets fixed.

The linter is doing its job; the cost of acting on a finding exceeds the perceived cost of ignoring it. Each of those 143 findings requires an engineer to open the file, understand the rule, work out the correct edit for this particular Dockerfile, make it, and not break the build. Multiplied out, that is a week of unglamorous work with no feature attached, competing against a roadmap. The ticket moves to the next sprint, and the sprint after that.

Meanwhile the actual risk is concrete and unexciting:

FROM ubuntu
RUN apt-get update && apt-get install -y curl
ENTRYPOINT ["app"]

Three problems, all ordinary. FROM ubuntu is a floating tag, so a build that worked in March can fail in April with no change to the Dockerfile. apt-get install -y without --no-install-recommends pulls in packages nobody asked for, each one a CVE surface and a few megabytes. And no USER means the container runs as root, which turns a container escape from a serious problem into a very serious one.

None of this is controversial, and all of it is in every container hardening guide. It stays unfixed because fixing it is tedious rather than difficult.

The method

hadolint decides what is wrong; hadofix decides what can be repaired without guessing. hadofix has no rule list to keep in sync with yours. Your .hadolint.yaml, --ignore flags and inline # hadolint ignore= pragmas are the configuration, so a rule you switched off is never fixed. hadofix adds four rules of its own, namespaced HF#### so they cannot collide with hadolint's DL####. The output is the diff on stdout and a report on stderr of what was fixed and what was left. This is the example from the repository README:

$ hadofix Dockerfile
--- Dockerfile
+++ Dockerfile (hadofix)
@@ -1,8 +1,19 @@
 FROM python:3.12-slim
-RUN apt-get update && apt-get install -y curl
+# hadofix(DL3015): apt pulls every `Recommends` of every package by default —
+# software nobody in this Dockerfile asked for, shipped in the image and
+# reported by every scanner. The flag installs what is named here and stops.
+# hadofix(DL3009): The cleanup has to run in the same RUN as the install: a
+# later layer can hide the lists but every layer is still in the image, still
+# pushed, still pullable. Same layer, or it is only hidden.
+RUN apt-get update && apt-get install --no-install-recommends -y curl && rm -rf /var/lib/apt/lists/*
 COPY entrypoint.sh /entrypoint.sh
 COPY app /opt/app
 EXPOSE 8000
 USER 10001
 ENTRYPOINT ["/entrypoint.sh"]
-CMD gunicorn --bind 0.0.0.0:8000 app:wsgi
+# hadofix(HF1001): Shell form runs `/bin/sh -c 'gunicorn --bind 0.0.0.0:8000
+# app:wsgi'`, so PID 1 is sh. `docker stop` sends SIGTERM to PID 1, sh does not
+# forward it, and ten seconds later the container is SIGKILLed mid-request.
+# Exec form makes `gunicorn` PID 1 and puts the signal where the shutdown
+# handler is.
+CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:wsgi"]
hadofix: fixed 4 findings in Dockerfile: DL3009, DL3015, DL3025, HF1001
hadofix: 3 findings left for you:
  Dockerfile:2 warning DL3008
      Pin versions in apt get install. Instead of `apt-get install <package>`
      use `apt-get install <package>=<version>`
      → hadofix does not invent versions: nothing in this file pins `curl`.
        `apt-cache policy <package>` inside the base image prints the version
        to write.
  Dockerfile:7 warning HF1002
      `entrypoint.sh` never calls `exec`, so the command it starts is a child
      of the script: PID 1 stays /bin/sh, SIGTERM stops the wrapper and the app
      is killed on the timeout. End the script with `exec "$@"` (or `exec
      <command>`).
  Dockerfile:8 info HF1003
      `gunicorn` forks worker processes, and as PID 1 it also inherits every
      orphan in the container and is expected to reap it — PID 1 gets no
      default signal handlers either. Put an init in front (tini, dumb-init) or
      run the container with `docker run --init`.
hadofix: nothing written — re-run with --write to apply

Pin from evidence, or refuse. A version is taken from another stage in the same file, from a requirements.txt the Dockerfile copies in, or, for a base image, from a Docker Hub digest you asked for with --resolve-digests. Where none exists, hadofix leaves the line alone and says what you have to decide. For the Problem's FROM ubuntu, the report reads "hadofix does not pick versions: nothing else in this file pins ubuntu", and the suggested remedies are writing the tag you test against or passing --resolve-digests. The same refusal covers a shell-form command that needs a shell (CMD app --port $PORT stays as it is, because exec form would stop expanding $PORT), the control flow of your entrypoint script (HF1002), and installing an init for you (HF1003).

Fixing a fixed file is a no-op. A second run over hadofix's own output changes nothing, so a clean file stays clean in CI and --write does not drift a file on repeated runs. The Methodology section says how that is tested.

The trade-off. hadofix is narrower than a hand edit and more invasive than a linter. It fixes eleven hadolint rules and two of its own; everything else hadolint reports is listed untouched. It needs hadolint installed, since the linter is the engine rather than a bundled copy. And USER 10001 can still be the wrong change for an image that needs root. The mitigation is that the default output is a diff to stdout: you see it before it lands, and --write is an explicit opt-in.

The headline results

  • One golden directory per rule.
  • Idempotency, per case.
  • Evidence, not invention.
  • The CLI contract.

The full results, with the tables and the reasoning behind each figure, are in the PDF.

What is in the PDF

  • The problem — what goes unanswered without it, and who notices first.
  • Why the obvious alternative falls short — stated plainly, including where it is the better choice.
  • How it works — the method, not a feature list.
  • Concrete use cases — with console output quoted from the repository, never reconstructed.
  • The methodology behind any number it emits — every term shown, so the figure survives a question.
  • What it deliberately does not do — the section most papers leave out.

Part of the Security & supply chain theme.

Get the PDF

This paper already selected; the download starts when you send the form. No follow-up sequence.

Get the PDF