mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user