diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index a65e12aadad..8b7233317f0 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -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", () => { diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index fa62398aa41..8cec16c410c 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -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; } diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts index 50fa84e2c91..7e21710453b 100644 --- a/src/infra/exec-wrapper-trust-plan.test.ts +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -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", diff --git a/src/infra/exec-wrapper-trust-plan.ts b/src/infra/exec-wrapper-trust-plan.ts index 5fa9d9c481b..f16e9832b58 100644 --- a/src/infra/exec-wrapper-trust-plan.ts +++ b/src/infra/exec-wrapper-trust-plan.ts @@ -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); }