Infra: preserve wrapper executable for multiplexer trust

This commit is contained in:
Vincent Koc
2026-03-23 13:33:30 -07:00
committed by Peter Steinberger
parent 2d5f822ca1
commit 32e89b4687
4 changed files with 62 additions and 5 deletions

View File

@@ -154,7 +154,7 @@ describe("exec-command-resolution", () => {
expect(timeResolution?.executableName).toBe(fixture.exeName);
});
it("unwraps shell multiplexers before resolving the effective executable", () => {
it("keeps shell multiplexer wrappers as the trusted executable target", () => {
if (process.platform === "win32") {
return;
}
@@ -164,9 +164,43 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const resolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
expect(resolution?.rawExecutable).toBe("sh");
expect(resolution?.rawExecutable).toBe(busybox);
expect(resolution?.effectiveArgv).toEqual(["sh", "-lc", "echo hi"]);
expect(resolution?.wrapperChain).toEqual(["busybox"]);
expect(resolution?.executableName.toLowerCase()).toContain("sh");
expect(resolution?.resolvedPath).toBe(busybox);
expect(resolution?.executableName.toLowerCase()).toContain("busybox");
});
it("does not satisfy inner-shell allowlists when invoked through busybox wrappers", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = path.join(dir, "busybox");
fs.writeFileSync(busybox, "");
fs.chmodSync(busybox, 0o755);
const shellResolution = resolveCommandResolutionFromArgv(["sh", "-lc", "echo hi"]);
expect(shellResolution?.resolvedPath).toBeTruthy();
const wrappedResolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
const evalResult = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: `${busybox} sh -lc echo hi`,
argv: [busybox, "sh", "-lc", "echo hi"],
resolution: wrappedResolution,
},
],
},
allowlist: [{ pattern: shellResolution?.resolvedPath ?? "" }],
safeBins: normalizeSafeBins([]),
cwd: dir,
});
expect(evalResult.allowlistSatisfied).toBe(false);
});
it("blocks semantic env wrappers, env -S, and deep transparent-wrapper chains", () => {

View File

@@ -98,7 +98,8 @@ export function resolveCommandResolutionFromArgv(
): CommandResolution | null {
const plan = resolveExecWrapperTrustPlan(argv);
const effectiveArgv = plan.argv;
const rawExecutable = effectiveArgv[0]?.trim();
const policyArgv = plan.policyArgv;
const rawExecutable = policyArgv[0]?.trim();
if (!rawExecutable) {
return null;
}

View File

@@ -10,6 +10,7 @@ describe("resolveExecWrapperTrustPlan", () => {
resolveExecWrapperTrustPlan(["/usr/bin/time", "-p", "busybox", "sh", "-lc", "echo hi"]),
).toEqual({
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["busybox", "sh", "-lc", "echo hi"],
wrapperChain: ["time", "busybox"],
policyBlocked: false,
shellWrapperExecutable: true,
@@ -20,6 +21,7 @@ describe("resolveExecWrapperTrustPlan", () => {
test("fails closed for unsupported shell multiplexer applets", () => {
expect(resolveExecWrapperTrustPlan(["busybox", "sed", "-n", "1p"])).toEqual({
argv: ["busybox", "sed", "-n", "1p"],
policyArgv: ["busybox", "sed", "-n", "1p"],
wrapperChain: [],
policyBlocked: true,
blockedWrapper: "busybox",
@@ -33,6 +35,7 @@ describe("resolveExecWrapperTrustPlan", () => {
resolveExecWrapperTrustPlan(["nohup", "timeout", "5s", "busybox", "sh", "-lc", "echo hi"], 2),
).toEqual({
argv: ["busybox", "sh", "-lc", "echo hi"],
policyArgv: ["busybox", "sh", "-lc", "echo hi"],
wrapperChain: ["nohup", "timeout"],
policyBlocked: true,
blockedWrapper: "busybox",

View File

@@ -11,6 +11,7 @@ import {
export type ExecWrapperTrustPlan = {
argv: string[];
policyArgv: string[];
wrapperChain: string[];
policyBlocked: boolean;
blockedWrapper?: string;
@@ -20,11 +21,13 @@ export type ExecWrapperTrustPlan = {
function blockedExecWrapperTrustPlan(params: {
argv: string[];
policyArgv?: string[];
wrapperChain: string[];
blockedWrapper: string;
}): ExecWrapperTrustPlan {
return {
argv: params.argv,
policyArgv: params.policyArgv ?? params.argv,
wrapperChain: params.wrapperChain,
policyBlocked: true,
blockedWrapper: params.blockedWrapper,
@@ -35,6 +38,7 @@ function blockedExecWrapperTrustPlan(params: {
function finalizeExecWrapperTrustPlan(
argv: string[],
policyArgv: string[],
wrapperChain: string[],
policyBlocked: boolean,
blockedWrapper?: string,
@@ -44,6 +48,7 @@ function finalizeExecWrapperTrustPlan(
!policyBlocked && rawExecutable.length > 0 && isShellWrapperExecutable(rawExecutable);
return {
argv,
policyArgv,
wrapperChain,
policyBlocked,
blockedWrapper,
@@ -57,12 +62,15 @@ export function resolveExecWrapperTrustPlan(
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
): ExecWrapperTrustPlan {
let current = argv;
let policyArgv = argv;
let sawShellMultiplexer = false;
const wrapperChain: string[] = [];
for (let depth = 0; depth < maxDepth; depth += 1) {
const dispatchPlan = resolveDispatchWrapperTrustPlan(current, maxDepth - wrapperChain.length);
if (dispatchPlan.policyBlocked) {
return blockedExecWrapperTrustPlan({
argv: dispatchPlan.argv,
policyArgv,
wrapperChain,
blockedWrapper: dispatchPlan.blockedWrapper ?? current[0] ?? "unknown",
});
@@ -70,6 +78,9 @@ export function resolveExecWrapperTrustPlan(
if (dispatchPlan.wrappers.length > 0) {
wrapperChain.push(...dispatchPlan.wrappers);
current = dispatchPlan.argv;
if (!sawShellMultiplexer) {
policyArgv = current;
}
if (wrapperChain.length >= maxDepth) {
break;
}
@@ -80,12 +91,18 @@ export function resolveExecWrapperTrustPlan(
if (shellMultiplexerUnwrap.kind === "blocked") {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: shellMultiplexerUnwrap.wrapper,
});
}
if (shellMultiplexerUnwrap.kind === "unwrapped") {
wrapperChain.push(shellMultiplexerUnwrap.wrapper);
if (!sawShellMultiplexer) {
// Preserve the real executable target for trust checks.
policyArgv = current;
sawShellMultiplexer = true;
}
current = shellMultiplexerUnwrap.argv;
if (wrapperChain.length >= maxDepth) {
break;
@@ -101,6 +118,7 @@ export function resolveExecWrapperTrustPlan(
if (dispatchOverflow.kind === "blocked" || dispatchOverflow.kind === "unwrapped") {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: dispatchOverflow.wrapper,
});
@@ -112,11 +130,12 @@ export function resolveExecWrapperTrustPlan(
) {
return blockedExecWrapperTrustPlan({
argv: current,
policyArgv,
wrapperChain,
blockedWrapper: shellMultiplexerOverflow.wrapper,
});
}
}
return finalizeExecWrapperTrustPlan(current, wrapperChain, false);
return finalizeExecWrapperTrustPlan(current, policyArgv, wrapperChain, false);
}