From d7f6a6abf423d620b92dfdab30dc08cd9cccee20 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 3 Aug 2026 14:59:29 +0200 Subject: [PATCH 1/2] fix(docker): adopt hadolint 2.15.1 and fix the four findings it reports Follow-up to the digest pin in the previous commit, which restored the gate by freezing the linter at 2.14.0. Freezing is not the end state: 2.14.0 reports nothing only because it predates the rules, so staying there would have greened CI by un-seeing findings rather than by addressing them. Move the pin forward to 2.15.1, the version that surfaced them, and fix all four in the Dockerfiles. No `.hadolint.yaml` ignore entries and no inline `# hadolint ignore=` directives are used. DL3066, non-numeric user id (Dockerfile:149, Dockerfile.test:21, Dockerfile.dev:62). A name-form USER cannot be verified by Kubernetes `runAsNonRoot` and similar admission checks, which see only the numeric id. Dockerfile and Dockerfile.test already pinned their uids explicitly (1000 and 10001), so those are substitutions with no behaviour change. Dockerfile.dev created its user with `adduser -S`, which takes the first free system id and so varies with the base image; its uid/gid are now pinned to 1001 so the numeric USER has a value fixed at build time rather than discovered. Nothing outside Dockerfile.dev refers to `devuser`, so the changed id is contained. DL3025, JSON notation for CMD/ENTRYPOINT (Dockerfile:165). The flagged line is the HEALTHCHECK CMD, not the container ENTRYPOINT/CMD, which were already exec form. It reads `curl -f http://localhost:8080/health || exit 1`, where `||` is a shell operator: a naive exec-form list would pass `||` and `exit` to curl as literal arguments and the healthcheck would stop reporting unhealthy correctly. Written instead as `["/bin/sh", "-c", "..."]`, which is JSON notation and the same process tree the shell form produced, with the dependency on a shell declared rather than implied. Verified, with Docker available locally: - hadolint 2.15.1 against the three Dockerfiles: exit 0. - All three images build: Dockerfile, Dockerfile.dev, Dockerfile.test, exit 0. - Runtime identity is unchanged where it was already pinned: uid=1000(cudly) in the main image, uid=10001(e2e) in the test image, and uid=1001(devuser) in the dev image. - The healthcheck is recorded as ["CMD","/bin/sh","-c","curl -f http://localhost:8080/health || exit 1"] and exercised both directions: exit 1 with no server listening (so `||` is evaluated by the shell rather than handed to curl) and exit 0 on the short-circuit path. Closes #1695 --- .pre-commit-config.yaml | 23 ++++++++++++++--------- Dockerfile | 15 ++++++++++++--- Dockerfile.dev | 11 ++++++++--- Dockerfile.test | 4 +++- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c37ad9873..0cd4ffb32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,21 +67,26 @@ repos: # The upstream hadolint-docker hook (hadolint/hadolint's own # .pre-commit-hooks.yaml) declares `entry: ghcr.io/hadolint/hadolint # hadolint` with no image tag, so Docker resolves it to `:latest` on every - # run -- independent of the `rev:` pin above, which only pins which - # commit of the *hook definition* is used, not the Docker image it runs. - # When upstream published hadolint 2.15.1 as `latest`, CI silently - # started linting with a newer ruleset (new DL3066/DL3025 findings on - # unchanged Dockerfiles) with no corresponding change in this repo. + # run -- independent of any `rev:` pin, which only pins which commit of the + # *hook definition* is used, not the Docker image it runs. When upstream + # published hadolint 2.15.1 as `latest`, CI silently started linting with a + # newer ruleset (new DL3066/DL3025 findings on unchanged Dockerfiles) with no + # corresponding change in this repo, and `main` went red (issue #1695). # - # Defined as a local hook instead, pinned to the immutable digest of the - # v2.14.0 image, so the linter version can only change via an explicit - # bump here. + # Defined as a local hook instead, pinned to an immutable digest, so the + # linter version can only change through an explicit edit to this line. + # + # Pinned to 2.15.1 -- the version that surfaced the findings -- rather than + # back to 2.14.0. Pinning to the older image would also have made CI green, + # but only by un-seeing findings that are real; the four it reported are + # fixed in the Dockerfiles rather than silenced. Bumping this digest is + # expected to require fixing whatever the new version finds. - repo: local hooks: - id: hadolint name: Lint Dockerfiles language: docker_image - entry: ghcr.io/hadolint/hadolint:v2.14.0@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint + entry: ghcr.io/hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d hadolint types: [dockerfile] # Markdown linting diff --git a/Dockerfile b/Dockerfile index cf499e9b1..6e398cef8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -145,8 +145,11 @@ COPY --from=frontend-builder --chown=cudly:cudly /frontend/dist /app/static COPY --chown=cudly:cudly scripts/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -# Switch to non-root user -USER cudly +# Switch to non-root user. Numeric form so the identity is resolvable without +# the image's /etc/passwd: Kubernetes `runAsNonRoot` and similar admission +# checks cannot verify a name-form USER and will refuse to start the pod. +# 1000:1000 is exactly the uid:gid created above, so this is a rename only. +USER 1000:1000 # Environment defaults ENV DB_MIGRATIONS_PATH=/app/migrations \ @@ -162,8 +165,14 @@ ENV DB_MIGRATIONS_PATH=/app/migrations \ EXPOSE 8080 # Health check (works for HTTP mode, ignored in Lambda mode) +# +# JSON (exec) form, but invoking /bin/sh explicitly: the `||` is a shell +# operator, so a bare exec-form list would hand `||` and `exit` to curl as +# literal arguments and the healthcheck would never report unhealthy correctly. +# This is the same process tree the shell form produced, written so the +# dependency on a shell is declared rather than implied. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 + CMD ["/bin/sh", "-c", "curl -f http://localhost:8080/health || exit 1"] # Unified entrypoint handles both Lambda and HTTP modes ENTRYPOINT ["/entrypoint.sh"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 22528951e..ff3ccb8fb 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -55,11 +55,16 @@ RUN if [ ! -f .air.toml ]; then air init; fi EXPOSE 8080 -# Create non-root user for security; grant ownership of app dir and Go paths -RUN addgroup -S devuser && adduser -S devuser -G devuser && \ +# Create non-root user for security; grant ownership of app dir and Go paths. +# uid/gid are pinned explicitly rather than left to `adduser -S`, which picks +# the first free system id and so varies with the base image's existing users. +# A numeric USER below needs a value that is known here, not discovered later. +RUN addgroup -g 1001 devuser && \ + adduser -D -u 1001 -G devuser devuser && \ chown -R devuser:devuser /app /root/go /root/.cache 2>/dev/null || true -USER devuser +# Numeric form so the identity is resolvable without the image's /etc/passwd. +USER 1001:1001 # Start with Air for hot reload CMD ["air", "-c", ".air.toml"] diff --git a/Dockerfile.test b/Dockerfile.test index 09af056b8..a31d233d8 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -18,7 +18,9 @@ WORKDIR /e2e COPY --chown=e2e:e2e tests/e2e/ ./ -USER e2e +# Numeric form so the identity is resolvable without the image's /etc/passwd. +# 10001 is exactly the uid created above, so this is a rename only. +USER 10001 # Pre-compile the test binary so `docker compose up` failures are runtime # failures, not compile errors discovered after the stack is already up. From 61a6274fd2a38b883bf95d95a5592daaa9bded1e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 3 Aug 2026 15:38:08 +0200 Subject: [PATCH 2/2] docs(ci): stop restating the hadolint version in the workflow comment CodeRabbit found the note in .github/workflows/pre-commit.yml still claiming the hook is pinned to v2.14.0 after the pin moved to v2.15.1. That is the second time this comment has gone stale, and the earlier staleness is what hid the tag float behind the `rev:` pin in the first place. Rather than update the number and wait for it to rot again, remove the restated version and the `.pre-commit-config.yaml:81` line reference, which was also stale (the hook is at line 86 now). The comment points at the hook by `id:` instead, and names the digest entry as the single source of truth for the version. The remaining v2.14.0 mention further down is historical and accurate: it records what an earlier version of the comment wrongly claimed. Refs #1701 --- .github/workflows/pre-commit.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index eed395c60..dab68fe8d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -182,8 +182,11 @@ jobs: exit 1 } - # Note: the local `hadolint` pre-commit hook (.pre-commit-config.yaml:81) - # runs ghcr.io/hadolint/hadolint pinned by digest to v2.14.0. We do NOT + # Note: the local `hadolint` hook in .pre-commit-config.yaml (search for + # `id: hadolint`; no line number, because this comment has already gone + # stale twice as that file shifted) runs ghcr.io/hadolint/hadolint pinned + # by digest. The version lives in that entry and is the single source of + # truth; do not restate it here, or this comment rots again. We do NOT # install a host binary here — it would be dead code (never invoked by # the hook) AND a supply-chain hole (latest tag, no checksum). Note: # an earlier version of this comment claimed the hook already pinned