@@ -3,6 +3,7 @@ package detector
33import (
44 "context"
55 "encoding/base64"
6+ "fmt"
67 "os"
78 "path/filepath"
89 "sort"
@@ -30,12 +31,72 @@ func getMaxProjectScanBytes() int64 {
3031
3132// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded).
3233type NodeScanner struct {
33- exec executor.Executor
34- log * progress.Logger
34+ exec executor.Executor
35+ log * progress.Logger
36+ loggedInUser string // when non-empty and running as root, commands run as this user
3537}
3638
37- func NewNodeScanner (exec executor.Executor , log * progress.Logger ) * NodeScanner {
38- return & NodeScanner {exec : exec , log : log }
39+ func NewNodeScanner (exec executor.Executor , log * progress.Logger , loggedInUser string ) * NodeScanner {
40+ return & NodeScanner {exec : exec , log : log , loggedInUser : loggedInUser }
41+ }
42+
43+ // shouldRunAsUser returns true when commands should be delegated to the logged-in user.
44+ // Only applies on Unix — RunAsUser uses sudo which is not available on Windows.
45+ func (s * NodeScanner ) shouldRunAsUser () bool {
46+ return s .exec .GOOS () != "windows" && s .exec .IsRoot () && s .loggedInUser != ""
47+ }
48+
49+ // runCmd runs a command, delegating to the logged-in user when running as root.
50+ // This ensures package manager commands use the real user's PATH and config.
51+ func (s * NodeScanner ) runCmd (ctx context.Context , timeout time.Duration , name string , args ... string ) (string , string , int , error ) {
52+ if s .shouldRunAsUser () {
53+ ctx , cancel := context .WithTimeout (ctx , timeout )
54+ defer cancel ()
55+ cmd := name
56+ for _ , a := range args {
57+ cmd += " " + a
58+ }
59+ stdout , err := s .exec .RunAsUser (ctx , s .loggedInUser , cmd )
60+ if err != nil {
61+ if ctx .Err () == context .DeadlineExceeded {
62+ return stdout , "" , 124 , fmt .Errorf ("command timed out after %s" , timeout )
63+ }
64+ return stdout , "" , 1 , err
65+ }
66+ return stdout , "" , 0 , nil
67+ }
68+ return s .exec .RunWithTimeout (ctx , timeout , name , args ... )
69+ }
70+
71+ // runShellCmd runs a shell command string, delegating to the logged-in user when running as root.
72+ // Falls through to the platform-aware free function for the normal (non-delegation) path.
73+ func (s * NodeScanner ) runShellCmd (ctx context.Context , timeout time.Duration , shellCmd string ) (string , string , int , error ) {
74+ if s .shouldRunAsUser () {
75+ ctx , cancel := context .WithTimeout (ctx , timeout )
76+ defer cancel ()
77+ stdout , err := s .exec .RunAsUser (ctx , s .loggedInUser , shellCmd )
78+ if err != nil {
79+ if ctx .Err () == context .DeadlineExceeded {
80+ return stdout , "" , 124 , fmt .Errorf ("command timed out after %s" , timeout )
81+ }
82+ return stdout , "" , 1 , err
83+ }
84+ return stdout , "" , 0 , nil
85+ }
86+ return runShellCmd (ctx , s .exec , timeout , shellCmd )
87+ }
88+
89+ // checkPath checks if a binary is available, using the logged-in user's PATH when running as root.
90+ func (s * NodeScanner ) checkPath (ctx context.Context , name string ) error {
91+ if s .shouldRunAsUser () {
92+ path , err := s .exec .RunAsUser (ctx , s .loggedInUser , "which " + name )
93+ if err != nil || path == "" {
94+ return fmt .Errorf ("%s not found in user PATH" , name )
95+ }
96+ return nil
97+ }
98+ _ , err := s .exec .LookPath (name )
99+ return err
39100}
40101
41102// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results.
@@ -61,7 +122,7 @@ func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanRe
61122}
62123
63124func (s * NodeScanner ) scanNPMGlobal (ctx context.Context ) (model.NodeScanResult , bool ) {
64- if _ , err := s .exec . LookPath ( "npm" ); err != nil {
125+ if err := s .checkPath ( ctx , "npm" ); err != nil {
65126 return model.NodeScanResult {}, false
66127 }
67128
@@ -72,7 +133,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
72133 }
73134
74135 start := time .Now ()
75- stdout , stderr , exitCode , _ := s .exec . RunWithTimeout (ctx , 60 * time .Second , "npm" , "list" , "-g" , "--json" , "--depth=3" )
136+ stdout , stderr , exitCode , _ := s .runCmd (ctx , 60 * time .Second , "npm" , "list" , "-g" , "--json" , "--depth=3" )
76137 duration := time .Since (start ).Milliseconds ()
77138
78139 errMsg := ""
@@ -94,7 +155,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
94155}
95156
96157func (s * NodeScanner ) scanYarnGlobal (ctx context.Context ) (model.NodeScanResult , bool ) {
97- if _ , err := s .exec . LookPath ( "yarn" ); err != nil {
158+ if err := s .checkPath ( ctx , "yarn" ); err != nil {
98159 return model.NodeScanResult {}, false
99160 }
100161
@@ -106,7 +167,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
106167
107168 start := time .Now ()
108169 shellCmd := "cd " + platformShellQuote (s .exec , globalDir ) + " && yarn list --json --depth=0"
109- stdout , stderr , exitCode , _ := runShellCmd (ctx , s . exec , 60 * time .Second , shellCmd )
170+ stdout , stderr , exitCode , _ := s . runShellCmd (ctx , 60 * time .Second , shellCmd )
110171 duration := time .Since (start ).Milliseconds ()
111172
112173 errMsg := ""
@@ -128,7 +189,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
128189}
129190
130191func (s * NodeScanner ) scanPnpmGlobal (ctx context.Context ) (model.NodeScanResult , bool ) {
131- if _ , err := s .exec . LookPath ( "pnpm" ); err != nil {
192+ if err := s .checkPath ( ctx , "pnpm" ); err != nil {
132193 return model.NodeScanResult {}, false
133194 }
134195
@@ -140,7 +201,7 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
140201 globalDir = filepath .Dir (globalDir )
141202
142203 start := time .Now ()
143- stdout , stderr , exitCode , _ := s .exec . RunWithTimeout (ctx , 60 * time .Second , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
204+ stdout , stderr , exitCode , _ := s .runCmd (ctx , 60 * time .Second , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
144205 duration := time .Since (start ).Milliseconds ()
145206
146207 errMsg := ""
@@ -286,7 +347,7 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
286347 for _ , a := range args {
287348 cmdStr += " " + a
288349 }
289- stdout , stderr , exitCode , _ := runShellCmd (ctx , s . exec , 30 * time .Second , cmdStr )
350+ stdout , stderr , exitCode , _ := s . runShellCmd (ctx , 30 * time .Second , cmdStr )
290351 duration := time .Since (start ).Milliseconds ()
291352
292353 errMsg := ""
@@ -308,15 +369,15 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
308369}
309370
310371func (s * NodeScanner ) getVersion (ctx context.Context , binary , flag string ) string {
311- stdout , _ , _ , err := s .exec . RunWithTimeout (ctx , 10 * time .Second , binary , flag )
372+ stdout , _ , _ , err := s .runCmd (ctx , 10 * time .Second , binary , flag )
312373 if err != nil {
313374 return "unknown"
314375 }
315376 return strings .TrimSpace (stdout )
316377}
317378
318379func (s * NodeScanner ) getOutput (ctx context.Context , binary string , args ... string ) string {
319- stdout , _ , _ , err := s .exec . RunWithTimeout (ctx , 10 * time .Second , binary , args ... )
380+ stdout , _ , _ , err := s .runCmd (ctx , 10 * time .Second , binary , args ... )
320381 if err != nil {
321382 return ""
322383 }
0 commit comments