feat: consolidate proxy config into structured section and add support for custom commands to skip proxy (#240)

* feat: add ProxyConfig struct with per-PM skip_commands and legacy fallback

* feat: consolidate proxy config into structured section with backward compat

Replaces flat proxy_mode/proxy_install_only keys with a structured proxy
section supporting per-package-manager skip_commands. Legacy keys are
respected via fallback when user's config lacks the new proxy section.
Removes deprecated experimental_proxy_mode config and flag.

* fix: env var resolution for nested config keys and deduplicate skip command matching

- Add "." to "_" in Viper env key replacer so nested keys like
  sandbox.enabled resolve from PMG_SANDBOX_ENABLED (was silently broken)
- Export IsFirstNonFlagArgInList and remove duplicate from proxy_flow.go
- Add table-driven tests for skip command matching with real-world cases
- Remove redundant env var test

* docs: update proxy configuration and env var documentation

Update config.md env var table to reflect new proxy.enabled and
proxy.install_only keys. Add proxy configuration section to proxy.md
covering config structure, per-PM skip commands, CLI flags, and env vars.

* fix: legacy fallback precedence
This commit is contained in:
Sahil Bansal
2026-05-06 18:27:23 +05:30
committed by GitHub
parent d6755d3f44
commit d1dd2560a4
15 changed files with 423 additions and 71 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ func executeSetupInfo() error {
configEntries := make(map[string]string) configEntries := make(map[string]string)
configEntries["Config File"] = cfg.ConfigFilePath() configEntries["Config File"] = cfg.ConfigFilePath()
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled()) 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) ui.PrintInfoSection("Configuration", configEntries)
// Shell Integration section // Shell Integration section
+2 -6
View File
@@ -28,10 +28,8 @@ func ApplyCobraFlags(cmd *cobra.Command) {
globalConfig.Config.Paranoid, "Enable high-security defaults (treat suspicious as malicious)") globalConfig.Config.Paranoid, "Enable high-security defaults (treat suspicious as malicious)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log", cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log",
globalConfig.Config.SkipEventLogging, "Skip event logging") globalConfig.Config.SkipEventLogging, "Skip event logging")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode", cmd.PersistentFlags().BoolVar(&globalConfig.Config.Proxy.Enabled, "proxy-mode",
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)") globalConfig.Config.Proxy.Enabled, "Use proxy based interception")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ProxyMode, "proxy-mode",
globalConfig.Config.ProxyMode, "Use proxy based interception")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox", cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox",
globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)") globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, "sandbox-enforce", 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", cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown",
false, "Skip dependency cooldown enforcement") 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 // FinalizeDependencyCooldownOverride disables dependency cooldown in the global
+20 -13
View File
@@ -65,17 +65,10 @@ type Config struct {
// EventLogRetentionDays is the number of days to retain event logs. // EventLogRetentionDays is the number of days to retain event logs.
EventLogRetentionDays int `mapstructure:"event_log_retention_days"` EventLogRetentionDays int `mapstructure:"event_log_retention_days"`
// ProxyMode enables proxy-based package interception when supported by package managers. // Deprecated: Use Proxy.Enabled instead. Kept for backward compatibility with old config files.
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
ProxyMode bool `mapstructure:"proxy_mode"` ProxyMode bool `mapstructure:"proxy_mode"`
// ExperimentalProxyMode is same as ProxyMode. Kept here for backward compatibility because // Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files.
// 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"` 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".
@@ -88,6 +81,8 @@ type Config struct {
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"` DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
Cloud CloudConfig `mapstructure:"cloud"` Cloud CloudConfig `mapstructure:"cloud"`
Proxy ProxyConfig `mapstructure:"proxy"`
} }
// CloudConfig configures audit event sync to SafeDep Cloud. // CloudConfig configures audit event sync to SafeDep Cloud.
@@ -96,6 +91,16 @@ type CloudConfig struct {
EndpointID string `mapstructure:"endpoint_id"` 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. // SandboxConfig configures the sandbox system for isolating package manager processes.
type SandboxConfig struct { type SandboxConfig struct {
// Enabled enables sandbox mode (opt-in by default for backward compatibility). // Enabled enables sandbox mode (opt-in by default for backward compatibility).
@@ -199,10 +204,8 @@ func (r *RuntimeConfig) ConfigDir() string {
return r.configDir return r.configDir
} }
// IsProxyModeEnabled is a helper function to check for proxy mode with
// support for backward compatibility
func (r *RuntimeConfig) IsProxyModeEnabled() bool { 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. // SandboxAllowType represents the type of a sandbox allow override.
@@ -248,7 +251,6 @@ func DefaultConfig() RuntimeConfig {
DisableTelemetry: false, DisableTelemetry: false,
EventLogRetentionDays: 7, EventLogRetentionDays: 7,
SkipEventLogging: false, SkipEventLogging: false,
ExperimentalProxyMode: false,
TrustedPackages: []TrustedPackage{}, TrustedPackages: []TrustedPackage{},
ProxyMode: true, ProxyMode: true,
Verbosity: VerbosityNormal, Verbosity: VerbosityNormal,
@@ -263,6 +265,11 @@ func DefaultConfig() RuntimeConfig {
Cloud: CloudConfig{ Cloud: CloudConfig{
Enabled: false, Enabled: false,
}, },
Proxy: ProxyConfig{
Enabled: true,
InstallOnly: false,
Policies: map[string]ProxyPolicy{},
},
}, },
DryRun: false, DryRun: false,
InsecureInstallation: insecureInstallation, InsecureInstallation: insecureInstallation,
+16 -7
View File
@@ -32,18 +32,27 @@ skip_event_logging: false
# This is the number of days to retain event logs. # This is the number of days to retain event logs.
event_log_retention_days: 7 event_log_retention_days: 7
# Proxy mode. Default is true. # Proxy configuration.
# When enabled, PMG uses a proxy-based interception approach instead of the # 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 # 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 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. # 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 true, only install commands are proxied. Other commands
# When false, the proxy intercepts all package manager commands (e.g., npm install, npm ls). # (e.g., npm ls, pip list) bypass the proxy and execute directly.
# When true, non-install commands bypass the proxy and execute directly, which can improve install_only: false
# performance for commands that don't download packages (e.g., npm ls, pip list, npm outdated).
proxy_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. # 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.
+102 -29
View File
@@ -28,7 +28,7 @@ func TestConfigHasDefaultValues(t *testing.T) {
assert.Len(t, config.Config.TrustedPackages, 1) 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.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) assert.Equal(t, false, config.Config.Proxy.InstallOnly)
}) })
t.Run("when no config directory is set", func(t *testing.T) { 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 // Missing keys should fall back to DefaultConfig() values, not Go zero values
defaults := DefaultConfig().Config defaults := DefaultConfig().Config
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) 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.Verbosity, config.Config.Verbosity)
assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays) assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays)
assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled) 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 // Top-level fields should fall back to defaults
assert.Equal(t, defaults.Transitive, config.Config.Transitive) assert.Equal(t, defaults.Transitive, config.Config.Transitive)
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) 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) { func TestProxyInstallOnlyConfig(t *testing.T) {
t.Run("defaults to false", func(t *testing.T) { t.Run("defaults to false", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
initConfig() 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) { 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) t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml") 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) require.NoError(t, err)
initConfig() initConfig()
assert.Equal(t, true, Get().Config.ProxyInstallOnly) assert.Equal(t, true, Get().Config.Proxy.InstallOnly)
}) })
} }
// TestConfigPrecedence verifies the expected override order: // TestConfigPrecedence verifies the expected override order:
// flags > env var > config file > default // flags > env var > config file > default
func TestConfigPrecedence(t *testing.T) { 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() tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir) t.Setenv("PMG_CONFIG_DIR", tmpDir)
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
@@ -138,7 +138,8 @@ func TestConfigPrecedence(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
initConfig() 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) { 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", "") t.Setenv("PMG_PROXY_INSTALL_ONLY", "")
configPath := filepath.Join(tmpDir, "config.yml") 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) require.NoError(t, err)
initConfig() 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) { 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") 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.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_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") t.Setenv("PMG_PARANOID", "true")
initConfig() 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) { 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) require.NoError(t, err)
initConfig() 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) { 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") assert.Contains(t, raw, "transitive_depth: 10")
// New keys from template added // 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, "sandbox:")
assert.Contains(t, raw, "verbosity:") assert.Contains(t, raw, "verbosity:")
} }
@@ -329,3 +316,89 @@ func TestWriteTemplateConfigCreatesNewFile(t *testing.T) {
// Should be the full template // Should be the full template
assert.Equal(t, templateConfig, string(result)) 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")
})
}
+42 -1
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/spf13/viper" "github.com/spf13/viper"
"gopkg.in/yaml.v3"
) )
// loadViperConfig loads the configuration using Viper. // loadViperConfig loads the configuration using Viper.
@@ -21,7 +22,7 @@ func loadViperConfig() error {
v.SetConfigType("yaml") v.SetConfigType("yaml")
v.SetEnvPrefix("PMG") v.SetEnvPrefix("PMG")
v.AutomaticEnv() v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
// Load the embedded template as the base so Viper knows all keys and their // 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 // defaults. This is required for AutomaticEnv to resolve PMG_* env vars for
@@ -44,5 +45,45 @@ func loadViperConfig() error {
} }
globalConfig.Config = merged 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 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")
}
}
+40
View File
@@ -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")
})
}
+4 -2
View File
@@ -31,14 +31,16 @@ file. This is useful for CI/CD pipelines or temporary overrides.
|---|---| |---|---|
| `transitive` | `PMG_TRANSITIVE` | | `transitive` | `PMG_TRANSITIVE` |
| `paranoid` | `PMG_PARANOID` | | `paranoid` | `PMG_PARANOID` |
| `proxy_mode` | `PMG_PROXY_MODE` | | `proxy.enabled` | `PMG_PROXY_ENABLED` |
| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` | | `proxy.install_only` | `PMG_PROXY_INSTALL_ONLY` |
| `verbosity` | `PMG_VERBOSITY` | | `verbosity` | `PMG_VERBOSITY` |
| `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` | | `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` |
| `sandbox.enabled` | `PMG_SANDBOX_ENABLED` | | `sandbox.enabled` | `PMG_SANDBOX_ENABLED` |
| `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` | | `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` |
| `cloud.enabled` | `PMG_CLOUD_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:** **Example:**
```bash ```bash
+49
View File
@@ -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. 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. 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 ## Features
- Selective interception of HTTPS traffic - Selective interception of HTTPS traffic
+13 -4
View File
@@ -52,12 +52,20 @@ 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 // Skip proxy for commands that don't download packages when install_only is enabled
if cfg.Config.ProxyInstallOnly && !parsedCmd.MayDownloadPackages() { if cfg.Config.Proxy.InstallOnly && !parsedCmd.MayDownloadPackages() {
log.Debugf("Skipping proxy for non-download command (proxy_install_only=true)") log.Debugf("Skipping proxy for non-download command (install_only=true)")
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun) 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 // Initialize report data at the start
reportData := ui.NewReportData() reportData := ui.NewReportData()
reportData.PackageManagerName = f.pm.Name() 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 // Check if dry-run mode is enabled
if cfg.DryRun { 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) log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
reportData.Outcome = ui.OutcomeDryRun reportData.Outcome = ui.OutcomeDryRun
@@ -560,3 +568,4 @@ func (f *proxyFlow) handlePackageManagerExecutionError(err error) error {
WithHelp("Check the package manager command and its arguments"). WithHelp("Check the package manager command and its arguments").
Wrap(err) Wrap(err)
} }
+1 -1
View File
@@ -165,7 +165,7 @@ func logDebugContext() {
log.Debugf("PMG %s (commit: %s) running on %s/%s with %s", log.Debugf("PMG %s (commit: %s) running on %s/%s with %s",
appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version()) appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version())
log.Debugf("Using config file: %s", cfg.ConfigFilePath()) 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("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("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) log.Debugf("Dependency cooldown enabled: %t (days: %d)", cfg.Config.DependencyCooldown.Enabled, cfg.Config.DependencyCooldown.Days)
+1 -1
View File
@@ -134,7 +134,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
} }
if installCmdIndex == -1 { 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 // Extract arguments after the install command
+4 -4
View File
@@ -47,7 +47,7 @@ type ParsedCommand struct {
// IsKnownNonDownloadCommand is true for commands that are known to not download packages // 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 // (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. // the proxy runs — fail safe when a new subcommand is added to a package manager.
IsKnownNonDownloadCommand bool IsKnownNonDownloadCommand bool
} }
@@ -77,10 +77,10 @@ func (pc *ParsedCommand) ShouldExtractFromManifest() bool {
return pc.IsManifestInstall && !pc.HasInstallTarget() 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 // 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. // names or script arguments happen to match a known command.
func isFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool { func IsFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool {
for _, arg := range args { for _, arg := range args {
if strings.HasPrefix(arg, "-") { if strings.HasPrefix(arg, "-") {
continue continue
+126
View File
@@ -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)
})
}
}
+2 -2
View File
@@ -148,7 +148,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
} }
if installCmdIndex == -1 { 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 // Extract arguments after the install command
@@ -375,7 +375,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
} }
if installCmdIndex == -1 { 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 // Extract arguments after the install command