diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 621ef77..8515d72 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -41,7 +41,7 @@ func executeSetupInfo() error { configEntries := make(map[string]string) configEntries["Config File"] = cfg.ConfigFilePath() configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled()) - configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.ProxyInstallOnly) + configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly) ui.PrintInfoSection("Configuration", configEntries) // Shell Integration section diff --git a/config/cobra.go b/config/cobra.go index 25eda7d..bfc3bfe 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -28,10 +28,8 @@ func ApplyCobraFlags(cmd *cobra.Command) { globalConfig.Config.Paranoid, "Enable high-security defaults (treat suspicious as malicious)") cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log", globalConfig.Config.SkipEventLogging, "Skip event logging") - cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode", - globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)") - cmd.PersistentFlags().BoolVar(&globalConfig.Config.ProxyMode, "proxy-mode", - globalConfig.Config.ProxyMode, "Use proxy based interception") + cmd.PersistentFlags().BoolVar(&globalConfig.Config.Proxy.Enabled, "proxy-mode", + globalConfig.Config.Proxy.Enabled, "Use proxy based interception") cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox", globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)") cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, "sandbox-enforce", @@ -44,8 +42,6 @@ func ApplyCobraFlags(cmd *cobra.Command) { cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown", false, "Skip dependency cooldown enforcement") - // Hide the experimental proxy mode flag but keep it for backward compatibility - _ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode") } // FinalizeDependencyCooldownOverride disables dependency cooldown in the global diff --git a/config/config.go b/config/config.go index f29a746..776bec4 100644 --- a/config/config.go +++ b/config/config.go @@ -65,17 +65,10 @@ type Config struct { // EventLogRetentionDays is the number of days to retain event logs. EventLogRetentionDays int `mapstructure:"event_log_retention_days"` - // ProxyMode enables proxy-based package interception when supported by package managers. - // When enabled, PMG starts a proxy server and intercepts package manager requests in real-time. + // Deprecated: Use Proxy.Enabled instead. Kept for backward compatibility with old config files. ProxyMode bool `mapstructure:"proxy_mode"` - // ExperimentalProxyMode is same as ProxyMode. Kept here for backward compatibility because - // 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. + // Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files. ProxyInstallOnly bool `mapstructure:"proxy_install_only"` // Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose". @@ -88,6 +81,8 @@ type Config struct { DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"` Cloud CloudConfig `mapstructure:"cloud"` + + Proxy ProxyConfig `mapstructure:"proxy"` } // CloudConfig configures audit event sync to SafeDep Cloud. @@ -96,6 +91,16 @@ type CloudConfig struct { EndpointID string `mapstructure:"endpoint_id"` } +type ProxyPolicy struct { + SkipCommands []string `mapstructure:"skip_commands"` +} + +type ProxyConfig struct { + Enabled bool `mapstructure:"enabled"` + InstallOnly bool `mapstructure:"install_only"` + Policies map[string]ProxyPolicy `mapstructure:"policies"` +} + // SandboxConfig configures the sandbox system for isolating package manager processes. type SandboxConfig struct { // Enabled enables sandbox mode (opt-in by default for backward compatibility). @@ -199,10 +204,8 @@ func (r *RuntimeConfig) ConfigDir() string { return r.configDir } -// IsProxyModeEnabled is a helper function to check for proxy mode with -// support for backward compatibility func (r *RuntimeConfig) IsProxyModeEnabled() bool { - return (r.Config.ExperimentalProxyMode || r.Config.ProxyMode) + return r.Config.Proxy.Enabled } // SandboxAllowType represents the type of a sandbox allow override. @@ -248,7 +251,6 @@ func DefaultConfig() RuntimeConfig { DisableTelemetry: false, EventLogRetentionDays: 7, SkipEventLogging: false, - ExperimentalProxyMode: false, TrustedPackages: []TrustedPackage{}, ProxyMode: true, Verbosity: VerbosityNormal, @@ -263,6 +265,11 @@ func DefaultConfig() RuntimeConfig { Cloud: CloudConfig{ Enabled: false, }, + Proxy: ProxyConfig{ + Enabled: true, + InstallOnly: false, + Policies: map[string]ProxyPolicy{}, + }, }, DryRun: false, InsecureInstallation: insecureInstallation, diff --git a/config/config.template.yml b/config/config.template.yml index 897f951..edec8fe 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -32,18 +32,27 @@ skip_event_logging: false # This is the number of days to retain event logs. event_log_retention_days: 7 -# Proxy mode. Default is true. +# Proxy configuration. # When enabled, PMG uses a proxy-based interception approach instead of the # default guard-based analysis. The proxy intercepts package manager requests in real-time # and analyzes packages as they are downloaded. Proxy mode may not work in all environments, # and can be disabled to fall back to the guard-based analysis. -proxy_mode: true +proxy: + enabled: 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 + # When true, only install commands are proxied. Other commands + # (e.g., npm ls, pip list) bypass the proxy and execute directly. + install_only: false + + # Per-package-manager proxy policies. + # Additional commands to bypass the proxy. + # Example: + # policies: + # pip: + # skip_commands: ["list", "show"] + policies: + npm: + skip_commands: [] # 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. diff --git a/config/config_test.go b/config/config_test.go index 28e0819..1551b92 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -28,7 +28,7 @@ func TestConfigHasDefaultValues(t *testing.T) { 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) + assert.Equal(t, false, config.Config.Proxy.InstallOnly) }) t.Run("when no config directory is set", func(t *testing.T) { @@ -69,7 +69,7 @@ func TestPartialConfigFallsBackToDefaults(t *testing.T) { // Missing keys should fall back to DefaultConfig() values, not Go zero values defaults := DefaultConfig().Config assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) - assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode) + assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled) assert.Equal(t, defaults.Verbosity, config.Config.Verbosity) assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays) assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled) @@ -102,14 +102,14 @@ func TestPartialConfigWithNestedOverride(t *testing.T) { // Top-level fields should fall back to defaults assert.Equal(t, defaults.Transitive, config.Config.Transitive) assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) - assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode) + assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled) } 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) + assert.Equal(t, false, Get().Config.Proxy.InstallOnly) }) t.Run("can be set to true via config file", func(t *testing.T) { @@ -117,18 +117,18 @@ func TestProxyInstallOnlyConfig(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", tmpDir) configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644) + err := os.WriteFile(configPath, []byte("proxy:\n install_only: true\n"), 0o644) require.NoError(t, err) initConfig() - assert.Equal(t, true, Get().Config.ProxyInstallOnly) + assert.Equal(t, true, Get().Config.Proxy.InstallOnly) }) } // 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) { + t.Run("env var sets legacy flat field", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") @@ -138,7 +138,8 @@ func TestConfigPrecedence(t *testing.T) { require.NoError(t, err) initConfig() - assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should override config file") + // PMG_PROXY_INSTALL_ONLY maps to the flat proxy_install_only key, not nested proxy.install_only + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should set legacy flat field") }) t.Run("config file overrides default", func(t *testing.T) { @@ -147,11 +148,11 @@ func TestConfigPrecedence(t *testing.T) { t.Setenv("PMG_PROXY_INSTALL_ONLY", "") configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644) + err := os.WriteFile(configPath, []byte("proxy:\n install_only: true\n"), 0o644) require.NoError(t, err) initConfig() - assert.Equal(t, true, Get().Config.ProxyInstallOnly, "config file should override default") + assert.Equal(t, true, Get().Config.Proxy.InstallOnly, "config file should override default") }) t.Run("telemetry can be disabled via config", func(t *testing.T) { @@ -167,27 +168,12 @@ func TestConfigPrecedence(t *testing.T) { assert.Equal(t, true, Get().Config.DisableTelemetry, "config file should disable telemetry") }) - 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") + t.Setenv("PMG_PARANOID", "true") initConfig() - assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even without a config file") + assert.Equal(t, true, Get().Config.Paranoid, "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) { @@ -200,7 +186,8 @@ func TestConfigPrecedence(t *testing.T) { require.NoError(t, err) initConfig() - assert.Equal(t, false, Get().Config.ProxyInstallOnly, "should use default when key absent from config and env") + assert.Equal(t, false, Get().Config.Proxy.InstallOnly, "should use default when key absent from config and env") + assert.Equal(t, false, Get().Config.ProxyInstallOnly, "legacy flat field should also default to false") }) t.Run("cobra flag overrides env var", func(t *testing.T) { @@ -308,7 +295,7 @@ func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { assert.Contains(t, raw, "transitive_depth: 10") // New keys from template added - assert.Contains(t, raw, "proxy_mode:") + assert.Contains(t, raw, "proxy:") assert.Contains(t, raw, "sandbox:") assert.Contains(t, raw, "verbosity:") } @@ -329,3 +316,89 @@ func TestWriteTemplateConfigCreatesNewFile(t *testing.T) { // Should be the full template assert.Equal(t, templateConfig, string(result)) } + +func TestProxyConfigSection(t *testing.T) { + t.Run("defaults to enabled with install_only false", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + initConfig() + + cfg := Get() + assert.Equal(t, true, cfg.Config.Proxy.Enabled) + assert.Equal(t, false, cfg.Config.Proxy.InstallOnly) + assert.NotNil(t, cfg.Config.Proxy.Policies) + }) + + t.Run("reads proxy section from config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configYAML := `proxy: + enabled: true + install_only: true + policies: + npm: + skip_commands: ["my-script", "dev"] +` + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte(configYAML), 0o644) + require.NoError(t, err) + + initConfig() + cfg := Get() + + assert.Equal(t, true, cfg.Config.Proxy.Enabled) + assert.Equal(t, true, cfg.Config.Proxy.InstallOnly) + assert.Equal(t, []string{"my-script", "dev"}, cfg.Config.Proxy.Policies["npm"].SkipCommands) + }) + + t.Run("falls back to legacy keys from config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configYAML := `proxy_mode: false +proxy_install_only: true +` + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte(configYAML), 0o644) + require.NoError(t, err) + + initConfig() + cfg := Get() + + assert.Equal(t, false, cfg.Config.Proxy.Enabled) + assert.Equal(t, true, cfg.Config.Proxy.InstallOnly) + }) + + t.Run("falls back to legacy keys from env vars", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + t.Setenv("PMG_PROXY_MODE", "false") + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + initConfig() + cfg := Get() + + assert.Equal(t, false, cfg.Config.Proxy.Enabled, "PMG_PROXY_MODE=false should set Proxy.Enabled=false") + assert.Equal(t, true, cfg.Config.Proxy.InstallOnly, "PMG_PROXY_INSTALL_ONLY=true should set Proxy.InstallOnly=true") + }) + + t.Run("new proxy section takes precedence over old keys", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configYAML := `proxy_mode: false +proxy_install_only: true +proxy: + enabled: true + install_only: false +` + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte(configYAML), 0o644) + require.NoError(t, err) + + initConfig() + cfg := Get() + + assert.Equal(t, true, cfg.Config.Proxy.Enabled, "new proxy.enabled should win over old proxy_mode") + assert.Equal(t, false, cfg.Config.Proxy.InstallOnly, "new proxy.install_only should win over old proxy_install_only") + }) +} diff --git a/config/viper.go b/config/viper.go index a96310c..a81717e 100644 --- a/config/viper.go +++ b/config/viper.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) // loadViperConfig loads the configuration using Viper. @@ -21,7 +22,7 @@ func loadViperConfig() error { v.SetConfigType("yaml") v.SetEnvPrefix("PMG") v.AutomaticEnv() - v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) // 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 @@ -44,5 +45,45 @@ func loadViperConfig() error { } globalConfig.Config = merged + + // Resolve proxy config: new proxy section > legacy flat keys. + // Viper can't distinguish "value from template" vs "value from user config" + // (v.IsSet is always true for template keys), so we check the raw user file. + if !hasProxySectionInFile(configPath) { + applyProxyLegacyFallback(v) + } + return nil } + +// hasProxySectionInFile checks whether the user's config file contains a +// top-level "proxy" key. Returns false if the file doesn't exist or can't +// be parsed. +func hasProxySectionInFile(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return false + } + + _, ok := raw["proxy"] + return ok +} + +// applyProxyLegacyFallback populates the new Proxy struct from deprecated +// flat keys when the user's config file does not have a proxy: section. +// New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence +// over legacy config file keys to respect the documented precedence order. +func applyProxyLegacyFallback(v *viper.Viper) { + if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") { + globalConfig.Config.Proxy.Enabled = v.GetBool("proxy_mode") + } + + if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") { + globalConfig.Config.Proxy.InstallOnly = v.GetBool("proxy_install_only") + } +} diff --git a/config/viper_env_test.go b/config/viper_env_test.go new file mode 100644 index 0000000..f4fd5ce --- /dev/null +++ b/config/viper_env_test.go @@ -0,0 +1,40 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEnvVarNotOverriddenByLegacyConfigFile(t *testing.T) { + t.Run("PMG_PROXY_ENABLED wins over proxy_mode in config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_ENABLED", "false") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_mode: true\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, false, Get().Config.Proxy.Enabled, + "PMG_PROXY_ENABLED=false should not be overridden by proxy_mode: true in config") + }) + + t.Run("PMG_PROXY_INSTALL_ONLY wins over proxy_install_only in 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.Proxy.InstallOnly, + "PMG_PROXY_INSTALL_ONLY=true should not be overridden by proxy_install_only: false in config") + }) +} diff --git a/docs/config.md b/docs/config.md index 546c9aa..7bcf2d5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -31,14 +31,16 @@ file. This is useful for CI/CD pipelines or temporary overrides. |---|---| | `transitive` | `PMG_TRANSITIVE` | | `paranoid` | `PMG_PARANOID` | -| `proxy_mode` | `PMG_PROXY_MODE` | -| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` | +| `proxy.enabled` | `PMG_PROXY_ENABLED` | +| `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` | +Legacy environment variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat keys) are still supported when the `proxy:` section does not exist in the config file. + **Example:** ```bash diff --git a/docs/proxy.md b/docs/proxy.md index 6aa7416..bf525bf 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -3,6 +3,55 @@ A generic, extensible HTTP/HTTPS proxy server with man-in-the-middle (MITM) capabilities for intercepting and analyzing package manager traffic. Built with [goproxy](https://github.com/elazarl/goproxy) library. +## Configuration + +Proxy behavior is configured under the `proxy:` section in `config.yml`: + +```yaml +proxy: + enabled: true + install_only: false + policies: + npm: + skip_commands: ["my-script"] +``` + +| Key | Default | Description | +|---|---|---| +| `enabled` | `true` | Enable proxy-based interception. When `false`, PMG falls back to guard-based analysis. | +| `install_only` | `false` | When `true`, only install commands are proxied. Other commands (e.g., `npm ls`, `pip list`) bypass the proxy and execute directly. | +| `policies` | `{}` | Per-package-manager policies. Each entry maps a package manager name to a policy with `skip_commands`. | + +### Per-package-manager skip commands + +The `policies` section lets you define additional commands that should bypass the proxy for specific package managers: + +```yaml +proxy: + policies: + npm: + skip_commands: ["dev", "my-script"] + pip: + skip_commands: ["list", "show"] +``` + +Commands in `skip_commands` are matched against the first non-flag argument. For example, `npm dev` would match `dev`, but `npm install dev` would not since `install` is the first non-flag argument. + +### CLI flags + +| Flag | Description | +|---|---| +| `--proxy-mode` | Override `proxy.enabled` | + +### Environment variables + +| Variable | Description | +|---|---| +| `PMG_PROXY_ENABLED` | Override `proxy.enabled` | +| `PMG_PROXY_INSTALL_ONLY` | Override `proxy.install_only` | + +Legacy variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat config keys) are still supported when the `proxy:` section does not exist in the config file. + ## Features - Selective interception of HTTPS traffic diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 6f7b512..5c705f8 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -52,12 +52,20 @@ 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)") + // Skip proxy for commands that don't download packages when install_only is enabled + if cfg.Config.Proxy.InstallOnly && !parsedCmd.MayDownloadPackages() { + log.Debugf("Skipping proxy for non-download command (install_only=true)") return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun) } + // Skip proxy for user-defined skip commands + if policy, ok := cfg.Config.Proxy.Policies[f.pm.Name()]; ok && len(policy.SkipCommands) > 0 { + if packagemanager.IsFirstNonFlagArgInList(parsedCmd.Command.Args, policy.SkipCommands) { + log.Debugf("Skipping proxy for user-defined skip command") + return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun) + } + } + // Initialize report data at the start reportData := ui.NewReportData() reportData.PackageManagerName = f.pm.Name() @@ -99,7 +107,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema // Check if dry-run mode is enabled if cfg.DryRun { - log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name()) + log.Infof("Dry-run mode: Would execute %s with proxy protection", f.pm.Name()) log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args) reportData.Outcome = ui.OutcomeDryRun @@ -560,3 +568,4 @@ func (f *proxyFlow) handlePackageManagerExecutionError(err error) error { WithHelp("Check the package manager command and its arguments"). Wrap(err) } + diff --git a/main.go b/main.go index fc9debc..e55f972 100644 --- a/main.go +++ b/main.go @@ -165,7 +165,7 @@ func logDebugContext() { log.Debugf("PMG %s (commit: %s) running on %s/%s with %s", appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version()) log.Debugf("Using config file: %s", cfg.ConfigFilePath()) - log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.ProxyInstallOnly) + log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.Proxy.InstallOnly) log.Debugf("Sandbox enabled: %t, enforce always: %t", cfg.Config.Sandbox.Enabled, cfg.Config.Sandbox.EnforceAlways) log.Debugf("Transitive analysis enabled: %t (depth: %d), paranoid: %t", cfg.Config.Transitive, cfg.Config.TransitiveDepth, cfg.Config.Paranoid) log.Debugf("Dependency cooldown enabled: %t (days: %d)", cfg.Config.DependencyCooldown.Enabled, cfg.Config.DependencyCooldown.Days) diff --git a/packagemanager/npm.go b/packagemanager/npm.go index cdb7004..f17035b 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -134,7 +134,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error } if installCmdIndex == -1 { - return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil + return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil } // Extract arguments after the install command diff --git a/packagemanager/packagemanager.go b/packagemanager/packagemanager.go index feaeade..f10f4f2 100644 --- a/packagemanager/packagemanager.go +++ b/packagemanager/packagemanager.go @@ -47,7 +47,7 @@ type ParsedCommand struct { // IsKnownNonDownloadCommand is true for commands that are known to not download packages // (e.g., npm ls, pip list, yarn why). Used by the proxy to decide whether to skip - // interception when proxy_install_only is enabled. Unknown commands default to false so + // interception when proxy.install_only is enabled. Unknown commands default to false so // the proxy runs — fail safe when a new subcommand is added to a package manager. IsKnownNonDownloadCommand bool } @@ -77,10 +77,10 @@ func (pc *ParsedCommand) ShouldExtractFromManifest() bool { return pc.IsManifestInstall && !pc.HasInstallTarget() } -// isFirstNonFlagArgInList checks if the first non-flag argument in args is in nonDownloadCmds. +// IsFirstNonFlagArgInList checks if the first non-flag argument in args is in the given list. // Only the first non-flag arg (the subcommand) is checked to avoid false positives when package -// names or script arguments happen to match a known non-download command. -func isFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool { +// names or script arguments happen to match a known command. +func IsFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool { for _, arg := range args { if strings.HasPrefix(arg, "-") { continue diff --git a/packagemanager/packagemanager_test.go b/packagemanager/packagemanager_test.go new file mode 100644 index 0000000..0bae3d9 --- /dev/null +++ b/packagemanager/packagemanager_test.go @@ -0,0 +1,126 @@ +package packagemanager + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsFirstNonFlagArgInList(t *testing.T) { + tests := []struct { + name string + args []string + list []string + expected bool + }{ + { + name: "exact match on first arg", + args: []string{"list", "express"}, + list: []string{"list", "show"}, + expected: true, + }, + { + name: "no match", + args: []string{"install", "express"}, + list: []string{"list", "show"}, + expected: false, + }, + { + name: "flags before subcommand are skipped", + args: []string{"--global", "list"}, + list: []string{"list"}, + expected: true, + }, + { + name: "only first non-flag arg is checked", + args: []string{"install", "list"}, + list: []string{"list"}, + expected: false, + }, + { + name: "empty args", + args: []string{}, + list: []string{"list"}, + expected: false, + }, + { + name: "empty list", + args: []string{"list"}, + list: []string{}, + expected: false, + }, + { + name: "all flags no subcommand", + args: []string{"--verbose", "-g", "--save"}, + list: []string{"list"}, + expected: false, + }, + { + name: "short flag before subcommand", + args: []string{"-g", "list"}, + list: []string{"list"}, + expected: true, + }, + { + name: "package name matches list entry but is second arg", + args: []string{"install", "dev"}, + list: []string{"dev"}, + expected: false, + }, + { + name: "partial match does not count", + args: []string{"listing"}, + list: []string{"list"}, + expected: false, + }, + { + name: "case sensitive", + args: []string{"List"}, + list: []string{"list"}, + expected: false, + }, + { + name: "npm run script matching skip command", + args: []string{"run", "dev"}, + list: []string{"run"}, + expected: true, + }, + { + name: "flag-like value after subcommand is irrelevant", + args: []string{"install", "--save", "express"}, + list: []string{"install"}, + expected: true, + }, + { + name: "real world: npm ls", + args: []string{"ls"}, + list: []string{"ls", "list", "outdated", "why", "explain"}, + expected: true, + }, + { + name: "real world: pip install is not in non-download list", + args: []string{"install", "requests"}, + list: []string{"list", "show", "freeze", "check"}, + expected: false, + }, + { + name: "real world: pip list", + args: []string{"list", "--outdated"}, + list: []string{"list", "show", "freeze", "check"}, + expected: true, + }, + { + name: "flag with value before subcommand", + args: []string{"--registry", "https://registry.npmjs.org", "list"}, + list: []string{"list"}, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsFirstNonFlagArgInList(tc.args, tc.list) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index 03428f7..a946a78 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -148,7 +148,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { } if installCmdIndex == -1 { - return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil + return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil } // Extract arguments after the install command @@ -375,7 +375,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error } if installCmdIndex == -1 { - return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil + return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil } // Extract arguments after the install command