diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index 024cd9df7dc..cf48dfbc8ff 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -33,43 +33,67 @@ type DependabotConfig = { updates?: DependabotUpdate[]; }; -function resolveFirstFromReference(dockerfile: string): string | undefined { +function resolveArgDefaults(dockerfile: string): Map { const argDefaults = new Map(); - for (const line of dockerfile.split(/\r?\n/)) { const trimmed = line.trim(); - if (!trimmed) { - continue; - } - if (trimmed.startsWith("FROM ")) { - break; - } const argMatch = trimmed.match(/^ARG\s+([A-Z0-9_]+)=(.+)$/); if (!argMatch) { continue; } const [, name, rawValue] = argMatch; - const value = rawValue.replace(/^["']|["']$/g, ""); - argDefaults.set(name, value); - } - - const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM ")); - if (!fromLine) { - return undefined; + argDefaults.set(name, rawValue.replace(/^["']|["']$/g, "")); } + return argDefaults; +} +function resolveFromImageRef(fromLine: string, argDefaults: Map): string { const fromMatch = fromLine.trim().match(/^FROM\s+(\S+?)(?:\s+AS\s+\S+)?$/); if (!fromMatch) { - return undefined; + return fromLine; } const imageRef = fromMatch[1]; const argName = imageRef.match(/^\$\{([A-Z0-9_]+)\}$/)?.[1] ?? imageRef.match(/^\$([A-Z0-9_]+)$/)?.[1]; - if (!argName) { return imageRef; } - return argDefaults.get(argName); + return argDefaults.get(argName) ?? imageRef; +} + +function resolveAllArgBackedFromReferences( + dockerfile: string, +): { stage: string; imageRef: string }[] { + const argDefaults = resolveArgDefaults(dockerfile); + const results: { stage: string; imageRef: string }[] = []; + let stageIndex = 0; + for (const line of dockerfile.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("FROM ")) { + continue; + } + const imageRef = resolveFromImageRef(trimmed, argDefaults); + // Only check FROM lines that use an ARG — literal `FROM scratch` etc. are intentionally unpinned. + const usesArg = + trimmed.match(/FROM\s+\$\{[A-Z0-9_]+\}/) !== null || + trimmed.match(/FROM\s+\$[A-Z0-9_]+/) !== null; + if (usesArg) { + const stageMatch = trimmed.match(/AS\s+(\S+)/i); + const stageName = stageMatch ? stageMatch[1] : `stage-${stageIndex}`; + results.push({ stage: stageName, imageRef }); + } + stageIndex += 1; + } + return results; +} + +function resolveFirstFromReference(dockerfile: string): string | undefined { + const argDefaults = resolveArgDefaults(dockerfile); + const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM ")); + if (!fromLine) { + return undefined; + } + return resolveFromImageRef(fromLine, argDefaults); } describe("docker base image pinning", () => { @@ -84,6 +108,18 @@ describe("docker base image pinning", () => { } }); + it("pins all ARG-backed FROM stages in selected Dockerfiles to sha256 digests", async () => { + for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) { + const dockerfile = await readFile(resolve(repoRoot, dockerfilePath), "utf8"); + const stages = resolveAllArgBackedFromReferences(dockerfile); + for (const { stage, imageRef } of stages) { + expect(imageRef, `${dockerfilePath} stage "${stage}" must be digest-pinned`).toMatch( + /^\S+@sha256:[a-f0-9]{64}$/, + ); + } + } + }); + it("keeps Dependabot Docker updates enabled for root Dockerfiles", async () => { const raw = await readFile(resolve(repoRoot, ".github/dependabot.yml"), "utf8"); const config = parse(raw) as DependabotConfig;