diff --git a/scripts/check-node-version-policy.mjs b/scripts/check-node-version-policy.mjs index cceb9c7519..41f1dcce00 100644 --- a/scripts/check-node-version-policy.mjs +++ b/scripts/check-node-version-policy.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isNode24ImageTag } from "./node-image-policy.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const expectedEngine = ">=24.11.0"; @@ -48,7 +49,7 @@ walk(repoRoot, (filePath) => { if (!path.basename(filePath).startsWith("Dockerfile")) return; const source = fs.readFileSync(filePath, "utf8"); for (const match of source.matchAll(/^\s*FROM\s+node:([^\s]+)/gm)) { - if (!match[1].startsWith("24-")) failures.push(`${relative(filePath)}: Node base image must use the Node 24 major, found node:${match[1]}`); + if (!isNode24ImageTag(match[1])) failures.push(`${relative(filePath)}: Node base image must use the Node 24 major, found node:${match[1]}`); } for (const match of source.matchAll(/^\s*ARG\s+NODE_(?:MAJOR|VERSION)=([^\s]+)/gm)) { if (match[1] !== "24") failures.push(`${relative(filePath)}: Node build argument must be 24, found ${match[1]}`); diff --git a/scripts/node-image-policy.mjs b/scripts/node-image-policy.mjs new file mode 100644 index 0000000000..318302ad2d --- /dev/null +++ b/scripts/node-image-policy.mjs @@ -0,0 +1,5 @@ +// Accept the existing major tags and exact patch pins, with a required image variant. +// A digest, when present, must be a complete SHA-256 pin. +export function isNode24ImageTag(tag) { + return /^24(?:\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))?-[a-z0-9][a-z0-9._-]*(?:@sha256:[a-f0-9]{64})?$/.test(tag); +} diff --git a/scripts/standard-image-contract.test.mjs b/scripts/standard-image-contract.test.mjs index c903e95664..32e7677371 100644 --- a/scripts/standard-image-contract.test.mjs +++ b/scripts/standard-image-contract.test.mjs @@ -2,6 +2,23 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import test from "node:test"; import { standardImageDigest, resolveStandardImageDigest } from "./standard-image-contract.mjs"; +import { isNode24ImageTag } from "./node-image-policy.mjs"; + +test("Node image policy accepts Node 24 major tags and exact security pins", () => { + for (const tag of ["24-trixie-slim", "24-bookworm-slim", "24-alpine3.23", "24.21.0-bookworm", + `24.21.0-bookworm@sha256:${"a".repeat(64)}`, `24-bookworm@sha256:${"0".repeat(64)}`]) { + assert.equal(isNode24ImageTag(tag), true, tag); + } +}); + +test("Node image policy rejects other majors, partial versions and malformed pins", () => { + for (const tag of ["240-bookworm", "25.21.0-bookworm", "v24.21.0-bookworm", "latest", + "24.21-bookworm", "24.21.0", "24.21.0-", "24..21.0-bookworm", "24.021.0-bookworm", + "24.21.0-bookworm@sha256:short", `24.21.0-bookworm@@sha256:${"a".repeat(64)}`, + `24.21.0-bookworm@sha256:${"a".repeat(64)}junk`, "${NODE_VERSION}-bookworm", "24-$(whoami)"]) { + assert.equal(isNode24ImageTag(tag), false, tag); + } +}); const descriptor = architecture => ({ platform: { os: "linux", architecture }, digest: `sha256:${"a".repeat(64)}`, size: 100 }); const index = manifests => Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.index.v1+json", manifests })); test("signs the exact registry bytes, including both supported platforms", () => {