diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts index 70f1bf10db57c..e7fec18bfbc03 100644 --- a/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts @@ -524,7 +524,7 @@ export class Bundling implements cdk.BundlingOptions { // Powershell.exe instead of cmd.exe because the quoting rules are saner. // See https://github.com/aws/aws-cdk/issues/37387 if (isWindows) { - exec('powershell.exe', ['-NoProfile', '-Command', `& ${step.command.map(powershellEscape).join(' ')}`], { + exec('powershell.exe', ['-NoProfile', '-Command', powershellCommandLine(step.command)], { ...execOptions, cwd: step.cwd ?? cwd, }); @@ -685,6 +685,28 @@ function powershellEscape(arg: string): string { return "'" + arg.replace(/'/g, "''") + "'"; } +/** + * Build the PowerShell command line used to run a bundling spawn step on Windows. + * + * The executable is resolved with `Get-Command -CommandType Application` rather than + * being passed to the call operator directly. Given a bare command name such as `npm`, + * PowerShell's own command discovery can select the `npm.ps1` shim, and `.ps1` scripts + * are subject to the execution policy: under `AllSigned` (typically set at + * `MachinePolicy` scope via Group Policy, which overrides every other scope) the shim is + * refused and bundling fails. `-CommandType Application` only matches external programs + * such as `npm.cmd`, which the execution policy does not apply to. + * + * Arguments remain individually quoted, so this keeps the argument handling introduced + * to fix command injection in local bundling. + * + * See https://github.com/aws/aws-cdk/issues/38439 + */ +function powershellCommandLine(command: string[]): string { + const [executable, ...args] = command; + const resolved = `(Get-Command ${powershellEscape(executable)} -CommandType Application)[0].Source`; + return ['&', resolved, ...args.map(powershellEscape)].join(' '); +} + /** * Chain commands */ diff --git a/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts b/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts index 2d183a6f16ea8..fbea7e71c93b2 100644 --- a/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts +++ b/packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts @@ -1605,6 +1605,50 @@ test('Local bundling on Windows uses powershell for spawn steps', () => { expect(cmdString).toContain("'--bundle'"); expect(cmdString).toContain("'--platform=node'"); + // The executable is resolved to an Application so PowerShell cannot select the + // .ps1 shim, which the AllSigned execution policy refuses. See issue #38439. + expect(cmdString).toMatch(/^& \(Get-Command '[^']+' -CommandType Application\)\[0\]\.Source /); + + spawnSyncMock.mockRestore(); + osPlatformMock.mockRestore(); +}); + +test('Local bundling on Windows resolves the spawned executable to an Application', () => { + // A bare command name passed to the PowerShell call operator can resolve to the + // .ps1 shim (e.g. npm.ps1), which is blocked under an AllSigned execution policy. + // Resolving with -CommandType Application only matches npm.cmd / npm.exe. + // See https://github.com/aws/aws-cdk/issues/38439 + const osPlatformMock = jest.spyOn(os, 'platform').mockReturnValue('win32'); + const spawnSyncMock = jest.spyOn(child_process, 'spawnSync').mockReturnValue(spawnSyncMockReturnValue); + jest.spyOn(fs, 'copyFileSync').mockReturnValue(); + jest.spyOn(fs, 'writeFileSync').mockReturnValue(); + + const packageLock = path.join(__dirname, '..', 'package-lock.json'); + const bundler = new Bundling(stack, { + entry: __filename, + projectRoot: path.dirname(packageLock), + depsLockFilePath: packageLock, + runtime: STANDARD_RUNTIME, + architecture: Architecture.X86_64, + nodeModules: ['delay'], + }); + + bundler.local?.tryBundle('/outdir', { image: STANDARD_RUNTIME.bundlingDockerImage }); + + const psCommands = spawnSyncMock.mock.calls + .filter(c => c[0] === 'powershell.exe') + .map(c => (c[1] as string[])[2]); + expect(psCommands.length).toBeGreaterThan(0); + + for (const command of psCommands) { + // Every spawn resolves its executable rather than invoking a bare name. + expect(command).toMatch(/^& \(Get-Command '[^']+' -CommandType Application\)\[0\]\.Source/); + expect(command).not.toMatch(/^& '/); + } + + // The npm install step is present and goes through the resolved form. + expect(psCommands.some(c => c.includes('-CommandType Application') && c.includes("'ci'"))).toEqual(true); + spawnSyncMock.mockRestore(); osPlatformMock.mockRestore(); });