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:
Abhisek Datta
2026-01-27 17:50:26 +05:30
committed by GitHub
parent 0aa82033a5
commit 36ac3e3384
14 changed files with 1022 additions and 66 deletions
+91 -39
View File
@@ -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
View File
@@ -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)
+16
View File
@@ -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{
+62 -3
View File
@@ -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
}
+42
View File
@@ -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
}
+272
View File
@@ -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)
}
})
}
}
+44 -3
View File
@@ -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)
+307
View File
@@ -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"
}
+1 -1
View File
@@ -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
View File
@@ -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)))
}
}
}
+37
View File
@@ -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
}
+4
View File
@@ -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
+2
View File
@@ -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,
},
}
+120
View File
@@ -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
}