Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion test/dockerfile-run-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -11,6 +14,60 @@ const invocation = [command, ...requiredArguments].join(" ");
const splicedCommand = command.replace("strip-types", "strip-\\\ntypes");

describe("Dockerfile RUN command discovery", () => {
it("finds only executable unquoted npm command words in RUN instructions (#9933)", () => {
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",
"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",
'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");

expect(dockerfileRunCommandPositions(source, "npm")).toEqual([
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"),
source.indexOf("npm ci --prefix /quoted-assignment"),
source.indexOf("npm install --prefix /escaped-assignment"),
]);
});

it("ignores npm assignment values, redirection operands, and here-document delimiters (#9933)", () => {
const source = [
"RUN VALUE=npm ci",
"RUN echo ok > npm",
"RUN cat <<npm",
"payload",
"npm",
"",
].join("\n");

expect(dockerfileRunCommandPositions(source, "npm")).toEqual([]);
});

it("ignores command text in comments, strings, and non-RUN instructions", () => {
const source = [
`# ${command}`,
Expand Down
99 changes: 96 additions & 3 deletions test/helpers/dockerfile-run-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -134,6 +135,98 @@ function unquotedTextIndexes(source: string, text: string): number[] {
return indexes;
}

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;
}

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),
);
}

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)) {
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 (
startsShellWord(collapsed.text, index) &&
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, " ")
Expand Down
Loading
Loading