fix(private-tools): resolve executables against PATH not cwd on Windows - #1905
Open
iankhou wants to merge 12 commits into
Open
fix(private-tools): resolve executables against PATH not cwd on Windows#1905iankhou wants to merge 12 commits into
iankhou wants to merge 12 commits into
Conversation
Contributor
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
…s; reject %VAR% paths Two Windows-specific hardening fixes to the shared subprocess module, plus a test that actually exercises the .cmd path. 1. resolveExecutable(): on Windows a bare program name spawned without a shell is searched for in the cwd *before* PATH, so a file planted in a handed-over cloud assembly (e.g. docker.bat) could shadow the real binary. run()/runSync() now resolve the executable to an absolute PATH hit up front and refuse a name that is not on PATH rather than let the cwd satisfy it. POSIX is unchanged (execvp already searches PATH only); explicit paths are honored verbatim. 2. quoteShellPart() (toolkit-lib): cmd.exe expands %VAR% even inside double quotes and a `cmd /c` line cannot reliably escape a percent, so a discovered path carrying a %...% reference is now refused loudly instead of being silently rewritten. 3. Adds a Windows-only test that runs a real .cmd shim with hostile arguments — the one path where cross-spawn's cmd.exe escaping is exercised (a plain .exe never is). NOTE: items 1 and 3 change Windows spawn behavior and must be validated by the Windows integ tests; the resolveExecutable logic is unit-tested cross-platform via a `platform` parameter.
iankhou
force-pushed
the
iankhou-windows-exec-resolution
branch
from
August 26, 2026 20:27
b334c08 to
9f440cb
Compare
iankhou
marked this pull request as ready for review
August 28, 2026 15:19
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens Windows subprocess execution against executable shadowing and unsafe %VAR% path expansion.
Changes:
- Resolves bare Windows executables through PATH before spawning.
- Rejects shell-bound Windows paths containing
%...%. - Adds Windows subprocess and unit coverage.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
packages/@aws-cdk/private-tools/lib/subprocess/index.ts |
Adds executable resolution and ENOENT handling. |
packages/@aws-cdk/private-tools/test/subprocess/subprocess.test.ts |
Tests PATH resolution and .cmd escaping. |
packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts |
Rejects unsafe percent-containing paths. |
packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts |
Tests percent-path rejection. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…able
Harden the Windows executable resolver against three defects found in review:
- Relative PATH entries (e.g. '.') were joined into non-absolute candidates
that cross-spawn then re-resolves against the child cwd, reopening the cwd
shadowing hole. Skip relative entries so the resolver only ever returns an
absolute path and the cwd is never consulted.
- Quoted PATH entries ('"C:\Program Files\..."', legal on Windows) were not
unwrapped, so an installed tool was reported as not found. Unwrap them, as
which does.
- A name containing a dot was only probed exactly when it ended in a PATHEXT
entry, so 'tool.exe' under a custom PATHEXT or a 'my.tool'-style name got a
false ENOENT. Probe the exact name first when the name contains a dot.
Also correct the resolver docstring and the module trust-boundary note, and
drop the inaccurate 'runs on Windows CI' claim on the .cmd escaping test (CI
has no Windows unit-test lane today).
2 tasks
The quoteShellPart %VAR% guard is an independent fix on the shell path and now lives in its own PR (#1924). This PR is scoped to the resolveExecutable (PATH-vs-cwd) hardening on the no-shell run() path.
Simplify comment regarding executable resolution against PATH.
…resolution # Conflicts: # packages/@aws-cdk/private-tools/lib/subprocess/index.ts
…he threat basis Substantiate the vulnerability premise in-code: run() resolves through cross-spawn -> node-which, whose getPathInfo searches [process.cwd(), ...PATH] on Windows (cwd first, per which's own source comment). Also note that runSync resolves and spawns against process.env so the two stay in sync.
…ks PATH
resolveExecutable resolved PATH strictly from the caller-provided env, so
run(argv, { env: { ...noPath } }) on Windows returned a synthetic ENOENT even
when the tool was on the system PATH. cross-spawn/which fall back to
process.env.PATH when the spawn env has no PATH key; mirror that. An explicitly
empty PATH is still treated as 'no dirs' (uses ?? not ||), so the off-PATH
refusal is unchanged. Same fallback applied to PATHEXT.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1919.
Follow-up to #1763 / #1849. Windows-specific hardening of executable resolution on the no-shell
run()path.Problem
resolveExecutable()— on Windows a bare program name spawned without a shell is searched for in the working directory before PATH, so a file planted in a handed-over cloud assembly (e.g.docker.bat) shadows the real binary. This can result in unexpected behavior.run()spawns via cross-spawn (cross-spawn@7.0.6), whose resolver iswhich@2.0.2, and itsgetPathInfobuilds the Windows search path cwd-first —which.js#L17-L24(cwd prepended at L20-L21):Fix
run()/runSync()now resolve the executable to an absolute PATH hit up front and refuse a name that is not on PATH, rather than let the cwd satisfy it. The resolver only ever returns an absolute candidate: relative PATH entries (e.g..) are skipped, quoted PATH entries are unwrapped, and a name containing a dot is probed exactly (matchingwhich). We honor explicit paths (absolute or containing a separator).Testing
resolveExecutable(cross-platform via aplatformoverride; run on the Ubuntubuildjob).Tested on a Windows machine on Node v24.18.0, since there's no Windows unit-test lane in CI (it may or may not be worthwhile to introduce one at a later time, depending on how often we need these types of changes). Installed a cross-built tarball of
@aws-cdk/private-toolsand introduced a hostiledocker.batin the cwd. All three checks ran in the same session.Step 1 - Setup
On Windows, install the tarball and run the following to create the bad batfile:
Step 2 - Negative Control
Run the following to check the negative control:
node -e "const r=require('cross-spawn').sync('docker',['--version'],{stdio:'inherit',encoding:'utf-8'}); console.log('exit:', r.status)"Expected: shadow-ran + CONTROL: VULNERABLE.
Step 3 - Fixed code
Then run the fixed code:
node -e "require('@aws-cdk/private-tools/lib/subprocess').run(['docker','--version']).then(r=>console.log('RAN:', JSON.stringify(r.stdout.trim()))).catch(e=>console.log('REFUSED:', e.message))"My Windows machine had Docker installed, so it showed:
However, if your machine doesn't have Docker installed, it should show something like:
In either case, we showed that
shadow-randid not run. If our verification failed, we would have seen something like:Step 4 - env/PATH fallback
node -e "require('@aws-cdk/private-tools/lib/subprocess').run(['node','--version'], { env: { FOO: 'bar' } }).then(r=>console.log('FALLBACK PASS:', r.stdout.trim())).catch(e=>console.log('FALLBACK FAIL:', e.message))"It shows:
docker.batran?cross-spawn.sync('docker', ...)directly (base-equivalent)shadow-ran, wrotepwned.txtrun(['docker','--version'])dockerv29.6.2 ran, nopwned.txtrun(['node','--version'], { env: { FOO: 'bar' } })(PATH-less custom env)v24.18.0Takeaways
8a0a0d7) resolves a PATH-less custom env viaprocess.env.PATHinstead of a synthetic ENOENT.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license.