fix: detect joined inline eval flags [AI] (#101353)

* fix: detect joined inline eval flags

* fix: avoid joined eval flag false positives

* fix: detect clustered inline eval flags

* fix: avoid cluster matcher string spread

* fix: cover common eval flag clusters

* fix: cover ruby perl eval clusters

* fix: detect python x inline eval cluster

* fix: detect numeric eval clusters

* fix: cover ruby perl eval clusters
This commit is contained in:
Pavan Kumar Gondhi
2026-07-08 15:49:54 +05:30
committed by GitHub
parent 5f04dc97e6
commit 58891c85b6
3 changed files with 285 additions and 3 deletions
@@ -18,15 +18,52 @@ function expectInlineEvalDescription(hit: InterpreterInlineEvalHit | null, expec
describe("exec inline eval detection", () => {
it.each([
{ argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" },
{ argv: ["python3", "-cprint('hi')"], expected: "python3 -c" },
{ argv: ["python3", "-bc", "print('hi')"], expected: "python3 -c" },
{ argv: ["python3", "-Sc", "print('hi')"], expected: "python3 -c" },
{ argv: ["python3", "-xc", "print('hi')"], expected: "python3 -c" },
{ argv: ["python3.13", "-c", "print('hi')"], expected: "python3.13 -c" },
{ argv: ["/usr/bin/pypy3.10", "-c", "print('hi')"], expected: "pypy3.10 -c" },
{ argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" },
{ argv: ["/usr/bin/node", "--eval=console.log('hi')"], expected: "node --eval" },
{ argv: ["bun", "-pconsole.log('hi')"], expected: "bun -p" },
{ argv: ["deno", "--print=1 + 1"], expected: "deno --print" },
{ argv: ["ruby", "-eputs 1"], expected: "ruby -e" },
{ argv: ["ruby", "-ane", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-ce", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-ne", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-00pe", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-p00e", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-pe", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-Se", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-We", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-W2e", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-ve", "puts 1"], expected: "ruby -e" },
{ argv: ["ruby", "-we", "puts 1"], expected: "ruby -e" },
{ argv: ["perl", "-E", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-Esay 1"], expected: "perl -e" },
{ argv: ["perl", "-ce", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-de", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-fe", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-l0e", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-ne", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-0777pe", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-p0777e", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-Se", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-Te", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-UE", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-Ve", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-We", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-we", "say 1"], expected: "perl -e" },
{ argv: ["perl", "-Xe", "say 1"], expected: "perl -e" },
{ argv: ["php", "-B", "system('id');"], expected: "php -B" },
{ argv: ["php", "-rsystem('id');"], expected: "php -r" },
{ argv: ["php", "-E", "system('id');"], expected: "php -E" },
{ argv: ["php", "-R", "system('id');"], expected: "php -R" },
{ argv: ["Rscript", "-e", "system('id')"], expected: "rscript -e" },
{ argv: ["lua", "-eprint(1)"], expected: "lua -e" },
{ argv: ["osascript", "-e", "beep"], expected: "osascript -e" },
{ argv: ["osascript", '-edisplay alert "hi"'], expected: "osascript -e" },
{ argv: ["awk", "BEGIN { print 1 }"], expected: "awk inline program" },
{ argv: ["gawk", "-F", ",", "{print $1}", "data.csv"], expected: "gawk inline program" },
] as const)("detects interpreter eval flags for %j", ({ argv, expected }) => {
@@ -68,6 +105,20 @@ describe("exec inline eval detection", () => {
expect(detectInterpreterInlineEvalArgv(["python3", "script.py"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["python3.13", "script.py"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["node", "script.js"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["node", "--evalish=console.log(1)"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["python3", "-Wc", "print('hi')"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["python3", "-Xc", "print('hi')"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["find", ".", "-execute", "id"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["ruby", "-EUTF-8", "script.rb"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["ruby", "-Itest", "script.rb"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["ruby", "-W:deprecatede", "puts 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["ruby", "-7pe", "puts 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-C0e", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-D0e", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-me", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-Me", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-7pe", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["perl", "-0xFFpe", "say 1"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["php", "-F", "filter.php"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["Rscript", "script.R"])).toBeNull();
expect(detectInterpreterInlineEvalArgv(["r2", "-e", "bin.cache=true", "program"])).toBeNull();
+185 -3
View File
@@ -21,9 +21,18 @@ type InterpreterFlagSpec = {
rawExactFlags?: ReadonlyMap<string, string>;
rawPrefixFlags?: readonly PrefixFlagSpec[];
prefixFlags?: readonly PrefixFlagSpec[];
shortClusterFlags?: readonly ShortClusterFlagSpec[];
scanPastDoubleDash?: boolean;
};
type ShortClusterFlagSpec = {
label: string;
flag: string;
prefixChars: ReadonlySet<string>;
allowNumericRecordSeparator?: boolean;
numericValuePrefixChars?: ReadonlySet<string>;
};
type PositionalInterpreterSpec = {
names: readonly string[];
fileFlags?: ReadonlySet<string>;
@@ -37,7 +46,33 @@ type PositionalInterpreterSpec = {
const VERSION_SUFFIX_PATTERN = /-?\d+(?:\.\d+)*$/;
const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
{ names: ["python", "python2", "python3", "pypy", "pypy3"], exactFlags: new Set(["-c"]) },
{
names: ["python", "python2", "python3", "pypy", "pypy3"],
exactFlags: new Set(["-c"]),
shortClusterFlags: [
{
label: "-c",
flag: "c",
prefixChars: new Set([
"B",
"E",
"I",
"O",
"P",
"R",
"S",
"b",
"d",
"i",
"q",
"s",
"u",
"v",
"x",
]),
},
],
},
{
names: ["node", "nodejs", "bun", "deno"],
exactFlags: new Set(["-e", "--eval", "-p", "--print"]),
@@ -47,8 +82,75 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
exactFlags: new Set(["-e", "--source"]),
prefixFlags: [{ label: "--source", prefix: "--source=" }],
},
{ names: ["ruby"], exactFlags: new Set(["-e"]) },
{ names: ["perl"], exactFlags: new Set(["-e", "-E"]) },
{
names: ["ruby"],
exactFlags: new Set(["-e"]),
shortClusterFlags: [
{
label: "-e",
flag: "e",
prefixChars: new Set(["S", "U", "W", "a", "c", "d", "l", "n", "p", "s", "v", "w"]),
allowNumericRecordSeparator: true,
numericValuePrefixChars: new Set(["W"]),
},
],
},
{
names: ["perl"],
exactFlags: new Set(["-e", "-E"]),
shortClusterFlags: [
{
label: "-e",
flag: "e",
prefixChars: new Set([
"S",
"T",
"W",
"X",
"U",
"V",
"a",
"c",
"d",
"f",
"l",
"n",
"p",
"s",
"t",
"u",
"w",
]),
allowNumericRecordSeparator: true,
numericValuePrefixChars: new Set(["l"]),
},
{
label: "-e",
flag: "E",
prefixChars: new Set([
"S",
"T",
"W",
"X",
"U",
"V",
"a",
"c",
"d",
"f",
"l",
"n",
"p",
"s",
"t",
"u",
"w",
]),
allowNumericRecordSeparator: true,
numericValuePrefixChars: new Set(["l"]),
},
],
},
{
names: ["php"],
exactFlags: new Set(["-r"]),
@@ -216,6 +318,74 @@ function createInlineEvalHit(
};
}
function matchJoinedExactFlag(
spec: InterpreterFlagSpec,
token: string,
lower: string,
): string | null {
for (const flag of spec.exactFlags) {
if (flag.startsWith("--")) {
const prefix = `${flag}=`;
if (lower.startsWith(prefix) && lower.length > prefix.length) {
return flag;
}
continue;
}
if (/^-[A-Za-z]$/.test(flag) && token.startsWith(flag) && token.length > flag.length) {
return normalizeLowercaseStringOrEmpty(flag);
}
}
return null;
}
function matchJoinedRawExactFlag(spec: InterpreterFlagSpec, token: string): string | null {
for (const [flag, label] of spec.rawExactFlags ?? []) {
if (/^-[A-Za-z]$/.test(flag) && token.startsWith(flag) && token.length > flag.length) {
return label;
}
}
return null;
}
function matchShortClusterFlag(spec: InterpreterFlagSpec, token: string): string | null {
if (!token.startsWith("-") || token.startsWith("--")) {
return null;
}
for (const clusterFlag of spec.shortClusterFlags ?? []) {
const index = token.indexOf(clusterFlag.flag, 2);
if (index < 2) {
continue;
}
const prefix = token.slice(1, index);
if (isShortClusterPrefixAllowed(clusterFlag, prefix)) {
return clusterFlag.label;
}
}
return null;
}
function isShortClusterPrefixAllowed(clusterFlag: ShortClusterFlagSpec, prefix: string): boolean {
for (let index = 0; index < prefix.length; index += 1) {
const char = prefix[index] ?? "";
if (clusterFlag.prefixChars.has(char)) {
if (clusterFlag.numericValuePrefixChars?.has(char) === true) {
while (/^[0-9]$/.test(prefix[index + 1] ?? "")) {
index += 1;
}
}
continue;
}
if (clusterFlag.allowNumericRecordSeparator === true && char === "0") {
while (/^[0-9]$/.test(prefix[index + 1] ?? "")) {
index += 1;
}
continue;
}
return false;
}
return true;
}
export function detectInterpreterInlineEvalArgv(
argv: string[] | undefined | null,
): InterpreterInlineEvalHit | null {
@@ -243,6 +413,10 @@ export function detectInterpreterInlineEvalArgv(
if (rawExactFlag) {
return createInlineEvalHit(executable, argv, rawExactFlag);
}
const joinedRawExactFlag = matchJoinedRawExactFlag(spec, token);
if (joinedRawExactFlag) {
return createInlineEvalHit(executable, argv, joinedRawExactFlag);
}
const rawPrefixFlag = spec.rawPrefixFlags?.find(
({ prefix }) => token.startsWith(prefix) && token.length > prefix.length,
);
@@ -253,6 +427,14 @@ export function detectInterpreterInlineEvalArgv(
if (spec.exactFlags.has(lower)) {
return createInlineEvalHit(executable, argv, lower);
}
const joinedExactFlag = matchJoinedExactFlag(spec, token, lower);
if (joinedExactFlag) {
return createInlineEvalHit(executable, argv, joinedExactFlag);
}
const shortClusterFlag = matchShortClusterFlag(spec, token);
if (shortClusterFlag) {
return createInlineEvalHit(executable, argv, shortClusterFlag);
}
const prefixFlag = spec.prefixFlags?.find(
({ prefix }) => lower.startsWith(prefix) && lower.length > prefix.length,
);
@@ -109,6 +109,55 @@ describe("authorization-backed exec allowlist", () => {
]);
});
it("keeps glued allowlisted inline-eval commands one-shot in strict mode", async () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const pythonPath = makeExecutable(dir, "python3");
const env = makePathEnv(dir);
const command = "python3 -xcprint";
const result = await evaluateShellAllowlistWithAuthorization({
command,
allowlist: [{ pattern: pythonPath }],
safeBins: new Set(),
cwd: dir,
env,
platform: process.platform,
});
expect(result.analysisOk).toBe(true);
expect(result.allowlistSatisfied).toBe(true);
expect(result.segments.map((segment) => segment.argv)).toEqual([["python3", "-xcprint"]]);
expect(detectPolicyInlineEval(result.segments)).toEqual(
expect.objectContaining({
executable: "python3",
flag: "-c",
}),
);
const allowAlwaysPersistence = resolveAllowAlwaysPersistenceDecision({
segments: result.segments,
commandText: command,
cwd: dir,
env,
platform: process.platform,
strictInlineEval: true,
authorizationPlan: result.authorizationPlan,
});
expect(allowAlwaysPersistence).toEqual({
kind: "one-shot",
reasons: expect.arrayContaining(["no-reusable-pattern"]),
});
expect(resolveExecApprovalAllowedDecisions({ allowAlwaysPersistence })).toEqual([
"allow-once",
"deny",
]);
});
it("does not satisfy path-scoped shell wrappers from trusted inner payloads", async () => {
if (process.platform === "win32") {
return;