Merge bundle-A: Container & supply-chain hardening — 3 findings closed; All High closed

This commit is contained in:
Shankar
2026-04-27 01:28:38 +00:00
4 changed files with 119 additions and 6 deletions
+48
View File
@@ -166,6 +166,54 @@ jobs:
exit 1
fi
- name: Forbidden bare FROM regression guard (H-001)
# Bundle A / Audit H-001 (CWE-829): every FROM line in every
# Dockerfile in the repo MUST carry an @sha256:... digest pin in
# addition to the human-readable tag. A registry-side tag swap
# cannot then change what we pull. This step grep-fails the
# build if any new FROM lands without the @sha256 suffix.
run: |
set -e
# Match any "FROM image[:tag]" that does NOT contain @sha256.
# Strip comments and blank lines defensively.
BAD=$(find . -name 'Dockerfile*' -not -path './web/node_modules/*' \
-exec grep -HnE '^FROM\s+[^@#]+(\s+AS\s+\S+)?\s*$' {} \; || true)
if [ -n "$BAD" ]; then
echo "::error::Dockerfile has bare FROM (no @sha256 digest pin):"
echo "$BAD"
echo ""
echo "Pin every FROM to an immutable digest. See the bump"
echo "procedure in Dockerfile's header comment (Bundle A / H-001)."
exit 1
fi
- name: Forbidden missing USER regression guard (M-012)
# Bundle A / Audit M-012 (CWE-250): every Dockerfile in the repo
# MUST end with a `USER <non-root>` directive before the
# ENTRYPOINT/CMD so the container never runs as uid=0. This step
# grep-fails the build if any Dockerfile is missing such a USER.
# `USER root` and `USER 0` are explicitly rejected.
run: |
set -e
BAD=""
for df in $(find . -name 'Dockerfile*' -not -path './web/node_modules/*'); do
# Find the LAST USER directive in the file.
last_user=$(grep -E '^USER\s+\S+' "$df" | tail -1 | awk '{print $2}')
if [ -z "$last_user" ]; then
BAD="$BAD\n$df: no USER directive at all"
continue
fi
if [ "$last_user" = "root" ] || [ "$last_user" = "0" ]; then
BAD="$BAD\n$df: terminal USER is $last_user (must drop privileges)"
continue
fi
done
if [ -n "$BAD" ]; then
echo "::error::Dockerfile USER-drop regression:"
echo -e "$BAD"
exit 1
fi
- name: Forbidden README JWT advertising regression guard (H-009)
# H-009 closed by Bundle D as verified-already-clean: at audit time
# the README does NOT advertise JWT support (certctl does not ship
+24
View File
@@ -4,6 +4,30 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
## [unreleased] — 2026-04-26
### Bundle A (Container & Supply-Chain Hardening): 3 audit findings closed — All High closed
> Closes the audit's container/supply-chain cluster — `H-001` (5 FROM lines pinned to immutable Docker Hub digests + bump-procedure runbook + CI grep guard), `M-012` (verified-already-clean: both Dockerfiles already had `USER certctl`; CI guard now enforces every Dockerfile drops to non-root), `M-014` (broken `|| ... && \` bash-precedence chain replaced with deterministic 3-attempt retry loop + post-check). **All High audit findings now closed (9/9, 100%).**
#### Changed
- **`Dockerfile` + `Dockerfile.agent` (Audit H-001 / CWE-829)** — 5 FROM lines pinned to live digests fetched from Docker Hub at audit time:
- `node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293`
- `golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f` (×2)
- `alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1` (×2)
Header doc-comment in `Dockerfile` documents the operator bump procedure (quarterly cadence; `docker manifest inspect` and Hub Registry API alternatives for fetching the next digest). A registry-side tag swap can no longer change what we pull.
- **`Dockerfile:25` (Audit M-014)** — `npm ci` retry refactor. Pre-bundle `npm ci --include=dev || npm ci --include=dev && tsc && build` had broken bash precedence (`A || (B && C && D)`) that silently skipped `tsc && build` on transient registry blips. Replaced with `for i in 1 2 3; do npm ci --include=dev && break; sleep 5; done` plus a fail-loud `[ -d node_modules ]` post-check.
#### Added
- **CI step `Forbidden bare FROM regression guard (H-001)` in `.github/workflows/ci.yml`** — Greps every `Dockerfile*` in the repo and fails the build if any `FROM` line lacks an `@sha256` digest pin. Adding a new Dockerfile or refactoring an existing one without preserving the pin fails CI permanently.
- **CI step `Forbidden missing USER regression guard (M-012)` in `.github/workflows/ci.yml`** — Greps every `Dockerfile*` for the LAST `USER` directive; fails the build if missing OR if it equals `root`/`0`. Adding a new Dockerfile or refactoring an existing one to run as root fails CI permanently.
#### Audit Deliverables Updated
- `cowork/comprehensive-audit-2026-04-25/audit-report.md` — score 52/55 → **49/55** (corrected from over-counted 52 — actual closure count after Bundle A is 49 closed C+H+M+L of 55 total scope; **High 9/9 = 100%** for the first time; Medium 24/27; Low 19/19 with L-004 deferred). H-001 / M-012 / M-014 boxes flipped `[x]` with closure notes.
- `cowork/comprehensive-audit-2026-04-25/findings.yaml` — 3 status flips with closure notes citing the Bundle A mechanism.
### Bundle E (Mechanical Sweeps & Defensive Polish): 6 audit findings closed; L-004 deferred
> Closes the audit's mechanical-sweep cluster — `L-009` (ZeroSSL EAB URL configurable; audit's "no timeout" claim was wrong — 15s already in place), `L-010` (verified-already-clean: 0 mock.Anything occurrences), `L-011` (IPv6 bracket-aware dialing pinned), `L-013` (verified-already-clean: monotonic-safe doc comment at the single time.Now().Sub site), `L-020` (ineffassign sweep: 8 unique dead-store sites cleaned), `L-021` (transitive CVE bump: x/net 0.42→0.47, x/crypto 0.41→0.45, all 5 advisories cleared). **`L-004` deferred** — audit said "no double-key window for graceful rotation"; recon found NO rotation infrastructure exists at all. Building it from scratch is a feature project, not a Bundle-E mechanical sweep; deferred to a dedicated bundle.
+40 -4
View File
@@ -1,7 +1,28 @@
# Multi-stage build for certctl server
#
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
# immutable digest in addition to the human-readable tag. The tag is
# advisory; the digest is what Docker actually pulls. A registry-side
# tag swap (the documented prior-art for tag-only pulls being unsafe)
# can no longer change the build.
#
# Bump procedure (operator):
# 1. Quarterly cadence (or sooner if a CVE lands on a base image).
# 2. For each FROM:
# docker pull <image>:<tag>
# docker manifest inspect <image>:<tag> | grep -m1 digest
# OR via Docker Hub Registry API:
# curl -sSL https://hub.docker.com/v2/repositories/library/<image>/tags/<tag> \
# | jq -r .digest
# 3. Replace the @sha256:... portion of the FROM line.
# 4. Run `docker build` locally + verify CI.
# 5. Commit with the bump procedure cited in the message body.
#
# The CI step "Forbidden bare FROM regression guard (H-001)" rejects
# any future commit that lands a FROM without an @sha256 pin.
# Stage 1: Build frontend
FROM node:20-alpine AS frontend
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
@@ -22,12 +43,27 @@ ENV HTTP_PROXY=${HTTP_PROXY} \
WORKDIR /app/web
COPY web/ .
RUN npm ci --include=dev || npm ci --include=dev && \
# Bundle A / Audit M-014: explicit retry loop for `npm ci`. Pre-bundle
# this was `npm ci || npm ci && tsc && build` — the bash precedence is
# `A || (B && C && D)` so the second `npm ci` only ran on the failure
# path of the first, but the `tsc && build` chain only ran on the
# success path of the second. Net effect: a transient registry blip
# turned the build into a silent skip of the production step.
#
# New shape: a deterministic 3-attempt retry with 5-second backoff and
# an explicit `[ -d node_modules ]` post-check so a silent failure is
# impossible.
RUN for i in 1 2 3; do \
npm ci --include=dev && break; \
echo "npm ci attempt $i failed; sleeping 5s before retry"; \
sleep 5; \
done && \
[ -d node_modules ] || (echo "ERROR: npm ci failed after 3 attempts; node_modules missing" && exit 1) && \
node_modules/.bin/tsc --version && \
npm run build
# Stage 2: Build Go binary
FROM golang:1.25-alpine AS builder
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
ARG HTTP_PROXY=
@@ -57,7 +93,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
./cmd/server
# Stage 3: Runtime
FROM alpine:3.19
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates tzdata curl
+7 -2
View File
@@ -1,6 +1,11 @@
# Multi-stage build for certctl agent
#
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
# immutable digest. See Dockerfile (server) for the bump-procedure
# operator runbook; the pins here MUST be bumped in the same pass.
# Stage 1: Build
FROM golang:1.25-alpine AS builder
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
@@ -34,7 +39,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
./cmd/agent
# Stage 2: Runtime
FROM alpine:3.19
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
# U-2: `procps` ships pgrep, which the HEALTHCHECK below uses to verify the
# agent process is alive. Pre-U-2 the deploy/docker-compose.yml agent