Skip to content

fix(workflow-executor): apply Debian security updates to the Docker image - #1899

Merged
Scra3 merged 6 commits into
mainfrom
chore/bump-base-image-pcre2-cves
Sep 14, 2026
Merged

Scra3 merged 6 commits into
mainfrom
chore/bump-base-image-pcre2-cves

Conversation

@Scra3

@Scra3 Scra3 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Why

The Validate Dockerfile build job has been failing on every build since 12 Sep on two HIGH CVEs in libpcre2-8-0:

Total: 2 (HIGH: 2, CRITICAL: 0)
libpcre2-8-0  CVE-2026-86145  HIGH  fixed  10.42-1 → 10.42-1+deb12u1
              CVE-2026-89161

This is not branch-specific. main and 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-slim still ships the vulnerable 10.42-1. Upstream has not rebuilt.

newest node:22-bookworm-slim → libpcre2-8-0 10.42-1
pinned digest 813a7480…      → libpcre2-8-0 10.42-1

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 base stage addresses the class of failure rather than this instance of it, so the next advisory does not need a digest hunt.

What

  • The runtime stage runs apt-get update && apt-get upgrade -y --with-new-pkgs and drops the index afterwards. In runtime rather than base so a refresh rebuilds COPY layers, not yarn install and lerna run build.
  • APT_EPOCH build arg, set by CI to github.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:

Before After
Trivy HIGH/CRITICAL 2 0
libpcre2-8-0 10.42-1 10.42-1+deb12u1
docker/smoke-test.sh pass pass
Image size ~410 MB ~435 MB

Also checked the cache key: a second build with the same APT_EPOCH shows the apt layer as CACHED, 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 main and on every open PR, including #1897.

🤖 Generated with Claude Code

Note

Apply Debian security updates to workflow-executor Docker image

  • Adds an apt-get update && apt-get upgrade -y --with-new-pkgs step 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.
  • Introduces an APT_SECURITY_EPOCH build 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.
  • The epoch is computed in both the validate and publish jobs in docker-publish.yml and passed through the gated and push-by-digest builds so the published image matches the one that was validated.
  • Risk: base package contents now vary over time, so reproducibility of runtime layers depends on archive availability and the epoch value; bump the serial in apt-refresh to force a refresh.

Changes since #1899 opened

  • Replaced APT_SECURITY_EPOCH build argument with APT_EPOCH across the docker-publish workflow and workflow-executor Dockerfile, computing the epoch value from github.run_id and github.run_attempt instead of a weekly security epoch [d3f93d2]
  • Added -o APT::Update::Error-Mode=any flag to the apt-get update command in the workflow-executor Dockerfile runtime stage [d3f93d2]
  • Deleted the apt-refresh file from the workflow-executor docker directory [d3f93d2]
  • Updated the documented image size from approximately 410 MB to approximately 435 MB in the workflow-executor docker README and clarified that the dependency tree is fully pinned [d3f93d2]

Macroscope summarized 2c4b08f.

Comment thread packages/workflow-executor/Dockerfile Outdated
# 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 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/workflow-executor/Dockerfile Outdated
@qltysh

qltysh Bot commented Sep 14, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Comment thread packages/workflow-executor/Dockerfile Outdated

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
  1. Open docker/README.md to size the runtime image.
  2. It reports 410 MB and calls the build reproducible.
  3. 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.

Comment thread .github/workflows/docker-publish.yml Outdated
Comment thread packages/workflow-executor/Dockerfile Outdated
Comment thread .github/workflows/docker-publish.yml Outdated
Comment thread packages/workflow-executor/Dockerfile Outdated
Comment thread .github/workflows/docker-publish.yml Outdated
Comment thread packages/workflow-executor/Dockerfile Outdated
Comment thread packages/workflow-executor/Dockerfile Outdated
@Scra3

Scra3 commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

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.

@hercemer42

hercemer42 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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

docker/README.md:5 no longer calls the build reproducible, which the runtime stage stopped being.

The apt-refresh half of the finding is moot because d3f93d2 deleted the file.

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 checked

The predicate was a decision costly to invert, and reverting is now deleting four lines.

Dockerfile:63 keeps the reason in the file, which is what the ADR would have carried.

alban bertolini and others added 6 commits September 14, 2026 15:42
…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>
@Scra3
Scra3 force-pushed the chore/bump-base-image-pcre2-cves branch from d3f93d2 to 2c4b08f Compare September 14, 2026 13:42
@Scra3
Scra3 merged commit 9838ce7 into main Sep 14, 2026
38 checks passed
@Scra3
Scra3 deleted the chore/bump-base-image-pcre2-cves branch September 14, 2026 14:08
forest-bot added a commit that referenced this pull request Sep 14, 2026
## @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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants