mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add proxy_install_only config to restrict proxy to download commands
Introduces proxy_install_only (default: false) which, when enabled, skips the proxy for package manager commands that do not download packages (e.g. npm ls, pip list), avoiding unnecessary MITM overhead. - Add ProxyInstallOnly to Config and config template - Add IsKnownDownloadCommand / MayDownloadPackages to ParsedCommand - Add DownloadCommands to npm and pypi PM configs covering update, ci, audit, dlx, exec, x, download, run and equivalents per PM - Extract shared runner.Execute used by both proxy flow and guard - Proxy flow short-circuits to runner.Execute for non-download commands when proxy_install_only=true
This commit is contained in:
@@ -70,6 +70,11 @@ type Config struct {
|
|||||||
// we initially introduced it as an experimental feature.
|
// we initially introduced it as an experimental feature.
|
||||||
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
|
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 controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
|
||||||
Verbosity string `mapstructure:"verbosity"`
|
Verbosity string `mapstructure:"verbosity"`
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ event_log_retention_days: 7
|
|||||||
# and can be disabled to fall back to the guard-based analysis.
|
# and can be disabled to fall back to the guard-based analysis.
|
||||||
proxy_mode: true
|
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.
|
# 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.
|
# This is useful for packages that are known to be safe and are used in the application.
|
||||||
# Example:
|
# Example:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func TestConfigHasDefaultValues(t *testing.T) {
|
|||||||
assert.Equal(t, []TrustedPackage{}, config.Config.TrustedPackages)
|
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.configDir)
|
||||||
assert.Equal(t, "/tmp/pmg-test/random-does-not-exist/config.yml", config.configFilePath)
|
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) {
|
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)
|
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) {
|
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|||||||
+5
-48
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,10 +15,9 @@ import (
|
|||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/extractor"
|
"github.com/safedep/pmg/extractor"
|
||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
|
"github.com/safedep/pmg/internal/runner"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
"github.com/safedep/pmg/sandbox/executor"
|
|
||||||
"github.com/safedep/pmg/usefulerror"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type PackageManagerGuardInteraction struct {
|
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 {
|
func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
||||||
if len(pc.Command.Exe) == 0 {
|
pmName := ""
|
||||||
return fmt.Errorf("no command to execute")
|
if g.packageManager != nil {
|
||||||
|
pmName = g.packageManager.Name()
|
||||||
}
|
}
|
||||||
|
return runner.Execute(ctx, pc, pmName, g.config.DryRun)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/safedep/pmg/guard"
|
"github.com/safedep/pmg/guard"
|
||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
"github.com/safedep/pmg/internal/pty"
|
"github.com/safedep/pmg/internal/pty"
|
||||||
|
"github.com/safedep/pmg/internal/runner"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
@@ -51,6 +52,12 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
|||||||
|
|
||||||
cfg := config.Get()
|
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
|
// Initialize report data at the start
|
||||||
reportData := ui.NewReportData()
|
reportData := ui.NewReportData()
|
||||||
reportData.PackageManagerName = f.pm.Name()
|
reportData.PackageManagerName = f.pm.Name()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+23
-7
@@ -11,35 +11,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NpmPackageManagerConfig struct {
|
type NpmPackageManagerConfig struct {
|
||||||
InstallCommands []string
|
InstallCommands []string
|
||||||
CommandName string
|
DownloadCommands []string
|
||||||
|
CommandName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
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 {
|
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
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 {
|
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
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 {
|
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "add", ""},
|
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 {
|
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
|
return &ParsedCommand{Command: command}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-2
@@ -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",
|
command: "npm update @types/node",
|
||||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, parsedCommand)
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,13 +38,26 @@ type ParsedCommand struct {
|
|||||||
// ManifestFiles contains the list of manifest files to install from
|
// ManifestFiles contains the list of manifest files to install from
|
||||||
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
|
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
|
||||||
ManifestFiles []string
|
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).
|
// 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 {
|
func (pc *ParsedCommand) IsInstallationCommand() bool {
|
||||||
return pc.HasInstallTarget() || pc.HasManifestInstall()
|
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 {
|
func (pc *ParsedCommand) HasInstallTarget() bool {
|
||||||
return len(pc.InstallTargets) > 0
|
return len(pc.InstallTargets) > 0
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-12
@@ -18,35 +18,42 @@ type pypiCommandParser interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PypiPackageManagerConfig struct {
|
type PypiPackageManagerConfig struct {
|
||||||
InstallCommands []string
|
InstallCommands []string
|
||||||
CommandName string
|
DownloadCommands []string
|
||||||
|
CommandName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"install"},
|
InstallCommands: []string{"install"},
|
||||||
CommandName: "pip",
|
DownloadCommands: []string{"download"},
|
||||||
|
CommandName: "pip",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"install"},
|
InstallCommands: []string{"install"},
|
||||||
CommandName: "pip3",
|
DownloadCommands: []string{"download"},
|
||||||
|
CommandName: "pip3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"add", "install"},
|
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 {
|
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"add"},
|
InstallCommands: []string{"add"},
|
||||||
CommandName: "poetry",
|
DownloadCommands: []string{"update", "install"},
|
||||||
|
CommandName: "poetry",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +130,13 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
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
|
return &ParsedCommand{Command: command}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +255,13 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
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
|
return &ParsedCommand{Command: command}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +359,13 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
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
|
return &ParsedCommand{Command: command}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package packagemanager
|
package packagemanager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user