Images: a current Debian base, checked nightly, bumped by the bot

990181a · Gabriel Morell · 2026-10-23 06:07

10 files +229 -46
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/audit.yml
+56 −0
@@ -14,6 +14,14 @@ name: audit
14 14 #
15 15 # The Android app isn't covered: osv-scanner reads Gradle lockfiles,
16 16 # and the app has a version catalog (gradle/libs.versions.toml) only.
17 +#
18 +# `image` checks what's deployed rather than what it's built from: the
19 +# published :latest, for Debian security updates it hasn't got. It
20 +# fails on those alone. Debian leaves some CVEs unfixed in a stable
21 +# release, and failing on those would mean failing forever. The fix is
22 +# a newer base tag (the Dockerfile's RUNNER_DEBIAN_VERSION, which
23 +# dependency-updates.yml bumps) or a rebuild. osv-scanner's view of the
24 +# image, unfixed CVEs included, is printed after it for reading.
17 25 on:
18 26 schedule:
19 27 - cron: "17 6 * * *"
@@ -75,3 +83,51 @@ jobs:
75 83 (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
76 84 scan $changed > /tmp/head.json
77 85 python3 scripts/osv-diff /tmp/base.json /tmp/head.json
86 +
87 + image:
88 + if: github.event_name != 'pull_request'
89 + runs-on: self-hosted
90 + steps:
91 + # The job's own token reads its repository's image, as build.yml's
92 + # pushes it.
93 + - uses: docker/login-action@v3
94 + with:
95 + registry: ${{ vars.CI_REGISTRY }}
96 + username: gitgud-actions
97 + password: ${{ github.token }}
98 +
99 + # As root, since the image runs as nobody; apt only reads here.
100 + - name: Security updates the published image hasn't got
101 + env:
102 + IMAGE: ${{ vars.CI_REGISTRY }}/${{ github.repository }}:latest
103 + run: |
104 + docker pull --quiet "$IMAGE"
105 + pending=$(docker run --rm --user root --entrypoint sh "$IMAGE" -c \
106 + 'apt-get update -qq >/dev/null && apt list --upgradable 2>/dev/null | grep -- -security' || true)
107 + if [ -n "$pending" ]; then
108 + echo "$pending"
109 + echo "::error::$(echo "$pending" | wc -l) Debian security update(s) not in the published image: bump RUNNER_DEBIAN_VERSION, or rebuild."
110 + exit 1
111 + fi
112 + echo "No Debian security updates pending."
113 +
114 + - name: Fetch osv-scanner
115 + if: always()
116 + env:
117 + OSV_VERSION: v2.6.0
118 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
119 + run: |
120 + curl -sSfL -o /tmp/osv-scanner \
121 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
122 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
123 + chmod +x /tmp/osv-scanner
124 +
125 + # For reading, not judging: most of what it lists Debian hasn't
126 + # fixed in this release.
127 + - name: Known vulnerabilities in the image (informational)
128 + if: always()
129 + env:
130 + IMAGE: ${{ vars.CI_REGISTRY }}/${{ github.repository }}:latest
131 + run: |
132 + docker save -o /tmp/image.tar "$IMAGE"
133 + /tmp/osv-scanner scan image --archive /tmp/image.tar || true
modified .forgejo/workflows/dependency-updates.yml
+3 −0
@@ -6,6 +6,9 @@ name: dependency-updates
6 6 # place, closing it once there's nothing left to fix. The pull requests
7 7 # are opened by the forge's CI account (gg-security) with this job's own
8 8 # token; their CI (audit.yml among it) is what shows the fixes landed.
9 +# deps/security-docker moves the Dockerfile's marked base tag
10 +# (RUNNER_DEBIAN_VERSION) to Docker Hub's newest date, which is how
11 +# Debian's security fixes reach the image; merging it deploys.
9 12 #
10 13 # Daily, an hour before audit.yml, so a fix released overnight is a pull
11 14 # request by morning; and by hand from the Actions page (`gg ci dispatch
modified Dockerfile
+15 −1
@@ -15,10 +15,20 @@ ARG ELIXIR_VERSION=1.19.5
15 15 # OTP 28.5.0.2 (was 28.4.1): refreshes the Erlang :ssh app (embedded git
16 16 # SSH daemon was hitting intermittent "incorrect signature" on 5.5.1).
17 17 ARG OTP_VERSION=28.5.0.2
18 +# The builder's Debian is whichever dated tag hexpm built this Elixir/OTP
19 +# pair on; it stops getting new ones, and nothing of it ships but the
20 +# release (same Debian release, so the same glibc and libssl ABI).
18 21 ARG DEBIAN_VERSION=trixie-20260623-slim
22 +# What ships is the runner, so it moves on by itself: the newest dated
23 +# tag, which scripts/dependency-update bumps (the marker below) when
24 +# Debian publishes one. A dated tag rather than `trixie-slim` so a
25 +# rebuild is the image it was, and so moving it busts the layer cache —
26 +# the registry cache would otherwise keep an old `apt-get upgrade`.
27 +# dependency-update: docker.io/library/debian
28 +ARG RUNNER_DEBIAN_VERSION=trixie-20261020-slim
19 29
20 30 ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
21 −ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
31 +ARG RUNNER_IMAGE="docker.io/debian:${RUNNER_DEBIAN_VERSION}"
22 32
23 33 FROM ${BUILDER_IMAGE} AS builder
24 34
@@ -135,7 +145,11 @@ FROM ${RUNNER_IMAGE} AS final
135 145 # SSH-side pushes. Mandatory.
136 146 # * tini — proper PID 1 / zombie reaping, recommended for any container
137 147 # image running long-lived child processes (we fork git per session).
148 +#
149 +# `upgrade` first: Debian's security fixes since the base tag was cut.
150 +# audit.yml fails nightly while the published image has some pending.
138 151 RUN apt-get update \
152 + && apt-get upgrade -y --no-install-recommends \
139 153 && apt-get install -y --no-install-recommends \
140 154 libstdc++6 openssl libncurses6 locales ca-certificates \
141 155 git openssh-client curl tini \
modified docs/plans/dependency-security.md
+13 −0
@@ -390,6 +390,19 @@ then `./gradlew dependencies --write-locks`) commits a
390 390 6. **C3** — the org roll-up, dismissals in the audit log, the API and
391 391 `gg security`, the "open an update PR" link.
392 392 7. **Android** — Gradle dependency locking, so the app is scanned too.
393 +8. **Images** — what ships, not only what it's built from. **Built.**
394 + The runner image has its own dated base tag
395 + (`RUNNER_DEBIAN_VERSION`; the builder stays on the last date hexpm
396 + built its Elixir/OTP pair on), and the final stage runs
397 + `apt-get upgrade`. `audit.yml`'s `image` job fails nightly while the
398 + published `:latest` has Debian security updates pending — the
399 + actionable count; Debian leaves some CVEs unfixed in a stable
400 + release, so osv-scanner's image report is printed for reading, not
401 + gated on. `dependency-update` moves a Dockerfile ARG marked
402 + `# dependency-update: docker.io/<repo>` to Docker Hub's newest date
403 + as `deps/security-docker`. Measured 2026-10-23: the pinned
404 + trixie-20260623 base had 13 security updates pending and 128
405 + advisories; trixie-20261020, 0 and 69.
393 406
394 407 ## Done when
395 408
modified scripts/dependency-update
+142 −45
@@ -9,6 +9,15 @@ date with the result:
9 9
10 10 deps/security-hex mix.lock files (mix deps.update)
11 11 deps/security-cargo Cargo.lock files (cargo update --precise)
12 + deps/security-docker Dockerfile base tags (a marked dated ARG)
13 +
14 +A Dockerfile's base is a dated tag (debian:trixie-20261020-slim) so a
15 +rebuild is the image it was; Debian's security fixes arrive as newer
16 +dates. A line `# dependency-update: docker.io/library/debian` directly
17 +above an `ARG NAME=<prefix><YYYYMMDD><suffix>` says that ARG is a tag of
18 +that Docker Hub repository, and it's moved to the newest date with the
19 +same prefix and suffix. Only marked ARGs: a builder pinned to the dates
20 +its image was published on must stay put.
12 21
13 22 The branch is force-pushed and its pull request refreshed in place, so
14 23 there is never a pile of them; once an ecosystem has nothing left to
@@ -309,6 +318,136 @@ def describe(human, bumped, left, carried):
309 318 # ── main ────────────────────────────────────────────────────────────────
310 319
311 320
321 +def publish(forge, base, branch, human, names, count, body, paths):
322 + """Commit `paths` on `branch` (checked out from `base`, with the
323 + changes made) and open or refresh its pull request; with no `body`,
324 + nothing was bumped, so close the pull request if there is one. A
325 + branch with someone else's commits is left alone."""
326 + existing = forge.open_pull(branch)
327 +
328 + others = authors_besides_bot(base, branch)
329 + if others:
330 + run(["git", "checkout", "--force", base])
331 + print(f"{human}: {branch} has commits by {', '.join(others)}; leaving it alone")
332 + return
333 +
334 + if body is None:
335 + run(["git", "checkout", "--force", base])
336 + if existing:
337 + forge.call("POST", f"/pulls/{existing['number']}/comments",
338 + {"body": "Nothing left here to fix: closing."})
339 + forge.call("POST", f"/pulls/{existing['number']}/state", {"state": "closed"})
340 + print(f"{human}: nothing to fix; closed #{existing['number']}")
341 + else:
342 + print(f"{human}: nothing to fix")
343 + return
344 +
345 + title = f"Security updates: {human} — {names}"
346 + if len(title) > 120:
347 + title = f"Security updates: {human} — {count} packages"
348 +
349 + run(["git", "add"] + paths)
350 + run(["git", "commit", "--quiet", "-m", title, "-m", body])
351 +
352 + # Push only when the result differs from what the branch has — the
353 + # bumps, or the base it sits on — so an unchanged day doesn't
354 + # re-run the pull request's CI, but a moved base does move it.
355 + remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False)
356 + same = remote.returncode == 0 and run(
357 + ["git", "diff", "--quiet", f"origin/{branch}", "HEAD"], check=False
358 + ).returncode == 0
359 + if not same:
360 + run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"])
361 +
362 + if existing:
363 + forge.call("PATCH", f"/pulls/{existing['number']}", {"title": title, "body": body})
364 + print(f"{human}: refreshed #{existing['number']}" + ("" if not same else " (no new changes)"))
365 + else:
366 + created = forge.call("POST", "/pulls", {"title": title, "body": body, "head": branch, "base": base})
367 + print(f"{human}: opened #{created['pull']['number']}")
368 +
369 + run(["git", "checkout", "--force", base])
370 +
371 +
372 +DOCKER_MARK = re.compile(r"^\s*#\s*dependency-update:\s*docker\.io/(?P<repo>[a-z0-9._-]+/[a-z0-9._-]+)\s*$")
373 +DOCKER_ARG = re.compile(r"^(?P<lead>\s*ARG\s+(?P<name>\w+)=)(?P<q>\"?)(?P<pre>[^\"\s]*?)(?P<date>\d{8})(?P<suf>[^\"\s]*)(?P=q)\s*$")
374 +
375 +
376 +def dockerfiles():
377 + """The Dockerfiles git tracks."""
378 + out = run(["git", "ls-files", "-z"]).stdout.split("\0")
379 + pat = re.compile(r"(^|/)((Docker|Container)file(\.[^/]*)?|[^/]+\.(Docker|Container)file)$")
380 + return sorted(f for f in out if f and pat.search(f) and not re.search(r"(^|/)(node_modules|vendor|deps)/", f))
381 +
382 +
383 +def hub_dates(repo, prefix, suffix):
384 + """The dates Docker Hub has `repo:<prefix><YYYYMMDD><suffix>` tags for."""
385 + url = f"https://hub.docker.com/v2/repositories/{repo}/tags?page_size=100&name={prefix}"
386 + tag = re.compile(re.escape(prefix) + r"(\d{8})" + re.escape(suffix) + "$")
387 + dates = set()
388 + for _ in range(10):
389 + with urllib.request.urlopen(url, timeout=30) as r:
390 + page = json.load(r)
391 + dates |= {m.group(1) for t in page.get("results", []) if (m := tag.match(t["name"]))}
392 + url = page.get("next")
393 + if not url:
394 + break
395 + return dates
396 +
397 +
398 +def docker(forge, base, dry_run):
399 + """Move each marked base tag to its newest date; one pull request."""
400 + human, branch = "Docker base images", "deps/security-docker"
401 + found = [] # (file, line index, match, repo)
402 + for path in dockerfiles():
403 + lines = open(path).read().split("\n")
404 + for i, line in enumerate(lines[1:], 1):
405 + mark = DOCKER_MARK.match(lines[i - 1])
406 + arg = DOCKER_ARG.match(line)
407 + if mark and arg:
408 + found.append((path, i, arg, mark.group("repo")))
409 + if not found:
410 + return
411 +
412 + bumps = []
413 + for path, i, arg, repo in found:
414 + try:
415 + dates = hub_dates(repo, arg.group("pre"), arg.group("suf"))
416 + except (urllib.error.URLError, TimeoutError, ValueError) as e:
417 + print(f"{human}: {path}: {arg.group('name')}: couldn't ask Docker Hub ({e}); skipped")
418 + continue
419 + newest = max(dates, default=None)
420 + old = arg.group("pre") + arg.group("date") + arg.group("suf")
421 + if newest and newest > arg.group("date"):
422 + bumps.append((path, i, arg, repo, old, arg.group("pre") + newest + arg.group("suf")))
423 + else:
424 + print(f"{human}: {path}: {arg.group('name')}={old} is the newest")
425 +
426 + if dry_run:
427 + for path, _, arg, repo, old, new in bumps:
428 + print(f"{human}: {path}: {arg.group('name')} {repo}:{old} → {new}")
429 + return
430 +
431 + if bumps:
432 + run(["git", "checkout", "-B", branch, base])
433 + for path, i, arg, _, _, new in bumps:
434 + lines = open(path).read().split("\n")
435 + lines[i] = f"{arg.group('lead')}{arg.group('q')}{new}{arg.group('q')}"
436 + open(path, "w").write("\n".join(lines))
437 +
438 + body = None
439 + if bumps:
440 + rows = "\n".join(f"| `{p}` | `{a.group('name')}` | `{repo}:{old}` | `{new}` |" for p, _, a, repo, old, new in bumps)
441 + body = (
442 + "A newer dated base image: Debian's security fixes since the last one.\n\n"
443 + "| Dockerfile | ARG | was | now |\n|---|---|---|---|\n" + rows + "\n\n"
444 + "Merging rebuilds and deploys the image. audit.yml's `image` job checks the "
445 + "published image for security updates it still hasn't got."
446 + )
447 + publish(forge, base, branch, human, ", ".join(sorted({b[4] + " → " + b[5] for b in bumps})),
448 + len(bumps), body, sorted({b[0] for b in bumps}))
449 +
450 +
312 451 def main():
313 452 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
314 453 parser.add_argument("--dry-run", action="store_true", help="show what would change; change nothing")
@@ -348,53 +487,11 @@ def main():
348 487 if opts.dry_run:
349 488 continue
350 489
351 − existing = forge.open_pull(branch)
352 −
353 − others = authors_besides_bot(base, branch)
354 − if others:
355 − run(["git", "checkout", "--force", base])
356 − print(f"{human}: {branch} has commits by {', '.join(others)}; leaving it alone")
357 − continue
358 −
359 − if not bumped:
360 − run(["git", "checkout", "--force", base])
361 − if existing:
362 − forge.call("POST", f"/pulls/{existing['number']}/comments",
363 − {"body": "Nothing left here to fix: closing."})
364 − forge.call("POST", f"/pulls/{existing['number']}/state", {"state": "closed"})
365 − print(f"{human}: nothing to fix; closed #{existing['number']}")
366 − else:
367 − print(f"{human}: nothing to fix")
368 − continue
369 −
370 490 names = ", ".join(sorted({b["name"] for b in bumped}))
371 − title = f"Security updates: {human} — {names}"
372 − if len(title) > 120:
373 − title = f"Security updates: {human} — {len(bumped)} packages"
374 − body = describe(human, bumped, left, carried)
375 −
376 − run(["git", "add"] + lockfiles)
377 − run(["git", "commit", "--quiet", "-m", title, "-m", body])
378 −
379 − # Push only when the result differs from what the branch has — the
380 − # bumps, or the base it sits on — so an unchanged day doesn't
381 − # re-run the pull request's CI, but a moved base does move it.
382 − remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False)
383 − same = remote.returncode == 0 and run(
384 − ["git", "diff", "--quiet", f"origin/{branch}", "HEAD"], check=False
385 − ).returncode == 0
386 − if not same:
387 − run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"])
388 −
389 − if existing:
390 − forge.call("PATCH", f"/pulls/{existing['number']}", {"title": title, "body": body})
391 − print(f"{human}: refreshed #{existing['number']}" + ("" if not same else " (no new changes)"))
392 − else:
393 − created = forge.call("POST", "/pulls", {"title": title, "body": body, "head": branch, "base": base})
394 − print(f"{human}: opened #{created['pull']['number']}")
395 −
396 − run(["git", "checkout", "--force", base])
491 + publish(forge, base, branch, human, names, len(bumped),
492 + describe(human, bumped, left, carried) if bumped else None, lockfiles)
397 493
494 + docker(forge, base, opts.dry_run)
398 495 return 0
399 496
400 497

Parents: dba03dd