import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { splitShellArgs } from "../utils/shell-argv.js"; import {
resolveCommandResolutionFromArgv,
type CommandResolution,
} from "./exec-command-resolution.js";
export {
matchAllowlist,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveApprovalAuditCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolvePolicyAllowlistCandidatePath,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
type CommandResolution,
type ExecutableResolution,
type ExecArgvToken,
} from "./exec-command-resolution.js";
for (let i = 0; i < command.length; i += 1) { const ch = command[i]; const next = command[i + 1];
if (inHeredocBody) { if (ch === "\n" || ch === "\r") { const current = pendingHeredocs[0]; if (current) { const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; if (current.quoted) { if (line === current.delimiter) {
pendingHeredocs.shift();
}
} else { // An unquoted heredoc body whose previous physical line ended with // `\<newline>` is spliced into the next line at runtime. In that // case bash does not treat the next physical line as the delimiter, // even if it matches literally — the splice wins and the body // continues. Only recognize the delimiter when no continuation is // pending. if (line === current.delimiter && unquotedHeredocLogicalChunks.length === 0) {
pendingHeredocs.shift();
} else { const continued = stripUnquotedHeredocLineContinuation(line);
unquotedHeredocLogicalChunks.push(continued.line); if (unquotedHeredocLogicalChunks.length > MAX_UNQUOTED_HEREDOC_CONTINUATION_LINES) { return {
ok: false,
reason: "heredoc continuation too long",
segments: [],
};
}
unquotedHeredocLogicalLength += continued.line.length; if (unquotedHeredocLogicalLength > MAX_UNQUOTED_HEREDOC_LOGICAL_LINE_LENGTH) { return {
ok: false,
reason: "heredoc logical line too large",
segments: [],
};
} if (!continued.continues) { if (hasUnquotedHeredocExpansionToken(unquotedHeredocLogicalChunks.join(""))) { return { ok: false, reason: "shell expansion in unquoted heredoc", segments: [] };
}
unquotedHeredocLogicalChunks = [];
unquotedHeredocLogicalLength = 0;
}
}
}
}
heredocLine = ""; if (pendingHeredocs.length === 0) {
inHeredocBody = false;
} if (ch === "\r" && next === "\n") {
i += 1;
}
} else {
heredocLine += ch;
} continue;
}
if (inHeredocBody && pendingHeredocs.length > 0) { const current = pendingHeredocs[0]; const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; // Mirror the in-loop guard: a pending unquoted continuation splices into // the trailing line and prevents the delimiter from terminating the // heredoc, so only accept the tail as a delimiter when no continuation // chunks are pending. If a continuation is pending, splice the tail into // the buffered logical line and run the expansion check against what bash // would actually expand at runtime, so payloads like // `cat <<KEY\n$OPENAI_API_\\\nKEY` cannot slip through as "unterminated". const pendingContinuation = !current.quoted && unquotedHeredocLogicalChunks.length > 0; if (pendingContinuation) { const continued = stripUnquotedHeredocLineContinuation(line); const logical = [...unquotedHeredocLogicalChunks, continued.line].join(""); if (hasUnquotedHeredocExpansionToken(logical)) { return { ok: false, reason: "shell expansion in unquoted heredoc", segments: [] };
}
} elseif (line === current.delimiter) {
pendingHeredocs.shift();
unquotedHeredocLogicalChunks = [];
unquotedHeredocLogicalLength = 0; if (pendingHeredocs.length === 0) {
inHeredocBody = false;
}
}
}
// Characters that remain unsafe even inside double-quoted strings. // - \n / \r: newlines break command parsing regardless of quoting. // - %: cmd.exe expands %VAR% inside double quotes, so % can still be used // for injection even when quoted. // - `: PowerShell escape character; forms escape sequences (`n, `0, `") even // inside double-quoted strings, so it cannot be safely quoted. const WINDOWS_ALWAYS_UNSAFE_TOKENS = new Set(["\n", "\r", "%", "`"]);
function findWindowsUnsupportedToken(command: string): string | null {
let inDouble = false; // Single-quote tracking is intentionally omitted here. cmd.exe (used by the // node-host exec path via buildNodeShellCommand) does not recognise single // quotes as quoting, so metacharacters inside single-quoted strings remain // active at runtime. Rejecting them at this layer keeps both execution paths // (PowerShell gateway and cmd.exe node-host) safe. // tokenizeWindowsSegment does track single quotes for accurate argv extraction // during enforcement, which is a separate concern from the safety check here. for (let i = 0; i < command.length; i++) { const ch = command[i]; if (ch === '"') {
inDouble = !inDouble; continue;
} // PowerShell expands $var, ${var}, and $(expr) inside double-quoted strings, // so $ followed by an identifier-start character, {, or ( is always unsafe — // regardless of quoting context. A bare $ not followed by those characters // is safe (e.g. UNC admin share suffix \\host\C$). if (ch === "$") { const next = command[i + 1]; // Block $var, ${var}, $(expr), $? (exit status), and $$ (PID) — all expanded // by PowerShell inside double-quoted strings. A bare $ not followed by these // characters is safe (e.g. the UNC admin share suffix \\host\C$). if (next !== undefined && /[A-Za-z_{(?$]/.test(next)) { return"$";
} continue;
} if (WINDOWS_UNSUPPORTED_TOKENS.has(ch)) { // Inside double-quoted strings, most special characters are safe literal // values (e.g. "2026-03-28 (土) - LifeLog" contains "()" which are fine). // tokenizeWindowsSegment already handles all of these correctly inside quotes. if (inDouble && !WINDOWS_ALWAYS_UNSAFE_TOKENS.has(ch)) { continue;
} if (ch === "\n" || ch === "\r") { return"newline";
} return ch;
}
} returnnull;
}
function tokenizeWindowsSegment(segment: string): string[] | null { const tokens: string[] = [];
let buf = "";
let inDouble = false;
let inSingle = false; // Set to true when a quote-open is seen; ensures empty quoted args ("" or '') // are preserved as empty-string tokens rather than being silently dropped.
let wasQuoted = false;
// PowerShell invocation: powershell[.exe] [-flags] -Command|-c|--command "inner" // Also handles pwsh[.exe] and the common -c / --command abbreviations of -Command. // Flags before -Command may be bare (-NoProfile) or take a single value // (-ExecutionPolicy Bypass, -WindowStyle Hidden). The lookahead (?!-) // prevents a flag value from consuming the next flag name. // psFlags matches zero or more PowerShell flags before the command-introducing flag. // Each flag is either bare (-NoProfile) or takes a single value. // Flag values may be unquoted (-ExecutionPolicy Bypass) or quoted with // double-quotes (-WorkingDirectory "C:\Users\Jane Doe\proj") or single- // quotes (-WorkingDirectory 'C:\Users\Jane Doe\proj'). \S+ alone cannot // match quoted values that contain spaces, so we try double-quoted and // single-quoted patterns first, then fall back to \S+ for unquoted values. // // The negative lookahead (?!c(?:ommand)?\b|-command\b) prevents psFlags from // consuming -c or -command as an ordinary flag before the command-introducing // flag is matched. Without it, -c "inner" would be swallowed as a value-taking // flag and the outer pattern would never see -c to match against psCommandFlag. const psFlags =
/(?:-(?!c(?:ommand)?\b|-command\b)\w+(?:\s+(?!-)(?:"[^"]*(?:""[^"]*)*"|'[^']*(?:''[^']*)*'|\S+))?\s+)*/i
.source; // Matches -Command, its abbreviation -c, and the --command double-dash alias. const psCommandFlag = `(?:-command|-c|--command)`; const psInvokeMatch = command.match( new RegExp(`^(?:powershell|pwsh)(?:\\.exe)?\\s+${psFlags}${psCommandFlag}\\s+"(.+)"$`, "is"),
); if (psInvokeMatch) { // Within a double-quoted -Command argument, "" is the escape sequence for a // literal ". Unescape before passing the payload to the tokenizer so that // `powershell -Command "node a.js ""hello world"""` correctly yields the // single argv token "hello world" rather than splitting on the space. return psInvokeMatch[1].replace(/""/g, '"');
} // PowerShell -Command (or -c/--command) with single-quoted payload const psInvokeSingleQuote = command.match( new RegExp(`^(?:powershell|pwsh)(?:\\.exe)?\\s+${psFlags}${psCommandFlag}\\s+'(.+)'$`, "is"),
); if (psInvokeSingleQuote) { // Inside a PowerShell single-quoted string '' encodes a literal apostrophe. // Unescape before tokenizing so that 'node a.js ''hello world''' correctly // yields the single argv token "hello world". return psInvokeSingleQuote[1].replace(/''/g, "'");
} // PowerShell -Command (or -c/--command) without quotes (bare unquoted payload) const psInvokeNoQuote = command.match( new RegExp(`^(?:powershell|pwsh)(?:\\.exe)?\\s+${psFlags}${psCommandFlag}\\s+(.+)$`, "is"),
); if (psInvokeNoQuote) { return psInvokeNoQuote[1];
}
// Note: cmd /c is intentionally NOT stripped here. If a command is wrapped // with `cmd /c`, its inner payload would later be executed by PowerShell, which // changes semantics for cmd.exe builtins (dir, copy, etc.). Callers that submit // `cmd /c <thing>` must have an explicit allowlist entry for `cmd` itself, or // the command will require user approval.
function parseSegmentsFromParts(
parts: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): ExecCommandSegment[] | null { const segments: ExecCommandSegment[] = []; for (const raw of parts) { const argv = splitShellArgs(raw); if (!argv || argv.length === 0) { returnnull;
}
segments.push({
raw,
argv,
resolution: resolveCommandResolutionFromArgv(argv, cwd, env),
});
} return segments;
}
/** * Splits a command string by chain operators (&&, ||, ;) while preserving the operators. * Returns null when no chain is present or when the chain is malformed.
*/
export function splitCommandChainWithOperators(command: string): ShellChainPart[] | null { const parts: ShellChainPart[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
let foundChain = false;
let invalidChain = false;
function shellEscapeSingleArg(value: string): string { // Shell-safe across sh/bash/zsh: single-quote everything, escape embedded single quotes. // Example: foo'bar -> 'foo'"'"'bar' const singleQuoteEscape = `'"'"'`; return `'${value.replace(/'/g, singleQuoteEscape)}'`;
}
// Characters that cannot be safely double-quoted in PowerShell enforced commands. // % — cmd.exe immediate/delayed expansion; also blocked in analysis phase. // $id — PowerShell variable expansion: "$env:SECRET", "${var}", "$x" ($ followed by identifier // start or {). A bare $ not followed by [A-Za-z_{] is treated literally (e.g. "C$"). // ` — PowerShell escape character; can form escape sequences like `n, `0 inside double quotes. // Note: ! is intentionally omitted — PowerShell does not treat ! as special in double-quoted // strings (unlike cmd.exe delayed expansion), so "Hello!" is safe to pass through. const WINDOWS_UNSAFE_CMD_META = /[%`]|\$(?=[A-Za-z_{(?$])/;
export function windowsEscapeArg(value: string): { ok: true; escaped: string } | { ok: false } { if (value === "") { return { ok: true, escaped: '""' };
} // Reject tokens containing cmd.exe / PowerShell meta characters that cannot be safely quoted. if (WINDOWS_UNSAFE_CMD_META.test(value)) { return { ok: false };
} // If the value contains only safe characters, return as-is. if (/^[a-zA-Z0-9_./:~\\=-]+$/.test(value)) { return { ok: true, escaped: value };
} // Double-quote the value, escaping embedded double-quotes. const escaped = value.replace(/"/g, '""'); return { ok: true, escaped: `"${escaped}"` };
}
/** * Splits a command string by chain operators (&&, ||, ;) while respecting quotes. * Returns null when no chain is present or when the chain is malformed.
*/
export function splitCommandChain(command: string): string[] | null { const parts = splitCommandChainWithOperators(command); if (!parts) { returnnull;
} return parts.map((p) => p.part);
}
export function analyzeShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
platform?: string | null;
}): ExecCommandAnalysis { if (isWindowsPlatform(params.platform)) { return analyzeWindowsShellCommand(params);
} // First try splitting by chain operators (&&, ||, ;) const chainParts = splitCommandChain(params.command); if (chainParts) { const chains: ExecCommandSegment[][] = []; const allSegments: ExecCommandSegment[] = [];
for (const part of chainParts) { const pipelineSplit = splitShellPipeline(part); if (!pipelineSplit.ok) { return { ok: false, reason: pipelineSplit.reason, segments: [] };
} const segments = parseSegmentsFromParts(pipelineSplit.segments, params.cwd, params.env); if (!segments) { return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
chains.push(segments);
allSegments.push(...segments);
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.