Security/Exec: persist inner commands for shell-wrapper approvals

This commit is contained in:
Vignesh Natarajan
2026-02-21 21:26:06 -08:00
parent 2f023a4775
commit 98b2b16ac3
5 changed files with 279 additions and 4 deletions

View File

@@ -205,6 +205,148 @@ export type ExecAllowlistAnalysis = {
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
};
const SHELL_WRAPPER_EXECUTABLES = new Set([
"ash",
"bash",
"cmd",
"cmd.exe",
"dash",
"fish",
"ksh",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
"sh",
"zsh",
]);
function normalizeExecutableName(name: string | undefined): string {
return (name ?? "").trim().toLowerCase();
}
function isShellWrapperSegment(segment: ExecCommandSegment): boolean {
const candidates = [
normalizeExecutableName(segment.resolution?.executableName),
normalizeExecutableName(segment.resolution?.rawExecutable),
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
if (SHELL_WRAPPER_EXECUTABLES.has(candidate)) {
return true;
}
const base = candidate.split(/[\\/]/).pop();
if (base && SHELL_WRAPPER_EXECUTABLES.has(base)) {
return true;
}
}
return false;
}
function extractShellInlineCommand(argv: string[]): string | null {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i];
if (!token) {
continue;
}
const lower = token.toLowerCase();
if (lower === "--") {
break;
}
if (
lower === "-c" ||
lower === "--command" ||
lower === "-command" ||
lower === "/c" ||
lower === "/k"
) {
const next = argv[i + 1]?.trim();
return next ? next : null;
}
if (/^-[^-]*c[^-]*$/i.test(token)) {
const commandIndex = lower.indexOf("c");
const inline = token.slice(commandIndex + 1).trim();
if (inline) {
return inline;
}
const next = argv[i + 1]?.trim();
return next ? next : null;
}
}
return null;
}
function collectAllowAlwaysPatterns(params: {
segment: ExecCommandSegment;
cwd?: string;
env?: NodeJS.ProcessEnv;
platform?: string | null;
depth: number;
out: Set<string>;
}) {
const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd);
if (!candidatePath) {
return;
}
if (!isShellWrapperSegment(params.segment)) {
params.out.add(candidatePath);
return;
}
if (params.depth >= 3) {
return;
}
const inlineCommand = extractShellInlineCommand(params.segment.argv);
if (!inlineCommand) {
return;
}
const nested = analyzeShellCommand({
command: inlineCommand,
cwd: params.cwd,
env: params.env,
platform: params.platform,
});
if (!nested.ok) {
return;
}
for (const nestedSegment of nested.segments) {
collectAllowAlwaysPatterns({
segment: nestedSegment,
cwd: params.cwd,
env: params.env,
platform: params.platform,
depth: params.depth + 1,
out: params.out,
});
}
}
/**
* Derive persisted allowlist patterns for an "allow always" decision.
* When a command is wrapped in a shell (for example `zsh -lc "<cmd>"`),
* persist the inner executable(s) rather than the shell binary.
*/
export function resolveAllowAlwaysPatterns(params: {
segments: ExecCommandSegment[];
cwd?: string;
env?: NodeJS.ProcessEnv;
platform?: string | null;
}): string[] {
const patterns = new Set<string>();
for (const segment of params.segments) {
collectAllowAlwaysPatterns({
segment,
cwd: params.cwd,
env: params.env,
platform: params.platform,
depth: 0,
out: patterns,
});
}
return Array.from(patterns);
}
/**
* Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata.
*/