From eed9ed21f0a1b5bed90e12b3b0c5122347b2c15f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:56:56 -0700 Subject: [PATCH 01/11] test(security): close tar remediation evidence gaps Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 28 +++++++++++------------ test/patch-bundled-npm-tar.test.ts | 26 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 871b5606409..30d93f64c02 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -48,7 +48,7 @@ const dockerfiles = [ { file: "agents/pi/Dockerfile.base", installsPatchDownloader: true, - installsWithNpm: false, + installsWithNpm: true, patchCount: 2, }, { @@ -64,8 +64,18 @@ const pinnedBaseDockerfiles = [ "Dockerfile.base", "agents/hermes/Dockerfile.base", "agents/langchain-deepagents-code/Dockerfile.base", + "agents/pi/Dockerfile.base", ] as const; const reviewedNodeBases = new Set(NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH); +const npmConsumerPattern = + /\bnpm\s+(?:--?[\w-]+(?:=\S+)?\s+(?:\S+\s+)?)*(?:ci|install)\b/gu; + +function npmConsumerPositions(source: string): number[] { + const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => + " ".repeat(comment.length), + ); + return [...executableSource.matchAll(npmConsumerPattern)].map((match) => match.index); +} function nodeBaseReferences(source: string): string[] { return [ @@ -207,12 +217,7 @@ describe("node-tar image remediation contract", () => { aptInstallCleanup < firstPatchRun, file, ).toBe(installsPatchDownloader); - const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => - " ".repeat(comment.length), - ); - const npmConsumers = [...executableSource.matchAll(/\bnpm\s+(?:ci|install)\b/gu)].map( - (match) => match.index, - ); + const npmConsumers = npmConsumerPositions(source); expect(npmConsumers.length > 0, file).toBe(installsWithNpm); expect( npmConsumers.every((index) => index > lastPatchRun), @@ -227,7 +232,7 @@ describe("reviewed npm image remediation contract", () => { { file: "Dockerfile.base", installsWithNpm: true }, { file: "agents/hermes/Dockerfile.base", installsWithNpm: true }, { file: "agents/langchain-deepagents-code/Dockerfile.base", installsWithNpm: false }, - { file: "agents/pi/Dockerfile.base", installsWithNpm: false }, + { file: "agents/pi/Dockerfile.base", installsWithNpm: true }, ])( "patches tar before and after upgrading the complete npm tree in $file", ({ file, installsWithNpm }) => { @@ -252,12 +257,7 @@ describe("reviewed npm image remediation contract", () => { expect(upgradeRun, file).toBeGreaterThan(patchRuns[0]!.commandStart); expect(patchRuns[1]!.commandStart, file).toBeGreaterThan(upgradeRun); - const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => - " ".repeat(comment.length), - ); - const npmConsumers = [...executableSource.matchAll(/\bnpm\s+(?:ci|install)\b/gu)].map( - (match) => match.index, - ); + const npmConsumers = npmConsumerPositions(source); expect(npmConsumers.length > 0, file).toBe(installsWithNpm); expect( npmConsumers.every((index) => index > patchRuns[1]!.commandStart), diff --git a/test/patch-bundled-npm-tar.test.ts b/test/patch-bundled-npm-tar.test.ts index c26fe9d5cc2..d0949f89601 100644 --- a/test/patch-bundled-npm-tar.test.ts +++ b/test/patch-bundled-npm-tar.test.ts @@ -123,6 +123,32 @@ describe("npm bundled node-tar remediation", () => { expect(commands).toEqual(["curl", "tar", "npm", "npx", "cleanup"]); }); + it("rejects mismatched tar@7.5.21 archive bytes before extraction or npm-tree mutation (#9933)", () => { + const target = fixture("11.18.0", "7.5.19"); + const commands: string[] = []; + + expect(() => + patchBundledNpmTarFromRegistry(target.npmRoot, { + commandRunner(command, args) { + commands.push(command); + expect(command).toBe("curl"); + expect(args).toContain(FIXED_TAR_TARBALL); + const outputIndex = args.indexOf("--output"); + expect(outputIndex).toBeGreaterThanOrEqual(0); + fs.writeFileSync(args[outputIndex + 1]!, "mismatched archive bytes\n"); + }, + }), + ).toThrow("npm bundled tar replacement integrity mismatch"); + + expect(commands).toEqual(["curl"]); + expect(fs.existsSync(path.join(target.npmRoot, "node_modules", "tar", "old.js"))).toBe(true); + expect( + fs.existsSync(path.join(target.npmRoot, "node_modules", "tar", "lib", "fixed.js")), + ).toBe(false); + expect(fs.readdirSync(path.join(target.npmRoot, "node_modules"))).toEqual(["tar"]); + expect(() => verifyBundledNpmTar(target.npmRoot)).toThrow("bundles affected tar@7.5.19"); + }); + it("is idempotent when npm already bundles a safe release", () => { const target = fixture("10.9.7", FIXED_TAR_VERSION); expect(patchBundledNpmTar(target)).toMatchObject({ state: "fixed" }); From bfd071fcab18f26248711f7fc3e8b65e13bb7077 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:23:25 -0700 Subject: [PATCH 02/11] test(security): avoid backtracking in npm matcher Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 51 ++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 30d93f64c02..a034533c7df 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -67,14 +67,53 @@ const pinnedBaseDockerfiles = [ "agents/pi/Dockerfile.base", ] as const; const reviewedNodeBases = new Set(NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH); -const npmConsumerPattern = - /\bnpm\s+(?:--?[\w-]+(?:=\S+)?\s+(?:\S+\s+)?)*(?:ci|install)\b/gu; + +interface ShellToken { + end: number; + value: string; +} + +function isShellTokenBoundary(character: string): boolean { + return character === " " || character === "\t" || character === "\r" || character === "\n"; +} + +function readShellToken(source: string, start: number): ShellToken | undefined { + let cursor = start; + while (cursor < source.length && isShellTokenBoundary(source[cursor]!)) cursor += 1; + const tokenStart = cursor; + while ( + cursor < source.length && + !isShellTokenBoundary(source[cursor]!) && + !";&|".includes(source[cursor]!) + ) { + cursor += 1; + } + const value = source.slice(tokenStart, cursor); + return value.length === 0 ? undefined : { end: cursor, value }; +} + +function npmSubcommand(source: string, start: number): ShellToken | undefined { + const token = readShellToken(source, start); + const prefix = token?.value === "--prefix" ? readShellToken(source, token.end) : undefined; + return token?.value === "--prefix" + ? prefix === undefined + ? undefined + : readShellToken(source, prefix.end) + : token?.value.startsWith("--prefix=") === true + ? readShellToken(source, token.end) + : token; +} function npmConsumerPositions(source: string): number[] { - const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => - " ".repeat(comment.length), - ); - return [...executableSource.matchAll(npmConsumerPattern)].map((match) => match.index); + const executableSource = source + .replace(/^\s*#.*$/gmu, (comment) => " ".repeat(comment.length)) + .replace(/\\\s*\n/gu, (continuation) => " ".repeat(continuation.length)); + return [...executableSource.matchAll(/\bnpm\b/gu)] + .filter((match) => { + const subcommand = npmSubcommand(executableSource, match.index + match[0].length); + return subcommand?.value === "ci" || subcommand?.value === "install"; + }) + .map((match) => match.index); } function nodeBaseReferences(source: string): string[] { From 2cce17258d3f7e20cea060149c0a9dc1db846cdc Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:42:29 -0700 Subject: [PATCH 03/11] test(security): ignore non-command npm text Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/dockerfile-run-commands.test.ts | 24 +++++++++++++++- test/helpers/dockerfile-run-commands.ts | 35 +++++++++++++++++++++-- test/node-tar-dockerfile-contract.test.ts | 15 +++++----- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index e9448f61038..bbc71e55c68 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { requireSingleReviewedDockerfileRunCommand } from "./helpers/dockerfile-run-commands"; +import { + dockerfileRunCommandPositions, + requireSingleReviewedDockerfileRunCommand, +} from "./helpers/dockerfile-run-commands"; const command = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; const corporateCaPath = "/usr/local/share/nemoclaw/corporate-ca.pem"; @@ -11,6 +14,25 @@ const invocation = [command, ...requiredArguments].join(" "); const splicedCommand = command.replace("strip-types", "strip-\\\ntypes"); describe("Dockerfile RUN command discovery", () => { + it("finds only unquoted npm command words in RUN instructions", () => { + const source = [ + "# npm install", + 'LABEL example="npm install"', + 'RUN echo "npm install"', + "RUN echo npm install # npm ci", + "RUN true && \\", + " # install from the reviewed lock", + " npm --prefix /runtime ci", + "RUN if true; then npm --prefix=/runtime install; fi", + "", + ].join("\n"); + + expect(dockerfileRunCommandPositions(source, "npm")).toEqual([ + source.indexOf("npm --prefix /runtime"), + source.indexOf("npm --prefix=/runtime"), + ]); + }); + it("ignores command text in comments, strings, and non-RUN instructions", () => { const source = [ `# ${command}`, diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 9623f75d35d..8a2f9b12ade 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -47,14 +47,15 @@ export function dockerfileInstructions(source: string): DockerfileInstruction[] } let end = endOfFirstLine; - let currentLine = firstLine; - while (continuesInstruction(currentLine)) { + let continues = continuesInstruction(firstLine); + while (continues) { if (end >= source.length) { throw new Error(`Dockerfile ends inside the ${instructionMatch[1]} instruction`); } const nextEnd = lineEnd(source, end); - currentLine = source.slice(end, nextEnd); + const currentLine = source.slice(end, nextEnd); end = nextEnd; + continues = /^[ \t]*#/u.test(currentLine) || continuesInstruction(currentLine); } const bodyStart = offset + instructionMatch[0].length; @@ -134,6 +135,34 @@ function unquotedTextIndexes(source: string, text: string): number[] { return indexes; } +function followsShellCommandSeparator(source: string, index: number): boolean { + let cursor = index - 1; + while (cursor >= 0 && /[ \t\r]/u.test(source[cursor]!)) cursor -= 1; + if (cursor < 0 || source[cursor] === "\n" || ";&|".includes(source[cursor]!)) return true; + + const wordEnd = cursor + 1; + while (cursor >= 0 && !/[ \t\r\n;&|]/u.test(source[cursor]!)) cursor -= 1; + return ["do", "else", "then"].includes(source.slice(cursor + 1, wordEnd)); +} + +export function dockerfileRunCommandPositions(source: string, command: string): number[] { + const positions: number[] = []; + for (const instruction of dockerfileInstructions(source)) { + if (instruction.keyword !== "RUN") continue; + const collapsed = collapseDockerfileContinuations(instruction.body); + for (const index of unquotedTextIndexes(collapsed.text, command)) { + const afterCommand = collapsed.text[index + command.length]; + if ( + followsShellCommandSeparator(collapsed.text, index) && + (afterCommand === undefined || /[ \t\r\n;&|]/u.test(afterCommand)) + ) { + positions.push(instruction.bodyStart + collapsed.originalIndexes[index]!); + } + } + } + return positions; +} + function normalizedInstructionBody(source: string): string { return source .replace(/\\\r?\n/gu, " ") diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index a034533c7df..34a09dbf389 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH } from "../scripts/patch-bundled-npm-tar.mts"; import { + dockerfileRunCommandPositions, requireReviewedDockerfileRunCommands, requireSingleReviewedDockerfileRunCommand, } from "./helpers/dockerfile-run-commands"; @@ -105,15 +106,13 @@ function npmSubcommand(source: string, start: number): ShellToken | undefined { } function npmConsumerPositions(source: string): number[] { - const executableSource = source - .replace(/^\s*#.*$/gmu, (comment) => " ".repeat(comment.length)) - .replace(/\\\s*\n/gu, (continuation) => " ".repeat(continuation.length)); - return [...executableSource.matchAll(/\bnpm\b/gu)] - .filter((match) => { - const subcommand = npmSubcommand(executableSource, match.index + match[0].length); + const executableSource = source.replace(/\\\s*\n/gu, (continuation) => + " ".repeat(continuation.length), + ); + return dockerfileRunCommandPositions(source, "npm").filter((index) => { + const subcommand = npmSubcommand(executableSource, index + "npm".length); return subcommand?.value === "ci" || subcommand?.value === "install"; - }) - .map((match) => match.index); + }); } function nodeBaseReferences(source: string): string[] { From 143ca81c170b1b2bbdb6d6c1d60ae16ecb576a46 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:50:43 -0700 Subject: [PATCH 04/11] test(security): recognize npm command assignments Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/dockerfile-run-commands.test.ts | 2 ++ test/helpers/dockerfile-run-commands.ts | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index bbc71e55c68..d9293e92079 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -24,12 +24,14 @@ describe("Dockerfile RUN command discovery", () => { " # install from the reviewed lock", " npm --prefix /runtime ci", "RUN if true; then npm --prefix=/runtime install; fi", + "RUN true && NPM_CONFIG_OFFLINE=true OTHER=value npm ci --prefix /runtime", "", ].join("\n"); expect(dockerfileRunCommandPositions(source, "npm")).toEqual([ source.indexOf("npm --prefix /runtime"), source.indexOf("npm --prefix=/runtime"), + source.indexOf("npm ci --prefix"), ]); }); diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 8a2f9b12ade..34d2af22508 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -136,13 +136,20 @@ function unquotedTextIndexes(source: string, text: string): number[] { } function followsShellCommandSeparator(source: string, index: number): boolean { - let cursor = index - 1; - while (cursor >= 0 && /[ \t\r]/u.test(source[cursor]!)) cursor -= 1; - if (cursor < 0 || source[cursor] === "\n" || ";&|".includes(source[cursor]!)) return true; - - const wordEnd = cursor + 1; - while (cursor >= 0 && !/[ \t\r\n;&|]/u.test(source[cursor]!)) cursor -= 1; - return ["do", "else", "then"].includes(source.slice(cursor + 1, wordEnd)); + let wordStart = index; + while (wordStart > 0) { + let cursor = wordStart - 1; + while (cursor >= 0 && /[ \t\r]/u.test(source[cursor]!)) cursor -= 1; + if (cursor < 0 || source[cursor] === "\n" || ";&|".includes(source[cursor]!)) return true; + + const wordEnd = cursor + 1; + while (cursor >= 0 && !/[ \t\r\n;&|]/u.test(source[cursor]!)) cursor -= 1; + const previousWord = source.slice(cursor + 1, wordEnd); + if (["do", "else", "then"].includes(previousWord)) return true; + if (!/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(previousWord)) return false; + wordStart = cursor + 1; + } + return true; } export function dockerfileRunCommandPositions(source: string, command: string): number[] { From 951aa298162b5c7c4369ea78c690853c330ac2c6 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:06:18 -0700 Subject: [PATCH 05/11] test(security): detect grouped npm consumers Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/dockerfile-run-commands.test.ts | 18 ++++++++++- test/helpers/dockerfile-run-commands.ts | 12 +++++--- test/node-tar-dockerfile-contract.test.ts | 37 +++++++++++++++++++++-- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index d9293e92079..a713346777b 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -14,7 +14,7 @@ const invocation = [command, ...requiredArguments].join(" "); const splicedCommand = command.replace("strip-types", "strip-\\\ntypes"); describe("Dockerfile RUN command discovery", () => { - it("finds only unquoted npm command words in RUN instructions", () => { + it("finds only executable unquoted npm command words in RUN instructions (#9933)", () => { const source = [ "# npm install", 'LABEL example="npm install"', @@ -25,6 +25,14 @@ describe("Dockerfile RUN command discovery", () => { " npm --prefix /runtime ci", "RUN if true; then npm --prefix=/runtime install; fi", "RUN true && NPM_CONFIG_OFFLINE=true OTHER=value npm ci --prefix /runtime", + "RUN if npm ci --prefix /if; then true; fi", + "RUN if false; then true; elif npm install --prefix /elif; then true; fi", + "RUN while npm ci --prefix /while; do true; done", + "RUN until npm install --prefix /until; do true; done", + "RUN ( npm ci --prefix /subshell )", + "RUN { npm install --prefix /group; }", + "RUN case value in value) npm ci --prefix /case ;; esac", + "RUN ! npm install --prefix /negated", "", ].join("\n"); @@ -32,6 +40,14 @@ describe("Dockerfile RUN command discovery", () => { source.indexOf("npm --prefix /runtime"), source.indexOf("npm --prefix=/runtime"), source.indexOf("npm ci --prefix"), + source.indexOf("npm ci --prefix /if"), + source.indexOf("npm install --prefix /elif"), + source.indexOf("npm ci --prefix /while"), + source.indexOf("npm install --prefix /until"), + source.indexOf("npm ci --prefix /subshell"), + source.indexOf("npm install --prefix /group"), + source.indexOf("npm ci --prefix /case"), + source.indexOf("npm install --prefix /negated"), ]); }); diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 34d2af22508..784a4d0103a 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -140,13 +140,17 @@ function followsShellCommandSeparator(source: string, index: number): boolean { while (wordStart > 0) { let cursor = wordStart - 1; while (cursor >= 0 && /[ \t\r]/u.test(source[cursor]!)) cursor -= 1; - if (cursor < 0 || source[cursor] === "\n" || ";&|".includes(source[cursor]!)) return true; + if (cursor < 0 || source[cursor] === "\n" || ";&|({)".includes(source[cursor]!)) { + return true; + } const wordEnd = cursor + 1; while (cursor >= 0 && !/[ \t\r\n;&|]/u.test(source[cursor]!)) cursor -= 1; const previousWord = source.slice(cursor + 1, wordEnd); - if (["do", "else", "then"].includes(previousWord)) return true; - if (!/^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(previousWord)) return false; + const continuesCommandPrefix = + ["!", "do", "elif", "else", "if", "then", "until", "while"].includes(previousWord) || + /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(previousWord); + if (!continuesCommandPrefix) return false; wordStart = cursor + 1; } return true; @@ -161,7 +165,7 @@ export function dockerfileRunCommandPositions(source: string, command: string): const afterCommand = collapsed.text[index + command.length]; if ( followsShellCommandSeparator(collapsed.text, index) && - (afterCommand === undefined || /[ \t\r\n;&|]/u.test(afterCommand)) + (afterCommand === undefined || /[ \t\r\n;&|(){}<>]/u.test(afterCommand)) ) { positions.push(instruction.bodyStart + collapsed.originalIndexes[index]!); } diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 34a09dbf389..8affd3f223f 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -75,7 +75,13 @@ interface ShellToken { } function isShellTokenBoundary(character: string): boolean { - return character === " " || character === "\t" || character === "\r" || character === "\n"; + return ( + character === " " || + character === "\t" || + character === "\r" || + character === "\n" || + ";&|(){}<>".includes(character) + ); } function readShellToken(source: string, start: number): ShellToken | undefined { @@ -84,8 +90,7 @@ function readShellToken(source: string, start: number): ShellToken | undefined { const tokenStart = cursor; while ( cursor < source.length && - !isShellTokenBoundary(source[cursor]!) && - !";&|".includes(source[cursor]!) + !isShellTokenBoundary(source[cursor]!) ) { cursor += 1; } @@ -266,6 +271,32 @@ describe("node-tar image remediation contract", () => { }); describe("reviewed npm image remediation contract", () => { + it.each([ + ["an if condition", "if npm ci; then true; fi"], + ["an elif condition", "if false; then true; elif npm install; then true; fi"], + ["a while condition", "while npm ci; do true; done"], + ["an until condition", "until npm install; do true; done"], + ["a subshell group", "( npm ci )"], + ["a brace group", "{ npm install; }"], + ["a case branch", "case value in value) npm ci ;; esac"], + ["a negated command", "! npm install"], + ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { + const source = [ + `RUN ${body}`, + `RUN ${patchCommand} ${npmRootArguments.join(" ")}`, + "", + ].join("\n"); + const patchRun = requireSingleReviewedDockerfileRunCommand( + source, + patchCommand, + npmRootArguments, + ); + const npmConsumers = npmConsumerPositions(source); + + expect(npmConsumers).toEqual([source.indexOf("npm")]); + expect(npmConsumers.every((index) => index > patchRun.commandStart)).toBe(false); + }); + it.each([ { file: "Dockerfile.base", installsWithNpm: true }, { file: "agents/hermes/Dockerfile.base", installsWithNpm: true }, From 7a91078f4f2f84a20e4c414c8a7cf3b52c125617 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:15:12 -0700 Subject: [PATCH 06/11] test(security): parse npm assignment values Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/dockerfile-run-commands.test.ts | 4 ++ test/helpers/dockerfile-run-commands.ts | 83 ++++++++++++++++++----- test/node-tar-dockerfile-contract.test.ts | 4 +- 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index a713346777b..132b4dd343e 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -33,6 +33,8 @@ describe("Dockerfile RUN command discovery", () => { "RUN { npm install --prefix /group; }", "RUN case value in value) npm ci --prefix /case ;; esac", "RUN ! npm install --prefix /negated", + 'RUN NPM_CONFIG_CACHE="/tmp/npm cache" npm ci --prefix /quoted-assignment', + "RUN NPM_CONFIG_CACHE=/tmp/npm\\ cache npm install --prefix /escaped-assignment", "", ].join("\n"); @@ -48,6 +50,8 @@ describe("Dockerfile RUN command discovery", () => { source.indexOf("npm install --prefix /group"), source.indexOf("npm ci --prefix /case"), source.indexOf("npm install --prefix /negated"), + source.indexOf("npm ci --prefix /quoted-assignment"), + source.indexOf("npm install --prefix /escaped-assignment"), ]); }); diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 784a4d0103a..0db1f2ab98c 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -135,25 +135,72 @@ function unquotedTextIndexes(source: string, text: string): number[] { return indexes; } -function followsShellCommandSeparator(source: string, index: number): boolean { - let wordStart = index; - while (wordStart > 0) { - let cursor = wordStart - 1; - while (cursor >= 0 && /[ \t\r]/u.test(source[cursor]!)) cursor -= 1; - if (cursor < 0 || source[cursor] === "\n" || ";&|({)".includes(source[cursor]!)) { - return true; - } - - const wordEnd = cursor + 1; - while (cursor >= 0 && !/[ \t\r\n;&|]/u.test(source[cursor]!)) cursor -= 1; - const previousWord = source.slice(cursor + 1, wordEnd); - const continuesCommandPrefix = - ["!", "do", "elif", "else", "if", "then", "until", "while"].includes(previousWord) || - /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(previousWord); - if (!continuesCommandPrefix) return false; - wordStart = cursor + 1; +function shellCommandPrefixWords(source: string, end: number): string[] { + const words: string[] = []; + let wordStart: number | undefined; + let quote: "'" | '"' | "`" | null = null; + let comment = false; + + const finishWord = (wordEnd: number): void => { + if (wordStart === undefined) return; + words.push(source.slice(wordStart, wordEnd)); + wordStart = undefined; + }; + + for (let index = 0; index < end; index += 1) { + const character = source[index]!; + if (comment) { + if (character === "\n") { + comment = false; + words.length = 0; + } + continue; + } + if (quote !== null) { + if (character === "\\" && quote !== "'") { + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === "'" || character === '"' || character === "`") { + wordStart ??= index; + quote = character; + continue; + } + if (character === "\\") { + wordStart ??= index; + index += 1; + continue; + } + if (character === "#" && (index === 0 || /[\s;&|(){}]/u.test(source[index - 1]!))) { + finishWord(index); + comment = true; + continue; + } + if (/[ \t\r]/u.test(character)) { + finishWord(index); + continue; + } + if (character === "\n" || ";&|({)".includes(character)) { + finishWord(index); + words.length = 0; + continue; + } + wordStart ??= index; } - return true; + + finishWord(end); + return words; +} + +function followsShellCommandSeparator(source: string, index: number): boolean { + return shellCommandPrefixWords(source, index).every( + (word) => + ["!", "do", "elif", "else", "if", "then", "until", "while"].includes(word) || + /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(word), + ); } export function dockerfileRunCommandPositions(source: string, command: string): number[] { diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 8affd3f223f..ea91826d6c5 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -280,6 +280,8 @@ describe("reviewed npm image remediation contract", () => { ["a brace group", "{ npm install; }"], ["a case branch", "case value in value) npm ci ;; esac"], ["a negated command", "! npm install"], + ["a quoted assignment value", 'NPM_CONFIG_CACHE="/tmp/npm cache" npm ci'], + ["an escaped-space assignment value", "NPM_CONFIG_CACHE=/tmp/npm\\ cache npm install"], ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { const source = [ `RUN ${body}`, @@ -293,7 +295,7 @@ describe("reviewed npm image remediation contract", () => { ); const npmConsumers = npmConsumerPositions(source); - expect(npmConsumers).toEqual([source.indexOf("npm")]); + expect(npmConsumers).toEqual([source.indexOf(" npm") + 1]); expect(npmConsumers.every((index) => index > patchRun.commandStart)).toBe(false); }); From 2a8b0c8cec59c8645aaacb6072471567292e3118 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:44:47 -0700 Subject: [PATCH 07/11] test(security): parse npm global options Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 79 +++++++++++++++++++---- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index ea91826d6c5..3ce35accedf 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -98,16 +98,44 @@ function readShellToken(source: string, start: number): ShellToken | undefined { return value.length === 0 ? undefined : { end: cursor, value }; } -function npmSubcommand(source: string, start: number): ShellToken | undefined { - const token = readShellToken(source, start); - const prefix = token?.value === "--prefix" ? readShellToken(source, token.end) : undefined; - return token?.value === "--prefix" - ? prefix === undefined - ? undefined - : readShellToken(source, prefix.end) - : token?.value.startsWith("--prefix=") === true - ? readShellToken(source, token.end) - : token; +type NpmSubcommand = + | { kind: "known"; token: ShellToken } + | { kind: "none" } + | { kind: "unclassified" }; + +function npmSubcommand(source: string, start: number): NpmSubcommand { + let token = readShellToken(source, start); + while (token?.value.startsWith("-") === true) { + switch (token.value) { + case "--silent": + token = readShellToken(source, token.end); + continue; + case "--prefix": { + const prefix = readShellToken(source, token.end); + switch (prefix?.value.startsWith("-")) { + case false: + token = readShellToken(source, prefix.end); + continue; + default: + return { kind: "unclassified" }; + } + } + default: { + const inlinePrefix = token.value.startsWith("--prefix=") + ? token.value.slice("--prefix=".length) + : undefined; + switch (inlinePrefix) { + case undefined: + case "": + return { kind: "unclassified" }; + default: + token = readShellToken(source, token.end); + continue; + } + } + } + } + return token === undefined ? { kind: "none" } : { kind: "known", token }; } function npmConsumerPositions(source: string): number[] { @@ -115,9 +143,13 @@ function npmConsumerPositions(source: string): number[] { " ".repeat(continuation.length), ); return dockerfileRunCommandPositions(source, "npm").filter((index) => { - const subcommand = npmSubcommand(executableSource, index + "npm".length); - return subcommand?.value === "ci" || subcommand?.value === "install"; - }); + const subcommand = npmSubcommand(executableSource, index + "npm".length); + return ( + subcommand.kind === "unclassified" || + (subcommand.kind === "known" && + (subcommand.token.value === "ci" || subcommand.token.value === "install")) + ); + }); } function nodeBaseReferences(source: string): string[] { @@ -271,6 +303,25 @@ describe("node-tar image remediation contract", () => { }); describe("reviewed npm image remediation contract", () => { + it.each([ + ["a flag-only global option", "npm --silent ci"], + ["mixed global options", "npm --prefix /work --silent install"], + ["a missing global option operand", "npm --prefix --silent ci"], + ["an empty inline global option operand", "npm --prefix= --silent ci"], + ["an unknown global option", "npm --future-option ci"], + ])("discovers npm consumers with %s (#9933)", (_label, body) => { + const source = `RUN ${body}\n`; + + expect(npmConsumerPositions(source)).toEqual([source.indexOf("npm")]); + }); + + it.each(["npm --silent view", "npm --prefix /work view"])( + "ignores a supported global option before a non-consumer subcommand in %s (#9933)", + (body) => { + expect(npmConsumerPositions(`RUN ${body}\n`)).toEqual([]); + }, + ); + it.each([ ["an if condition", "if npm ci; then true; fi"], ["an elif condition", "if false; then true; elif npm install; then true; fi"], @@ -282,6 +333,8 @@ describe("reviewed npm image remediation contract", () => { ["a negated command", "! npm install"], ["a quoted assignment value", 'NPM_CONFIG_CACHE="/tmp/npm cache" npm ci'], ["an escaped-space assignment value", "NPM_CONFIG_CACHE=/tmp/npm\\ cache npm install"], + ["a flag-only global option", "npm --silent ci"], + ["mixed global options", "npm --prefix /work --silent install"], ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { const source = [ `RUN ${body}`, From 8aa7c2ed1f02a4ccaa209f4088228689ca54bb11 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:56:46 -0700 Subject: [PATCH 08/11] test(security): parse spaced npm option values Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 70 ++++++++++++++++++----- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 3ce35accedf..28679162752 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -70,6 +70,7 @@ const pinnedBaseDockerfiles = [ const reviewedNodeBases = new Set(NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH); interface ShellToken { + complete: boolean; end: number; value: string; } @@ -88,14 +89,37 @@ function readShellToken(source: string, start: number): ShellToken | undefined { let cursor = start; while (cursor < source.length && isShellTokenBoundary(source[cursor]!)) cursor += 1; const tokenStart = cursor; - while ( - cursor < source.length && - !isShellTokenBoundary(source[cursor]!) - ) { - cursor += 1; + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + token: while (cursor < source.length) { + const character = source[cursor]!; + switch (true) { + case escaped: + escaped = false; + cursor += 1; + continue; + case character === "\\" && quote !== "'": + escaped = true; + cursor += 1; + continue; + case quote !== null: + quote = character === quote ? null : quote; + cursor += 1; + continue; + case character === "'" || character === '"' || character === "`": + quote = character; + cursor += 1; + continue; + case isShellTokenBoundary(character): + break token; + default: + cursor += 1; + } } const value = source.slice(tokenStart, cursor); - return value.length === 0 ? undefined : { end: cursor, value }; + return value.length === 0 + ? undefined + : { complete: quote === null && !escaped, end: cursor, value }; } type NpmSubcommand = @@ -112,12 +136,17 @@ function npmSubcommand(source: string, start: number): NpmSubcommand { continue; case "--prefix": { const prefix = readShellToken(source, token.end); - switch (prefix?.value.startsWith("-")) { - case false: - token = readShellToken(source, prefix.end); - continue; - default: + switch (prefix) { + case undefined: return { kind: "unclassified" }; + default: + switch (prefix.complete && !prefix.value.startsWith("-")) { + case true: + token = readShellToken(source, prefix.end); + continue; + default: + return { kind: "unclassified" }; + } } } default: { @@ -135,7 +164,11 @@ function npmSubcommand(source: string, start: number): NpmSubcommand { } } } - return token === undefined ? { kind: "none" } : { kind: "known", token }; + return token === undefined + ? { kind: "none" } + : token.complete + ? { kind: "known", token } + : { kind: "unclassified" }; } function npmConsumerPositions(source: string): number[] { @@ -306,6 +339,10 @@ describe("reviewed npm image remediation contract", () => { it.each([ ["a flag-only global option", "npm --silent ci"], ["mixed global options", "npm --prefix /work --silent install"], + ["repeated flag-only global options", "npm --silent --silent ci"], + ["a nonempty inline global option operand", "npm --prefix=/work install"], + ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], + ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], ["a missing global option operand", "npm --prefix --silent ci"], ["an empty inline global option operand", "npm --prefix= --silent ci"], ["an unknown global option", "npm --future-option ci"], @@ -315,7 +352,12 @@ describe("reviewed npm image remediation contract", () => { expect(npmConsumerPositions(source)).toEqual([source.indexOf("npm")]); }); - it.each(["npm --silent view", "npm --prefix /work view"])( + it.each([ + "npm --silent view", + "npm --prefix /work view", + "npm --silent --silent view", + "npm --prefix=/work view", + ])( "ignores a supported global option before a non-consumer subcommand in %s (#9933)", (body) => { expect(npmConsumerPositions(`RUN ${body}\n`)).toEqual([]); @@ -335,6 +377,8 @@ describe("reviewed npm image remediation contract", () => { ["an escaped-space assignment value", "NPM_CONFIG_CACHE=/tmp/npm\\ cache npm install"], ["a flag-only global option", "npm --silent ci"], ["mixed global options", "npm --prefix /work --silent install"], + ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], + ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { const source = [ `RUN ${body}`, From 6480d2e2f7b2f1c6f2aa3957308b4ee2c7937479 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:05:08 -0700 Subject: [PATCH 09/11] test(security): reject incomplete npm option tokens Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 28679162752..685cd7e2624 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -129,7 +129,7 @@ type NpmSubcommand = function npmSubcommand(source: string, start: number): NpmSubcommand { let token = readShellToken(source, start); - while (token?.value.startsWith("-") === true) { + while (token?.value.startsWith("-") === true && token.complete) { switch (token.value) { case "--silent": token = readShellToken(source, token.end); @@ -343,6 +343,7 @@ describe("reviewed npm image remediation contract", () => { ["a nonempty inline global option operand", "npm --prefix=/work install"], ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], + ["an incomplete inline global option operand", 'npm --prefix="/tmp/npm cache install'], ["a missing global option operand", "npm --prefix --silent ci"], ["an empty inline global option operand", "npm --prefix= --silent ci"], ["an unknown global option", "npm --future-option ci"], @@ -379,6 +380,7 @@ describe("reviewed npm image remediation contract", () => { ["mixed global options", "npm --prefix /work --silent install"], ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], + ["an incomplete inline global option operand", 'npm --prefix="/tmp/npm cache install'], ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { const source = [ `RUN ${body}`, From f3d886097354e423d2760ec7cd05c864cb6eac75 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:23:40 -0700 Subject: [PATCH 10/11] test(security): normalize npm subcommand tokens Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/node-tar-dockerfile-contract.test.ts | 112 ++++++++++++++-------- 1 file changed, 73 insertions(+), 39 deletions(-) diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 685cd7e2624..f66c97d35b6 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -70,9 +70,8 @@ const pinnedBaseDockerfiles = [ const reviewedNodeBases = new Set(NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH); interface ShellToken { - complete: boolean; end: number; - value: string; + staticValue: string | undefined; } function isShellTokenBoundary(character: string): boolean { @@ -91,46 +90,79 @@ function readShellToken(source: string, start: number): ShellToken | undefined { const tokenStart = cursor; let quote: "'" | '"' | "`" | null = null; let escaped = false; + let expanded = false; + let staticValue = ""; token: while (cursor < source.length) { const character = source[cursor]!; switch (true) { case escaped: escaped = false; + staticValue += character; cursor += 1; continue; - case character === "\\" && quote !== "'": + case character === "\\" && + quote !== "'" && + (quote !== '"' || ["$", "`", '"', "\\"].includes(source[cursor + 1]!)): escaped = true; cursor += 1; continue; case quote !== null: - quote = character === quote ? null : quote; + switch (quote === "`" || (quote === '"' && character === "$")) { + case true: + expanded = true; + } + switch (character === quote) { + case true: + quote = null; + break; + default: + staticValue += character; + } cursor += 1; continue; case character === "'" || character === '"' || character === "`": + expanded = expanded || character === "`"; quote = character; cursor += 1; continue; case isShellTokenBoundary(character): break token; default: + switch (character === "$" || "*?[~".includes(character)) { + case true: + expanded = true; + break; + default: + staticValue += character; + } cursor += 1; } } - const value = source.slice(tokenStart, cursor); - return value.length === 0 - ? undefined - : { complete: quote === null && !escaped, end: cursor, value }; + switch (cursor === tokenStart) { + case true: + return undefined; + } + return { + end: cursor, + staticValue: quote === null && !escaped && !expanded ? staticValue : undefined, + }; } -type NpmSubcommand = - | { kind: "known"; token: ShellToken } - | { kind: "none" } - | { kind: "unclassified" }; +type NpmSubcommand = { kind: "known"; value: string } | { kind: "none" } | { kind: "unclassified" }; function npmSubcommand(source: string, start: number): NpmSubcommand { let token = readShellToken(source, start); - while (token?.value.startsWith("-") === true && token.complete) { - switch (token.value) { + while (token !== undefined) { + const value = token.staticValue; + switch (value) { + case undefined: + return { kind: "unclassified" }; + } + switch (value.startsWith("-")) { + case false: + return { kind: "known", value }; + } + switch (value) { case "--silent": token = readShellToken(source, token.end); continue; @@ -139,19 +171,24 @@ function npmSubcommand(source: string, start: number): NpmSubcommand { switch (prefix) { case undefined: return { kind: "unclassified" }; - default: - switch (prefix.complete && !prefix.value.startsWith("-")) { + default: { + const prefixValue = prefix.staticValue; + switch ( + prefixValue === undefined || + prefixValue === "" || + prefixValue.startsWith("-") + ) { case true: - token = readShellToken(source, prefix.end); - continue; - default: return { kind: "unclassified" }; } + token = readShellToken(source, prefix.end); + continue; + } } } default: { - const inlinePrefix = token.value.startsWith("--prefix=") - ? token.value.slice("--prefix=".length) + const inlinePrefix = value.startsWith("--prefix=") + ? value.slice("--prefix=".length) : undefined; switch (inlinePrefix) { case undefined: @@ -164,11 +201,7 @@ function npmSubcommand(source: string, start: number): NpmSubcommand { } } } - return token === undefined - ? { kind: "none" } - : token.complete - ? { kind: "known", token } - : { kind: "unclassified" }; + return { kind: "none" }; } function npmConsumerPositions(source: string): number[] { @@ -179,8 +212,7 @@ function npmConsumerPositions(source: string): number[] { const subcommand = npmSubcommand(executableSource, index + "npm".length); return ( subcommand.kind === "unclassified" || - (subcommand.kind === "known" && - (subcommand.token.value === "ci" || subcommand.token.value === "install")) + (subcommand.kind === "known" && (subcommand.value === "ci" || subcommand.value === "install")) ); }); } @@ -343,6 +375,9 @@ describe("reviewed npm image remediation contract", () => { ["a nonempty inline global option operand", "npm --prefix=/work install"], ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], + ["a quoted subcommand", 'npm "ci"'], + ["an escaped subcommand", "npm in\\stall"], + ["a dynamic subcommand", 'npm "$NPM_SUBCOMMAND"'], ["an incomplete inline global option operand", 'npm --prefix="/tmp/npm cache install'], ["a missing global option operand", "npm --prefix --silent ci"], ["an empty inline global option operand", "npm --prefix= --silent ci"], @@ -358,12 +393,10 @@ describe("reviewed npm image remediation contract", () => { "npm --prefix /work view", "npm --silent --silent view", "npm --prefix=/work view", - ])( - "ignores a supported global option before a non-consumer subcommand in %s (#9933)", - (body) => { - expect(npmConsumerPositions(`RUN ${body}\n`)).toEqual([]); - }, - ); + 'npm "view"', + ])("ignores a supported global option before a non-consumer subcommand in %s (#9933)", (body) => { + expect(npmConsumerPositions(`RUN ${body}\n`)).toEqual([]); + }); it.each([ ["an if condition", "if npm ci; then true; fi"], @@ -380,13 +413,14 @@ describe("reviewed npm image remediation contract", () => { ["mixed global options", "npm --prefix /work --silent install"], ["a quoted global option operand", 'npm --prefix "/tmp/npm cache" ci'], ["an escaped-space global option operand", "npm --prefix /tmp/npm\\ cache install"], + ["a quoted subcommand", 'npm "ci"'], + ["an escaped subcommand", "npm in\\stall"], + ["a dynamic subcommand", 'npm "$NPM_SUBCOMMAND"'], ["an incomplete inline global option operand", 'npm --prefix="/tmp/npm cache install'], ])("detects npm consumers in %s before the final patch (#9933)", (_label, body) => { - const source = [ - `RUN ${body}`, - `RUN ${patchCommand} ${npmRootArguments.join(" ")}`, - "", - ].join("\n"); + const source = [`RUN ${body}`, `RUN ${patchCommand} ${npmRootArguments.join(" ")}`, ""].join( + "\n", + ); const patchRun = requireSingleReviewedDockerfileRunCommand( source, patchCommand, From ec6721279662747d178ee37063df89195fe5a020 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:44:52 -0700 Subject: [PATCH 11/11] test(security): reject npm assignment data Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/dockerfile-run-commands.test.ts | 13 +++++++++++++ test/helpers/dockerfile-run-commands.ts | 6 ++++++ test/node-tar-dockerfile-contract.test.ts | 10 ++++++++++ 3 files changed, 29 insertions(+) diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index 132b4dd343e..150c5b05190 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -55,6 +55,19 @@ describe("Dockerfile RUN command discovery", () => { ]); }); + it("ignores npm assignment values, redirection operands, and here-document delimiters (#9933)", () => { + const source = [ + "RUN VALUE=npm ci", + "RUN echo ok > npm", + "RUN cat < { const source = [ `# ${command}`, diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 0db1f2ab98c..36d48f9cef6 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -203,6 +203,11 @@ function followsShellCommandSeparator(source: string, index: number): boolean { ); } +function startsShellWord(source: string, index: number): boolean { + const previousCharacter = source[index - 1]; + return previousCharacter === undefined || /[\t\r\n &|();<>]/u.test(previousCharacter); +} + export function dockerfileRunCommandPositions(source: string, command: string): number[] { const positions: number[] = []; for (const instruction of dockerfileInstructions(source)) { @@ -211,6 +216,7 @@ export function dockerfileRunCommandPositions(source: string, command: string): for (const index of unquotedTextIndexes(collapsed.text, command)) { const afterCommand = collapsed.text[index + command.length]; if ( + startsShellWord(collapsed.text, index) && followsShellCommandSeparator(collapsed.text, index) && (afterCommand === undefined || /[ \t\r\n;&|(){}<>]/u.test(afterCommand)) ) { diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index f66c97d35b6..8a6b74f8432 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -398,6 +398,16 @@ describe("reviewed npm image remediation contract", () => { expect(npmConsumerPositions(`RUN ${body}\n`)).toEqual([]); }); + it("does not treat an assignment value as a pre-remediation npm consumer (#9933)", () => { + const source = [ + "RUN VALUE=npm ci", + `RUN ${patchCommand} ${npmRootArguments.join(" ")}`, + "", + ].join("\n"); + + expect(npmConsumerPositions(source)).toEqual([]); + }); + it.each([ ["an if condition", "if npm ci; then true; fi"], ["an elif condition", "if false; then true; elif npm install; then true; fi"],