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
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
This commit is contained in:
+23
-7
@@ -11,35 +11,45 @@ import (
|
||||
)
|
||||
|
||||
type NpmPackageManagerConfig struct {
|
||||
InstallCommands []string
|
||||
CommandName string
|
||||
InstallCommands []string
|
||||
DownloadCommands []string
|
||||
CommandName string
|
||||
}
|
||||
|
||||
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||
return NpmPackageManagerConfig{
|
||||
InstallCommands: []string{"install", "i", "add"},
|
||||
CommandName: "npm",
|
||||
// "exec" is the npm v7+ built-in equivalent of npx.
|
||||
DownloadCommands: []string{"update", "up", "upgrade", "ci", "audit", "dedupe", "exec"},
|
||||
CommandName: "npm",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
|
||||
return NpmPackageManagerConfig{
|
||||
InstallCommands: []string{"install", "i", "add"},
|
||||
CommandName: "pnpm",
|
||||
// "dlx" downloads and runs a package (pnpm's npx equivalent).
|
||||
// "exec" runs a command from the project's node_modules (may resolve packages).
|
||||
DownloadCommands: []string{"update", "up", "upgrade", "dedupe", "dlx", "exec"},
|
||||
CommandName: "pnpm",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
|
||||
return NpmPackageManagerConfig{
|
||||
InstallCommands: []string{"install", "i", "add"},
|
||||
CommandName: "bun",
|
||||
// "x" is bun's npx equivalent (also exposed as the `bunx` binary).
|
||||
DownloadCommands: []string{"update", "upgrade", "x"},
|
||||
CommandName: "bun",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
|
||||
return NpmPackageManagerConfig{
|
||||
InstallCommands: []string{"install", "add", ""},
|
||||
CommandName: "yarn",
|
||||
// "dlx" downloads and runs a package without installing it (yarn's npx equivalent).
|
||||
DownloadCommands: []string{"upgrade", "up", "dlx"},
|
||||
CommandName: "yarn",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +106,13 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
|
||||
}
|
||||
|
||||
if installCmdIndex == -1 {
|
||||
// No install command found, return as-is
|
||||
// Check if this is a known download command (e.g., npm update, npm ci)
|
||||
for _, arg := range args {
|
||||
if slices.Contains(npm.Config.DownloadCommands, arg) {
|
||||
return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
|
||||
+130
-2
@@ -71,12 +71,53 @@ func TestNpmParseCommand(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not an installation command",
|
||||
name: "update is a known download command",
|
||||
command: "npm update @types/node",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, parsedCommand)
|
||||
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
|
||||
assert.Empty(t, parsedCommand.InstallTargets)
|
||||
assert.True(t, parsedCommand.IsKnownDownloadCommand)
|
||||
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ci is a known download command",
|
||||
command: "npm ci",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, parsedCommand.IsKnownDownloadCommand)
|
||||
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||
assert.False(t, parsedCommand.IsInstallationCommand())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit is a known download command",
|
||||
command: "npm audit",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, parsedCommand.IsKnownDownloadCommand)
|
||||
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ls is not a download command",
|
||||
command: "npm ls",
|
||||
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, parsedCommand.IsKnownDownloadCommand)
|
||||
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.IsKnownDownloadCommand)
|
||||
assert.True(t, parsedCommand.IsInstallationCommand())
|
||||
assert.True(t, parsedCommand.MayDownloadPackages())
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -403,3 +444,90 @@ func TestBunParseCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmDownloadCommands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pm func() (*npmPackageManager, error)
|
||||
command string
|
||||
isKnownDownloadCommand bool
|
||||
isInstallationCommand bool
|
||||
}{
|
||||
{
|
||||
name: "yarn upgrade is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||
command: "yarn upgrade",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "pnpm update is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||
command: "pnpm update",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "bun update is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
|
||||
command: "bun update",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "npm exec is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||
command: "npm exec create-react-app",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "pnpm dlx is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||
command: "pnpm dlx create-react-app",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "pnpm exec is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
|
||||
command: "pnpm exec tsc",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "yarn dlx is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
|
||||
command: "yarn dlx create-react-app",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "bun x is a known download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
|
||||
command: "bun x create-vite",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "npm outdated is not a download command",
|
||||
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
|
||||
command: "npm outdated",
|
||||
isKnownDownloadCommand: 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.isKnownDownloadCommand, parsed.IsKnownDownloadCommand)
|
||||
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
|
||||
assert.Equal(t, tc.isKnownDownloadCommand || tc.isInstallationCommand, parsed.MayDownloadPackages())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,26 @@ type ParsedCommand struct {
|
||||
// ManifestFiles contains the list of manifest files to install from
|
||||
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
|
||||
ManifestFiles []string
|
||||
|
||||
// IsKnownDownloadCommand is true for commands that may download packages but are not
|
||||
// fully parsed (e.g., npm update, npm ci, poetry update). Used by the proxy to decide
|
||||
// whether to intercept when proxy_install_only is enabled.
|
||||
IsKnownDownloadCommand bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return pc.HasInstallTarget() || pc.HasManifestInstall()
|
||||
}
|
||||
|
||||
// MayDownloadPackages returns true if the command may download packages from a registry.
|
||||
// This is broader than IsInstallationCommand and includes commands like npm update or npm ci
|
||||
// that download packages but are not fully parsed. Used by the proxy to decide interception scope.
|
||||
func (pc *ParsedCommand) MayDownloadPackages() bool {
|
||||
return pc.IsInstallationCommand() || pc.IsKnownDownloadCommand
|
||||
}
|
||||
|
||||
func (pc *ParsedCommand) HasInstallTarget() bool {
|
||||
return len(pc.InstallTargets) > 0
|
||||
}
|
||||
|
||||
+37
-12
@@ -18,35 +18,42 @@ type pypiCommandParser interface {
|
||||
}
|
||||
|
||||
type PypiPackageManagerConfig struct {
|
||||
InstallCommands []string
|
||||
CommandName string
|
||||
InstallCommands []string
|
||||
DownloadCommands []string
|
||||
CommandName string
|
||||
}
|
||||
|
||||
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
||||
return PypiPackageManagerConfig{
|
||||
InstallCommands: []string{"install"},
|
||||
CommandName: "pip",
|
||||
InstallCommands: []string{"install"},
|
||||
DownloadCommands: []string{"download"},
|
||||
CommandName: "pip",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
||||
return PypiPackageManagerConfig{
|
||||
InstallCommands: []string{"install"},
|
||||
CommandName: "pip3",
|
||||
InstallCommands: []string{"install"},
|
||||
DownloadCommands: []string{"download"},
|
||||
CommandName: "pip3",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
||||
return PypiPackageManagerConfig{
|
||||
InstallCommands: []string{"add", "install"},
|
||||
CommandName: "uv",
|
||||
// "download" covers both `uv pip download` and bare `uv download`.
|
||||
// "run" covers `uv run` which auto-installs script dependencies.
|
||||
DownloadCommands: []string{"download", "run"},
|
||||
CommandName: "uv",
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
||||
return PypiPackageManagerConfig{
|
||||
InstallCommands: []string{"add"},
|
||||
CommandName: "poetry",
|
||||
InstallCommands: []string{"add"},
|
||||
DownloadCommands: []string{"update", "install"},
|
||||
CommandName: "poetry",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +130,13 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
||||
}
|
||||
|
||||
if installCmdIndex == -1 {
|
||||
// No install command found, return as-is
|
||||
// Check if this is a known download command
|
||||
for _, arg := range args {
|
||||
if slices.Contains(p.config.DownloadCommands, arg) {
|
||||
return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
@@ -242,7 +255,13 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
||||
}
|
||||
|
||||
if installCmdIndex == -1 {
|
||||
// No install command found, return as-is
|
||||
// Check if this is a known download command
|
||||
for _, arg := range args {
|
||||
if slices.Contains(u.config.DownloadCommands, arg) {
|
||||
return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
@@ -340,7 +359,13 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
|
||||
}
|
||||
|
||||
if installCmdIndex == -1 {
|
||||
// No install command found, return as-is
|
||||
// Check if this is a known download command
|
||||
for _, arg := range args {
|
||||
if slices.Contains(p.config.DownloadCommands, arg) {
|
||||
return &ParsedCommand{Command: command, IsKnownDownloadCommand: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &ParsedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package packagemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -856,3 +857,83 @@ func TestPypiConvertWildcardConstraint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPypiDownloadCommands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pm func() (*pypiPackageManager, error)
|
||||
command string
|
||||
isKnownDownloadCommand bool
|
||||
isInstallationCommand bool
|
||||
}{
|
||||
{
|
||||
name: "poetry update is a known download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||
command: "poetry update",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "poetry add is an installation command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
|
||||
command: "poetry add django",
|
||||
isKnownDownloadCommand: false,
|
||||
isInstallationCommand: true,
|
||||
},
|
||||
{
|
||||
name: "uv sync is an installation command (manifest install)",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||
command: "uv sync",
|
||||
isKnownDownloadCommand: false,
|
||||
isInstallationCommand: true,
|
||||
},
|
||||
{
|
||||
name: "pip download is a known download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||
command: "pip download requests",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "pip3 download is a known download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPip3PackageManagerConfig()) },
|
||||
command: "pip3 download django",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "uv pip download is a known download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||
command: "uv pip download requests",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "uv run is a known download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
|
||||
command: "uv run script.py",
|
||||
isKnownDownloadCommand: true,
|
||||
isInstallationCommand: false,
|
||||
},
|
||||
{
|
||||
name: "pip list is not a download command",
|
||||
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
|
||||
command: "pip list",
|
||||
isKnownDownloadCommand: 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.isKnownDownloadCommand, parsed.IsKnownDownloadCommand)
|
||||
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
|
||||
assert.Equal(t, tc.isKnownDownloadCommand || tc.isInstallationCommand, parsed.MayDownloadPackages())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user