refactor(security): refine safeBins hardening

This commit is contained in:
Peter Steinberger
2026-02-14 19:59:03 +01:00
parent eed6113359
commit 24d2c6292e
6 changed files with 173 additions and 121 deletions

View File

@@ -772,109 +772,75 @@ export function buildSafeShellCommand(params: { command: string; platform?: stri
return { ok: true, command: out };
}
function renderQuotedArgv(argv: string[]): string {
return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
}
/**
* Rebuilds a shell command and selectively single-quotes argv tokens for segments that
* must be treated as literal (safeBins hardening) while preserving the rest of the
* shell syntax (pipes + chaining).
*/
export function buildSafeBinsShellCommand(params: {
command: string;
segments: ExecCommandSegment[];
segmentSatisfiedBy: ("allowlist" | "safeBins" | "skills" | null)[];
platform?: string | null;
}): { ok: boolean; command?: string; reason?: string } {
const platform = params.platform ?? null;
if (isWindowsPlatform(platform)) {
return { ok: false, reason: "unsupported platform" };
}
if (params.segments.length !== params.segmentSatisfiedBy.length) {
return { ok: false, reason: "segment metadata mismatch" };
}
const chain = splitCommandChainWithOperators(params.command.trim());
const chainParts: ShellChainPart[] = chain ?? [{ part: params.command.trim(), opToNext: null }];
let segIndex = 0;
let out = "";
for (const part of chainParts) {
const pipelineSplit = splitShellPipeline(part.part);
if (!pipelineSplit.ok) {
return { ok: false, reason: pipelineSplit.reason ?? "unable to parse pipeline" };
}
const rendered: string[] = [];
for (const raw of pipelineSplit.segments) {
const seg = params.segments[segIndex];
const by = params.segmentSatisfiedBy[segIndex];
if (!seg || by === undefined) {
return { ok: false, reason: "segment mapping failed" };
}
const needsLiteral = by === "safeBins";
rendered.push(needsLiteral ? renderQuotedArgv(seg.argv) : raw.trim());
segIndex += 1;
}
out += rendered.join(" | ");
if (part.opToNext) {
out += ` ${part.opToNext} `;
}
}
if (segIndex !== params.segments.length) {
return { ok: false, reason: "segment count mismatch" };
}
return { ok: true, command: out };
}
/**
* 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: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
let foundChain = false;
let invalidChain = false;
const pushPart = () => {
const trimmed = buf.trim();
if (trimmed) {
parts.push(trimmed);
buf = "";
return true;
}
buf = "";
return false;
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
const next = command[i + 1];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
}
buf += ch;
continue;
}
if (inDouble) {
if (ch === "\\" && isDoubleQuoteEscape(next)) {
buf += ch;
buf += next;
i += 1;
continue;
}
if (ch === '"') {
inDouble = false;
}
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "&" && command[i + 1] === "&") {
if (!pushPart()) {
invalidChain = true;
}
i += 1;
foundChain = true;
continue;
}
if (ch === "|" && command[i + 1] === "|") {
if (!pushPart()) {
invalidChain = true;
}
i += 1;
foundChain = true;
continue;
}
if (ch === ";") {
if (!pushPart()) {
invalidChain = true;
}
foundChain = true;
continue;
}
buf += ch;
}
const pushedFinal = pushPart();
if (!foundChain) {
const parts = splitCommandChainWithOperators(command);
if (!parts) {
return null;
}
if (invalidChain || !pushedFinal) {
return null;
}
return parts.length > 0 ? parts : null;
return parts.map((p) => p.part);
}
export function analyzeShellCommand(params: {