diff --git a/README.md b/README.md index 4be0cfd..f7565e8 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ pnpm add - [Dry Run](#dry-run) - [Verbose Mode](#verbose-mode) - [Debugging](#debugging) + - [Environment Variables](#environment-variables) - [🤝 Contributing](#-contributing) - [🚫 Limitations](#-limitations) @@ -214,6 +215,19 @@ Store the debug logs in a file: pmg --debug --log /tmp/debug.json npm install ``` +## Environment Variables + +### PMG_INSECURE_INSTALLATION + +Allows bypassing the blocking behavior when malicious packages are detected during installation. + +> ⚠️ **Warning**: This is a security feature bypass. Use with extreme caution and only when you understand the risks. + +```bash +export PMG_INSECURE_INSTALLATION=true +pmg npm install +``` + ## 🤝 Contributing Refer to [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/config/config.go b/config/config.go index 30dbe4a..635fa1f 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,9 @@ type Config struct { // DryRun to check for packages for risks. // Do not actually execute any commands. DryRun bool + + // InsecureInstallation allows bypassing install blocking on malicious packages + InsecureInstallation bool } // Inject config into context while protecting against context poisoning diff --git a/guard/guard.go b/guard/guard.go index 97a79d7..a881cd8 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -24,6 +24,9 @@ type PackageManagerGuardInteraction struct { // ClearStatus is called to clear the status of the guard in the UI ClearStatus func() + // ShowWarning is called to show a warning message to the user + ShowWarning func(message string) + // GetConfirmationOnMalware is called to get the confirmation of the user on the malware packages GetConfirmationOnMalware func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) @@ -38,6 +41,7 @@ type PackageManagerGuardConfig struct { MaxConcurrentAnalyzes int AnalysisTimeout time.Duration DryRun bool + InsecureInstallation bool } func DefaultPackageManagerGuardConfig() PackageManagerGuardConfig { @@ -46,6 +50,7 @@ func DefaultPackageManagerGuardConfig() PackageManagerGuardConfig { MaxConcurrentAnalyzes: 10, AnalysisTimeout: 5 * time.Minute, DryRun: false, + InsecureInstallation: false, } } @@ -75,6 +80,12 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig, func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedCommand *packagemanager.ParsedCommand) error { log.Debugf("Running package manager guard with args: %v", args) + if g.config.InsecureInstallation { + log.Debugf("Bypassing block for unconfirmed malicious packages due to PMG_INSECURE_INSTALLATION") + g.showWarning("⚠️ WARNING: INSECURE INSTALLATION MODE - Malware protection bypassed!") + return g.continueExecution(ctx, parsedCommand) + } + if !parsedCommand.HasInstallTarget() { // Check if this is a manifest-based installation if parsedCommand.ShouldExtractFromManifest() { @@ -283,6 +294,14 @@ func (g *packageManagerGuard) clearStatus() { g.interaction.ClearStatus() } +func (g *packageManagerGuard) showWarning(message string) { + if g.interaction.ShowWarning == nil { + return + } + + g.interaction.ShowWarning(message) +} + func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, parsedCommand *packagemanager.ParsedCommand) error { extractorConfig := extractor.NewDefaultExtractorConfig() extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name()) diff --git a/guard/guard_test.go b/guard/guard_test.go index d147df5..c2c958f 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -6,6 +6,8 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/packagemanager" "github.com/stretchr/testify/assert" ) @@ -16,7 +18,9 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { } pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{}) + []analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{ + ShowWarning: func(message string) {}, + }) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -45,3 +49,222 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { assert.Equal(t, analyzer.ActionBlock, r[0].Action) }) } + +func TestGuardInsecureInstallation(t *testing.T) { + mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) + if err != nil { + t.Fatalf("failed to create mq: %v", err) + } + + t.Run("should bypass malware blocking when InsecureInstallation is enabled", func(t *testing.T) { + // Create guard with InsecureInstallation enabled + config := DefaultPackageManagerGuardConfig() + config.InsecureInstallation = true + config.DryRun = true // Enable dry run to avoid actual command execution + config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues + + blockCalled := false + warningCalled := false + var warningMessage string + + interaction := PackageManagerGuardInteraction{ + ShowWarning: func(message string) { + warningCalled = true + warningMessage = message + }, + Block: func(config *ui.BlockConfig) error { + blockCalled = true + return nil + }, + } + + pg, err := NewPackageManagerGuard(config, nil, nil, + []analyzer.PackageVersionAnalyzer{mq}, interaction) + if err != nil { + t.Fatalf("failed to create pg: %v", err) + } + + // Create a parsed command with a known malicious package + parsedCommand := &packagemanager.ParsedCommand{ + Command: packagemanager.Command{ + Exe: "npm", + Args: []string{"install", "nyc-config@10.0.0"}, + }, + InstallTargets: []*packagemanager.PackageInstallTarget{ + { + PackageVersion: &packagev1.PackageVersion{ + Package: &packagev1.Package{ + Name: "nyc-config", + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + }, + Version: "10.0.0", + }, + }, + }, + } + + err = pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand) + + // With dry run enabled, we expect no error even though we're bypassing execution + assert.NoError(t, err) + + // Block should not be called because InsecureInstallation bypasses the analysis + assert.False(t, blockCalled, "Block should not be called when InsecureInstallation is enabled") + + // Warning should be called to inform user about insecure installation + assert.True(t, warningCalled, "Warning should be called when InsecureInstallation is enabled") + assert.Contains(t, warningMessage, "INSECURE INSTALLATION MODE", "Warning message should mention insecure installation") + }) + + t.Run("should block malware when InsecureInstallation is disabled", func(t *testing.T) { + // Create guard with InsecureInstallation disabled (default) + config := DefaultPackageManagerGuardConfig() + config.InsecureInstallation = false + config.DryRun = true + config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues + + blockCalled := false + var blockedPackages []*analyzer.PackageVersionAnalysisResult + + interaction := PackageManagerGuardInteraction{ + ShowWarning: func(message string) {}, + Block: func(config *ui.BlockConfig) error { + blockCalled = true + blockedPackages = config.MalwarePackages + return nil + }, + } + + pg, err := NewPackageManagerGuard(config, nil, nil, + []analyzer.PackageVersionAnalyzer{mq}, interaction) + if err != nil { + t.Fatalf("failed to create pg: %v", err) + } + + // Create a parsed command with a known malicious package + parsedCommand := &packagemanager.ParsedCommand{ + Command: packagemanager.Command{ + Exe: "npm", + Args: []string{"install", "nyc-config@10.0.0"}, + }, + InstallTargets: []*packagemanager.PackageInstallTarget{ + { + PackageVersion: &packagev1.PackageVersion{ + Package: &packagev1.Package{ + Name: "nyc-config", + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + }, + Version: "10.0.0", + }, + }, + }, + } + + err = pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand) + + // We expect no error from the guard itself (blocking is handled via the Block callback) + assert.NoError(t, err) + + // Block should be called because InsecureInstallation is disabled + assert.True(t, blockCalled, "Block should be called when InsecureInstallation is disabled") + + // Verify that the malicious package was detected and blocked + assert.NotEmpty(t, blockedPackages, "Blocked packages should not be empty") + if len(blockedPackages) > 0 { + assert.Equal(t, "nyc-config", blockedPackages[0].PackageVersion.GetPackage().GetName()) + assert.Equal(t, "10.0.0", blockedPackages[0].PackageVersion.GetVersion()) + assert.Equal(t, analyzer.ActionBlock, blockedPackages[0].Action) + } + }) + + t.Run("should continue execution for commands without install targets when InsecureInstallation is enabled", func(t *testing.T) { + // Create guard with InsecureInstallation enabled + config := DefaultPackageManagerGuardConfig() + config.InsecureInstallation = true + config.DryRun = true + config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues + + blockCalled := false + + interaction := PackageManagerGuardInteraction{ + ShowWarning: func(message string) {}, + Block: func(config *ui.BlockConfig) error { + blockCalled = true + return nil + }, + } + + pg, err := NewPackageManagerGuard(config, nil, nil, + []analyzer.PackageVersionAnalyzer{mq}, interaction) + if err != nil { + t.Fatalf("failed to create pg: %v", err) + } + + // Create a parsed command without install targets (e.g., npm list) + parsedCommand := &packagemanager.ParsedCommand{ + Command: packagemanager.Command{ + Exe: "npm", + Args: []string{"list"}, + }, + InstallTargets: []*packagemanager.PackageInstallTarget{}, // No install targets + } + + err = pg.Run(context.Background(), []string{"npm", "list"}, parsedCommand) + + // Should not error since there are no install targets to analyze + assert.NoError(t, err) + + // Block should not be called since there are no packages to analyze + assert.False(t, blockCalled, "Block should not be called when there are no install targets") + }) + + t.Run("should handle manifest-based installation when InsecureInstallation is enabled", func(t *testing.T) { + // Create guard with InsecureInstallation enabled + config := DefaultPackageManagerGuardConfig() + config.InsecureInstallation = true + config.DryRun = true + config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues + + blockCalled := false + + interaction := PackageManagerGuardInteraction{ + ShowWarning: func(message string) {}, + Block: func(config *ui.BlockConfig) error { + blockCalled = true + return nil + }, + } + + pg, err := NewPackageManagerGuard(config, nil, nil, + []analyzer.PackageVersionAnalyzer{mq}, interaction) + if err != nil { + t.Fatalf("failed to create pg: %v", err) + } + + // Create a parsed command for manifest-based installation + parsedCommand := &packagemanager.ParsedCommand{ + Command: packagemanager.Command{ + Exe: "npm", + Args: []string{"install"}, + }, + InstallTargets: []*packagemanager.PackageInstallTarget{}, // No direct install targets + IsManifestInstall: true, + ManifestFiles: []string{"package.json"}, + } + + err = pg.Run(context.Background(), []string{"npm", "install"}, parsedCommand) + + // Should not error and should bypass malware checking + assert.NoError(t, err) + + // Block should not be called because InsecureInstallation bypasses analysis + assert.False(t, blockCalled, "Block should not be called when InsecureInstallation is enabled for manifest installation") + }) + + t.Run("should verify InsecureInstallation defaults to false", func(t *testing.T) { + config := DefaultPackageManagerGuardConfig() + + // Verify that InsecureInstallation defaults to false + assert.False(t, config.InsecureInstallation, "InsecureInstallation should default to false") + }) +} diff --git a/internal/flows/common.go b/internal/flows/common.go index a49ac90..f7af8d3 100644 --- a/internal/flows/common.go +++ b/internal/flows/common.go @@ -49,12 +49,14 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem interaction := guard.PackageManagerGuardInteraction{ SetStatus: ui.SetStatus, ClearStatus: ui.ClearStatus, + ShowWarning: ui.ShowWarning, GetConfirmationOnMalware: ui.GetConfirmationOnMalware, Block: ui.Block, } guardConfig := guard.DefaultPackageManagerGuardConfig() guardConfig.DryRun = f.config.DryRun + guardConfig.InsecureInstallation = f.config.InsecureInstallation proxy, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) if err != nil { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 17cd1a4..12f75e7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -105,6 +105,11 @@ func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysis return false, nil } +func ShowWarning(message string) { + // Print colored warning to stderr immediately - it won't be cleared by other output + fmt.Fprintf(os.Stderr, "%s\n", Colors.Red(message)) +} + func Fatalf(msg string, args ...interface{}) { ClearStatus() diff --git a/main.go b/main.go index b52ed2c..720b0ad 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strconv" "github.com/safedep/dry/log" "github.com/safedep/pmg/cmd/npm" @@ -56,6 +57,13 @@ func main() { ui.SetVerbosityLevel(ui.VerbosityLevelVerbose) } + // Check for PMG_INSECURE_INSTALLATION environment variable + if val := os.Getenv("PMG_INSECURE_INSTALLATION"); val != "" { + if boolVal, err := strconv.ParseBool(val); err == nil { + globalConfig.InsecureInstallation = boolVal + } + } + log.InitZapLogger("pmg", "cli") cmd.SetContext(globalConfig.Inject(cmd.Context())) },