mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add proxy_install_only config to restrict proxy to download commands (#222)
* feat: Add proxy_install_only config to restrict proxy to download commands Introduces proxy_install_only (default: false) which, when enabled, skips the proxy for package manager commands that do not download packages (e.g. npm ls, pip list), avoiding unnecessary MITM overhead. - Add ProxyInstallOnly to Config and config template - Add IsKnownDownloadCommand / MayDownloadPackages to ParsedCommand - Add DownloadCommands to npm and pypi PM configs covering update, ci, audit, dlx, exec, x, download, run and equivalents per PM - Extract shared runner.Execute used by both proxy flow and guard - Proxy flow short-circuits to runner.Execute for non-download commands when proxy_install_only=true * refactor: Inject CommandExecutor into guard to fix dependency direction guard depended on internal/runner, which inverted the intended layer hierarchy. Now guard defines a CommandExecutor function type and accepts it as a constructor argument. internal/flows (the composition root) creates the executor closure wrapping runner.Execute and injects it, keeping guard free of internal/ dependencies. * refactor: Invert proxy_install_only logic to use known non-download commands Replace the DownloadCommands allowlist (opt-in, fail-open) with a NonDownloadCommands denylist (opt-out, fail-safe). The proxy now runs for all commands except those explicitly known to not download packages. Unknown or future package manager subcommands default to running with the proxy. Includes script runners (run, start, test, stop, restart) that can spin up local servers — setting proxy env vars on these breaks them without providing any security benefit. Also covers removal commands and local operations that never contact the registry. * fix: Support PMG_* env vars regardless of config file state AutomaticEnv only resolves env vars for keys Viper already knows about via AllKeys(). When a key is absent from the config file (commented out, new key added after last setup, or no config file at all), Viper had no knowledge of it and silently skipped the env var. Fix by registering all Config struct fields as Viper defaults via reflection (using mapstructure tags) before reading the config file. This ensures PMG_* env vars work in all cases. Precedence: cobra flags > env vars > config file > defaults. SetDefault is used (not Set) so env vars and config file can still override the Go defaults freely. Tests added covering all precedence levels including the key-absent- from-config-file case that was the original bug report. * fix: Only check first non-flag arg against NonDownloadCommands Scanning all args caused false proxy bypasses when package names or script arguments matched a NonDownloadCommands entry. For example: - npm exec test → "test" matched, proxy incorrectly skipped - npm update config → "config" matched, proxy skipped - npm publish --tag version → "version" matched, proxy skipped Fix by checking only the first non-flag argument (the subcommand). If it is not in NonDownloadCommands we break immediately, so trailing args never influence the classification. Applied to all four parsers: npm, pip/pip3, uv, and poetry. Regression tests added for the false positive cases. * refactor: Replace reflection-based Viper defaults with embedded template Load the embedded config template as the Viper base so all keys are registered upfront, enabling PMG_* env vars to work regardless of whether a key exists in the user's config file. * fix: Restore trusted_packages template entry and revert DefaultConfig change * docs: Document environment variable overrides for config keys * update npm test cmd * refactor: extract shared non-download command detection helper Replaces duplicated first-non-flag-arg detection loops in npm.go and pypi.go (pip + poetry parsers) with a shared isFirstNonFlagArgInList helper in packagemanager.go. https://claude.ai/code/session_01AHaKF3vc2Haj9tK3jgUBAs --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,7 @@ pip install requests
|
|||||||
Verify PMG is working by installing a test package. This is a harmless package flagged as malicious in the SafeDep database, specifically meant for testing:
|
Verify PMG is working by installing a test package. This is a harmless package flagged as malicious in the SafeDep database, specifically meant for testing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i safedep-test-pkg@0.1.3
|
npm --prefer-online --no-cache i safedep-test-pkg@0.1.3
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ type Config struct {
|
|||||||
// we initially introduced it as an experimental feature.
|
// we initially introduced it as an experimental feature.
|
||||||
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
|
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
|
||||||
|
|
||||||
|
// ProxyInstallOnly restricts proxy interception to install commands only.
|
||||||
|
// When false (default), proxy runs for all package manager commands.
|
||||||
|
// When true, non-install commands (e.g., npm ls, pip list) bypass the proxy and execute directly.
|
||||||
|
ProxyInstallOnly bool `mapstructure:"proxy_install_only"`
|
||||||
|
|
||||||
// Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
|
// Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
|
||||||
Verbosity string `mapstructure:"verbosity"`
|
Verbosity string `mapstructure:"verbosity"`
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ event_log_retention_days: 7
|
|||||||
# 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_mode: true
|
||||||
|
|
||||||
|
# Restrict proxy to install commands only. Default is false.
|
||||||
|
# When false, the proxy intercepts all package manager commands (e.g., npm install, npm ls).
|
||||||
|
# When true, non-install commands bypass the proxy and execute directly, which can improve
|
||||||
|
# performance for commands that don't download packages (e.g., npm ls, pip list, npm outdated).
|
||||||
|
proxy_install_only: false
|
||||||
|
|
||||||
# Trusted packages are packages that are trusted by the user and will be ignored by the security guardrails.
|
# 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.
|
||||||
# Example:
|
# Example:
|
||||||
|
|||||||
+106
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -24,9 +25,10 @@ func TestConfigHasDefaultValues(t *testing.T) {
|
|||||||
assert.Equal(t, 5, config.Config.TransitiveDepth)
|
assert.Equal(t, 5, config.Config.TransitiveDepth)
|
||||||
assert.Equal(t, false, config.Config.IncludeDevDependencies)
|
assert.Equal(t, false, config.Config.IncludeDevDependencies)
|
||||||
assert.Equal(t, false, config.Config.Paranoid)
|
assert.Equal(t, false, config.Config.Paranoid)
|
||||||
assert.Equal(t, []TrustedPackage{}, config.Config.TrustedPackages)
|
assert.Len(t, config.Config.TrustedPackages, 1)
|
||||||
assert.Equal(t, "/tmp/pmg-test/random-does-not-exist", config.configDir)
|
assert.Equal(t, "/tmp/pmg-test/random-does-not-exist", config.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)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("when no config directory is set", func(t *testing.T) {
|
t.Run("when no config directory is set", func(t *testing.T) {
|
||||||
@@ -103,6 +105,109 @@ func TestPartialConfigWithNestedOverride(t *testing.T) {
|
|||||||
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
|
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProxyInstallOnlyConfig(t *testing.T) {
|
||||||
|
t.Run("defaults to false", func(t *testing.T) {
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, false, Get().Config.ProxyInstallOnly)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("can be set to true via config file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.ProxyInstallOnly)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigPrecedence verifies the expected override order:
|
||||||
|
// flags > env var > config file > default
|
||||||
|
func TestConfigPrecedence(t *testing.T) {
|
||||||
|
t.Run("env var overrides config file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte("proxy_install_only: false\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should override config file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("config file overrides default", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
t.Setenv("PMG_PROXY_INSTALL_ONLY", "")
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "config file should override default")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env var works when key is absent from config file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
|
||||||
|
|
||||||
|
// Config file exists but proxy_install_only is not in it (e.g. commented out
|
||||||
|
// or user hasn't re-run "pmg setup install" after an upgrade)
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even when key is absent from config file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env var works without a config file", func(t *testing.T) {
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
|
||||||
|
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even without a config file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default is used when neither env var nor config file sets the key", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
t.Setenv("PMG_PROXY_INSTALL_ONLY", "")
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, false, Get().Config.ProxyInstallOnly, "should use default when key absent from config and env")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cobra flag overrides env var", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
t.Setenv("PMG_PARANOID", "true")
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
assert.Equal(t, true, Get().Config.Paranoid, "env var should set paranoid=true")
|
||||||
|
|
||||||
|
// Simulate cobra flag parsing — BoolVar writes directly to the struct
|
||||||
|
// field after Viper, giving flags the highest effective precedence.
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
ApplyCobraFlags(cmd)
|
||||||
|
require.NoError(t, cmd.ParseFlags([]string{"--paranoid=false"}))
|
||||||
|
|
||||||
|
assert.Equal(t, false, Get().Config.Paranoid, "cobra flag should override env var")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
|
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|||||||
+16
-22
@@ -8,42 +8,36 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadViperConfig loads the configuration using Viper if available.
|
// loadViperConfig loads the configuration using Viper.
|
||||||
// It returns an error if the config file exists but cannot be read or parsed,
|
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
|
||||||
// allowing the caller to fall back to default configuration.
|
// Cobra flags write directly to the config struct after this function runs.
|
||||||
func loadViperConfig() error {
|
func loadViperConfig() error {
|
||||||
configPath, err := configFilePath()
|
configPath, err := configFilePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get config file path: %w", err)
|
return fmt.Errorf("failed to get config file path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if config file exists before attempting to load
|
|
||||||
// If it doesn't exist, we use the default configuration (see config.go)
|
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
|
|
||||||
v.SetConfigFile(configPath)
|
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
v.SetEnvPrefix("PMG")
|
v.SetEnvPrefix("PMG")
|
||||||
v.AutomaticEnv()
|
v.AutomaticEnv()
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||||
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
// Load the embedded template as the base so Viper knows all keys and their
|
||||||
return fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
// defaults. This is required for AutomaticEnv to resolve PMG_* env vars for
|
||||||
|
// keys that are absent from or newer than the user's config file.
|
||||||
|
if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil {
|
||||||
|
return fmt.Errorf("failed to load default config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge user config on top if it exists.
|
||||||
|
if _, statErr := os.Stat(configPath); statErr == nil {
|
||||||
|
v.SetConfigFile(configPath)
|
||||||
|
if err := v.MergeInConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal into a copy of the current defaults (not a zero-value struct) so that
|
|
||||||
// keys missing from the YAML retain their defaults. This is critical for users who
|
|
||||||
// upgrade PMG without re-running "pmg setup install" — their old config.yml won't have
|
|
||||||
// newer keys (e.g. dependency_cooldown), and those must fall back to defaults rather than
|
|
||||||
// silently becoming Go zero values (false/0/"").
|
|
||||||
//
|
|
||||||
// We use a copy rather than unmarshalling directly into globalConfig.Config so that
|
|
||||||
// on error the caller's "using defaults" fallback is truthful — globalConfig.Config
|
|
||||||
// stays in a clean default state instead of being partially overwritten.
|
|
||||||
merged := globalConfig.Config
|
merged := globalConfig.Config
|
||||||
if err := v.Unmarshal(&merged); err != nil {
|
if err := v.Unmarshal(&merged); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
|||||||
@@ -13,3 +13,38 @@ pmg setup info
|
|||||||
```
|
```
|
||||||
|
|
||||||
See [config template](../config/config.template.yml) for the configuration schema.
|
See [config template](../config/config.template.yml) for the configuration schema.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Any configuration key can be overridden using environment variables, without modifying the config file. This is useful for CI/CD pipelines or temporary overrides.
|
||||||
|
|
||||||
|
**Format:** `PMG_<KEY>` where the key is the config key uppercased, with nested keys joined by `_`.
|
||||||
|
|
||||||
|
| Config key | Environment variable |
|
||||||
|
|---|---|
|
||||||
|
| `transitive` | `PMG_TRANSITIVE` |
|
||||||
|
| `paranoid` | `PMG_PARANOID` |
|
||||||
|
| `proxy_mode` | `PMG_PROXY_MODE` |
|
||||||
|
| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` |
|
||||||
|
| `verbosity` | `PMG_VERBOSITY` |
|
||||||
|
| `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` |
|
||||||
|
| `sandbox.enabled` | `PMG_SANDBOX_ENABLED` |
|
||||||
|
| `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` |
|
||||||
|
| `cloud.enabled` | `PMG_CLOUD_ENABLED` |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable paranoid mode without editing the config file
|
||||||
|
PMG_PARANOID=true pmg npm install express
|
||||||
|
|
||||||
|
# Restrict proxy to install commands only
|
||||||
|
PMG_PROXY_INSTALL_ONLY=true pmg npm install express
|
||||||
|
```
|
||||||
|
|
||||||
|
**Precedence (highest to lowest):**
|
||||||
|
|
||||||
|
1. CLI flags
|
||||||
|
2. Environment variables (`PMG_*`)
|
||||||
|
3. Config file (`config.yml`)
|
||||||
|
4. Built-in defaults
|
||||||
+9
-49
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,10 +17,13 @@ import (
|
|||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
"github.com/safedep/pmg/sandbox/executor"
|
|
||||||
"github.com/safedep/pmg/usefulerror"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CommandExecutor executes a parsed package manager command directly.
|
||||||
|
// It is injected into the guard so that callers control execution behavior
|
||||||
|
// (e.g., dry-run, sandbox application) without guard depending on internal packages.
|
||||||
|
type CommandExecutor func(ctx context.Context, pc *packagemanager.ParsedCommand) error
|
||||||
|
|
||||||
type PackageManagerGuardInteraction struct {
|
type PackageManagerGuardInteraction struct {
|
||||||
// SetStatus is called to set the status of the guard in the UI
|
// SetStatus is called to set the status of the guard in the UI
|
||||||
SetStatus func(status string)
|
SetStatus func(status string)
|
||||||
@@ -98,6 +100,7 @@ type packageManagerGuard struct {
|
|||||||
analyzers []analyzer.PackageVersionAnalyzer
|
analyzers []analyzer.PackageVersionAnalyzer
|
||||||
packageManager packagemanager.PackageManager
|
packageManager packagemanager.PackageManager
|
||||||
packageResolver packagemanager.PackageResolver
|
packageResolver packagemanager.PackageResolver
|
||||||
|
executor CommandExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
||||||
@@ -105,6 +108,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
|||||||
packageResolver packagemanager.PackageResolver,
|
packageResolver packagemanager.PackageResolver,
|
||||||
analyzers []analyzer.PackageVersionAnalyzer,
|
analyzers []analyzer.PackageVersionAnalyzer,
|
||||||
interaction PackageManagerGuardInteraction,
|
interaction PackageManagerGuardInteraction,
|
||||||
|
executor CommandExecutor,
|
||||||
) (*packageManagerGuard, error) {
|
) (*packageManagerGuard, error) {
|
||||||
return &packageManagerGuard{
|
return &packageManagerGuard{
|
||||||
interaction: interaction,
|
interaction: interaction,
|
||||||
@@ -112,6 +116,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
|||||||
packageManager: packageManager,
|
packageManager: packageManager,
|
||||||
packageResolver: packageResolver,
|
packageResolver: packageResolver,
|
||||||
config: config,
|
config: config,
|
||||||
|
executor: executor,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,52 +259,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
||||||
if len(pc.Command.Exe) == 0 {
|
return g.executor(ctx, pc)
|
||||||
return fmt.Errorf("no command to execute")
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.config.DryRun {
|
|
||||||
log.Debugf("Dry run, skipping command execution")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...)
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
pmName := g.packageManager.Name()
|
|
||||||
result, err := executor.ApplySandbox(ctx, cmd, pmName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply sandbox: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := result.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to close sandbox: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if result.ShouldRun() {
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
humanError := "Failed to execute package manager command"
|
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
||||||
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
return usefulerror.Useful().
|
|
||||||
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
||||||
WithHumanError(humanError).
|
|
||||||
WithHelp("Check the package manager command and its arguments").
|
|
||||||
Wrap(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
||||||
|
|||||||
+11
-5
@@ -11,6 +11,12 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// noopExecutor is a no-op executor for use in tests that set DryRun=true
|
||||||
|
// or otherwise don't reach actual command execution.
|
||||||
|
var noopExecutor CommandExecutor = func(_ context.Context, _ *packagemanager.ParsedCommand) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
||||||
mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
|
mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -20,7 +26,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
|||||||
pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil,
|
pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil,
|
||||||
[]analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{
|
[]analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{
|
||||||
ShowWarning: func(message string) {},
|
ShowWarning: func(message string) {},
|
||||||
})
|
}, noopExecutor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create pg: %v", err)
|
t.Fatalf("failed to create pg: %v", err)
|
||||||
}
|
}
|
||||||
@@ -80,7 +86,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pg, err := NewPackageManagerGuard(config, nil, nil,
|
pg, err := NewPackageManagerGuard(config, nil, nil,
|
||||||
[]analyzer.PackageVersionAnalyzer{mq}, interaction)
|
[]analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create pg: %v", err)
|
t.Fatalf("failed to create pg: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pg, err := NewPackageManagerGuard(config, nil, nil,
|
pg, err := NewPackageManagerGuard(config, nil, nil,
|
||||||
[]analyzer.PackageVersionAnalyzer{mq}, interaction)
|
[]analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create pg: %v", err)
|
t.Fatalf("failed to create pg: %v", err)
|
||||||
}
|
}
|
||||||
@@ -184,7 +190,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pg, err := NewPackageManagerGuard(config, nil, nil,
|
pg, err := NewPackageManagerGuard(config, nil, nil,
|
||||||
[]analyzer.PackageVersionAnalyzer{mq}, interaction)
|
[]analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create pg: %v", err)
|
t.Fatalf("failed to create pg: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +231,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pg, err := NewPackageManagerGuard(config, nil, nil,
|
pg, err := NewPackageManagerGuard(config, nil, nil,
|
||||||
[]analyzer.PackageVersionAnalyzer{mq}, interaction)
|
[]analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create pg: %v", err)
|
t.Fatalf("failed to create pg: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/guard"
|
"github.com/safedep/pmg/guard"
|
||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
|
"github.com/safedep/pmg/internal/runner"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
)
|
)
|
||||||
@@ -77,7 +78,12 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
|||||||
guardConfig.DryRun = cfg.DryRun
|
guardConfig.DryRun = cfg.DryRun
|
||||||
guardConfig.InsecureInstallation = cfg.InsecureInstallation
|
guardConfig.InsecureInstallation = cfg.InsecureInstallation
|
||||||
|
|
||||||
guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction)
|
pmName := f.pm.Name()
|
||||||
|
executor := func(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
||||||
|
return runner.Execute(ctx, pc, pmName, cfg.DryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction, executor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create package manager guard: %s", err)
|
return fmt.Errorf("failed to create package manager guard: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/safedep/pmg/guard"
|
"github.com/safedep/pmg/guard"
|
||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
"github.com/safedep/pmg/internal/pty"
|
"github.com/safedep/pmg/internal/pty"
|
||||||
|
"github.com/safedep/pmg/internal/runner"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
@@ -51,6 +52,12 @@ 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
|
||||||
|
if cfg.Config.ProxyInstallOnly && !parsedCmd.MayDownloadPackages() {
|
||||||
|
log.Debugf("Skipping proxy for non-download command (proxy_install_only=true)")
|
||||||
|
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize report data at the start
|
// Initialize report data at the start
|
||||||
reportData := ui.NewReportData()
|
reportData := ui.NewReportData()
|
||||||
reportData.PackageManagerName = f.pm.Name()
|
reportData.PackageManagerName = f.pm.Name()
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/packagemanager"
|
||||||
|
"github.com/safedep/pmg/sandbox/executor"
|
||||||
|
"github.com/safedep/pmg/usefulerror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute runs a package manager command without proxy or guard analysis.
|
||||||
|
// It applies sandbox policy if configured, then executes the command directly.
|
||||||
|
func Execute(ctx context.Context, pc *packagemanager.ParsedCommand, pmName string, dryRun bool) error {
|
||||||
|
if len(pc.Command.Exe) == 0 {
|
||||||
|
return fmt.Errorf("no command to execute")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
log.Debugf("Dry run, skipping command execution")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
result, err := executor.ApplySandbox(ctx, cmd, pmName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := result.Close(); err != nil {
|
||||||
|
log.Errorf("failed to close sandbox: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if result.ShouldRun() {
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
humanError := "Failed to execute package manager command"
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
return usefulerror.Useful().
|
||||||
|
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
||||||
|
WithHumanError(humanError).
|
||||||
|
WithHelp("Check the package manager command and its arguments").
|
||||||
|
Wrap(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+45
-8
@@ -11,35 +11,73 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NpmPackageManagerConfig struct {
|
type NpmPackageManagerConfig struct {
|
||||||
InstallCommands []string
|
InstallCommands []string
|
||||||
CommandName string
|
NonDownloadCommands []string
|
||||||
|
CommandName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
InstallCommands: []string{"install", "i", "add"},
|
||||||
CommandName: "npm",
|
// Commands that are known to never download packages from a registry.
|
||||||
|
// Anything not in this list (including unknown future commands) runs with the proxy.
|
||||||
|
//
|
||||||
|
// Script runners: "run", "start", "stop", "restart", "test"/"t" are all shorthand for
|
||||||
|
// "npm run <script>". They spin up local processes (dev servers, test runners) that make
|
||||||
|
// their own HTTP calls — setting proxy env vars breaks them without providing any security
|
||||||
|
// benefit since they don't contact the package registry themselves.
|
||||||
|
//
|
||||||
|
// "exec" is intentionally excluded — it downloads and runs a package (npx equivalent).
|
||||||
|
NonDownloadCommands: []string{
|
||||||
|
// Script runners — may start servers or long-running processes
|
||||||
|
"run", "start", "stop", "restart", "test", "t",
|
||||||
|
// Removal — uninstalls local packages, no registry download
|
||||||
|
"uninstall", "remove", "rm", "r", "un", "unlink",
|
||||||
|
// Local operations — no registry contact
|
||||||
|
"rebuild", "prune", "link", "cache", "pack",
|
||||||
|
// Inspection / read-only registry queries
|
||||||
|
"ls", "list", "outdated", "view", "info", "show", "search",
|
||||||
|
"config", "ping", "whoami", "version", "help",
|
||||||
|
},
|
||||||
|
CommandName: "npm",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
|
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
InstallCommands: []string{"install", "i", "add"},
|
||||||
CommandName: "pnpm",
|
NonDownloadCommands: []string{
|
||||||
|
"run", "start", "stop", "restart", "test",
|
||||||
|
"remove", "rm", "uninstall", "un",
|
||||||
|
"prune", "link", "unlink",
|
||||||
|
"ls", "list", "outdated", "info", "view", "config", "why",
|
||||||
|
},
|
||||||
|
CommandName: "pnpm",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
|
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "i", "add"},
|
InstallCommands: []string{"install", "i", "add"},
|
||||||
CommandName: "bun",
|
NonDownloadCommands: []string{
|
||||||
|
// Script runners and local operations
|
||||||
|
"run", "test", "build",
|
||||||
|
// Removal
|
||||||
|
"remove", "rm",
|
||||||
|
},
|
||||||
|
CommandName: "bun",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
|
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
|
||||||
return NpmPackageManagerConfig{
|
return NpmPackageManagerConfig{
|
||||||
InstallCommands: []string{"install", "add", ""},
|
InstallCommands: []string{"install", "add", ""},
|
||||||
CommandName: "yarn",
|
NonDownloadCommands: []string{
|
||||||
|
"run", "start", "stop", "restart", "test",
|
||||||
|
"remove", "unlink",
|
||||||
|
"ls", "list", "outdated", "info", "config", "why",
|
||||||
|
},
|
||||||
|
CommandName: "yarn",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +134,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
if installCmdIndex == -1 {
|
||||||
// No install command found, return as-is
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil
|
||||||
return &ParsedCommand{Command: command}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract arguments after the install command
|
// Extract arguments after the install command
|
||||||
|
|||||||
+205
-2
@@ -71,12 +71,53 @@ func TestNpmParseCommand(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not an installation command",
|
name: "update is not a known non-download command (proxy runs)",
|
||||||
command: "npm update @types/node",
|
command: "npm update @types/node",
|
||||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, parsedCommand)
|
assert.NotNil(t, parsedCommand)
|
||||||
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
|
assert.Empty(t, parsedCommand.InstallTargets)
|
||||||
|
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
|
||||||
|
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ci is not a known non-download command (proxy runs)",
|
||||||
|
command: "npm ci",
|
||||||
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
|
||||||
|
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||||
|
assert.False(t, parsedCommand.IsInstallationCommand())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audit is not a known non-download command (proxy runs; audit fix can download)",
|
||||||
|
command: "npm audit",
|
||||||
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
|
||||||
|
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ls is a known non-download command (proxy skipped)",
|
||||||
|
command: "npm ls",
|
||||||
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, parsedCommand.IsKnownNonDownloadCommand)
|
||||||
|
assert.False(t, parsedCommand.MayDownloadPackages())
|
||||||
|
assert.False(t, parsedCommand.IsInstallationCommand())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "install sets MayDownloadPackages via IsInstallationCommand",
|
||||||
|
command: "npm install express",
|
||||||
|
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
|
||||||
|
assert.True(t, parsedCommand.IsInstallationCommand())
|
||||||
|
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,3 +444,165 @@ func TestBunParseCommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNpmProxyBehavior(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pm func() (*npmPackageManager, error)
|
||||||
|
command string
|
||||||
|
isKnownNonDownloadCmd bool // proxy skipped when proxy_install_only=true
|
||||||
|
isInstallationCommand bool
|
||||||
|
}{
|
||||||
|
// Commands that proxy MUST run for (not known-safe)
|
||||||
|
{
|
||||||
|
name: "yarn upgrade — proxy runs (may download)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||||
|
command: "yarn upgrade",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pnpm update — proxy runs (may download)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||||
|
command: "pnpm update",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bun update — proxy runs (may download)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
|
||||||
|
command: "bun update",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "npm exec — proxy runs (may download and run package)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm exec create-react-app",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pnpm dlx — proxy runs (downloads and runs package)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||||
|
command: "pnpm dlx create-react-app",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pnpm exec — proxy runs (may resolve packages)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||||
|
command: "pnpm exec tsc",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yarn dlx — proxy runs (downloads and runs package)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||||
|
command: "yarn dlx create-react-app",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bun x — proxy runs (bun's npx equivalent)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
|
||||||
|
command: "bun x create-vite",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// Commands where proxy is safely skipped
|
||||||
|
{
|
||||||
|
name: "npm outdated — proxy skipped (read-only registry check)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm outdated",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "npm list — proxy skipped (lists installed packages)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm list",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pnpm why — proxy skipped (dependency reason lookup)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||||
|
command: "pnpm why express",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yarn why — proxy skipped (dependency reason lookup)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||||
|
command: "yarn why express",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// Script execution via run
|
||||||
|
{
|
||||||
|
name: "npm run dev — proxy skipped (executes local script, no registry contact)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm run dev",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yarn run build — proxy skipped (executes local script)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||||
|
command: "yarn run build",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bun run test — proxy skipped (executes local script)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
|
||||||
|
command: "bun run test",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// False positive regression: package/script names matching NonDownloadCommands words
|
||||||
|
{
|
||||||
|
name: "npm exec test — proxy runs (test is package arg, not subcommand)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm exec test",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "npm update config — proxy runs (config is package name, not subcommand)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm update config",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "npm publish --tag version — proxy runs (version is flag value, not subcommand)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm publish --tag version",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// Unknown commands default to proxy running (fail safe)
|
||||||
|
{
|
||||||
|
name: "unknown npm subcommand — proxy runs (fail safe)",
|
||||||
|
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||||
|
command: "npm some-future-command",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pm, err := tc.pm()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := pm.ParseCommand(strings.Split(tc.command, " "))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.isKnownNonDownloadCmd, parsed.IsKnownNonDownloadCommand)
|
||||||
|
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
|
||||||
|
assert.Equal(t, !tc.isKnownNonDownloadCmd, parsed.MayDownloadPackages())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package packagemanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
)
|
)
|
||||||
@@ -38,13 +40,27 @@ type ParsedCommand struct {
|
|||||||
// ManifestFiles contains the list of manifest files to install from
|
// ManifestFiles contains the list of manifest files to install from
|
||||||
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
|
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
|
||||||
ManifestFiles []string
|
ManifestFiles []string
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the proxy runs — fail safe when a new subcommand is added to a package manager.
|
||||||
|
IsKnownNonDownloadCommand bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInstallationCommand returns true if command installs packages (explicit targets or from manifest).
|
// IsInstallationCommand returns true if command installs packages (explicit targets or from manifest).
|
||||||
|
// This is used by guard mode where we need to know which packages are being installed.
|
||||||
func (pc *ParsedCommand) IsInstallationCommand() bool {
|
func (pc *ParsedCommand) IsInstallationCommand() bool {
|
||||||
return pc.HasInstallTarget() || pc.HasManifestInstall()
|
return pc.HasInstallTarget() || pc.HasManifestInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MayDownloadPackages returns true if the command may download packages from a registry.
|
||||||
|
// Returns false only for commands explicitly known to be non-download (e.g., npm ls, pip list).
|
||||||
|
// Unknown commands return true by default — fail safe when new package manager subcommands appear.
|
||||||
|
func (pc *ParsedCommand) MayDownloadPackages() bool {
|
||||||
|
return !pc.IsKnownNonDownloadCommand
|
||||||
|
}
|
||||||
|
|
||||||
func (pc *ParsedCommand) HasInstallTarget() bool {
|
func (pc *ParsedCommand) HasInstallTarget() bool {
|
||||||
return len(pc.InstallTargets) > 0
|
return len(pc.InstallTargets) > 0
|
||||||
}
|
}
|
||||||
@@ -57,6 +73,19 @@ 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.
|
||||||
|
// 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 {
|
||||||
|
for _, arg := range args {
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return slices.Contains(nonDownloadCmds, arg)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// PackageManager is the contract for implementing a package manager
|
// PackageManager is the contract for implementing a package manager
|
||||||
type PackageManager interface {
|
type PackageManager interface {
|
||||||
// Name of the package manager implementation
|
// Name of the package manager implementation
|
||||||
|
|||||||
+39
-11
@@ -18,35 +18,60 @@ type pypiCommandParser interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PypiPackageManagerConfig struct {
|
type PypiPackageManagerConfig struct {
|
||||||
InstallCommands []string
|
InstallCommands []string
|
||||||
CommandName string
|
NonDownloadCommands []string
|
||||||
|
CommandName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"install"},
|
InstallCommands: []string{"install"},
|
||||||
CommandName: "pip",
|
NonDownloadCommands: []string{
|
||||||
|
// Removal — no registry download
|
||||||
|
"uninstall",
|
||||||
|
// Inspection / read-only
|
||||||
|
"list", "show", "check", "freeze", "config",
|
||||||
|
},
|
||||||
|
CommandName: "pip",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"install"},
|
InstallCommands: []string{"install"},
|
||||||
CommandName: "pip3",
|
NonDownloadCommands: []string{
|
||||||
|
"uninstall",
|
||||||
|
"list", "show", "check", "freeze", "config",
|
||||||
|
},
|
||||||
|
CommandName: "pip3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"add", "install"},
|
InstallCommands: []string{"add", "install"},
|
||||||
CommandName: "uv",
|
// uv uses nested subcommands (e.g., `uv pip list`, `uv tool run`) making it
|
||||||
|
// unsafe to classify commands by a single arg scan. Run the proxy for all
|
||||||
|
// non-install uv commands to avoid missing coverage.
|
||||||
|
NonDownloadCommands: []string{},
|
||||||
|
CommandName: "uv",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
return PypiPackageManagerConfig{
|
return PypiPackageManagerConfig{
|
||||||
InstallCommands: []string{"add"},
|
InstallCommands: []string{"add"},
|
||||||
CommandName: "poetry",
|
NonDownloadCommands: []string{
|
||||||
|
// Script runners — "run" executes a command in the venv (e.g., `poetry run uvicorn app:app`).
|
||||||
|
// "shell" activates the venv shell. Both may start long-running processes and must not
|
||||||
|
// have proxy env vars set against them.
|
||||||
|
"run", "shell",
|
||||||
|
// Removal
|
||||||
|
"remove",
|
||||||
|
// Inspection / read-only
|
||||||
|
"show", "config", "check",
|
||||||
|
},
|
||||||
|
CommandName: "poetry",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +148,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
if installCmdIndex == -1 {
|
||||||
// No install command found, return as-is
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
|
||||||
return &ParsedCommand{Command: command}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract arguments after the install command
|
// Extract arguments after the install command
|
||||||
@@ -242,7 +266,12 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
if installCmdIndex == -1 {
|
||||||
// No install command found, return as-is
|
for _, arg := range args {
|
||||||
|
if slices.Contains(u.config.NonDownloadCommands, arg) {
|
||||||
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &ParsedCommand{Command: command}, nil
|
return &ParsedCommand{Command: command}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +369,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installCmdIndex == -1 {
|
if installCmdIndex == -1 {
|
||||||
// No install command found, return as-is
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
|
||||||
return &ParsedCommand{Command: command}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract arguments after the install command
|
// Extract arguments after the install command
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package packagemanager
|
package packagemanager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -856,3 +857,114 @@ func TestPypiConvertWildcardConstraint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPypiProxyBehavior(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pm func() (*pypiPackageManager, error)
|
||||||
|
command string
|
||||||
|
isKnownNonDownloadCmd bool // proxy skipped when proxy_install_only=true
|
||||||
|
isInstallationCommand bool
|
||||||
|
}{
|
||||||
|
// Commands where proxy MUST run
|
||||||
|
{
|
||||||
|
name: "poetry update — proxy runs (may download)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||||
|
command: "poetry update",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "poetry add — proxy runs (installation command)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||||
|
command: "poetry add django",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uv sync — proxy runs (manifest install)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||||
|
command: "uv sync",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pip download — proxy runs (explicitly downloads packages)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||||
|
command: "pip download requests",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pip3 download — proxy runs (explicitly downloads packages)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPip3PackageManagerConfig()) },
|
||||||
|
command: "pip3 download django",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uv pip download — proxy runs (uv has complex subcommands, fail safe)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||||
|
command: "uv pip download requests",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uv run — proxy runs (auto-installs script dependencies)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||||
|
command: "uv run script.py",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// Commands where proxy is safely skipped
|
||||||
|
{
|
||||||
|
name: "pip list — proxy skipped (lists installed packages)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||||
|
command: "pip list",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pip show — proxy skipped (shows package metadata)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||||
|
command: "pip show requests",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "poetry show — proxy skipped (shows dependency info)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||||
|
command: "poetry show",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "poetry check — proxy skipped (validates pyproject.toml)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||||
|
command: "poetry check",
|
||||||
|
isKnownNonDownloadCmd: true,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
// Unknown commands default to proxy running (fail safe)
|
||||||
|
{
|
||||||
|
name: "unknown pip subcommand — proxy runs (fail safe)",
|
||||||
|
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||||
|
command: "pip some-future-command",
|
||||||
|
isKnownNonDownloadCmd: false,
|
||||||
|
isInstallationCommand: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pm, err := tc.pm()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := pm.ParseCommand(strings.Split(tc.command, " "))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.isKnownNonDownloadCmd, parsed.IsKnownNonDownloadCommand)
|
||||||
|
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
|
||||||
|
assert.Equal(t, !tc.isKnownNonDownloadCmd, parsed.MayDownloadPackages())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user