diff --git a/Dockerfile b/Dockerfile index 62aef90240..ddcb04975b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,46 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar) -# ship the patched versions. These are npm's own internals — not application -# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) — -# but the container scanner flags the stale copies under -# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in -# the runner stages, so this is hygiene, not an exploitable runtime path. -RUN npm install -g npm@latest \ - && npm cache clean --force +# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are +# npm's own internals — not application dependencies (the app resolves its own, +# already-fixed copies) — but the container scanner reads them off +# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs. +# +# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest): +# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257 +# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272 +# tar 7.5.19 (needs >= 7.5.21) GHSA-r292-9mhp-454m +# undici 6.27.0 (needs >= 6.28.0) CVE-2026-16729/-16728/-15157 +# No published npm release carries patched copies, so `npm install -g npm@latest` +# alone was pure build time for zero CVEs — it is kept only to land on a known, +# current npm tree, and the patched copies are overlaid on top below. +# +# Deleting npm from the runner stages is NOT an option: the application shells +# out to npm at runtime (src/lib/services/installers/utils.ts::runNpm for the +# embedded services, src/lib/system/{autoUpdate,globalPackagePath}.ts, +# src/app/api/system/version). The previous version of this comment claimed the +# opposite; it was wrong. +# +# The overlay is semver-compatible with the ranges npm's own tree declares +# (minimatch → brace-expansion ^5.0.5, socks → ip-address ^10.1.1, node-gyp → +# tar ^7.5.4 and undici ^6.25.0 — hence undici stays on the 6.x line, NOT 8.x). +# --install-strategy=nested makes each replacement self-contained, so it cannot +# perturb the versions the rest of npm's flat tree resolves. +RUN set -eux; \ + npm install -g npm@latest; \ + npm install --prefix /tmp/npm-cve-patch --no-audit --no-fund --ignore-scripts \ + --install-strategy=nested \ + brace-expansion@5.0.9 ip-address@10.5.0 tar@7.5.22 undici@6.28.0; \ + for pkg in brace-expansion ip-address tar undici; do \ + test -d "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \ + rm -rf "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \ + cp -R "/tmp/npm-cve-patch/node_modules/$pkg" \ + "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \ + done; \ + rm -rf /tmp/npm-cve-patch; \ + node -e "for (const p of ['brace-expansion','ip-address','tar','undici']) console.log(p, require('/usr/local/lib/node_modules/npm/node_modules/'+p+'/package.json').version);"; \ + npm --version; \ + npm cache clean --force # ── Builder ──────────────────────────────────────────────────────────────── FROM base AS builder diff --git a/tests/unit/dockerfile-npm-bundled-cve-patch.test.ts b/tests/unit/dockerfile-npm-bundled-cve-patch.test.ts new file mode 100644 index 0000000000..4c78b52c59 --- /dev/null +++ b/tests/unit/dockerfile-npm-bundled-cve-patch.test.ts @@ -0,0 +1,133 @@ +/** + * Trivy image scan reported 9 HIGH/MEDIUM CVEs against the npm CLI's own + * *bundled* node_modules inside the published image: + * + * usr/local/lib/node_modules/npm/node_modules/brace-expansion CVE-2026-69152, CVE-2026-14257 + * usr/local/lib/node_modules/npm/node_modules/ip-address CVE-2026-69192, CVE-2026-69198, CVE-2026-54272 + * usr/local/lib/node_modules/npm/node_modules/tar GHSA-r292-9mhp-454m + * usr/local/lib/node_modules/npm/node_modules/undici CVE-2026-16729, CVE-2026-16728, CVE-2026-15157 + * + * The `base` stage used to claim `npm install -g npm@latest` shipped patched + * copies. That was false: npm@12.0.2 (the latest release at the time) bundles + * brace-expansion 5.0.7, ip-address 10.2.0, tar 7.5.19 and undici 6.27.0 — every + * one still vulnerable. No npm release fixes these, so the Dockerfile now + * overlays the patched versions onto npm's bundled tree. + * + * Removing npm from the runner stages was NOT viable: npm is invoked at runtime + * by src/lib/services/installers/utils.ts::runNpm (embedded services), + * src/lib/system/autoUpdate.ts, src/lib/system/globalPackagePath.ts and + * src/app/api/system/version/route.ts. + * + * This guards the mechanism (the overlay exists, targets all four packages, and + * pins versions at or above the fixed ones). The end-to-end proof is a clean + * Trivy scan on the next published image — this sandbox has no Docker daemon. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8"); + +/** Dockerfile source with line continuations joined, the way the shell sees it. */ +const joined = dockerfile.replace(/\\\n\s*/g, " "); +/** Instruction lines only — comments must never satisfy these assertions. */ +const instructions = joined + .split("\n") + .filter((l) => !l.trim().startsWith("#")) + .join("\n"); + +/** package -> lowest version that is not affected by the reported CVEs. */ +const FIXED_MINIMUMS: Record = { + "brace-expansion": [5, 0, 9], + "ip-address": [10, 3, 1], + tar: [7, 5, 21], + undici: [6, 28, 0], +}; + +function parseVersion(raw: string): [number, number, number] { + const parts = raw.split(".").map((n) => Number.parseInt(n, 10)); + assert.equal(parts.length, 3, `expected an exact x.y.z pin, got "${raw}"`); + assert.ok( + parts.every((n) => Number.isInteger(n)), + `expected an exact x.y.z pin, got "${raw}"` + ); + return [parts[0], parts[1], parts[2]]; +} + +function isAtLeast(actual: [number, number, number], min: [number, number, number]): boolean { + for (let i = 0; i < 3; i++) { + if (actual[i] > min[i]) return true; + if (actual[i] < min[i]) return false; + } + return true; +} + +test("base stage pins patched versions of every CVE-flagged npm-bundled package", () => { + for (const [pkg, min] of Object.entries(FIXED_MINIMUMS)) { + const match = new RegExp(`\\b${pkg}@(\\d+\\.\\d+\\.\\d+)\\b`).exec(instructions); + assert.ok( + match, + `Dockerfile must install an explicit patched ${pkg}@x.y.z for npm's bundled tree` + ); + const actual = parseVersion(match[1]); + assert.ok( + isAtLeast(actual, min), + `${pkg}@${match[1]} is below the fixed version ${min.join(".")} — the Trivy alert would return` + ); + } +}); + +test("undici stays on the 6.x line node-gyp declares (^6.25.0), never 8.x", () => { + const match = /\bundici@(\d+)\.\d+\.\d+\b/.exec(instructions); + assert.ok(match, "Dockerfile must pin an undici version"); + assert.equal( + match[1], + "6", + "npm's bundled node-gyp declares undici ^6.25.0 — an 8.x overlay would break its resolution" + ); +}); + +test("the patched copies actually replace npm's bundled ones", () => { + for (const pkg of Object.keys(FIXED_MINIMUMS)) { + assert.match( + instructions, + new RegExp(`for pkg in [^;]*\\b${pkg}\\b`), + `${pkg} must be part of the overlay loop that rewrites npm's bundled node_modules` + ); + } + assert.match( + instructions, + /rm -rf "\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/, + "the overlay must remove the vulnerable bundled copy before replacing it" + ); + assert.match( + instructions, + /cp -R "\/tmp\/npm-cve-patch\/node_modules\/\$pkg"\s+"\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/, + "the overlay must copy the patched package into npm's bundled node_modules" + ); + assert.match( + instructions, + /test -d "\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/, + "the overlay must fail the build loudly if npm's layout changes and a target path disappears" + ); +}); + +test("the overlay smoke-tests npm after patching it", () => { + assert.match( + instructions, + /npm --version/, + "the patched npm must be exercised in the same layer so a broken overlay fails the build" + ); +}); + +test("the stale 'npm is not invoked at runtime' claim is gone", () => { + assert.doesNotMatch( + dockerfile, + /npm is not invoked at runtime/, + "npm IS invoked at runtime (installers/utils.ts::runNpm, system/autoUpdate.ts, " + + "system/globalPackagePath.ts, api/system/version) — the comment must not claim otherwise" + ); +});