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 := 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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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` |
|
| `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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user