diff --git a/cmd/npm/common.go b/cmd/npm/common.go index 79e3830..60a40d7 100644 --- a/cmd/npm/common.go +++ b/cmd/npm/common.go @@ -6,6 +6,7 @@ import ( "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/guard" + "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" ) @@ -20,8 +21,15 @@ func executeCommonFlow(pm packagemanager.PackageManager, args []string) error { return fmt.Errorf("failed to create malysis query analyzer: %w", err) } + interaction := guard.PackageManagerGuardInteraction{ + SetStatus: ui.SetStatus, + ClearStatus: ui.ClearStatus, + GetConfirmationOnMalware: ui.GetConfirmationOnMalware, + Block: ui.Block, + } + proxy, err := guard.NewPackageManagerGuard(guard.DefaultPackageManagerGuardConfig(), - pm, packageResolver, []analyzer.MalysisAnalyzer{malysisQueryAnalyzer}) + pm, packageResolver, []analyzer.MalysisAnalyzer{malysisQueryAnalyzer}, interaction) if err != nil { return fmt.Errorf("failed to create package manager guard: %w", err) } diff --git a/go.mod b/go.mod index 75bb5e1..bb071f0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1 github.com/fatih/color v1.18.0 github.com/jedib0t/go-pretty/v6 v6.6.7 - github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af + github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175 github.com/safedep/vet v1.10.1 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 68b9423..0af01e7 100644 --- a/go.sum +++ b/go.sum @@ -511,6 +511,8 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af h1:3Pomh9zoEIwvzWLBAc5ikJ8kPT53O2APD06HmrlE6A4= github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af/go.mod h1:Mdqx/Q2DhAcN38XiUNTGCC5MktofYDQW9Az7YWGEF0s= +github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175 h1:TxAI6m/v01CL+kwIYE3RZsuxu01pbuGy3wOi3WyBT1E= +github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175/go.mod h1:Mdqx/Q2DhAcN38XiUNTGCC5MktofYDQW9Az7YWGEF0s= github.com/safedep/vet v1.10.1 h1:L8yG3X/t3I9fDVICc0DZ+zGweoLhDeyFIK6ubcNiXUc= github.com/safedep/vet v1.10.1/go.mod h1:DN+59m5kB1QSOpdstCk9rfLTMCPtT/ZLMP5rwuix/j8= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= diff --git a/guard/guard.go b/guard/guard.go index da19111..103cb02 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -16,7 +16,9 @@ import ( type PackageManagerGuardInteraction struct { SetStatus func(status string) + ClearStatus func() GetConfirmationOnMalware func(malwarePackages []*packagev1.PackageVersion) (bool, error) + Block func() error } type PackageManagerGuardConfig struct { @@ -44,8 +46,11 @@ type packageManagerGuard struct { func NewPackageManagerGuard(config PackageManagerGuardConfig, packageManager packagemanager.PackageManager, packageResolver packagemanager.PackageResolver, - analyzers []analyzer.MalysisAnalyzer) (*packageManagerGuard, error) { + analyzers []analyzer.MalysisAnalyzer, + interaction PackageManagerGuardInteraction, +) (*packageManagerGuard, error) { return &packageManagerGuard{ + interaction: interaction, analyzers: analyzers, packageManager: packageManager, packageResolver: packageResolver, @@ -127,12 +132,14 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string) error { } if !confirmed { + _ = g.blockInstallation() return fmt.Errorf("malicious packages detected, installation aborted") } } log.Debugf("No malicious packages found, continuing execution") + g.clearStatus() return g.continueExecution(ctx, parsedCommand) } @@ -172,7 +179,8 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context, for _, analyzer := range g.analyzers { analysisResult, err := analyzer.Analyze(ctx, pkg) if err != nil { - log.Errorf("failed to analyze package: %w", err) + // This is not an error because we may not have results for all packages + log.Debugf("failed to analyze package: %v", err) continue } @@ -224,3 +232,19 @@ func (g *packageManagerGuard) setStatus(status string) { g.interaction.SetStatus(status) } + +func (g *packageManagerGuard) blockInstallation() error { + if g.interaction.Block == nil { + return nil + } + + return g.interaction.Block() +} + +func (g *packageManagerGuard) clearStatus() { + if g.interaction.ClearStatus == nil { + return + } + + g.interaction.ClearStatus() +} diff --git a/internal/ui/colors.go b/internal/ui/colors.go new file mode 100644 index 0000000..9e6a5ff --- /dev/null +++ b/internal/ui/colors.go @@ -0,0 +1,17 @@ +package ui + +import "github.com/fatih/color" + +type TerminalColors struct { + Red func(format string, a ...interface{}) string + Yellow func(format string, a ...interface{}) string + Cyan func(format string, a ...interface{}) string + Green func(format string, a ...interface{}) string +} + +var Colors = TerminalColors{ + Red: color.New(color.FgRed, color.Bold).SprintfFunc(), + Yellow: color.New(color.FgYellow).SprintfFunc(), + Cyan: color.New(color.FgCyan).SprintfFunc(), + Green: color.New(color.FgGreen).SprintfFunc(), +} diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go new file mode 100644 index 0000000..28e2049 --- /dev/null +++ b/internal/ui/spinner.go @@ -0,0 +1,45 @@ +package ui + +import ( + "fmt" + "time" +) + +var spinnerChan chan bool + +func StartSpinner(msg string) { + style := `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` + frames := []rune(style) + length := len(frames) + + spinnerChan = make(chan bool) + + ticker := time.NewTicker(100 * time.Millisecond) + go func() { + pos := 0 + + for { + select { + case <-spinnerChan: + ticker.Stop() + return + case <-ticker.C: + fmt.Printf("\r%s ... %s", msg, string(frames[pos%length])) + pos += 1 + } + } + }() +} + +func StopSpinner() { + // Gracefully handle the case where the spinner is already stopped + // and the channel is closed, yet client code calls StopSpinner() again. + defer func() { + _ = recover() + }() + + close(spinnerChan) + + fmt.Printf("\r") + fmt.Println() +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..6c164ef --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,87 @@ +package ui + +import ( + "fmt" + "os" + "strings" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" +) + +// The UI is internal to PMG and opinionated for the CLI. +// It is not intended to be used outside of PMG. + +type VerbosityLevel int + +const ( + // PMG is hidden from the user except for errors + // and when malicious packages are detected + VerbosityLevelSilent VerbosityLevel = iota + + // Show minimal status updates + VerbosityLevelNormal + + // Show verbose status updates and information including + // information about malicious packages + VerbosityLevelVerbose +) + +var verbosityLevel VerbosityLevel = VerbosityLevelNormal + +func SetVerbosityLevel(level VerbosityLevel) { + verbosityLevel = level +} + +func ClearStatus() { + StopSpinner() + fmt.Print("\r") +} + +func Block() error { + StopSpinner() + + fmt.Println(Colors.Red("❌ Malicious packages detected, installation blocked!")) + os.Exit(1) + + return nil +} + +func SetStatus(status string) { + if verbosityLevel == VerbosityLevelSilent { + return + } + + StopSpinner() + + fmt.Print("\r", Colors.Green(status), " ") + StartSpinner(status) +} + +func GetConfirmationOnMalware(malwarePackages []*packagev1.PackageVersion) (bool, error) { + StopSpinner() + fmt.Println(Colors.Red("🚨 Malicious packages detected:")) + + for _, pkg := range malwarePackages { + fmt.Println(" ⚠️ ", Colors.Red(fmt.Sprintf("%s@%s", pkg.Package.Name, pkg.Version))) + } + + fmt.Println() + fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) ")) + + var response string + + // We don't care about the error here because we will return false + // if the user doesn't provide a valid response + _, _ = fmt.Scanln(&response) + + if len(response) == 0 { + return false, nil + } + + response = strings.ToLower(response) + if response == "y" || response == "yes" || response[0] == 'y' { + return true, nil + } + + return false, nil +}