diff --git a/README.md b/README.md index a2dd02c..01321d3 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ pip install requests Verify PMG is working by installing a test package. This is a harmless package flagged as malicious in the SafeDep database, specifically meant for testing: ```bash -npm i safedep-test-pkg@0.1.3 +npm --prefer-online --no-cache i safedep-test-pkg@0.1.3 ```
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..97f0320 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,9 +25,10 @@ func TestConfigHasDefaultValues(t *testing.T) { assert.Equal(t, 5, config.Config.TransitiveDepth) assert.Equal(t, false, config.Config.IncludeDevDependencies) assert.Equal(t, false, config.Config.Paranoid) - assert.Equal(t, []TrustedPackage{}, config.Config.TrustedPackages) + assert.Len(t, config.Config.TrustedPackages, 1) 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 +105,109 @@ 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) + }) +} + +// TestConfigPrecedence verifies the expected override order: +// flags > env var > config file > default +func TestConfigPrecedence(t *testing.T) { + t.Run("env var overrides config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_install_only: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should override config file") + }) + + t.Run("config file overrides default", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "") + + 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, "config file should override default") + }) + + t.Run("env var works when key is absent from config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + // Config file exists but proxy_install_only is not in it (e.g. commented out + // or user hasn't re-run "pmg setup install" after an upgrade) + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even when key is absent from config file") + }) + + t.Run("env var works without a config file", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even without a config file") + }) + + t.Run("default is used when neither env var nor config file sets the key", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, false, Get().Config.ProxyInstallOnly, "should use default when key absent from config and env") + }) + + t.Run("cobra flag overrides env var", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PARANOID", "true") + + initConfig() + assert.Equal(t, true, Get().Config.Paranoid, "env var should set paranoid=true") + + // Simulate cobra flag parsing — BoolVar writes directly to the struct + // field after Viper, giving flags the highest effective precedence. + cmd := &cobra.Command{} + ApplyCobraFlags(cmd) + require.NoError(t, cmd.ParseFlags([]string{"--paranoid=false"})) + + assert.Equal(t, false, Get().Config.Paranoid, "cobra flag should override env var") + }) +} + func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) diff --git a/config/viper.go b/config/viper.go index 12aa026..a96310c 100644 --- a/config/viper.go +++ b/config/viper.go @@ -8,42 +8,36 @@ import ( "github.com/spf13/viper" ) -// loadViperConfig loads the configuration using Viper if available. -// It returns an error if the config file exists but cannot be read or parsed, -// allowing the caller to fall back to default configuration. +// loadViperConfig loads the configuration using Viper. +// Precedence (highest to lowest): cobra flags > env vars > config file > defaults. +// Cobra flags write directly to the config struct after this function runs. func loadViperConfig() error { configPath, err := configFilePath() if err != nil { return fmt.Errorf("failed to get config file path: %w", err) } - // Check if config file exists before attempting to load - // If it doesn't exist, we use the default configuration (see config.go) - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil - } - v := viper.New() - - v.SetConfigFile(configPath) v.SetConfigType("yaml") v.SetEnvPrefix("PMG") v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - if err := v.ReadInConfig(); err != nil { - return fmt.Errorf("failed to read config file %s: %w", configPath, err) + // Load the embedded template as the base so Viper knows all keys and their + // defaults. This is required for AutomaticEnv to resolve PMG_* env vars for + // keys that are absent from or newer than the user's config file. + if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil { + return fmt.Errorf("failed to load default config: %w", err) + } + + // Merge user config on top if it exists. + if _, statErr := os.Stat(configPath); statErr == nil { + v.SetConfigFile(configPath) + if err := v.MergeInConfig(); err != nil { + return fmt.Errorf("failed to read config file %s: %w", configPath, err) + } } - // Unmarshal into a copy of the current defaults (not a zero-value struct) so that - // keys missing from the YAML retain their defaults. This is critical for users who - // upgrade PMG without re-running "pmg setup install" — their old config.yml won't have - // newer keys (e.g. dependency_cooldown), and those must fall back to defaults rather than - // silently becoming Go zero values (false/0/""). - // - // We use a copy rather than unmarshalling directly into globalConfig.Config so that - // on error the caller's "using defaults" fallback is truthful — globalConfig.Config - // stays in a clean default state instead of being partially overwritten. merged := globalConfig.Config if err := v.Unmarshal(&merged); err != nil { return fmt.Errorf("failed to unmarshal config: %w", err) diff --git a/docs/config.md b/docs/config.md index 74c4a3b..77b7df7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,4 +12,39 @@ To see the configuration file path and activated configuration, run: pmg setup info ``` -See [config template](../config/config.template.yml) for the configuration schema. \ No newline at end of file +See [config template](../config/config.template.yml) for the configuration schema. + +## Environment Variables + +Any configuration key can be overridden using environment variables, without modifying the config file. This is useful for CI/CD pipelines or temporary overrides. + +**Format:** `PMG_` where the key is the config key uppercased, with nested keys joined by `_`. + +| Config key | Environment variable | +|---|---| +| `transitive` | `PMG_TRANSITIVE` | +| `paranoid` | `PMG_PARANOID` | +| `proxy_mode` | `PMG_PROXY_MODE` | +| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` | +| `verbosity` | `PMG_VERBOSITY` | +| `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` | +| `sandbox.enabled` | `PMG_SANDBOX_ENABLED` | +| `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` | +| `cloud.enabled` | `PMG_CLOUD_ENABLED` | + +**Example:** + +```bash +# Enable paranoid mode without editing the config file +PMG_PARANOID=true pmg npm install express + +# Restrict proxy to install commands only +PMG_PROXY_INSTALL_ONLY=true pmg npm install express +``` + +**Precedence (highest to lowest):** + +1. CLI flags +2. Environment variables (`PMG_*`) +3. Config file (`config.yml`) +4. Built-in defaults \ No newline at end of file diff --git a/guard/guard.go b/guard/guard.go index 3f19333..c676ac3 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "os/exec" "slices" "sync" "time" @@ -18,10 +17,13 @@ import ( "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" - "github.com/safedep/pmg/sandbox/executor" - "github.com/safedep/pmg/usefulerror" ) +// CommandExecutor executes a parsed package manager command directly. +// It is injected into the guard so that callers control execution behavior +// (e.g., dry-run, sandbox application) without guard depending on internal packages. +type CommandExecutor func(ctx context.Context, pc *packagemanager.ParsedCommand) error + type PackageManagerGuardInteraction struct { // SetStatus is called to set the status of the guard in the UI SetStatus func(status string) @@ -98,6 +100,7 @@ type packageManagerGuard struct { analyzers []analyzer.PackageVersionAnalyzer packageManager packagemanager.PackageManager packageResolver packagemanager.PackageResolver + executor CommandExecutor } func NewPackageManagerGuard(config PackageManagerGuardConfig, @@ -105,6 +108,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig, packageResolver packagemanager.PackageResolver, analyzers []analyzer.PackageVersionAnalyzer, interaction PackageManagerGuardInteraction, + executor CommandExecutor, ) (*packageManagerGuard, error) { return &packageManagerGuard{ interaction: interaction, @@ -112,6 +116,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig, packageManager: packageManager, packageResolver: packageResolver, config: config, + executor: executor, }, nil } @@ -254,52 +259,7 @@ 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") - } - - 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 g.executor(ctx, pc) } func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context, diff --git a/guard/guard_test.go b/guard/guard_test.go index 09aa467..1b8ec50 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -11,6 +11,12 @@ import ( "github.com/stretchr/testify/assert" ) +// noopExecutor is a no-op executor for use in tests that set DryRun=true +// or otherwise don't reach actual command execution. +var noopExecutor CommandExecutor = func(_ context.Context, _ *packagemanager.ParsedCommand) error { + return nil +} + func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) if err != nil { @@ -20,7 +26,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil, []analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{ ShowWarning: func(message string) {}, - }) + }, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -80,7 +86,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -129,7 +135,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -184,7 +190,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -225,7 +231,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } diff --git a/internal/flows/common_flow.go b/internal/flows/common_flow.go index 153261d..df2f702 100644 --- a/internal/flows/common_flow.go +++ b/internal/flows/common_flow.go @@ -10,6 +10,7 @@ import ( "github.com/safedep/pmg/config" "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/runner" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" ) @@ -77,7 +78,12 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem guardConfig.DryRun = cfg.DryRun guardConfig.InsecureInstallation = cfg.InsecureInstallation - guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) + pmName := f.pm.Name() + executor := func(ctx context.Context, pc *packagemanager.ParsedCommand) error { + return runner.Execute(ctx, pc, pmName, cfg.DryRun) + } + + guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction, executor) if err != nil { return fmt.Errorf("failed to create package manager guard: %s", err) } 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..b69e749 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -11,35 +11,73 @@ import ( ) type NpmPackageManagerConfig struct { - InstallCommands []string - CommandName string + InstallCommands []string + NonDownloadCommands []string + CommandName string } func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "i", "add"}, - CommandName: "npm", + // Commands that are known to never download packages from a registry. + // Anything not in this list (including unknown future commands) runs with the proxy. + // + // Script runners: "run", "start", "stop", "restart", "test"/"t" are all shorthand for + // "npm run