diff --git a/config/config.go b/config/config.go index 650ca53..a8b9b3c 100644 --- a/config/config.go +++ b/config/config.go @@ -70,6 +70,11 @@ type Config struct { // we initially introduced it as an experimental feature. ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"` + // ProxyInstallOnly restricts proxy interception to install commands only. + // When false (default), proxy runs for all package manager commands. + // When true, non-install commands (e.g., npm ls, pip list) bypass the proxy and execute directly. + ProxyInstallOnly bool `mapstructure:"proxy_install_only"` + // Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose". Verbosity string `mapstructure:"verbosity"` diff --git a/config/config.template.yml b/config/config.template.yml index 86cfe0d..763baf9 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -36,6 +36,12 @@ event_log_retention_days: 7 # and can be disabled to fall back to the guard-based analysis. proxy_mode: true +# Restrict proxy to install commands only. Default is false. +# When false, the proxy intercepts all package manager commands (e.g., npm install, npm ls). +# When true, non-install commands bypass the proxy and execute directly, which can improve +# performance for commands that don't download packages (e.g., npm ls, pip list, npm outdated). +proxy_install_only: false + # Trusted packages are packages that are trusted by the user and will be ignored by the security guardrails. # This is useful for packages that are known to be safe and are used in the application. # Example: diff --git a/config/config_test.go b/config/config_test.go index 791404f..6c14b20 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -27,6 +27,7 @@ func TestConfigHasDefaultValues(t *testing.T) { assert.Equal(t, []TrustedPackage{}, config.Config.TrustedPackages) assert.Equal(t, "/tmp/pmg-test/random-does-not-exist", config.configDir) assert.Equal(t, "/tmp/pmg-test/random-does-not-exist/config.yml", config.configFilePath) + assert.Equal(t, false, config.Config.ProxyInstallOnly) }) t.Run("when no config directory is set", func(t *testing.T) { @@ -103,6 +104,26 @@ func TestPartialConfigWithNestedOverride(t *testing.T) { assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode) } +func TestProxyInstallOnlyConfig(t *testing.T) { + t.Run("defaults to false", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + initConfig() + assert.Equal(t, false, Get().Config.ProxyInstallOnly) + }) + + t.Run("can be set to true via config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly) + }) +} + func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) diff --git a/guard/guard.go b/guard/guard.go index 3f19333..ce062d8 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "os/exec" "slices" "sync" "time" @@ -16,10 +15,9 @@ import ( "github.com/safedep/pmg/config" "github.com/safedep/pmg/extractor" "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/runner" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" - "github.com/safedep/pmg/sandbox/executor" - "github.com/safedep/pmg/usefulerror" ) type PackageManagerGuardInteraction struct { @@ -254,52 +252,11 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm } func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error { - if len(pc.Command.Exe) == 0 { - return fmt.Errorf("no command to execute") + pmName := "" + if g.packageManager != nil { + pmName = g.packageManager.Name() } - - if g.config.DryRun { - log.Debugf("Dry run, skipping command execution") - return nil - } - - cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - pmName := g.packageManager.Name() - result, err := executor.ApplySandbox(ctx, cmd, pmName) - if err != nil { - return fmt.Errorf("failed to apply sandbox: %w", err) - } - - defer func() { - err := result.Close() - if err != nil { - log.Errorf("failed to close sandbox: %v", err) - } - }() - - if result.ShouldRun() { - err := cmd.Run() - if err != nil { - humanError := "Failed to execute package manager command" - if exitErr, ok := err.(*exec.ExitError); ok { - humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode()) - } - - return usefulerror.Useful(). - WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). - WithHumanError(humanError). - WithHelp("Check the package manager command and its arguments"). - Wrap(err) - } - - return nil - } - - return nil + return runner.Execute(ctx, pc, pmName, g.config.DryRun) } func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context, diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 3030fbc..9f597ec 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -16,6 +16,7 @@ import ( "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/pty" + "github.com/safedep/pmg/internal/runner" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/proxy" @@ -51,6 +52,12 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema cfg := config.Get() + // Skip proxy for commands that don't download packages when proxy_install_only is enabled + if cfg.Config.ProxyInstallOnly && !parsedCmd.MayDownloadPackages() { + log.Debugf("Skipping proxy for non-download command (proxy_install_only=true)") + return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun) + } + // Initialize report data at the start reportData := ui.NewReportData() reportData.PackageManagerName = f.pm.Name() diff --git a/internal/runner/execute.go b/internal/runner/execute.go new file mode 100644 index 0000000..06a5b03 --- /dev/null +++ b/internal/runner/execute.go @@ -0,0 +1,59 @@ +package runner + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/packagemanager" + "github.com/safedep/pmg/sandbox/executor" + "github.com/safedep/pmg/usefulerror" +) + +// Execute runs a package manager command without proxy or guard analysis. +// It applies sandbox policy if configured, then executes the command directly. +func Execute(ctx context.Context, pc *packagemanager.ParsedCommand, pmName string, dryRun bool) error { + if len(pc.Command.Exe) == 0 { + return fmt.Errorf("no command to execute") + } + + if dryRun { + log.Debugf("Dry run, skipping command execution") + return nil + } + + cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + result, err := executor.ApplySandbox(ctx, cmd, pmName) + if err != nil { + return fmt.Errorf("failed to apply sandbox: %w", err) + } + + defer func() { + if err := result.Close(); err != nil { + log.Errorf("failed to close sandbox: %v", err) + } + }() + + if result.ShouldRun() { + if err := cmd.Run(); err != nil { + humanError := "Failed to execute package manager command" + if exitErr, ok := err.(*exec.ExitError); ok { + humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode()) + } + + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError(humanError). + WithHelp("Check the package manager command and its arguments"). + Wrap(err) + } + } + + return nil +} diff --git a/packagemanager/npm.go b/packagemanager/npm.go index 302ed6d..7884021 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -11,35 +11,45 @@ import ( ) type NpmPackageManagerConfig struct { - InstallCommands []string - CommandName string + InstallCommands []string + DownloadCommands []string + CommandName string } func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "i", "add"}, - CommandName: "npm", + // "exec" is the npm v7+ built-in equivalent of npx. + DownloadCommands: []string{"update", "up", "upgrade", "ci", "audit", "dedupe", "exec"}, + CommandName: "npm", } } func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "i", "add"}, - CommandName: "pnpm", + // "dlx" downloads and runs a package (pnpm's npx equivalent). + // "exec" runs a command from the project's node_modules (may resolve packages). + DownloadCommands: []string{"update", "up", "upgrade", "dedupe", "dlx", "exec"}, + CommandName: "pnpm", } } func DefaultBunPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "i", "add"}, - CommandName: "bun", + // "x" is bun's npx equivalent (also exposed as the `bunx` binary). + DownloadCommands: []string{"update", "upgrade", "x"}, + CommandName: "bun", } } func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "add", ""}, - CommandName: "yarn", + // "dlx" downloads and runs a package without installing it (yarn's npx equivalent). + DownloadCommands: []string{"upgrade", "up", "dlx"}, + CommandName: "yarn", } } @@ -96,7 +106,13 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error } if installCmdIndex == -1 { - // No install command found, return as-is + // Check if this is a known download command (e.g., npm update, npm ci) + for _, arg := range args { + if slices.Contains(npm.Config.DownloadCommands, arg) { + return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil + } + } + return &ParsedCommand{Command: command}, nil } diff --git a/packagemanager/npm_test.go b/packagemanager/npm_test.go index c8cdc3c..692c7b6 100644 --- a/packagemanager/npm_test.go +++ b/packagemanager/npm_test.go @@ -71,12 +71,53 @@ func TestNpmParseCommand(t *testing.T) { }, }, { - name: "not an installation command", + name: "update is a known download command", command: "npm update @types/node", assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) { assert.NoError(t, err) assert.NotNil(t, parsedCommand) - assert.Equal(t, 0, len(parsedCommand.InstallTargets)) + assert.Empty(t, parsedCommand.InstallTargets) + assert.True(t, parsedCommand.IsKnownDownloadCommand) + assert.True(t, parsedCommand.MayDownloadPackages()) + }, + }, + { + name: "ci is a known download command", + command: "npm ci", + assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) { + assert.NoError(t, err) + assert.True(t, parsedCommand.IsKnownDownloadCommand) + assert.True(t, parsedCommand.MayDownloadPackages()) + assert.False(t, parsedCommand.IsInstallationCommand()) + }, + }, + { + name: "audit is a known download command", + command: "npm audit", + assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) { + assert.NoError(t, err) + assert.True(t, parsedCommand.IsKnownDownloadCommand) + assert.True(t, parsedCommand.MayDownloadPackages()) + }, + }, + { + name: "ls is not a download command", + command: "npm ls", + assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) { + assert.NoError(t, err) + assert.False(t, parsedCommand.IsKnownDownloadCommand) + assert.False(t, parsedCommand.MayDownloadPackages()) + assert.False(t, parsedCommand.IsInstallationCommand()) + }, + }, + { + name: "install sets MayDownloadPackages via IsInstallationCommand", + command: "npm install express", + assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) { + assert.NoError(t, err) + assert.False(t, parsedCommand.IsKnownDownloadCommand) + assert.True(t, parsedCommand.IsInstallationCommand()) + assert.True(t, parsedCommand.MayDownloadPackages()) }, }, { @@ -403,3 +444,90 @@ func TestBunParseCommand(t *testing.T) { }) } } + +func TestNpmDownloadCommands(t *testing.T) { + cases := []struct { + name string + pm func() (*npmPackageManager, error) + command string + isKnownDownloadCommand bool + isInstallationCommand bool + }{ + { + name: "yarn upgrade is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) }, + command: "yarn upgrade", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "pnpm update is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) }, + command: "pnpm update", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "bun update is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) }, + command: "bun update", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "npm exec is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) }, + command: "npm exec create-react-app", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "pnpm dlx is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) }, + command: "pnpm dlx create-react-app", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "pnpm exec is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) }, + command: "pnpm exec tsc", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "yarn dlx is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) }, + command: "yarn dlx create-react-app", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "bun x is a known download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) }, + command: "bun x create-vite", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "npm outdated is not a download command", + pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) }, + command: "npm outdated", + isKnownDownloadCommand: false, + isInstallationCommand: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pm, err := tc.pm() + assert.NoError(t, err) + + parsed, err := pm.ParseCommand(strings.Split(tc.command, " ")) + assert.NoError(t, err) + assert.Equal(t, tc.isKnownDownloadCommand, parsed.IsKnownDownloadCommand) + assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand()) + assert.Equal(t, tc.isKnownDownloadCommand || tc.isInstallationCommand, parsed.MayDownloadPackages()) + }) + } +} diff --git a/packagemanager/packagemanager.go b/packagemanager/packagemanager.go index 56c38b6..e25db2a 100644 --- a/packagemanager/packagemanager.go +++ b/packagemanager/packagemanager.go @@ -38,13 +38,26 @@ type ParsedCommand struct { // ManifestFiles contains the list of manifest files to install from // (e.g., ["requirements.txt"] for pip install -r requirements.txt) ManifestFiles []string + + // IsKnownDownloadCommand is true for commands that may download packages but are not + // fully parsed (e.g., npm update, npm ci, poetry update). Used by the proxy to decide + // whether to intercept when proxy_install_only is enabled. + IsKnownDownloadCommand bool } // IsInstallationCommand returns true if command installs packages (explicit targets or from manifest). +// This is used by guard mode where we need to know which packages are being installed. func (pc *ParsedCommand) IsInstallationCommand() bool { return pc.HasInstallTarget() || pc.HasManifestInstall() } +// MayDownloadPackages returns true if the command may download packages from a registry. +// This is broader than IsInstallationCommand and includes commands like npm update or npm ci +// that download packages but are not fully parsed. Used by the proxy to decide interception scope. +func (pc *ParsedCommand) MayDownloadPackages() bool { + return pc.IsInstallationCommand() || pc.IsKnownDownloadCommand +} + func (pc *ParsedCommand) HasInstallTarget() bool { return len(pc.InstallTargets) > 0 } diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index c803556..3f6d616 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -18,35 +18,42 @@ type pypiCommandParser interface { } type PypiPackageManagerConfig struct { - InstallCommands []string - CommandName string + InstallCommands []string + DownloadCommands []string + CommandName string } func DefaultPipPackageManagerConfig() PypiPackageManagerConfig { return PypiPackageManagerConfig{ - InstallCommands: []string{"install"}, - CommandName: "pip", + InstallCommands: []string{"install"}, + DownloadCommands: []string{"download"}, + CommandName: "pip", } } func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig { return PypiPackageManagerConfig{ - InstallCommands: []string{"install"}, - CommandName: "pip3", + InstallCommands: []string{"install"}, + DownloadCommands: []string{"download"}, + CommandName: "pip3", } } func DefaultUvPackageManagerConfig() PypiPackageManagerConfig { return PypiPackageManagerConfig{ InstallCommands: []string{"add", "install"}, - CommandName: "uv", + // "download" covers both `uv pip download` and bare `uv download`. + // "run" covers `uv run` which auto-installs script dependencies. + DownloadCommands: []string{"download", "run"}, + CommandName: "uv", } } func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig { return PypiPackageManagerConfig{ - InstallCommands: []string{"add"}, - CommandName: "poetry", + InstallCommands: []string{"add"}, + DownloadCommands: []string{"update", "install"}, + CommandName: "poetry", } } @@ -123,7 +130,13 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { } if installCmdIndex == -1 { - // No install command found, return as-is + // Check if this is a known download command + for _, arg := range args { + if slices.Contains(p.config.DownloadCommands, arg) { + return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil + } + } + return &ParsedCommand{Command: command}, nil } @@ -242,7 +255,13 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { } if installCmdIndex == -1 { - // No install command found, return as-is + // Check if this is a known download command + for _, arg := range args { + if slices.Contains(u.config.DownloadCommands, arg) { + return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil + } + } + return &ParsedCommand{Command: command}, nil } @@ -340,7 +359,13 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error } if installCmdIndex == -1 { - // No install command found, return as-is + // Check if this is a known download command + for _, arg := range args { + if slices.Contains(p.config.DownloadCommands, arg) { + return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil + } + } + return &ParsedCommand{Command: command}, nil } diff --git a/packagemanager/pypi_test.go b/packagemanager/pypi_test.go index 709f052..ea5ba6f 100644 --- a/packagemanager/pypi_test.go +++ b/packagemanager/pypi_test.go @@ -1,6 +1,7 @@ package packagemanager import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -856,3 +857,83 @@ func TestPypiConvertWildcardConstraint(t *testing.T) { }) } } + +func TestPypiDownloadCommands(t *testing.T) { + cases := []struct { + name string + pm func() (*pypiPackageManager, error) + command string + isKnownDownloadCommand bool + isInstallationCommand bool + }{ + { + name: "poetry update is a known download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) }, + command: "poetry update", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "poetry add is an installation command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) }, + command: "poetry add django", + isKnownDownloadCommand: false, + isInstallationCommand: true, + }, + { + name: "uv sync is an installation command (manifest install)", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) }, + command: "uv sync", + isKnownDownloadCommand: false, + isInstallationCommand: true, + }, + { + name: "pip download is a known download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) }, + command: "pip download requests", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "pip3 download is a known download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPip3PackageManagerConfig()) }, + command: "pip3 download django", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "uv pip download is a known download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) }, + command: "uv pip download requests", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "uv run is a known download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) }, + command: "uv run script.py", + isKnownDownloadCommand: true, + isInstallationCommand: false, + }, + { + name: "pip list is not a download command", + pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) }, + command: "pip list", + isKnownDownloadCommand: false, + isInstallationCommand: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pm, err := tc.pm() + assert.NoError(t, err) + + parsed, err := pm.ParseCommand(strings.Split(tc.command, " ")) + assert.NoError(t, err) + assert.Equal(t, tc.isKnownDownloadCommand, parsed.IsKnownDownloadCommand) + assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand()) + assert.Equal(t, tc.isKnownDownloadCommand || tc.isInstallationCommand, parsed.MayDownloadPackages()) + }) + } +}