mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add post-exec reporting support (#134)
* feat: Add post install reporting support * fix: UI report handling * fix: Duplicate reporting * fix: Show warning on insecure bypass * fix: Proxy event log insecure skip installation * fix: Proxy event log insecure skip installation * fix: Common definition for infer outcome
This commit is contained in:
+91
-39
@@ -77,6 +77,21 @@ func DefaultPackageManagerGuardConfig() PackageManagerGuardConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// GuardResult captures execution statistics from the guard for reporting.
|
||||
// It contains pure data - the calling flow is responsible for interpreting
|
||||
// the outcome based on this data.
|
||||
type GuardResult struct {
|
||||
TotalAnalyzed int
|
||||
TrustedSkipped int
|
||||
AllowedCount int
|
||||
ConfirmedCount int
|
||||
BlockedCount int
|
||||
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
// WasUserCancelled is true if the user declined to install suspicious packages
|
||||
WasUserCancelled bool
|
||||
}
|
||||
|
||||
type packageManagerGuard struct {
|
||||
config PackageManagerGuardConfig
|
||||
interaction PackageManagerGuardInteraction
|
||||
@@ -100,9 +115,11 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedCommand *packagemanager.ParsedCommand) error {
|
||||
func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedCommand *packagemanager.ParsedCommand) (*GuardResult, error) {
|
||||
log.Debugf("Running package manager guard with args: %v", args)
|
||||
|
||||
result := &GuardResult{}
|
||||
|
||||
// Log the installation start
|
||||
if g.packageManager != nil {
|
||||
eventlog.LogInstallStarted(g.packageManager.Name(), args)
|
||||
@@ -110,8 +127,8 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
|
||||
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)
|
||||
g.showWarning("INSECURE INSTALLATION MODE - Malware protection bypassed!")
|
||||
return result, g.continueExecution(ctx, parsedCommand)
|
||||
}
|
||||
|
||||
if !parsedCommand.HasInstallTarget() {
|
||||
@@ -122,7 +139,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
}
|
||||
|
||||
log.Debugf("No install target found, continuing execution")
|
||||
return g.continueExecution(ctx, parsedCommand)
|
||||
return result, g.continueExecution(ctx, parsedCommand)
|
||||
}
|
||||
|
||||
blockConfig := ui.NewDefaultBlockConfig()
|
||||
@@ -145,7 +162,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
log.Debugf("Resolving latest version for package: %s", pkg.PackageVersion.Package.Name)
|
||||
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.PackageVersion.GetPackage())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve latest version: %w", err)
|
||||
return result, fmt.Errorf("failed to resolve latest version: %w", err)
|
||||
}
|
||||
|
||||
pkg.PackageVersion.Version = latestVersion.GetVersion()
|
||||
@@ -155,7 +172,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
|
||||
dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg.PackageVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve dependencies: %w", err)
|
||||
return result, fmt.Errorf("failed to resolve dependencies: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("Resolved %d dependencies for package: %s@%s", len(dependencies),
|
||||
@@ -169,28 +186,36 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
|
||||
g.setStatus(fmt.Sprintf("Analyzing %d dependencies for malware", len(packagesToAnalyze)))
|
||||
|
||||
analysisResults, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze)
|
||||
analysisResults, trustedSkipped, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze packages: %w", err)
|
||||
return result, fmt.Errorf("failed to analyze packages: %w", err)
|
||||
}
|
||||
|
||||
// Populate result statistics
|
||||
result.TotalAnalyzed = len(packagesToAnalyze)
|
||||
result.TrustedSkipped = trustedSkipped
|
||||
|
||||
confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{}
|
||||
for _, result := range analysisResults {
|
||||
if result.Action == analyzer.ActionBlock {
|
||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, result)
|
||||
g.logMalwareDetection(result, true)
|
||||
return g.blockInstallation(blockConfig)
|
||||
for _, analysisResult := range analysisResults {
|
||||
if analysisResult.Action == analyzer.ActionBlock {
|
||||
result.BlockedCount++
|
||||
result.BlockedPackages = append(result.BlockedPackages, analysisResult)
|
||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, analysisResult)
|
||||
g.logMalwareDetection(analysisResult, true)
|
||||
return result, g.blockInstallation(blockConfig)
|
||||
}
|
||||
|
||||
if result.Action == analyzer.ActionConfirm {
|
||||
confirmableMalwarePackages = append(confirmableMalwarePackages, result)
|
||||
if analysisResult.Action == analyzer.ActionConfirm {
|
||||
confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult)
|
||||
} else {
|
||||
result.AllowedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(confirmableMalwarePackages) > 0 {
|
||||
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get confirmation on malware: %w", err)
|
||||
return result, fmt.Errorf("failed to get confirmation on malware: %w", err)
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
@@ -198,13 +223,18 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
blockConfig.MalwarePackages = confirmableMalwarePackages
|
||||
for _, pkg := range confirmableMalwarePackages {
|
||||
g.logMalwareDetection(pkg, true)
|
||||
result.BlockedCount++
|
||||
result.BlockedPackages = append(result.BlockedPackages, pkg)
|
||||
}
|
||||
return g.blockInstallation(blockConfig)
|
||||
result.WasUserCancelled = true
|
||||
return result, g.blockInstallation(blockConfig)
|
||||
}
|
||||
|
||||
// User confirmed installation despite warning
|
||||
for _, pkg := range confirmableMalwarePackages {
|
||||
g.logMalwareDetection(pkg, false)
|
||||
result.ConfirmedCount++
|
||||
result.ConfirmedPackages = append(result.ConfirmedPackages, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +253,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
||||
}
|
||||
|
||||
g.clearStatus()
|
||||
return g.continueExecution(ctx, parsedCommand)
|
||||
return result, g.continueExecution(ctx, parsedCommand)
|
||||
}
|
||||
|
||||
func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
||||
@@ -276,7 +306,7 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package
|
||||
}
|
||||
|
||||
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
||||
packages []*packagev1.PackageVersion) ([]*analyzer.PackageVersionAnalysisResult, error) {
|
||||
packages []*packagev1.PackageVersion) ([]*analyzer.PackageVersionAnalysisResult, int, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, g.config.AnalysisTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -303,12 +333,13 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
||||
}()
|
||||
}
|
||||
|
||||
// Queue all packages for analysis
|
||||
// Queue all packages for analysis, tracking trusted packages skipped
|
||||
trustedSkipped := 0
|
||||
for _, pkg := range packages {
|
||||
if config.IsTrustedPackage(pkg) {
|
||||
log.Debugf("Skipping trusted package: %s/%s@%s",
|
||||
pkg.GetPackage().GetEcosystem(), pkg.GetPackage().GetName(), pkg.GetVersion())
|
||||
|
||||
trustedSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -341,10 +372,10 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
||||
select {
|
||||
case <-waiter:
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("analysis timed out")
|
||||
return nil, 0, fmt.Errorf("analysis timed out")
|
||||
}
|
||||
|
||||
return analysisResults, nil
|
||||
return analysisResults, trustedSkipped, nil
|
||||
}
|
||||
|
||||
func (g *packageManagerGuard) getConfirmationOnMalware(ctx context.Context, malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||||
@@ -387,7 +418,9 @@ func (g *packageManagerGuard) showWarning(message string) {
|
||||
g.interaction.ShowWarning(message)
|
||||
}
|
||||
|
||||
func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, parsedCommand *packagemanager.ParsedCommand) error {
|
||||
func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, parsedCommand *packagemanager.ParsedCommand) (*GuardResult, error) {
|
||||
result := &GuardResult{}
|
||||
|
||||
extractorConfig := extractor.NewDefaultExtractorConfig()
|
||||
extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name())
|
||||
extractorConfig.ManifestFiles = parsedCommand.ManifestFiles
|
||||
@@ -396,14 +429,14 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
||||
|
||||
packages, err := packageExtractor.ExtractManifest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract packages from manifest files: %w", err)
|
||||
return result, fmt.Errorf("failed to extract packages from manifest files: %w", err)
|
||||
}
|
||||
|
||||
blockConfig := ui.NewDefaultBlockConfig()
|
||||
|
||||
if len(packages) == 0 {
|
||||
log.Debugf("No packages found in manifest files, continuing execution")
|
||||
return g.continueExecution(ctx, parsedCommand)
|
||||
return result, g.continueExecution(ctx, parsedCommand)
|
||||
}
|
||||
|
||||
log.Debugf("Extracted %d packages from manifest files", len(packages))
|
||||
@@ -422,7 +455,7 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
||||
log.Debugf("Resolving latest version for package: %s", pkg.Package.Name)
|
||||
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.GetPackage())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve latest version: %w", err)
|
||||
return result, fmt.Errorf("failed to resolve latest version: %w", err)
|
||||
}
|
||||
|
||||
pkg.Version = latestVersion.GetVersion()
|
||||
@@ -432,7 +465,7 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
||||
|
||||
dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve dependencies: %w", err)
|
||||
return result, fmt.Errorf("failed to resolve dependencies: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("Resolved %d dependencies for package: %s@%s", len(dependencies),
|
||||
@@ -446,41 +479,60 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
||||
|
||||
g.setStatus(fmt.Sprintf("Analyzing %d dependencies from manifest files", len(packagesToAnalyze)))
|
||||
|
||||
analysisResults, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze)
|
||||
analysisResults, trustedSkipped, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze packages: %w", err)
|
||||
return result, fmt.Errorf("failed to analyze packages: %w", err)
|
||||
}
|
||||
|
||||
// Populate result statistics
|
||||
result.TotalAnalyzed = len(packagesToAnalyze)
|
||||
result.TrustedSkipped = trustedSkipped
|
||||
|
||||
confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{}
|
||||
for _, result := range analysisResults {
|
||||
if result.Action == analyzer.ActionBlock {
|
||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, result)
|
||||
return g.blockInstallation(blockConfig)
|
||||
for _, analysisResult := range analysisResults {
|
||||
if analysisResult.Action == analyzer.ActionBlock {
|
||||
result.BlockedCount++
|
||||
result.BlockedPackages = append(result.BlockedPackages, analysisResult)
|
||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, analysisResult)
|
||||
|
||||
g.logMalwareDetection(analysisResult, true)
|
||||
|
||||
return result, g.blockInstallation(blockConfig)
|
||||
}
|
||||
|
||||
if result.Action == analyzer.ActionConfirm {
|
||||
confirmableMalwarePackages = append(confirmableMalwarePackages, result)
|
||||
if analysisResult.Action == analyzer.ActionConfirm {
|
||||
confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult)
|
||||
} else {
|
||||
result.AllowedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(confirmableMalwarePackages) > 0 {
|
||||
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get confirmation on malware: %w", err)
|
||||
return result, fmt.Errorf("failed to get confirmation on malware: %w", err)
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
blockConfig.ShowReference = false
|
||||
blockConfig.MalwarePackages = confirmableMalwarePackages
|
||||
|
||||
for _, pkg := range confirmableMalwarePackages {
|
||||
g.logMalwareDetection(pkg, true)
|
||||
|
||||
result.BlockedCount++
|
||||
result.BlockedPackages = append(result.BlockedPackages, pkg)
|
||||
}
|
||||
return g.blockInstallation(blockConfig)
|
||||
|
||||
result.WasUserCancelled = true
|
||||
return result, g.blockInstallation(blockConfig)
|
||||
}
|
||||
|
||||
// User confirmed installation despite warning
|
||||
for _, pkg := range confirmableMalwarePackages {
|
||||
g.logMalwareDetection(pkg, false)
|
||||
result.ConfirmedCount++
|
||||
result.ConfirmedPackages = append(result.ConfirmedPackages, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +550,7 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
||||
}
|
||||
|
||||
g.clearStatus()
|
||||
return g.continueExecution(ctx, parsedCommand)
|
||||
return result, g.continueExecution(ctx, parsedCommand)
|
||||
}
|
||||
|
||||
// logMalwareDetection logs malware detection events
|
||||
|
||||
+6
-5
@@ -26,7 +26,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("should resolve a single known malicious package version", func(t *testing.T) {
|
||||
r, err := pg.concurrentAnalyzePackages(context.Background(), []*packagev1.PackageVersion{
|
||||
r, trustedSkipped, err := pg.concurrentAnalyzePackages(context.Background(), []*packagev1.PackageVersion{
|
||||
{
|
||||
Package: &packagev1.Package{
|
||||
Name: "nyc-config",
|
||||
@@ -39,6 +39,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
||||
t.Fatalf("failed to analyze packages: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, trustedSkipped)
|
||||
assert.Equal(t, 1, len(r))
|
||||
assert.Equal(t, "nyc-config", r[0].PackageVersion.GetPackage().GetName())
|
||||
assert.Equal(t, "10.0.0", r[0].PackageVersion.GetVersion())
|
||||
@@ -103,7 +104,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err = pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand)
|
||||
_, 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)
|
||||
@@ -160,7 +161,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err = pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand)
|
||||
_, 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)
|
||||
@@ -209,7 +210,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
||||
InstallTargets: []*packagemanager.PackageInstallTarget{}, // No install targets
|
||||
}
|
||||
|
||||
err = pg.Run(context.Background(), []string{"npm", "list"}, parsedCommand)
|
||||
_, err = pg.Run(context.Background(), []string{"npm", "list"}, parsedCommand)
|
||||
|
||||
// Should not error since there are no install targets to analyze
|
||||
assert.NoError(t, err)
|
||||
@@ -252,7 +253,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
||||
ManifestFiles: []string{"package.json"},
|
||||
}
|
||||
|
||||
err = pg.Run(context.Background(), []string{"npm", "install"}, parsedCommand)
|
||||
_, err = pg.Run(context.Background(), []string{"npm", "install"}, parsedCommand)
|
||||
|
||||
// Should not error and should bypass malware checking
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
EventTypeInstallTrustedAllowed EventType = "install_trusted_allowed"
|
||||
EventTypeInstallStarted EventType = "install_started"
|
||||
EventTypeDependencyResolved EventType = "dependency_resolved"
|
||||
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
||||
EventTypeError EventType = "error"
|
||||
)
|
||||
|
||||
@@ -359,6 +360,21 @@ func LogInstallTrustedAllowed(packageName, version, ecosystem string) {
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallInsecureBypass logs when an installation skips analysis due to insecure installation mode.
|
||||
func LogInstallInsecureBypass(packageName, version, ecosystem string) {
|
||||
event := Event{
|
||||
EventType: EventTypeInstallInsecureBypass,
|
||||
Message: fmt.Sprintf("Installation bypassed analysis due to insecure installation mode: %s@%s", packageName, version),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log install insecure bypass event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallStarted logs when an installation starts
|
||||
func LogInstallStarted(packageManager string, args []string) {
|
||||
event := Event{
|
||||
|
||||
@@ -3,6 +3,8 @@ package flows
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
"github.com/safedep/pmg/config"
|
||||
@@ -34,6 +36,27 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
// Initialize report data at the start
|
||||
reportData := ui.NewReportData()
|
||||
reportData.PackageManagerName = f.pm.Name()
|
||||
reportData.FlowType = ui.FlowTypeGuard
|
||||
reportData.DryRun = cfg.DryRun
|
||||
reportData.InsecureMode = cfg.InsecureInstallation
|
||||
reportData.TransitiveEnabled = cfg.Config.Transitive
|
||||
reportData.ParanoidMode = cfg.Config.Paranoid
|
||||
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
|
||||
|
||||
if cfg.Config.Sandbox.Enabled {
|
||||
if policyRef, exists := cfg.Config.Sandbox.Policies[f.pm.Name()]; exists {
|
||||
reportData.SandboxProfile = policyRef.Profile
|
||||
}
|
||||
}
|
||||
if cfg.SandboxProfileOverride != "" {
|
||||
reportData.SandboxProfile = cfg.SandboxProfileOverride
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
if cfg.Config.Paranoid {
|
||||
malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
|
||||
if err != nil {
|
||||
@@ -55,7 +78,7 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
||||
ClearStatus: ui.ClearStatus,
|
||||
ShowWarning: ui.ShowWarning,
|
||||
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
|
||||
Block: ui.Block,
|
||||
Block: ui.BlockNoExit,
|
||||
}
|
||||
|
||||
guardConfig := guard.DefaultPackageManagerGuardConfig()
|
||||
@@ -67,10 +90,46 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
||||
return fmt.Errorf("failed to create package manager guard: %s", err)
|
||||
}
|
||||
|
||||
err = guardManager.Run(ctx, args, parsedCmd)
|
||||
guardResult, err := guardManager.Run(ctx, args, parsedCmd)
|
||||
|
||||
// Populate report data from guard result
|
||||
reportData.StartTime = startTime
|
||||
if guardResult != nil {
|
||||
reportData.TotalAnalyzed = guardResult.TotalAnalyzed
|
||||
reportData.TrustedSkipped = guardResult.TrustedSkipped
|
||||
reportData.AllowedCount = guardResult.AllowedCount
|
||||
reportData.ConfirmedCount = guardResult.ConfirmedCount
|
||||
reportData.BlockedCount = guardResult.BlockedCount
|
||||
reportData.BlockedPackages = guardResult.BlockedPackages
|
||||
reportData.ConfirmedPackages = guardResult.ConfirmedPackages
|
||||
}
|
||||
|
||||
// Infer outcome from data and config using shared inference logic
|
||||
blockedCount := 0
|
||||
userCancelledCount := 0
|
||||
|
||||
if guardResult != nil {
|
||||
blockedCount = guardResult.BlockedCount
|
||||
// In guard flow, if user cancelled, all blocked packages are due to user cancellation
|
||||
// (guard returns immediately on ActionBlock, so we can't have both types)
|
||||
if guardResult.WasUserCancelled {
|
||||
userCancelledCount = guardResult.BlockedCount
|
||||
}
|
||||
}
|
||||
|
||||
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, blockedCount, userCancelledCount, err)
|
||||
|
||||
// Show the report
|
||||
ui.Report(reportData)
|
||||
|
||||
// Exit after report for blocked/cancelled outcomes
|
||||
if reportData.Outcome == ui.OutcomeBlocked || reportData.Outcome == ui.OutcomeUserCancelled {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run package manager guard: %w", err)
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package flows
|
||||
|
||||
import "github.com/safedep/pmg/internal/ui"
|
||||
|
||||
// inferOutcome determines the execution outcome based on configuration and execution data.
|
||||
// This function is shared across different flow implementations (guard-based, proxy-based)
|
||||
// to maintain consistent outcome logic without coupling flows to each other.
|
||||
//
|
||||
// Outcome precedence:
|
||||
// 1. Error (if no packages were blocked)
|
||||
// 2. Insecure installation bypass
|
||||
// 3. Dry run mode
|
||||
// 4. User cancellation
|
||||
// 5. Packages blocked
|
||||
// 6. Success (default)
|
||||
func inferOutcome(insecureMode, dryRun bool, blockedCount, userCancelledCount int, err error) ui.ExecutionOutcome {
|
||||
// Error takes precedence unless we have blocked packages
|
||||
if err != nil && blockedCount == 0 {
|
||||
return ui.OutcomeError
|
||||
}
|
||||
|
||||
// Config-based outcomes
|
||||
if insecureMode {
|
||||
return ui.OutcomeInsecureBypass
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return ui.OutcomeDryRun
|
||||
}
|
||||
|
||||
// User cancellation
|
||||
if userCancelledCount > 0 {
|
||||
return ui.OutcomeUserCancelled
|
||||
}
|
||||
|
||||
// Blocked packages take precedence over errors
|
||||
if blockedCount > 0 {
|
||||
return ui.OutcomeBlocked
|
||||
}
|
||||
|
||||
return ui.OutcomeSuccess
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package flows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
)
|
||||
|
||||
func TestInferOutcome(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
insecureMode bool
|
||||
dryRun bool
|
||||
blockedCount int
|
||||
userCancelledCount int
|
||||
err error
|
||||
expectedOutcome ui.ExecutionOutcome
|
||||
}{
|
||||
{
|
||||
name: "success - no issues",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeSuccess,
|
||||
},
|
||||
{
|
||||
name: "error with no blocked packages",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: errors.New("execution failed"),
|
||||
expectedOutcome: ui.OutcomeError,
|
||||
},
|
||||
{
|
||||
name: "error with blocked packages - blocked takes precedence",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 2,
|
||||
userCancelledCount: 0,
|
||||
err: errors.New("execution failed"),
|
||||
expectedOutcome: ui.OutcomeBlocked,
|
||||
},
|
||||
{
|
||||
name: "insecure mode - bypasses all checks",
|
||||
insecureMode: true,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeInsecureBypass,
|
||||
},
|
||||
{
|
||||
name: "insecure mode with error - error takes precedence when blockedCount is 0",
|
||||
insecureMode: true,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: errors.New("some error"),
|
||||
expectedOutcome: ui.OutcomeError,
|
||||
},
|
||||
{
|
||||
name: "insecure mode with blocked packages",
|
||||
insecureMode: true,
|
||||
dryRun: false,
|
||||
blockedCount: 3,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeInsecureBypass,
|
||||
},
|
||||
{
|
||||
name: "dry run mode",
|
||||
insecureMode: false,
|
||||
dryRun: true,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeDryRun,
|
||||
},
|
||||
{
|
||||
name: "dry run with error - error takes precedence when blockedCount is 0",
|
||||
insecureMode: false,
|
||||
dryRun: true,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: errors.New("some error"),
|
||||
expectedOutcome: ui.OutcomeError,
|
||||
},
|
||||
{
|
||||
name: "dry run with user cancelled",
|
||||
insecureMode: false,
|
||||
dryRun: true,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeDryRun,
|
||||
},
|
||||
{
|
||||
name: "user cancelled",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeUserCancelled,
|
||||
},
|
||||
{
|
||||
name: "user cancelled with error - error takes precedence when blockedCount is 0",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: errors.New("some error"),
|
||||
expectedOutcome: ui.OutcomeError,
|
||||
},
|
||||
{
|
||||
name: "blocked packages",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 1,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeBlocked,
|
||||
},
|
||||
{
|
||||
name: "blocked packages with user cancelled - user cancelled takes precedence for UX",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 2,
|
||||
userCancelledCount: 2,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeUserCancelled,
|
||||
},
|
||||
{
|
||||
name: "precedence test - insecure overrides dry run",
|
||||
insecureMode: true,
|
||||
dryRun: true,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeInsecureBypass,
|
||||
},
|
||||
{
|
||||
name: "precedence test - insecure overrides user cancelled",
|
||||
insecureMode: true,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeInsecureBypass,
|
||||
},
|
||||
{
|
||||
name: "precedence test - insecure overrides blocked",
|
||||
insecureMode: true,
|
||||
dryRun: false,
|
||||
blockedCount: 5,
|
||||
userCancelledCount: 0,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeInsecureBypass,
|
||||
},
|
||||
{
|
||||
name: "precedence test - dry run overrides user cancelled",
|
||||
insecureMode: false,
|
||||
dryRun: true,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeDryRun,
|
||||
},
|
||||
{
|
||||
name: "precedence test - error overrides user cancelled when blockedCount is 0",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 0,
|
||||
userCancelledCount: 1,
|
||||
err: errors.New("execution failed"),
|
||||
expectedOutcome: ui.OutcomeError,
|
||||
},
|
||||
{
|
||||
name: "precedence test - user cancelled overrides blocked",
|
||||
insecureMode: false,
|
||||
dryRun: false,
|
||||
blockedCount: 1,
|
||||
userCancelledCount: 1,
|
||||
err: nil,
|
||||
expectedOutcome: ui.OutcomeUserCancelled,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
outcome := inferOutcome(tt.insecureMode, tt.dryRun, tt.blockedCount, tt.userCancelledCount, tt.err)
|
||||
|
||||
if outcome != tt.expectedOutcome {
|
||||
t.Errorf("inferOutcome() = %v, want %v", outcome, tt.expectedOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferOutcomePrecedence specifically tests the precedence order documented in the function
|
||||
func TestInferOutcomePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (insecureMode, dryRun bool, blockedCount, userCancelledCount int, err error)
|
||||
expected ui.ExecutionOutcome
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "1. error takes precedence when no blocks",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return false, false, 0, 0, errors.New("error")
|
||||
},
|
||||
expected: ui.OutcomeError,
|
||||
description: "Error should be returned when blockedCount is 0",
|
||||
},
|
||||
{
|
||||
name: "2. insecure mode overrides everything",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return true, true, 5, 2, errors.New("error")
|
||||
},
|
||||
expected: ui.OutcomeInsecureBypass,
|
||||
description: "Insecure mode is highest priority after error check",
|
||||
},
|
||||
{
|
||||
name: "3. dry run overrides user actions",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return false, true, 0, 1, nil
|
||||
},
|
||||
expected: ui.OutcomeDryRun,
|
||||
description: "Dry run takes precedence over user cancellation",
|
||||
},
|
||||
{
|
||||
name: "4. error overrides user cancelled when blockedCount is 0",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return false, false, 0, 1, errors.New("error")
|
||||
},
|
||||
expected: ui.OutcomeError,
|
||||
description: "Error takes precedence when blockedCount is 0, even with user cancellation",
|
||||
},
|
||||
{
|
||||
name: "5. blocked packages override everything below",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return false, false, 3, 0, nil
|
||||
},
|
||||
expected: ui.OutcomeBlocked,
|
||||
description: "Blocked packages take precedence when count > 0",
|
||||
},
|
||||
{
|
||||
name: "6. success is default",
|
||||
setup: func() (bool, bool, int, int, error) {
|
||||
return false, false, 0, 0, nil
|
||||
},
|
||||
expected: ui.OutcomeSuccess,
|
||||
description: "Success when no conditions are met",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
insecureMode, dryRun, blockedCount, userCancelledCount, err := tt.setup()
|
||||
outcome := inferOutcome(insecureMode, dryRun, blockedCount, userCancelledCount, err)
|
||||
|
||||
if outcome != tt.expected {
|
||||
t.Errorf("%s: got %v, want %v", tt.description, outcome, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -53,15 +53,39 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
// Initialize report data at the start
|
||||
reportData := ui.NewReportData()
|
||||
reportData.PackageManagerName = f.pm.Name()
|
||||
reportData.FlowType = ui.FlowTypeProxy
|
||||
reportData.DryRun = cfg.DryRun
|
||||
reportData.InsecureMode = cfg.InsecureInstallation
|
||||
reportData.TransitiveEnabled = cfg.Config.Transitive
|
||||
reportData.ParanoidMode = cfg.Config.Paranoid
|
||||
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
|
||||
|
||||
if cfg.Config.Sandbox.Enabled {
|
||||
if policyRef, exists := cfg.Config.Sandbox.Policies[f.pm.Name()]; exists {
|
||||
reportData.SandboxProfile = policyRef.Profile
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.SandboxProfileOverride != "" {
|
||||
reportData.SandboxProfile = cfg.SandboxProfileOverride
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Check if dry-run mode is enabled
|
||||
if cfg.DryRun {
|
||||
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())
|
||||
log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
|
||||
|
||||
reportData.Outcome = ui.OutcomeDryRun
|
||||
ui.Report(reportData)
|
||||
return nil
|
||||
}
|
||||
|
||||
ui.SetStatus("Initializing experimental proxy mode...")
|
||||
ui.SetStatus("Initializing proxy mode...")
|
||||
|
||||
// Setup CA certificate for MITM
|
||||
caCert, caCertPath, err := f.setupCACertificate()
|
||||
@@ -90,8 +114,9 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
return fmt.Errorf("failed to create analyzer: %w", err)
|
||||
}
|
||||
|
||||
// Create analysis cache
|
||||
// Create analysis cache and stats collector
|
||||
cache := interceptors.NewInMemoryAnalysisCache()
|
||||
statsCollector := interceptors.NewAnalysisStatsCollector()
|
||||
|
||||
// Create confirmation channel and start confirmation handler
|
||||
confirmationChan := make(chan *interceptors.ConfirmationRequest, 10)
|
||||
@@ -107,7 +132,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
}
|
||||
|
||||
// Create ecosystem-specific interceptor using factory
|
||||
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan)
|
||||
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan)
|
||||
interceptor, err := factory.CreateInterceptor(ecosystem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
|
||||
@@ -146,6 +171,22 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
executionError = f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
|
||||
}
|
||||
|
||||
// Populate report data from stats collector
|
||||
stats := statsCollector.GetStats()
|
||||
reportData.StartTime = startTime
|
||||
reportData.TotalAnalyzed = stats.TotalAnalyzed
|
||||
reportData.AllowedCount = stats.AllowedCount
|
||||
reportData.ConfirmedCount = stats.ConfirmedCount
|
||||
reportData.BlockedCount = stats.BlockedCount
|
||||
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
|
||||
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
|
||||
|
||||
// Set outcome based on execution result using shared inference logic
|
||||
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
||||
|
||||
// Show the report
|
||||
ui.Report(reportData)
|
||||
|
||||
// Run should always end with handleExecutionResultError to ensure the process exits with the correct exit code
|
||||
// from the execution result.
|
||||
return handleExecutionResultError(executionError)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
)
|
||||
|
||||
// FlowType indicates which execution flow was used
|
||||
type FlowType int
|
||||
|
||||
const (
|
||||
FlowTypeGuard FlowType = iota
|
||||
FlowTypeProxy
|
||||
)
|
||||
|
||||
func (f FlowType) String() string {
|
||||
switch f {
|
||||
case FlowTypeGuard:
|
||||
return "guard"
|
||||
case FlowTypeProxy:
|
||||
return "proxy"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ExecutionOutcome represents the final result of the PMG execution
|
||||
type ExecutionOutcome int
|
||||
|
||||
const (
|
||||
OutcomeSuccess ExecutionOutcome = iota
|
||||
OutcomeBlocked
|
||||
OutcomeUserCancelled
|
||||
OutcomeDryRun
|
||||
OutcomeError
|
||||
OutcomeInsecureBypass
|
||||
)
|
||||
|
||||
func (o ExecutionOutcome) String() string {
|
||||
switch o {
|
||||
case OutcomeSuccess:
|
||||
return "success"
|
||||
case OutcomeBlocked:
|
||||
return "blocked"
|
||||
case OutcomeUserCancelled:
|
||||
return "user_cancelled"
|
||||
case OutcomeDryRun:
|
||||
return "dry_run"
|
||||
case OutcomeError:
|
||||
return "error"
|
||||
case OutcomeInsecureBypass:
|
||||
return "insecure_bypass"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ReportData captures execution statistics for the post-execution report.
|
||||
// This is a pure data model with no rendering logic.
|
||||
type ReportData struct {
|
||||
// Execution metadata
|
||||
PackageManagerName string
|
||||
StartTime time.Time
|
||||
Duration time.Duration
|
||||
|
||||
// Package statistics (consistent across guard and proxy flows)
|
||||
TotalAnalyzed int
|
||||
TrustedSkipped int
|
||||
|
||||
// Analysis breakdown
|
||||
AllowedCount int
|
||||
ConfirmedCount int
|
||||
BlockedCount int
|
||||
|
||||
// Details for verbose mode
|
||||
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
|
||||
// Configuration context
|
||||
FlowType FlowType
|
||||
DryRun bool
|
||||
InsecureMode bool
|
||||
TransitiveEnabled bool
|
||||
ParanoidMode bool
|
||||
SandboxEnabled bool
|
||||
SandboxProfile string
|
||||
|
||||
// Outcome
|
||||
Outcome ExecutionOutcome
|
||||
}
|
||||
|
||||
// NewReportData creates a new ReportData with sensible defaults
|
||||
func NewReportData() *ReportData {
|
||||
return &ReportData{
|
||||
StartTime: time.Now(),
|
||||
Outcome: OutcomeSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize sets the duration based on start time
|
||||
func (r *ReportData) Finalize() {
|
||||
r.Duration = time.Since(r.StartTime)
|
||||
}
|
||||
|
||||
// HasIssues returns true if any packages were blocked or required confirmation
|
||||
func (r *ReportData) HasIssues() bool {
|
||||
return r.BlockedCount > 0 || r.ConfirmedCount > 0
|
||||
}
|
||||
|
||||
// WasSuccessful returns true if execution completed without blocks or errors
|
||||
func (r *ReportData) WasSuccessful() bool {
|
||||
return r.Outcome == OutcomeSuccess || r.Outcome == OutcomeDryRun
|
||||
}
|
||||
|
||||
// Report renders the execution report based on verbosity level.
|
||||
// This is the public API - flows call this with collected data.
|
||||
func Report(data *ReportData) {
|
||||
data.Finalize()
|
||||
|
||||
switch verbosityLevel {
|
||||
case VerbosityLevelSilent:
|
||||
reportSilent(data)
|
||||
case VerbosityLevelNormal:
|
||||
reportNormal(data)
|
||||
case VerbosityLevelVerbose:
|
||||
reportVerbose(data)
|
||||
}
|
||||
}
|
||||
|
||||
// reportSilent only shows output on errors or blocks
|
||||
// Normal successful execution produces no output
|
||||
func reportSilent(data *ReportData) {
|
||||
// Silent mode: no report output
|
||||
// Block messages and errors are already shown via ui.Block() and ui.ErrorExit()
|
||||
}
|
||||
|
||||
// reportNormal shows minimal, assuring output
|
||||
func reportNormal(data *ReportData) {
|
||||
if data.Outcome == OutcomeDryRun {
|
||||
return // Dry run already shows its own message
|
||||
}
|
||||
|
||||
if data.Outcome == OutcomeError {
|
||||
return // Error handling done elsewhere
|
||||
}
|
||||
|
||||
if data.Outcome == OutcomeInsecureBypass {
|
||||
// Security-sensitive: Always show warning when protection is bypassed
|
||||
icon := Colors.Red("⚠")
|
||||
message := "INSECURE MODE - Malware protection bypassed"
|
||||
|
||||
if data.TotalAnalyzed > 0 {
|
||||
fmt.Printf("%s %s (%d packages installed without analysis)\n",
|
||||
icon, Colors.Red(message), data.TotalAnalyzed)
|
||||
} else {
|
||||
fmt.Printf("%s %s\n", icon, Colors.Red(message))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if data.TotalAnalyzed == 0 {
|
||||
// No packages analyzed (e.g., npm install with no new packages)
|
||||
return
|
||||
}
|
||||
|
||||
var icon string
|
||||
var message string
|
||||
|
||||
switch data.Outcome {
|
||||
case OutcomeBlocked:
|
||||
icon = Colors.Red("✗")
|
||||
message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked",
|
||||
data.TotalAnalyzed, data.BlockedCount)
|
||||
case OutcomeUserCancelled:
|
||||
icon = Colors.Yellow("✗")
|
||||
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
|
||||
data.TotalAnalyzed)
|
||||
default:
|
||||
// Success case
|
||||
if data.HasIssues() {
|
||||
icon = Colors.Yellow("!")
|
||||
message = fmt.Sprintf("PMG: %d packages analyzed (%d confirmed)",
|
||||
data.TotalAnalyzed, data.ConfirmedCount)
|
||||
} else {
|
||||
icon = Colors.Green("✓")
|
||||
message = fmt.Sprintf("PMG: %d packages analyzed", data.TotalAnalyzed)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", icon, Colors.Dim(message))
|
||||
}
|
||||
|
||||
// reportVerbose shows detailed debugging information
|
||||
func reportVerbose(data *ReportData) {
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Cyan("PMG Execution Report"))
|
||||
fmt.Println(Colors.Normal("────────────────────────────────────────"))
|
||||
|
||||
// Outcome summary line
|
||||
printOutcomeLine(data)
|
||||
|
||||
// Statistics section
|
||||
fmt.Println()
|
||||
if data.TrustedSkipped > 0 {
|
||||
fmt.Printf(" %s %d analyzed (%d trusted skipped)\n",
|
||||
Colors.Bold("Packages:"),
|
||||
data.TotalAnalyzed,
|
||||
data.TrustedSkipped)
|
||||
} else {
|
||||
fmt.Printf(" %s %d analyzed\n",
|
||||
Colors.Bold("Packages:"),
|
||||
data.TotalAnalyzed)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s (allowed: %d, confirmed: %d, blocked: %d)\n",
|
||||
Colors.Bold("Analysis:"),
|
||||
formatDuration(data.Duration),
|
||||
data.AllowedCount,
|
||||
data.ConfirmedCount,
|
||||
data.BlockedCount)
|
||||
|
||||
// Configuration section
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s | %s flow | transitive: %s | paranoid: %s\n",
|
||||
Colors.Bold("Config:"),
|
||||
data.PackageManagerName,
|
||||
data.FlowType.String(),
|
||||
boolToOnOff(data.TransitiveEnabled),
|
||||
boolToOnOff(data.ParanoidMode))
|
||||
|
||||
if data.SandboxEnabled {
|
||||
profile := data.SandboxProfile
|
||||
if profile == "" {
|
||||
profile = "default"
|
||||
}
|
||||
fmt.Printf(" %s enabled (%s)\n",
|
||||
Colors.Bold("Sandbox:"),
|
||||
profile)
|
||||
}
|
||||
|
||||
// Show blocked/confirmed package details in verbose mode
|
||||
if len(data.BlockedPackages) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Red(" Blocked packages:"))
|
||||
for _, pkg := range data.BlockedPackages {
|
||||
printPackageDetail(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.ConfirmedPackages) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Yellow(" User-confirmed packages:"))
|
||||
for _, pkg := range data.ConfirmedPackages {
|
||||
printPackageDetail(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func printOutcomeLine(data *ReportData) {
|
||||
switch data.Outcome {
|
||||
case OutcomeSuccess:
|
||||
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
|
||||
case OutcomeBlocked:
|
||||
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked - malicious package detected"))
|
||||
case OutcomeUserCancelled:
|
||||
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
|
||||
case OutcomeDryRun:
|
||||
fmt.Printf(" %s %s\n", Colors.Cyan("○"), Colors.Cyan("Dry run completed - no packages installed"))
|
||||
case OutcomeError:
|
||||
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Execution failed with error"))
|
||||
case OutcomeInsecureBypass:
|
||||
fmt.Printf(" %s %s\n", Colors.Yellow("⚠"), Colors.Yellow("Installation completed (insecure mode - protection bypassed)"))
|
||||
}
|
||||
}
|
||||
|
||||
func printPackageDetail(pkg *analyzer.PackageVersionAnalysisResult) {
|
||||
if pkg == nil || pkg.PackageVersion == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := pkg.PackageVersion.GetPackage().GetName()
|
||||
version := pkg.PackageVersion.GetVersion()
|
||||
fmt.Printf(" - %s@%s\n", name, version)
|
||||
|
||||
if pkg.ReferenceURL != "" {
|
||||
fmt.Printf(" %s\n", Colors.Dim(pkg.ReferenceURL))
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
}
|
||||
|
||||
func boolToOnOff(b bool) string {
|
||||
if b {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func StartSpinnerWithColor(msg string, c ColorFn) {
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Printf("\r%s ... %s", c(msg), string(frames[pos%length]))
|
||||
fmt.Printf("\r%s ... %s", c("PMG: "+msg), string(frames[pos%length]))
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -64,15 +64,18 @@ func BlockNoExit(config *BlockConfig) error {
|
||||
func blockWithExit(config *BlockConfig, exit bool) error {
|
||||
StopSpinner()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Red("❌ Malicious package blocked!"))
|
||||
// We show the block message only in normal mode to avoid repeating information
|
||||
// already shown to the user in verbose mode as part of the reporting.
|
||||
if verbosityLevel != VerbosityLevelVerbose {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
||||
|
||||
if config.ShowReference {
|
||||
printMaliciousPackagesList(config.MalwarePackages)
|
||||
if config.ShowReference {
|
||||
printMaliciousPackagesList(config.MalwarePackages)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if exit {
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -86,7 +89,7 @@ func SetStatus(status string) {
|
||||
}
|
||||
|
||||
StopSpinner()
|
||||
StartSpinnerWithColor(fmt.Sprintf("ℹ️ %s", status), Colors.Green)
|
||||
StartSpinnerWithColor(status, Colors.Dim)
|
||||
}
|
||||
|
||||
// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages.
|
||||
@@ -101,12 +104,12 @@ func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersi
|
||||
StopSpinner()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Red(fmt.Sprintf("🚨 Suspicious package(s) detected: %d", len(malwarePackages))))
|
||||
fmt.Printf("%s %s\n", Colors.Yellow("!"), Colors.Yellow(fmt.Sprintf("Suspicious package(s) detected: %d", len(malwarePackages))))
|
||||
|
||||
printMaliciousPackagesList(malwarePackages)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) "))
|
||||
fmt.Print(Colors.Normal("Do you want to continue with the installation? (y/N) "))
|
||||
|
||||
// Use Scanner on the provided reader to support PTY input routing
|
||||
scanner := bufio.NewScanner(reader)
|
||||
@@ -128,7 +131,7 @@ func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersi
|
||||
|
||||
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))
|
||||
fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message))
|
||||
}
|
||||
|
||||
func Fatalf(msg string, args ...interface{}) {
|
||||
@@ -141,16 +144,16 @@ func Fatalf(msg string, args ...interface{}) {
|
||||
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
||||
for _, mp := range malwarePackages {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠️ ", Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
||||
mp.PackageVersion.GetVersion())))
|
||||
fmt.Printf(" %s %s\n", Colors.Red("-"),
|
||||
Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
||||
mp.PackageVersion.GetVersion())))
|
||||
|
||||
if verbosityLevel == VerbosityLevelVerbose {
|
||||
fmt.Println(Colors.Yellow(termWidthFormatText(mp.Summary, 80)))
|
||||
fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76)))
|
||||
}
|
||||
|
||||
if mp.ReferenceURL != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Yellow(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type baseRegistryInterceptor struct {
|
||||
analyzer analyzer.PackageVersionAnalyzer
|
||||
cache AnalysisCache
|
||||
statsCollector *AnalysisStatsCollector
|
||||
confirmationChan chan *ConfirmationRequest
|
||||
}
|
||||
|
||||
@@ -56,6 +57,17 @@ func (b *baseRegistryInterceptor) analyzePackage(
|
||||
Version: packageVersion,
|
||||
}
|
||||
|
||||
if cfg := config.Get(); cfg.InsecureInstallation {
|
||||
log.Debugf("[%s] Skipping insecure installation", ctx.RequestID)
|
||||
|
||||
eventlog.LogInstallInsecureBypass(packageName, packageVersion, ecosystem.String())
|
||||
|
||||
return &analyzer.PackageVersionAnalysisResult{
|
||||
PackageVersion: pkgVersion,
|
||||
Action: analyzer.ActionAllow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if config.IsTrustedPackage(pkgVersion) {
|
||||
log.Debugf("[%s] Skipping trusted package: %s/%s@%s",
|
||||
ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||
@@ -108,6 +120,10 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
||||
"reference_url": result.ReferenceURL,
|
||||
})
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordBlocked(result)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s",
|
||||
ecosystem.String(),
|
||||
packageName, packageVersion,
|
||||
@@ -126,6 +142,11 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
||||
confirmed, err := b.requestUserConfirmation(ctx, result)
|
||||
if err != nil {
|
||||
log.Errorf("[%s] Failed to get user confirmation: %v", ctx.RequestID, err)
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordBlocked(result)
|
||||
}
|
||||
|
||||
return &proxy.InterceptorResponse{
|
||||
Action: proxy.ActionBlock,
|
||||
BlockCode: http.StatusForbidden,
|
||||
@@ -141,6 +162,10 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
||||
"reference_url": result.ReferenceURL,
|
||||
})
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordUserCancelled(result)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s",
|
||||
ecosystem.String(),
|
||||
packageName, packageVersion,
|
||||
@@ -157,18 +182,30 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
||||
eventlog.LogMalwareConfirmed(packageName, packageVersion, ecosystem.String())
|
||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordConfirmed(result)
|
||||
}
|
||||
|
||||
log.Infof("[%s] User confirmed installation of suspicious package %s/%s@%s", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
|
||||
case analyzer.ActionAllow:
|
||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordAllowed(result)
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Package %s/%s@%s is safe, allowing request", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
|
||||
default:
|
||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
||||
|
||||
if b.statsCollector != nil {
|
||||
b.statsCollector.RecordAllowed(result)
|
||||
}
|
||||
|
||||
log.Warnf("[%s] Unknown analysis action %d for package %s/%s@%s, allowing by default", ctx.RequestID, result.Action, ecosystem.String(), packageName, packageVersion)
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type InterceptorFactory struct {
|
||||
analyzer analyzer.PackageVersionAnalyzer
|
||||
cache AnalysisCache
|
||||
statsCollector *AnalysisStatsCollector
|
||||
confirmationChan chan *ConfirmationRequest
|
||||
}
|
||||
|
||||
@@ -19,11 +20,13 @@ type InterceptorFactory struct {
|
||||
func NewInterceptorFactory(
|
||||
analyzer analyzer.PackageVersionAnalyzer,
|
||||
cache AnalysisCache,
|
||||
statsCollector *AnalysisStatsCollector,
|
||||
confirmationChan chan *ConfirmationRequest,
|
||||
) *InterceptorFactory {
|
||||
return &InterceptorFactory{
|
||||
analyzer: analyzer,
|
||||
cache: cache,
|
||||
statsCollector: statsCollector,
|
||||
confirmationChan: confirmationChan,
|
||||
}
|
||||
}
|
||||
@@ -36,6 +39,7 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p
|
||||
return NewNpmRegistryInterceptor(
|
||||
f.analyzer,
|
||||
f.cache,
|
||||
f.statsCollector,
|
||||
f.confirmationChan,
|
||||
), nil
|
||||
|
||||
|
||||
@@ -44,12 +44,14 @@ var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
|
||||
func NewNpmRegistryInterceptor(
|
||||
analyzer analyzer.PackageVersionAnalyzer,
|
||||
cache AnalysisCache,
|
||||
statsCollector *AnalysisStatsCollector,
|
||||
confirmationChan chan *ConfirmationRequest,
|
||||
) *NpmRegistryInterceptor {
|
||||
return &NpmRegistryInterceptor{
|
||||
baseRegistryInterceptor: baseRegistryInterceptor{
|
||||
analyzer: analyzer,
|
||||
cache: cache,
|
||||
statsCollector: statsCollector,
|
||||
confirmationChan: confirmationChan,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package interceptors
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
)
|
||||
|
||||
// AnalysisStats contains aggregated statistics from analysis results
|
||||
type AnalysisStats struct {
|
||||
TotalAnalyzed int
|
||||
AllowedCount int
|
||||
ConfirmedCount int
|
||||
BlockedCount int
|
||||
UserCancelledCount int
|
||||
}
|
||||
|
||||
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
|
||||
// It is separate from the cache to allow different cache implementations
|
||||
// without coupling them to reporting concerns.
|
||||
type AnalysisStatsCollector struct {
|
||||
mu sync.RWMutex
|
||||
stats AnalysisStats
|
||||
blockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
confirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||
}
|
||||
|
||||
// NewAnalysisStatsCollector creates a new stats collector
|
||||
func NewAnalysisStatsCollector() *AnalysisStatsCollector {
|
||||
return &AnalysisStatsCollector{}
|
||||
}
|
||||
|
||||
// RecordAllowed records a package that was allowed (safe)
|
||||
func (c *AnalysisStatsCollector) RecordAllowed(result *analyzer.PackageVersionAnalysisResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.stats.TotalAnalyzed++
|
||||
c.stats.AllowedCount++
|
||||
}
|
||||
|
||||
// RecordBlocked records a package that was automatically blocked (ActionBlock)
|
||||
func (c *AnalysisStatsCollector) RecordBlocked(result *analyzer.PackageVersionAnalysisResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.stats.TotalAnalyzed++
|
||||
c.stats.BlockedCount++
|
||||
c.blockedPackages = append(c.blockedPackages, result)
|
||||
}
|
||||
|
||||
// RecordUserCancelled records a package that was blocked because user declined confirmation (ActionConfirm declined)
|
||||
func (c *AnalysisStatsCollector) RecordUserCancelled(result *analyzer.PackageVersionAnalysisResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.stats.TotalAnalyzed++
|
||||
c.stats.UserCancelledCount++
|
||||
|
||||
// User cancelled packages are counted as blocked as well
|
||||
c.stats.BlockedCount++
|
||||
|
||||
c.blockedPackages = append(c.blockedPackages, result)
|
||||
}
|
||||
|
||||
// RecordConfirmed records a package where user confirmed installation despite warning
|
||||
func (c *AnalysisStatsCollector) RecordConfirmed(result *analyzer.PackageVersionAnalysisResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.stats.TotalAnalyzed++
|
||||
c.stats.ConfirmedCount++
|
||||
c.confirmedPackages = append(c.confirmedPackages, result)
|
||||
}
|
||||
|
||||
// GetStats returns the current statistics
|
||||
func (c *AnalysisStatsCollector) GetStats() AnalysisStats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return c.stats
|
||||
}
|
||||
|
||||
// GetBlockedPackages returns all blocked packages
|
||||
func (c *AnalysisStatsCollector) GetBlockedPackages() []*analyzer.PackageVersionAnalysisResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid race conditions
|
||||
result := make([]*analyzer.PackageVersionAnalysisResult, len(c.blockedPackages))
|
||||
copy(result, c.blockedPackages)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetConfirmedPackages returns all confirmed packages
|
||||
func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersionAnalysisResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid race conditions
|
||||
result := make([]*analyzer.PackageVersionAnalysisResult, len(c.confirmedPackages))
|
||||
copy(result, c.confirmedPackages)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user