From 58891c85b68b2ef93cbfec5b105dd05bd80a5061 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Wed, 8 Jul 2026 15:49:54 +0530 Subject: [PATCH] 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 --- .../command-analysis/inline-eval.test.ts | 51 +++++ src/infra/command-analysis/inline-eval.ts | 188 +++++++++++++++++- .../exec-authorization-allowlist.test.ts | 49 +++++ 3 files changed, 285 insertions(+), 3 deletions(-) diff --git a/src/infra/command-analysis/inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts index 10ba2c8cd636..64b5949d3792 100644 --- a/src/infra/command-analysis/inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -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(); diff --git a/src/infra/command-analysis/inline-eval.ts b/src/infra/command-analysis/inline-eval.ts index 842585c94b24..eca8b460e5e1 100644 --- a/src/infra/command-analysis/inline-eval.ts +++ b/src/infra/command-analysis/inline-eval.ts @@ -21,9 +21,18 @@ type InterpreterFlagSpec = { rawExactFlags?: ReadonlyMap; rawPrefixFlags?: readonly PrefixFlagSpec[]; prefixFlags?: readonly PrefixFlagSpec[]; + shortClusterFlags?: readonly ShortClusterFlagSpec[]; scanPastDoubleDash?: boolean; }; +type ShortClusterFlagSpec = { + label: string; + flag: string; + prefixChars: ReadonlySet; + allowNumericRecordSeparator?: boolean; + numericValuePrefixChars?: ReadonlySet; +}; + type PositionalInterpreterSpec = { names: readonly string[]; fileFlags?: ReadonlySet; @@ -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, ); diff --git a/src/infra/exec-authorization-allowlist.test.ts b/src/infra/exec-authorization-allowlist.test.ts index fab3d50c710d..f1cbafe319af 100644 --- a/src/infra/exec-authorization-allowlist.test.ts +++ b/src/infra/exec-authorization-allowlist.test.ts @@ -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;