Skip to content

Commit 58891c8

Browse files
authored
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
1 parent 5f04dc9 commit 58891c8

3 files changed

Lines changed: 285 additions & 3 deletions

File tree

src/infra/command-analysis/inline-eval.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,52 @@ function expectInlineEvalDescription(hit: InterpreterInlineEvalHit | null, expec
1818
describe("exec inline eval detection", () => {
1919
it.each([
2020
{ argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" },
21+
{ argv: ["python3", "-cprint('hi')"], expected: "python3 -c" },
22+
{ argv: ["python3", "-bc", "print('hi')"], expected: "python3 -c" },
23+
{ argv: ["python3", "-Sc", "print('hi')"], expected: "python3 -c" },
24+
{ argv: ["python3", "-xc", "print('hi')"], expected: "python3 -c" },
2125
{ argv: ["python3.13", "-c", "print('hi')"], expected: "python3.13 -c" },
2226
{ argv: ["/usr/bin/pypy3.10", "-c", "print('hi')"], expected: "pypy3.10 -c" },
2327
{ argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" },
28+
{ argv: ["/usr/bin/node", "--eval=console.log('hi')"], expected: "node --eval" },
29+
{ argv: ["bun", "-pconsole.log('hi')"], expected: "bun -p" },
30+
{ argv: ["deno", "--print=1 + 1"], expected: "deno --print" },
31+
{ argv: ["ruby", "-eputs 1"], expected: "ruby -e" },
32+
{ argv: ["ruby", "-ane", "puts 1"], expected: "ruby -e" },
33+
{ argv: ["ruby", "-ce", "puts 1"], expected: "ruby -e" },
34+
{ argv: ["ruby", "-ne", "puts 1"], expected: "ruby -e" },
35+
{ argv: ["ruby", "-00pe", "puts 1"], expected: "ruby -e" },
36+
{ argv: ["ruby", "-p00e", "puts 1"], expected: "ruby -e" },
37+
{ argv: ["ruby", "-pe", "puts 1"], expected: "ruby -e" },
38+
{ argv: ["ruby", "-Se", "puts 1"], expected: "ruby -e" },
39+
{ argv: ["ruby", "-We", "puts 1"], expected: "ruby -e" },
40+
{ argv: ["ruby", "-W2e", "puts 1"], expected: "ruby -e" },
41+
{ argv: ["ruby", "-ve", "puts 1"], expected: "ruby -e" },
42+
{ argv: ["ruby", "-we", "puts 1"], expected: "ruby -e" },
2443
{ argv: ["perl", "-E", "say 1"], expected: "perl -e" },
44+
{ argv: ["perl", "-Esay 1"], expected: "perl -e" },
45+
{ argv: ["perl", "-ce", "say 1"], expected: "perl -e" },
46+
{ argv: ["perl", "-de", "say 1"], expected: "perl -e" },
47+
{ argv: ["perl", "-fe", "say 1"], expected: "perl -e" },
48+
{ argv: ["perl", "-l0e", "say 1"], expected: "perl -e" },
49+
{ argv: ["perl", "-ne", "say 1"], expected: "perl -e" },
50+
{ argv: ["perl", "-0777pe", "say 1"], expected: "perl -e" },
51+
{ argv: ["perl", "-p0777e", "say 1"], expected: "perl -e" },
52+
{ argv: ["perl", "-Se", "say 1"], expected: "perl -e" },
53+
{ argv: ["perl", "-Te", "say 1"], expected: "perl -e" },
54+
{ argv: ["perl", "-UE", "say 1"], expected: "perl -e" },
55+
{ argv: ["perl", "-Ve", "say 1"], expected: "perl -e" },
56+
{ argv: ["perl", "-We", "say 1"], expected: "perl -e" },
57+
{ argv: ["perl", "-we", "say 1"], expected: "perl -e" },
58+
{ argv: ["perl", "-Xe", "say 1"], expected: "perl -e" },
2559
{ argv: ["php", "-B", "system('id');"], expected: "php -B" },
60+
{ argv: ["php", "-rsystem('id');"], expected: "php -r" },
2661
{ argv: ["php", "-E", "system('id');"], expected: "php -E" },
2762
{ argv: ["php", "-R", "system('id');"], expected: "php -R" },
2863
{ argv: ["Rscript", "-e", "system('id')"], expected: "rscript -e" },
64+
{ argv: ["lua", "-eprint(1)"], expected: "lua -e" },
2965
{ argv: ["osascript", "-e", "beep"], expected: "osascript -e" },
66+
{ argv: ["osascript", '-edisplay alert "hi"'], expected: "osascript -e" },
3067
{ argv: ["awk", "BEGIN { print 1 }"], expected: "awk inline program" },
3168
{ argv: ["gawk", "-F", ",", "{print $1}", "data.csv"], expected: "gawk inline program" },
3269
] as const)("detects interpreter eval flags for %j", ({ argv, expected }) => {
@@ -68,6 +105,20 @@ describe("exec inline eval detection", () => {
68105
expect(detectInterpreterInlineEvalArgv(["python3", "script.py"])).toBeNull();
69106
expect(detectInterpreterInlineEvalArgv(["python3.13", "script.py"])).toBeNull();
70107
expect(detectInterpreterInlineEvalArgv(["node", "script.js"])).toBeNull();
108+
expect(detectInterpreterInlineEvalArgv(["node", "--evalish=console.log(1)"])).toBeNull();
109+
expect(detectInterpreterInlineEvalArgv(["python3", "-Wc", "print('hi')"])).toBeNull();
110+
expect(detectInterpreterInlineEvalArgv(["python3", "-Xc", "print('hi')"])).toBeNull();
111+
expect(detectInterpreterInlineEvalArgv(["find", ".", "-execute", "id"])).toBeNull();
112+
expect(detectInterpreterInlineEvalArgv(["ruby", "-EUTF-8", "script.rb"])).toBeNull();
113+
expect(detectInterpreterInlineEvalArgv(["ruby", "-Itest", "script.rb"])).toBeNull();
114+
expect(detectInterpreterInlineEvalArgv(["ruby", "-W:deprecatede", "puts 1"])).toBeNull();
115+
expect(detectInterpreterInlineEvalArgv(["ruby", "-7pe", "puts 1"])).toBeNull();
116+
expect(detectInterpreterInlineEvalArgv(["perl", "-C0e", "say 1"])).toBeNull();
117+
expect(detectInterpreterInlineEvalArgv(["perl", "-D0e", "say 1"])).toBeNull();
118+
expect(detectInterpreterInlineEvalArgv(["perl", "-me", "say 1"])).toBeNull();
119+
expect(detectInterpreterInlineEvalArgv(["perl", "-Me", "say 1"])).toBeNull();
120+
expect(detectInterpreterInlineEvalArgv(["perl", "-7pe", "say 1"])).toBeNull();
121+
expect(detectInterpreterInlineEvalArgv(["perl", "-0xFFpe", "say 1"])).toBeNull();
71122
expect(detectInterpreterInlineEvalArgv(["php", "-F", "filter.php"])).toBeNull();
72123
expect(detectInterpreterInlineEvalArgv(["Rscript", "script.R"])).toBeNull();
73124
expect(detectInterpreterInlineEvalArgv(["r2", "-e", "bin.cache=true", "program"])).toBeNull();

src/infra/command-analysis/inline-eval.ts

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ type InterpreterFlagSpec = {
2121
rawExactFlags?: ReadonlyMap<string, string>;
2222
rawPrefixFlags?: readonly PrefixFlagSpec[];
2323
prefixFlags?: readonly PrefixFlagSpec[];
24+
shortClusterFlags?: readonly ShortClusterFlagSpec[];
2425
scanPastDoubleDash?: boolean;
2526
};
2627

28+
type ShortClusterFlagSpec = {
29+
label: string;
30+
flag: string;
31+
prefixChars: ReadonlySet<string>;
32+
allowNumericRecordSeparator?: boolean;
33+
numericValuePrefixChars?: ReadonlySet<string>;
34+
};
35+
2736
type PositionalInterpreterSpec = {
2837
names: readonly string[];
2938
fileFlags?: ReadonlySet<string>;
@@ -37,7 +46,33 @@ type PositionalInterpreterSpec = {
3746
const VERSION_SUFFIX_PATTERN = /-?\d+(?:\.\d+)*$/;
3847

3948
const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
40-
{ names: ["python", "python2", "python3", "pypy", "pypy3"], exactFlags: new Set(["-c"]) },
49+
{
50+
names: ["python", "python2", "python3", "pypy", "pypy3"],
51+
exactFlags: new Set(["-c"]),
52+
shortClusterFlags: [
53+
{
54+
label: "-c",
55+
flag: "c",
56+
prefixChars: new Set([
57+
"B",
58+
"E",
59+
"I",
60+
"O",
61+
"P",
62+
"R",
63+
"S",
64+
"b",
65+
"d",
66+
"i",
67+
"q",
68+
"s",
69+
"u",
70+
"v",
71+
"x",
72+
]),
73+
},
74+
],
75+
},
4176
{
4277
names: ["node", "nodejs", "bun", "deno"],
4378
exactFlags: new Set(["-e", "--eval", "-p", "--print"]),
@@ -47,8 +82,75 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
4782
exactFlags: new Set(["-e", "--source"]),
4883
prefixFlags: [{ label: "--source", prefix: "--source=" }],
4984
},
50-
{ names: ["ruby"], exactFlags: new Set(["-e"]) },
51-
{ names: ["perl"], exactFlags: new Set(["-e", "-E"]) },
85+
{
86+
names: ["ruby"],
87+
exactFlags: new Set(["-e"]),
88+
shortClusterFlags: [
89+
{
90+
label: "-e",
91+
flag: "e",
92+
prefixChars: new Set(["S", "U", "W", "a", "c", "d", "l", "n", "p", "s", "v", "w"]),
93+
allowNumericRecordSeparator: true,
94+
numericValuePrefixChars: new Set(["W"]),
95+
},
96+
],
97+
},
98+
{
99+
names: ["perl"],
100+
exactFlags: new Set(["-e", "-E"]),
101+
shortClusterFlags: [
102+
{
103+
label: "-e",
104+
flag: "e",
105+
prefixChars: new Set([
106+
"S",
107+
"T",
108+
"W",
109+
"X",
110+
"U",
111+
"V",
112+
"a",
113+
"c",
114+
"d",
115+
"f",
116+
"l",
117+
"n",
118+
"p",
119+
"s",
120+
"t",
121+
"u",
122+
"w",
123+
]),
124+
allowNumericRecordSeparator: true,
125+
numericValuePrefixChars: new Set(["l"]),
126+
},
127+
{
128+
label: "-e",
129+
flag: "E",
130+
prefixChars: new Set([
131+
"S",
132+
"T",
133+
"W",
134+
"X",
135+
"U",
136+
"V",
137+
"a",
138+
"c",
139+
"d",
140+
"f",
141+
"l",
142+
"n",
143+
"p",
144+
"s",
145+
"t",
146+
"u",
147+
"w",
148+
]),
149+
allowNumericRecordSeparator: true,
150+
numericValuePrefixChars: new Set(["l"]),
151+
},
152+
],
153+
},
52154
{
53155
names: ["php"],
54156
exactFlags: new Set(["-r"]),
@@ -216,6 +318,74 @@ function createInlineEvalHit(
216318
};
217319
}
218320

321+
function matchJoinedExactFlag(
322+
spec: InterpreterFlagSpec,
323+
token: string,
324+
lower: string,
325+
): string | null {
326+
for (const flag of spec.exactFlags) {
327+
if (flag.startsWith("--")) {
328+
const prefix = `${flag}=`;
329+
if (lower.startsWith(prefix) && lower.length > prefix.length) {
330+
return flag;
331+
}
332+
continue;
333+
}
334+
if (/^-[A-Za-z]$/.test(flag) && token.startsWith(flag) && token.length > flag.length) {
335+
return normalizeLowercaseStringOrEmpty(flag);
336+
}
337+
}
338+
return null;
339+
}
340+
341+
function matchJoinedRawExactFlag(spec: InterpreterFlagSpec, token: string): string | null {
342+
for (const [flag, label] of spec.rawExactFlags ?? []) {
343+
if (/^-[A-Za-z]$/.test(flag) && token.startsWith(flag) && token.length > flag.length) {
344+
return label;
345+
}
346+
}
347+
return null;
348+
}
349+
350+
function matchShortClusterFlag(spec: InterpreterFlagSpec, token: string): string | null {
351+
if (!token.startsWith("-") || token.startsWith("--")) {
352+
return null;
353+
}
354+
for (const clusterFlag of spec.shortClusterFlags ?? []) {
355+
const index = token.indexOf(clusterFlag.flag, 2);
356+
if (index < 2) {
357+
continue;
358+
}
359+
const prefix = token.slice(1, index);
360+
if (isShortClusterPrefixAllowed(clusterFlag, prefix)) {
361+
return clusterFlag.label;
362+
}
363+
}
364+
return null;
365+
}
366+
367+
function isShortClusterPrefixAllowed(clusterFlag: ShortClusterFlagSpec, prefix: string): boolean {
368+
for (let index = 0; index < prefix.length; index += 1) {
369+
const char = prefix[index] ?? "";
370+
if (clusterFlag.prefixChars.has(char)) {
371+
if (clusterFlag.numericValuePrefixChars?.has(char) === true) {
372+
while (/^[0-9]$/.test(prefix[index + 1] ?? "")) {
373+
index += 1;
374+
}
375+
}
376+
continue;
377+
}
378+
if (clusterFlag.allowNumericRecordSeparator === true && char === "0") {
379+
while (/^[0-9]$/.test(prefix[index + 1] ?? "")) {
380+
index += 1;
381+
}
382+
continue;
383+
}
384+
return false;
385+
}
386+
return true;
387+
}
388+
219389
export function detectInterpreterInlineEvalArgv(
220390
argv: string[] | undefined | null,
221391
): InterpreterInlineEvalHit | null {
@@ -243,6 +413,10 @@ export function detectInterpreterInlineEvalArgv(
243413
if (rawExactFlag) {
244414
return createInlineEvalHit(executable, argv, rawExactFlag);
245415
}
416+
const joinedRawExactFlag = matchJoinedRawExactFlag(spec, token);
417+
if (joinedRawExactFlag) {
418+
return createInlineEvalHit(executable, argv, joinedRawExactFlag);
419+
}
246420
const rawPrefixFlag = spec.rawPrefixFlags?.find(
247421
({ prefix }) => token.startsWith(prefix) && token.length > prefix.length,
248422
);
@@ -253,6 +427,14 @@ export function detectInterpreterInlineEvalArgv(
253427
if (spec.exactFlags.has(lower)) {
254428
return createInlineEvalHit(executable, argv, lower);
255429
}
430+
const joinedExactFlag = matchJoinedExactFlag(spec, token, lower);
431+
if (joinedExactFlag) {
432+
return createInlineEvalHit(executable, argv, joinedExactFlag);
433+
}
434+
const shortClusterFlag = matchShortClusterFlag(spec, token);
435+
if (shortClusterFlag) {
436+
return createInlineEvalHit(executable, argv, shortClusterFlag);
437+
}
256438
const prefixFlag = spec.prefixFlags?.find(
257439
({ prefix }) => lower.startsWith(prefix) && lower.length > prefix.length,
258440
);

src/infra/exec-authorization-allowlist.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,55 @@ describe("authorization-backed exec allowlist", () => {
109109
]);
110110
});
111111

112+
it("keeps glued allowlisted inline-eval commands one-shot in strict mode", async () => {
113+
if (process.platform === "win32") {
114+
return;
115+
}
116+
117+
const dir = makeTempDir();
118+
const pythonPath = makeExecutable(dir, "python3");
119+
const env = makePathEnv(dir);
120+
const command = "python3 -xcprint";
121+
122+
const result = await evaluateShellAllowlistWithAuthorization({
123+
command,
124+
allowlist: [{ pattern: pythonPath }],
125+
safeBins: new Set(),
126+
cwd: dir,
127+
env,
128+
platform: process.platform,
129+
});
130+
131+
expect(result.analysisOk).toBe(true);
132+
expect(result.allowlistSatisfied).toBe(true);
133+
expect(result.segments.map((segment) => segment.argv)).toEqual([["python3", "-xcprint"]]);
134+
expect(detectPolicyInlineEval(result.segments)).toEqual(
135+
expect.objectContaining({
136+
executable: "python3",
137+
flag: "-c",
138+
}),
139+
);
140+
141+
const allowAlwaysPersistence = resolveAllowAlwaysPersistenceDecision({
142+
segments: result.segments,
143+
commandText: command,
144+
cwd: dir,
145+
env,
146+
platform: process.platform,
147+
strictInlineEval: true,
148+
authorizationPlan: result.authorizationPlan,
149+
});
150+
151+
expect(allowAlwaysPersistence).toEqual({
152+
kind: "one-shot",
153+
reasons: expect.arrayContaining(["no-reusable-pattern"]),
154+
});
155+
expect(resolveExecApprovalAllowedDecisions({ allowAlwaysPersistence })).toEqual([
156+
"allow-once",
157+
"deny",
158+
]);
159+
});
160+
112161
it("does not satisfy path-scoped shell wrappers from trusted inner payloads", async () => {
113162
if (process.platform === "win32") {
114163
return;

0 commit comments

Comments
 (0)