/** * 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" ); });