Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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
*/
Expand Down
44 changes: 44 additions & 0 deletions packages/aws-cdk-lib/aws-lambda-nodejs/test/bundling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading