fix: permissions shell rule normalization (#1189)
This commit is contained in:
193
src/permissions/shell-command-normalization.ts
Normal file
193
src/permissions/shell-command-normalization.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
const SHELL_EXECUTORS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
||||
|
||||
function trimMatchingQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length < 2) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
if ((first === '"' || first === "'") && last === first) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeExecutableToken(token: string): string {
|
||||
const normalized = trimMatchingQuotes(token).replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const executable = parts[parts.length - 1] ?? normalized;
|
||||
return executable.toLowerCase();
|
||||
}
|
||||
|
||||
function tokenizeShell(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "single" | "double" | null = null;
|
||||
let escaping = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const ch = input[i];
|
||||
if (ch === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\" && quote !== "single") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "single") {
|
||||
if (ch === "'") {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "double") {
|
||||
if (ch === '"') {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'") {
|
||||
quote = "single";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
quote = "double";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
flush();
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isDashCFlag(token: string): boolean {
|
||||
return token === "-c" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(token);
|
||||
}
|
||||
|
||||
function extractInnerShellCommand(tokens: string[]): string | null {
|
||||
if (tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
if (normalizeExecutableToken(tokens[0] ?? "") === "env") {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index] ?? "";
|
||||
if (!token) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^-[A-Za-z]+$/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const executableToken = tokens[index];
|
||||
if (!executableToken) {
|
||||
return null;
|
||||
}
|
||||
if (!SHELL_EXECUTORS.has(normalizeExecutableToken(executableToken))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = index + 1; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (!isDashCFlag(token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const innerCommand = tokens[i + 1];
|
||||
if (!innerCommand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimMatchingQuotes(innerCommand);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function unwrapShellLauncherCommand(command: string): string {
|
||||
let current = command.trim();
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
const tokens = tokenizeShell(current);
|
||||
const inner = extractInnerShellCommand(tokens);
|
||||
if (!inner || inner === current) {
|
||||
break;
|
||||
}
|
||||
current = inner.trim();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function normalizeBashRulePayload(payload: string): string {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hasWildcardSuffix = trimmed.endsWith(":*");
|
||||
const withoutWildcard = hasWildcardSuffix
|
||||
? trimmed.slice(0, -2).trimEnd()
|
||||
: trimmed;
|
||||
const unwrapped = unwrapShellLauncherCommand(withoutWildcard);
|
||||
|
||||
if (hasWildcardSuffix) {
|
||||
return `${unwrapped}:*`;
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
Reference in New Issue
Block a user