neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Images: a current Debian base, checked nightly, bumped by the bot
990181a · Gabriel Morell · 2026-10-23 06:07
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