fix(agents): replace .* with \S* in interpreter heuristic regexes to prevent ReDoS

The inner `.*\s+` in `(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*` creates
catastrophic backtracking because both `.*` and `\s+` can match
whitespace. When the exec tool processes commands with `VAR=value`
assignments followed by whitespace-heavy text (e.g. HTML heredocs),
the regex engine hangs permanently at 100% CPU.

Replace `.*` with `\S*` in all three instances. Shell prefix variable
assignments cannot contain unquoted whitespace in the value, so `\S*`
is semantically correct and eliminates the ambiguity.

Fixes #61881
This commit is contained in:
Martin Garramon
2026-04-06 10:37:23 -03:00
committed by Peter Steinberger
parent c63a4f0f13
commit eede8f945f
2 changed files with 32 additions and 3 deletions

View File

@@ -783,3 +783,32 @@ describeWin("exec script preflight on windows path syntax", () => {
});
});
});
describe("exec interpreter heuristics ReDoS guard", () => {
it("does not hang on long commands with VAR=value assignments and whitespace-heavy text", async () => {
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
// Simulate a heredoc with HTML content after a VAR= assignment — the pattern
// that triggers catastrophic backtracking when .* is used instead of \S*
const htmlBlock = '<section style="padding: 30px 20px; font-family: Arial;">'.repeat(50);
const command = `ACCESS_TOKEN=$(curl -s https://api.example.com/token)\ncat > /tmp/out.html << 'EOF'\n${htmlBlock}\nEOF`;
const start = Date.now();
// The command itself will fail — we only care that the interpreter
// heuristics analysis completes without hanging.
try {
await Promise.race([
tool.execute("redos-guard", { command }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("ReDoS: regex hung for >5s")), 5000),
),
]);
} catch (e) {
// Any error EXCEPT the timeout is acceptable — it means the regex finished
if (e instanceof Error && e.message.includes("ReDoS")) {
throw e;
}
}
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
});
});

View File

@@ -418,11 +418,11 @@ function analyzeInterpreterHeuristicsFromUnquoted(raw: string): {
hasScriptHint: boolean;
} {
const hasPython =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*python(?:3(?:\.\d+)?)?(?=$|[\s|&;()<>\n\r`$])/i.test(
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*python(?:3(?:\.\d+)?)?(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasNode =
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*node(?=$|[\s|&;()<>\n\r`$])/i.test(
/(?:^|\s|(?<!\\)[|&;()])(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*node(?=$|[\s|&;()<>\n\r`$])/i.test(
raw,
);
const hasProcessSubstitution = /(?<!\\)<\(|(?<!\\)>\(/u.test(raw);
@@ -622,7 +622,7 @@ function shouldFailClosedInterpreterPreflight(command: string): {
};
const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => {
const segment = extractUnquotedShellText(rawSegment) ?? rawSegment;
return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=.*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test(
return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test(
segment,
);
};