Skip to content

Commit ce70bf6

Browse files
fix(pnpm): resolve v11 global-scan regression
Signed-off-by: prateek-stepsecurity <prateek@stepsecurity.io>
1 parent 6b34f69 commit ce70bf6

2 files changed

Lines changed: 175 additions & 105 deletions

File tree

internal/detector/nodescan.go

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -199,27 +199,60 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
199199

200200
func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) {
201201
if err := s.checkPath(ctx, "pnpm"); err != nil {
202+
s.log.Warn("pnpm not found on PATH — skipping pnpm global scan: %v", err)
202203
return model.NodeScanResult{}, false
203204
}
204205

205-
version := s.getVersion(ctx, "pnpm", "--version")
206-
globalDir := s.getOutput(ctx, "pnpm", "root", "-g")
206+
// pnpm v11 hard-fails any `-g` invocation when its user-local bin dir is
207+
// not on PATH. Two execution paths to handle:
208+
// - In-process (exec.Command): prepend the bin dir to this Go process's
209+
// PATH so the child inherits it.
210+
// - Root → user delegation (sudo -u): inline `PATH='…':$PATH` into the
211+
// shell command itself. This works regardless of sudo's env policy
212+
// (macOS keeps PATH via env_keep; Linux strips it via secure_path),
213+
// because the env-prefix is honored by the user's shell AFTER sudo
214+
// has already done whatever it does.
215+
extra := defaultPnpmBinDir(s.exec)
216+
if extra != "" {
217+
oldPath := os.Getenv("PATH")
218+
_ = os.Setenv("PATH", extra+string(os.PathListSeparator)+oldPath)
219+
defer os.Setenv("PATH", oldPath)
220+
}
221+
222+
// For the delegation path, embed `PATH='extra':$PATH` in the command name.
223+
// runCmd's delegation branch space-joins name+args into the shell command
224+
// string, so the env-prefix flows through to the user's shell intact.
225+
// Applied to every pnpm `-g` call, since v11 enforces the bin-dir check
226+
// on each one.
227+
pnpmCmd := "pnpm"
228+
if s.shouldRunAsUser() && extra != "" {
229+
pnpmCmd = "PATH=" + platformShellQuote(s.exec, extra) + ":$PATH pnpm"
230+
}
231+
232+
versionOut, _, _, verErr := s.runCmd(ctx, 10*time.Second, pnpmCmd, "--version")
233+
version := strings.TrimSpace(versionOut)
234+
if verErr != nil || version == "" {
235+
version = "unknown"
236+
}
237+
238+
rootOut, _, _, _ := s.runCmd(ctx, 10*time.Second, pnpmCmd, "root", "-g")
239+
globalDir := strings.TrimSpace(rootOut)
207240
if globalDir == "" {
241+
s.log.Warn("pnpm found but `pnpm root -g` returned empty — skipping pnpm global scan")
208242
return model.NodeScanResult{}, false
209243
}
210244
globalDir = filepath.Dir(globalDir)
211245

212246
start := time.Now()
213-
// pnpm v11 exits non-zero on `--depth=3` for global scans; use `--depth=Infinity` there.
214-
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", pnpmDepthArg(version))
247+
stdout, stderr, exitCode, err := s.runCmd(ctx, 60*time.Second, pnpmCmd, "list", "-g", "--json")
215248
duration := time.Since(start).Milliseconds()
216249

217250
errMsg := ""
218251
if exitCode != 0 {
219252
errMsg = "pnpm list -g command failed"
220253
s.log.Warn("pnpm list -g failed (exit_code=%d, %dms) — results may be incomplete", exitCode, duration)
221254
}
222-
s.log.Debug("pnpm global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms", version, globalDir, exitCode, len(stdout), duration)
255+
s.log.Debug("pnpm global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms err=%v", version, globalDir, exitCode, len(stdout), duration, err)
223256

224257
return model.NodeScanResult{
225258
ProjectPath: globalDir,
@@ -234,6 +267,26 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
234267
}, true
235268
}
236269

270+
// defaultPnpmBinDir returns the default pnpm global bin directory for the current OS
271+
// based on environment variables.
272+
func defaultPnpmBinDir(exec executor.Executor) string {
273+
switch exec.GOOS() {
274+
case model.PlatformDarwin:
275+
if home := exec.Getenv("HOME"); home != "" {
276+
return filepath.Join(home, "Library", "pnpm", "bin")
277+
}
278+
case model.PlatformLinux:
279+
if home := exec.Getenv("HOME"); home != "" {
280+
return filepath.Join(home, ".local", "share", "pnpm")
281+
}
282+
case model.PlatformWindows:
283+
if localAppData := exec.Getenv("LOCALAPPDATA"); localAppData != "" {
284+
return filepath.Join(localAppData, "pnpm")
285+
}
286+
}
287+
return ""
288+
}
289+
237290
// projectEntry holds a discovered package.json with its modification time for sorting.
238291
type projectEntry struct {
239292
dir string
@@ -405,19 +458,3 @@ func isInsideNodeModules(projectDir string) bool {
405458
normalized := strings.ReplaceAll(projectDir, "\\", "/")
406459
return strings.Contains(normalized, "/node_modules/")
407460
}
408-
409-
// pnpmDepthArg picks the `--depth` arg for `pnpm list -g` based on the pnpm
410-
// version. pnpm v11 exits non-zero when `--depth=3` is passed to a global
411-
// scan; `--depth=Infinity` works on v11 and preserves transitive depth.
412-
// Falls back to `--depth=3` for older / unparseable versions to preserve
413-
// existing behavior.
414-
func pnpmDepthArg(version string) string {
415-
v := strings.TrimSpace(version)
416-
v = strings.TrimPrefix(v, "v")
417-
major, _, _ := strings.Cut(v, ".")
418-
n, err := strconv.Atoi(major)
419-
if err == nil && n >= 11 {
420-
return "--depth=Infinity"
421-
}
422-
return "--depth=3"
423-
}

internal/detector/nodescan_test.go

Lines changed: 117 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/step-security/dev-machine-guard/internal/executor"
10+
"github.com/step-security/dev-machine-guard/internal/model"
1011
"github.com/step-security/dev-machine-guard/internal/progress"
1112
)
1213

@@ -122,7 +123,7 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
122123
// filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use
123124
// forward slashes here so the test works on macOS hosts too.
124125
mock.SetCommand("C:/Users/dev/AppData/Local/pnpm/global/5/node_modules\n", "", 0, "pnpm", "root", "-g")
125-
mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "pnpm", "list", "-g", "--json", "--depth=3")
126+
mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "pnpm", "list", "-g", "--json")
126127

127128
scanner := newTestScanner(mock)
128129
results := scanner.ScanGlobalPackages(context.Background())
@@ -139,13 +140,128 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
139140
if r.PMVersion != "9.1.0" {
140141
t.Errorf("expected PMVersion 9.1.0, got %s", r.PMVersion)
141142
}
143+
if r.ExitCode != 0 {
144+
t.Errorf("expected ExitCode 0 (mock stub matched), got %d — check that production args still match the SetCommand stub", r.ExitCode)
145+
}
142146
}
143147
}
144148
if !pnpmFound {
145149
t.Fatal("expected pnpm in global scan results on Windows")
146150
}
147151
}
148152

153+
// TestNodeScanner_ScanPnpmGlobal_Delegated exercises the root → user delegation
154+
// path (macOS-as-root or Linux-as-root with a logged-in user). Verifies that
155+
// the inline `PATH='…':$PATH pnpm <args>` shell prefix reaches the delegated
156+
// command intact. Without this prefix, pnpm v11 hard-fails each `-g` call on
157+
// hosts where sudo strips the caller's PATH (Linux `secure_path`, hardened
158+
// macOS sudoers).
159+
func TestNodeScanner_ScanPnpmGlobal_Delegated(t *testing.T) {
160+
mock := executor.NewMock()
161+
mock.SetGOOS("darwin")
162+
mock.SetIsRoot(true)
163+
mock.SetEnv("HOME", "/Users/testuser")
164+
165+
// checkPath in delegation mode runs `which pnpm` through RunAsUser, which
166+
// the Mock dispatches as `bash -c "<cmd>"`.
167+
mock.SetCommand("/opt/homebrew/bin/pnpm\n", "", 0, "bash", "-c", "which pnpm")
168+
169+
// All pnpm calls in scanPnpmGlobal are built as
170+
// PATH='<extra>':$PATH pnpm <args>
171+
// then sent through runCmd → RunAsUser → bash -c "<cmd>".
172+
prefix := `PATH='/Users/testuser/Library/pnpm/bin':$PATH pnpm`
173+
mock.SetCommand("11.1.2\n", "", 0, "bash", "-c", prefix+" --version")
174+
mock.SetCommand("/Users/testuser/Library/pnpm/global/v11/node_modules\n", "", 0, "bash", "-c", prefix+" root -g")
175+
mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "bash", "-c", prefix+" list -g --json")
176+
177+
log := progress.NewLogger(progress.LevelInfo)
178+
scanner := NewNodeScanner(mock, log, "testuser")
179+
180+
results := scanner.ScanGlobalPackages(context.Background())
181+
182+
var pnpm *model.NodeScanResult
183+
for i, r := range results {
184+
if r.PackageManager == "pnpm" {
185+
pnpm = &results[i]
186+
break
187+
}
188+
}
189+
if pnpm == nil {
190+
t.Fatal("expected pnpm in delegated scan results")
191+
}
192+
if pnpm.PMVersion != "11.1.2" {
193+
t.Errorf("PMVersion = %q, want 11.1.2 — PATH= prefix likely missing from `pnpm --version` invocation", pnpm.PMVersion)
194+
}
195+
if pnpm.ProjectPath != "/Users/testuser/Library/pnpm/global/v11" {
196+
t.Errorf("ProjectPath = %q, want /Users/testuser/Library/pnpm/global/v11 — PATH= prefix likely missing from `pnpm root -g` invocation", pnpm.ProjectPath)
197+
}
198+
if pnpm.ExitCode != 0 {
199+
t.Errorf("ExitCode = %d, want 0 — PATH= prefix likely missing from `pnpm list -g` invocation", pnpm.ExitCode)
200+
}
201+
}
202+
203+
// TestDefaultPnpmBinDir pins pnpm's per-platform global bin-dir layout. macOS
204+
// uses a /bin subdirectory; Linux and Windows place global binaries directly
205+
// in PNPM_HOME (no /bin). This asymmetry matches pnpm's own `pnpm setup`.
206+
func TestDefaultPnpmBinDir(t *testing.T) {
207+
tests := []struct {
208+
name string
209+
goos string
210+
envs map[string]string
211+
want string
212+
}{
213+
{
214+
name: "darwin with HOME → bin subdir",
215+
goos: "darwin",
216+
envs: map[string]string{"HOME": "/Users/foo"},
217+
want: "/Users/foo/Library/pnpm/bin",
218+
},
219+
{
220+
name: "darwin without HOME → empty",
221+
goos: "darwin",
222+
envs: map[string]string{},
223+
want: "",
224+
},
225+
{
226+
name: "linux with HOME → no bin suffix",
227+
goos: "linux",
228+
envs: map[string]string{"HOME": "/home/foo"},
229+
want: "/home/foo/.local/share/pnpm",
230+
},
231+
{
232+
name: "linux without HOME → empty",
233+
goos: "linux",
234+
envs: map[string]string{},
235+
want: "",
236+
},
237+
{
238+
name: "windows without LOCALAPPDATA → empty",
239+
goos: "windows",
240+
envs: map[string]string{},
241+
want: "",
242+
},
243+
{
244+
name: "unrecognized OS → empty",
245+
goos: "freebsd",
246+
envs: map[string]string{"HOME": "/home/foo"},
247+
want: "",
248+
},
249+
}
250+
for _, tt := range tests {
251+
t.Run(tt.name, func(t *testing.T) {
252+
mock := executor.NewMock()
253+
mock.SetGOOS(tt.goos)
254+
for k, v := range tt.envs {
255+
mock.SetEnv(k, v)
256+
}
257+
got := defaultPnpmBinDir(mock)
258+
if got != tt.want {
259+
t.Errorf("defaultPnpmBinDir() = %q, want %q", got, tt.want)
260+
}
261+
})
262+
}
263+
}
264+
149265
func TestNodeScanner_ScanProject_Windows(t *testing.T) {
150266
mock := executor.NewMock()
151267
mock.SetGOOS("windows")
@@ -206,89 +322,6 @@ func TestNodeScanner_ScanProject_YarnBerry_Windows(t *testing.T) {
206322
}
207323
}
208324

209-
func TestPnpmDepthArg(t *testing.T) {
210-
tests := []struct {
211-
version string
212-
want string
213-
}{
214-
{"", "--depth=3"},
215-
{"unknown", "--depth=3"},
216-
{"9.1.0", "--depth=3"},
217-
{"10.12.4", "--depth=3"},
218-
{"11.0.0", "--depth=Infinity"},
219-
{"11.1.1", "--depth=Infinity"},
220-
{"v12.0.0\n", "--depth=Infinity"},
221-
}
222-
for _, tt := range tests {
223-
t.Run(tt.version, func(t *testing.T) {
224-
got := pnpmDepthArg(tt.version)
225-
if got != tt.want {
226-
t.Errorf("pnpmDepthArg(%q) = %q, want %q", tt.version, got, tt.want)
227-
}
228-
})
229-
}
230-
}
231-
232-
func TestNodeScanner_ScanPnpmGlobal_V11(t *testing.T) {
233-
mock := executor.NewMock()
234-
mock.SetPath("pnpm", "/opt/homebrew/bin/pnpm")
235-
mock.SetCommand("11.1.1\n", "", 0, "pnpm", "--version")
236-
mock.SetCommand("/Users/dev/Library/pnpm/global/v11/abc/node_modules\n", "", 0, "pnpm", "root", "-g")
237-
// v11 must use --depth=Infinity; --depth=3 would fail in real life.
238-
mock.SetCommand(`{"dependencies":{"jest":{"version":"29.7.0"}}}`, "", 0, "pnpm", "list", "-g", "--json", "--depth=Infinity")
239-
240-
scanner := newTestScanner(mock)
241-
results := scanner.ScanGlobalPackages(context.Background())
242-
243-
pnpmFound := false
244-
for _, r := range results {
245-
if r.PackageManager == "pnpm" {
246-
pnpmFound = true
247-
if r.PMVersion != "11.1.1" {
248-
t.Errorf("expected PMVersion 11.1.1, got %s", r.PMVersion)
249-
}
250-
if r.ExitCode != 0 {
251-
t.Errorf("expected ExitCode 0, got %d", r.ExitCode)
252-
}
253-
decoded, _ := base64.StdEncoding.DecodeString(r.RawStdoutBase64)
254-
if len(decoded) == 0 {
255-
t.Error("expected non-empty RawStdoutBase64")
256-
}
257-
}
258-
}
259-
if !pnpmFound {
260-
t.Fatal("expected pnpm in global scan results for v11")
261-
}
262-
}
263-
264-
func TestNodeScanner_ScanPnpmGlobal_V10_StillUsesDepth3(t *testing.T) {
265-
mock := executor.NewMock()
266-
mock.SetPath("pnpm", "/usr/local/bin/pnpm")
267-
mock.SetCommand("10.12.4\n", "", 0, "pnpm", "--version")
268-
mock.SetCommand("/Users/dev/.local/share/pnpm/global/5/node_modules\n", "", 0, "pnpm", "root", "-g")
269-
// v10 must still use --depth=3 (proves we didn't break the legacy path).
270-
mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "pnpm", "list", "-g", "--json", "--depth=3")
271-
272-
scanner := newTestScanner(mock)
273-
results := scanner.ScanGlobalPackages(context.Background())
274-
275-
pnpmFound := false
276-
for _, r := range results {
277-
if r.PackageManager == "pnpm" {
278-
pnpmFound = true
279-
if r.PMVersion != "10.12.4" {
280-
t.Errorf("expected PMVersion 10.12.4, got %s", r.PMVersion)
281-
}
282-
if r.ExitCode != 0 {
283-
t.Errorf("expected ExitCode 0, got %d", r.ExitCode)
284-
}
285-
}
286-
}
287-
if !pnpmFound {
288-
t.Fatal("expected pnpm in global scan results for v10")
289-
}
290-
}
291-
292325
func TestNodeScanner_ScanGlobalPackages_NoneInstalled(t *testing.T) {
293326
mock := executor.NewMock()
294327
scanner := newTestScanner(mock)

0 commit comments

Comments
 (0)