fix(workflow-executor): apply Debian security updates to the Docker image - #1899
Conversation
| # CVE-2026-89161 (libpcre2) while the newest node:22-bookworm-slim still carried the vulnerable | ||
| # 10.42-1. Applying the security updates here keeps the pin reproducible and the image patched | ||
| # without chasing a digest per advisory. | ||
| RUN apt-get update \ |
There was a problem hiding this comment.
🟡 Medium workflow-executor/Dockerfile:19
This apt-get update/unversioned apt-get upgrade makes identical builds from the same base digest produce different runtime images as Debian publishes updates. Pin the repository to a dated snapshot or record and install explicit package versions to preserve reproducibility.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/Dockerfile around line 19:
This `apt-get update`/unversioned `apt-get upgrade` makes identical builds from the same base digest produce different runtime images as Debian publishes updates. Pin the repository to a dated snapshot or record and install explicit package versions to preserve reproducibility.
There was a problem hiding this comment.
Right, and it was the most serious thing wrong with the first version — thanks. Fixed in b2d34e5, though not quite the way you suggested.
The diagnosis needs one correction. A stale layer cannot put a vulnerable image in the registry: the publish job scans the loaded image with a blocking Trivy gate and only pushes afterwards (docker-publish.yml, Scan OS packages (blocking) → Push by digest). So the real failure mode is not a vulnerable release, it is a CI that goes red and stays red through re-runs, because every retry restores the same cached layer. Worth stating precisely, since it changes who is harmed and how they find out.
On the fix: I did not take the rotated-ARG shape as written, because "deliberately rotated" means a human has to remember. Nobody rotates it, and the person staring at a red Trivy gate has no way to discover the knob exists — so it reads as solved while behaving exactly like today. Instead CI computes the value:
- name: Compute apt security epoch
id: apt
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"passed as build-args: APT_SECURITY_EPOCH=${{ steps.apt.outputs.epoch }} to the validate build, the gated build and the push. Same value in the gated build and the push on purpose: a different one there would rebuild the apt layer and publish an image the scan never saw.
Also moved the layer from base to runtime. Identical shipped packages, since runtime is FROM base, but a buster in base invalidates yarn install and lerna run build beneath it. In runtime there is nothing below but COPY lines, so the weekly refresh costs ~30s instead of a cold monorepo rebuild. That was the part of your suggestion I could not take as-is.
Verified locally: the layer shows CACHED on an unchanged epoch and re-runs on a changed one, libpcre2-8-0 lands at 10.42-1+deb12u1, Trivy reports 0, and docker/smoke-test.sh passes.
There was a problem hiding this comment.
The weekly epoch prevents a permanently stale cached layer, but it does not resolve the reproducibility concern: the same pinned base and Dockerfile can still produce different package versions across weeks as Debian changes. Pinning a snapshot or explicit versions is still needed for reproducible output. Would you like me to prepare that change?
There was a problem hiding this comment.
You are right that reproducibility is not restored, and I am declining the fix rather than disputing the diagnosis — so let me be explicit about the trade instead of leaving it implicit.
Reproducible output and automatic patching are in direct opposition here. Pinning a snapshot or explicit versions gives back byte-identical builds, and in exchange the image is frozen at that snapshot until a human bumps it. That is precisely the failure this PR exists to fix: the digest pin already froze the package set, nobody noticed for months, and CI went red on two HIGH CVEs that Debian had fixed. Snapshot pinning reproduces that shape with a different knob.
There is a coherent design where both hold — pin a snapshot date and have the weekly job bump the date — but it is materially more machinery than the problem in front of us, and it still ships whatever that week's snapshot contains. It is not more reproducible in any sense that matters to an operator, only more auditable.
So the choice is deliberate: the image is reproducible from the commit plus the build date, not from the commit alone. What we keep is the pinned base digest, a lockfile-driven dependency tree, an SBOM on every push, and a blocking scan before publish, so a build is auditable after the fact even when it is not bit-identical.
The Dockerfile now states this in the comment rather than claiming otherwise — the first version of this PR asserted it "keeps the pin reproducible", which was simply false and is the kind of comment worth catching.
If the team later wants determinism back, the honest form is targeted --only-upgrade of named packages, one line per advisory, which trades the automation away openly. Worth a separate discussion, not this PR.
|
Coverage Impact This PR will not change total coverage. 🚦 See full report on Qlty Cloud »🛟 Help
|
hercemer42
left a comment
There was a problem hiding this comment.
Spec: no Linear ticket is linked anywhere on this PR, so the functional check was limited to the PR description.
Applies to: packages/workflow-executor/docker/README.md
Claude Opus 5 (claude-opus-5[1m]): Should fix: The docker README still promises a reproducible 410 MB image that this change no longer builds.
Code
docker/README.md:5 claims roughly 410 MB and reproducible, while the PR measures 435 MB and Dockerfile:63 drops reproducibility.
A way out: scope the claim to the dependency install and document the apt-refresh lever there.
Reproduction
- Open
docker/README.mdto size the runtime image. - It reports 410 MB and calls the build reproducible.
- Pull the published image and measure 435 MB with OS packages resolved at build time.
Applies to: the PR as a whole
Claude Opus 5 (claude-opus-5[1m]): Preferential: The reproducibility trade off lives only in Dockerfile comments, so the next reader relitigates it.
Code
Dockerfile:63 is the only record of the trade off, and no ADR under docs/adr/** covers it.
A way out: run /adr on the decision.
|
README: fixed in d3f93d2. It now says ~435 MB with a fully pinned dependency tree, which is what the lockfile still guarantees. ADR: skipped. The decision is one RUN line and trivially reversible, so it fails the hard-to-inverse gate. |
|
Claude Opus 5 (claude-opus-5[1m]): Closed: the README now reports 435 MB and scopes the pinning claim to the dependency tree. How it was checked
The No check pins the claim, and nothing in the repo has ever guarded it. Claude Opus 5 (claude-opus-5[1m]): Closed: the decision shrank to one RUN line in d3f93d2, which fails the hard to reverse gate. How it was checkedThe predicate was a decision costly to invert, and reverting is now deleting four lines.
|
…mage The Trivy gate has been failing every build since 2026-09-12 on two HIGH libpcre2 CVEs (CVE-2026-86145 out-of-bounds write, CVE-2026-89161 memory corruption), both fixed upstream in 10.42-1+deb12u1. This is not specific to any branch: main and every open PR fail the same step, and the same commit that passed on 2026-09-11 fails now. Bumping the pinned digest does not help — I checked, and the newest node:22-bookworm-slim still ships the vulnerable 10.42-1. The digest pin is right for reproducibility, but it also means Debian security updates can never reach the image on their own. Applying them in the base stage fixes that class of failure rather than this instance of it, so the next advisory does not need a digest hunt. Verified locally with the CI's own scan settings: 2 HIGH before, 0 after, libpcre2-8-0 at 10.42-1+deb12u1 in the final image, and docker/smoke-test.sh passes. Image 435 MB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cached Review found the first version fixes today's CVEs and then freezes. The layer keys on an unchanging instruction under an unchanging pinned digest, and both CI jobs read from a persistent GHA cache (docker-publish.yml cache-from/cache-to), so after one successful build it would never contact Debian again — while sitting in the file looking like it handles exactly that. APT_SECURITY_EPOCH takes the ISO week from CI, so a cached layer expires on its own with nobody to remember a manual knob. The gated build and the push pass the same value: a different one there would rebuild the layer and publish an image the scan never saw. Moved from `base` to `runtime`. Same shipped packages either way, since runtime is FROM base, but busting a layer in `base` invalidates yarn install and lerna build below it, while in `runtime` it only rebuilds COPY layers. Also: DEBIAN_FRONTEND inline rather than ENV, which would otherwise leak into the shipped image, plus --force-confold, because `upgrade` replaces packages that already have config on disk and a dpkg conffile prompt hangs a TTY-less build instead of failing. Dropped --no-install-recommends: `upgrade` never installs new packages, so the flag was inert and read as deliberate size control. The comment no longer claims the pin stays reproducible. It does not: versions resolve against the Debian archive at build time, which is the trade this change makes. It also drops the CVE ids, which would have invited a future reader to delete the RUN as fixed. Verified: apt layer CACHED on an unchanged epoch, re-run on a changed one; libpcre2-8-0 at 10.42-1+deb12u1; Trivy 0 findings; smoke test passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lever Asked what a developer does when an advisory lands mid-week and the Trivy gate goes red, the answer was: nothing useful. Re-running the job restores the same cached layer, so the only way out was deleting a GHA cache entry they would first have to find, or waiting for Monday. That is the same flaw I rejected in the review suggestion — a lever nobody staring at the red gate can discover. The epoch now carries an APT_SECURITY_SALT repository variable, defaulting to 0. Bumping it in Settings forces the layer to rebuild on the next run: no PR, no commit, no cache archaeology. Documented on the ARG itself, which is where anyone debugging that RUN will be looking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an reach it The repository variable was the wrong home: only maintainers can edit those, and the person looking at a red Trivy gate is usually the PR author. A lever you need someone else's permissions to pull is the same dead end as no lever, which is the flaw this was meant to fix in the first place. docker/apt-refresh is a one-line serial in the repo. Bumping it in a PR changes the build arg, rebuilds the Debian security layer, and shows up in the diff like any other change. It also sits under the path filter that triggers this workflow, so the bump runs the job that proves it worked. Verified: the layer re-runs apt on a bumped serial and stays cached otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plain `apt-get upgrade` holds back any fix whose package gained a dependency or was split, so the CVE survives an upgrade that reports success while the scan keeps flagging it — a red gate the refresh appears to have already handled. --with-new-pkgs allows those additions and still refuses removals, which is the distinction from dist-upgrade and the reason not to reach for it. Verified: libpcre2-8-0 at 10.42-1+deb12u1, Trivy 0 findings, smoke test passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the weekly epoch plus serial file with the run id and attempt, passed as a build arg. Every CI run refetches the Debian index, so a rebuild of a released tag, a re-run of a failed job and both arches of one release all resolve packages from the same, current archive. apt-get update now fails on any unreachable index instead of silently building a half patched layer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
d3f93d2 to
2c4b08f
Compare
## @forestadmin/workflow-executor [1.28.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/workflow-executor@1.28.4...@forestadmin/workflow-executor@1.28.5) (2026-09-14) ### Bug Fixes * **workflow-executor:** apply Debian security updates to the Docker image ([#1899](#1899)) ([9838ce7](9838ce7))

Why
The
Validate Dockerfile buildjob has been failing on every build since 12 Sep on two HIGH CVEs inlibpcre2-8-0:This is not branch-specific.
mainand every open PR fail the same step, and a commit that passed on 11 Sep fails today with no code change: the advisories landed in between.The image builds and the smoke test passes. Only the Trivy gate (step 8,
Scan OS packages (blocking)) fails.Why not just bump the digest
That was my first instinct, and I checked it before writing this: the newest
node:22-bookworm-slimstill ships the vulnerable10.42-1. Upstream has not rebuilt.The digest pin is the right call for reproducibility, but it also guarantees Debian security updates never reach the image on their own. Applying them in the
basestage addresses the class of failure rather than this instance of it, so the next advisory does not need a digest hunt.What
runtimestage runsapt-get update && apt-get upgrade -y --with-new-pkgsand drops the index afterwards. Inruntimerather thanbaseso a refresh rebuildsCOPYlayers, notyarn installandlerna run build.APT_EPOCHbuild arg, set by CI togithub.run_id-run_attempt. It sits in the layer's cache key, so every run refetches the Debian index: a rebuild of a released tag, a re-run of a failed job and both arches of one release all resolve against the current archive. The gated build and the push share the value, so the push is a cache hit and ships the image the scan saw.apt-get update -o APT::Update::Error-Mode=any: an unreachable mirror fails the build instead of exiting zero on a half patched layer.Verified locally, with the CI's own scan settings
--scanners vuln --pkg-types os --severity CRITICAL,HIGH --ignore-unfixed:libpcre2-8-010.42-110.42-1+deb12u1docker/smoke-test.shAlso checked the cache key: a second build with the same
APT_EPOCHshows the apt layer asCACHED, a different value re-runs it.Trade off, stated plainly
The image is reproducible from the commit plus the build date, not from the commit alone. That is the point: the digest pin had frozen the package set and nobody noticed until CI went red. What stays fixed is the base digest, the lockfile-driven dependency tree, the SBOM on every push and the blocking scan before publish. The README no longer claims byte reproducibility.
Unblocks the Docker job on
mainand on every open PR, including #1897.🤖 Generated with Claude Code
Note
Apply Debian security updates to
workflow-executorDocker imageapt-get update && apt-get upgrade -y --with-new-pkgsstep in the runtime stage of Dockerfile so published images install current Debian security and package updates rather than relying solely on the pinned base image.APT_SECURITY_EPOCHbuild arg composed of the current UTC ISO week plus a serial read from apt-refresh; the epoch busts the Docker build cache to force APT layer refresh on week rollover or serial bump.apt-refreshto force a refresh.Changes since #1899 opened
APT_SECURITY_EPOCHbuild argument withAPT_EPOCHacross thedocker-publishworkflow andworkflow-executorDockerfile, computing the epoch value fromgithub.run_idandgithub.run_attemptinstead of a weekly security epoch [d3f93d2]-o APT::Update::Error-Mode=anyflag to theapt-get updatecommand in theworkflow-executorDockerfile runtime stage [d3f93d2]apt-refreshfile from theworkflow-executordocker directory [d3f93d2]workflow-executordocker README and clarified that the dependency tree is fully pinned [d3f93d2]Macroscope summarized 2c4b08f.