mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
+1
-1
@@ -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
|
||||
|
||||
+2
-6
@@ -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
|
||||
|
||||
+20
-13
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+102
-29
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
+42
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user