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 {
|
type packageManagerGuard struct {
|
||||||
config PackageManagerGuardConfig
|
config PackageManagerGuardConfig
|
||||||
interaction PackageManagerGuardInteraction
|
interaction PackageManagerGuardInteraction
|
||||||
@@ -100,9 +115,11 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig,
|
|||||||
}, nil
|
}, 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)
|
log.Debugf("Running package manager guard with args: %v", args)
|
||||||
|
|
||||||
|
result := &GuardResult{}
|
||||||
|
|
||||||
// Log the installation start
|
// Log the installation start
|
||||||
if g.packageManager != nil {
|
if g.packageManager != nil {
|
||||||
eventlog.LogInstallStarted(g.packageManager.Name(), args)
|
eventlog.LogInstallStarted(g.packageManager.Name(), args)
|
||||||
@@ -110,8 +127,8 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
|||||||
|
|
||||||
if g.config.InsecureInstallation {
|
if g.config.InsecureInstallation {
|
||||||
log.Debugf("Bypassing block for unconfirmed malicious packages due to PMG_INSECURE_INSTALLATION")
|
log.Debugf("Bypassing block for unconfirmed malicious packages due to PMG_INSECURE_INSTALLATION")
|
||||||
g.showWarning("⚠️ WARNING: INSECURE INSTALLATION MODE - Malware protection bypassed!")
|
g.showWarning("INSECURE INSTALLATION MODE - Malware protection bypassed!")
|
||||||
return g.continueExecution(ctx, parsedCommand)
|
return result, g.continueExecution(ctx, parsedCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !parsedCommand.HasInstallTarget() {
|
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")
|
log.Debugf("No install target found, continuing execution")
|
||||||
return g.continueExecution(ctx, parsedCommand)
|
return result, g.continueExecution(ctx, parsedCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
blockConfig := ui.NewDefaultBlockConfig()
|
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)
|
log.Debugf("Resolving latest version for package: %s", pkg.PackageVersion.Package.Name)
|
||||||
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.PackageVersion.GetPackage())
|
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.PackageVersion.GetPackage())
|
||||||
if err != nil {
|
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()
|
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)
|
dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg.PackageVersion)
|
||||||
if err != nil {
|
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),
|
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)))
|
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 {
|
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{}
|
confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{}
|
||||||
for _, result := range analysisResults {
|
for _, analysisResult := range analysisResults {
|
||||||
if result.Action == analyzer.ActionBlock {
|
if analysisResult.Action == analyzer.ActionBlock {
|
||||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, result)
|
result.BlockedCount++
|
||||||
g.logMalwareDetection(result, true)
|
result.BlockedPackages = append(result.BlockedPackages, analysisResult)
|
||||||
return g.blockInstallation(blockConfig)
|
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, analysisResult)
|
||||||
|
g.logMalwareDetection(analysisResult, true)
|
||||||
|
return result, g.blockInstallation(blockConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Action == analyzer.ActionConfirm {
|
if analysisResult.Action == analyzer.ActionConfirm {
|
||||||
confirmableMalwarePackages = append(confirmableMalwarePackages, result)
|
confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult)
|
||||||
|
} else {
|
||||||
|
result.AllowedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(confirmableMalwarePackages) > 0 {
|
if len(confirmableMalwarePackages) > 0 {
|
||||||
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
||||||
if err != nil {
|
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 {
|
if !confirmed {
|
||||||
@@ -198,13 +223,18 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
|
|||||||
blockConfig.MalwarePackages = confirmableMalwarePackages
|
blockConfig.MalwarePackages = confirmableMalwarePackages
|
||||||
for _, pkg := range confirmableMalwarePackages {
|
for _, pkg := range confirmableMalwarePackages {
|
||||||
g.logMalwareDetection(pkg, true)
|
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
|
// User confirmed installation despite warning
|
||||||
for _, pkg := range confirmableMalwarePackages {
|
for _, pkg := range confirmableMalwarePackages {
|
||||||
g.logMalwareDetection(pkg, false)
|
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()
|
g.clearStatus()
|
||||||
return g.continueExecution(ctx, parsedCommand)
|
return result, g.continueExecution(ctx, parsedCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error {
|
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,
|
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)
|
ctx, cancel := context.WithTimeout(ctx, g.config.AnalysisTimeout)
|
||||||
defer cancel()
|
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 {
|
for _, pkg := range packages {
|
||||||
if config.IsTrustedPackage(pkg) {
|
if config.IsTrustedPackage(pkg) {
|
||||||
log.Debugf("Skipping trusted package: %s/%s@%s",
|
log.Debugf("Skipping trusted package: %s/%s@%s",
|
||||||
pkg.GetPackage().GetEcosystem(), pkg.GetPackage().GetName(), pkg.GetVersion())
|
pkg.GetPackage().GetEcosystem(), pkg.GetPackage().GetName(), pkg.GetVersion())
|
||||||
|
trustedSkipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,10 +372,10 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
|
|||||||
select {
|
select {
|
||||||
case <-waiter:
|
case <-waiter:
|
||||||
case <-ctx.Done():
|
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) {
|
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)
|
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 := extractor.NewDefaultExtractorConfig()
|
||||||
extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name())
|
extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name())
|
||||||
extractorConfig.ManifestFiles = parsedCommand.ManifestFiles
|
extractorConfig.ManifestFiles = parsedCommand.ManifestFiles
|
||||||
@@ -396,14 +429,14 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
|||||||
|
|
||||||
packages, err := packageExtractor.ExtractManifest()
|
packages, err := packageExtractor.ExtractManifest()
|
||||||
if err != nil {
|
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()
|
blockConfig := ui.NewDefaultBlockConfig()
|
||||||
|
|
||||||
if len(packages) == 0 {
|
if len(packages) == 0 {
|
||||||
log.Debugf("No packages found in manifest files, continuing execution")
|
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))
|
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)
|
log.Debugf("Resolving latest version for package: %s", pkg.Package.Name)
|
||||||
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.GetPackage())
|
latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.GetPackage())
|
||||||
if err != nil {
|
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()
|
pkg.Version = latestVersion.GetVersion()
|
||||||
@@ -432,7 +465,7 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa
|
|||||||
|
|
||||||
dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg)
|
dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg)
|
||||||
if err != nil {
|
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),
|
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)))
|
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 {
|
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{}
|
confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{}
|
||||||
for _, result := range analysisResults {
|
for _, analysisResult := range analysisResults {
|
||||||
if result.Action == analyzer.ActionBlock {
|
if analysisResult.Action == analyzer.ActionBlock {
|
||||||
blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, result)
|
result.BlockedCount++
|
||||||
return g.blockInstallation(blockConfig)
|
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 {
|
if analysisResult.Action == analyzer.ActionConfirm {
|
||||||
confirmableMalwarePackages = append(confirmableMalwarePackages, result)
|
confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult)
|
||||||
|
} else {
|
||||||
|
result.AllowedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(confirmableMalwarePackages) > 0 {
|
if len(confirmableMalwarePackages) > 0 {
|
||||||
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
|
||||||
if err != nil {
|
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 {
|
if !confirmed {
|
||||||
blockConfig.ShowReference = false
|
blockConfig.ShowReference = false
|
||||||
blockConfig.MalwarePackages = confirmableMalwarePackages
|
blockConfig.MalwarePackages = confirmableMalwarePackages
|
||||||
|
|
||||||
for _, pkg := range confirmableMalwarePackages {
|
for _, pkg := range confirmableMalwarePackages {
|
||||||
g.logMalwareDetection(pkg, true)
|
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
|
// User confirmed installation despite warning
|
||||||
for _, pkg := range confirmableMalwarePackages {
|
for _, pkg := range confirmableMalwarePackages {
|
||||||
g.logMalwareDetection(pkg, false)
|
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()
|
g.clearStatus()
|
||||||
return g.continueExecution(ctx, parsedCommand)
|
return result, g.continueExecution(ctx, parsedCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logMalwareDetection logs malware detection events
|
// 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) {
|
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{
|
Package: &packagev1.Package{
|
||||||
Name: "nyc-config",
|
Name: "nyc-config",
|
||||||
@@ -39,6 +39,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
|
|||||||
t.Fatalf("failed to analyze packages: %v", err)
|
t.Fatalf("failed to analyze packages: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 0, trustedSkipped)
|
||||||
assert.Equal(t, 1, len(r))
|
assert.Equal(t, 1, len(r))
|
||||||
assert.Equal(t, "nyc-config", r[0].PackageVersion.GetPackage().GetName())
|
assert.Equal(t, "nyc-config", r[0].PackageVersion.GetPackage().GetName())
|
||||||
assert.Equal(t, "10.0.0", r[0].PackageVersion.GetVersion())
|
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
|
// With dry run enabled, we expect no error even though we're bypassing execution
|
||||||
assert.NoError(t, err)
|
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)
|
// We expect no error from the guard itself (blocking is handled via the Block callback)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -209,7 +210,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
InstallTargets: []*packagemanager.PackageInstallTarget{}, // No install targets
|
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
|
// Should not error since there are no install targets to analyze
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -252,7 +253,7 @@ func TestGuardInsecureInstallation(t *testing.T) {
|
|||||||
ManifestFiles: []string{"package.json"},
|
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
|
// Should not error and should bypass malware checking
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
EventTypeInstallTrustedAllowed EventType = "install_trusted_allowed"
|
EventTypeInstallTrustedAllowed EventType = "install_trusted_allowed"
|
||||||
EventTypeInstallStarted EventType = "install_started"
|
EventTypeInstallStarted EventType = "install_started"
|
||||||
EventTypeDependencyResolved EventType = "dependency_resolved"
|
EventTypeDependencyResolved EventType = "dependency_resolved"
|
||||||
|
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
||||||
EventTypeError EventType = "error"
|
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
|
// LogInstallStarted logs when an installation starts
|
||||||
func LogInstallStarted(packageManager string, args []string) {
|
func LogInstallStarted(packageManager string, args []string) {
|
||||||
event := Event{
|
event := Event{
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package flows
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
@@ -34,6 +36,27 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
|||||||
|
|
||||||
cfg := config.Get()
|
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 {
|
if cfg.Config.Paranoid {
|
||||||
malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
|
malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,7 +78,7 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
|
|||||||
ClearStatus: ui.ClearStatus,
|
ClearStatus: ui.ClearStatus,
|
||||||
ShowWarning: ui.ShowWarning,
|
ShowWarning: ui.ShowWarning,
|
||||||
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
|
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
|
||||||
Block: ui.Block,
|
Block: ui.BlockNoExit,
|
||||||
}
|
}
|
||||||
|
|
||||||
guardConfig := guard.DefaultPackageManagerGuardConfig()
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to run package manager guard: %w", err)
|
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()
|
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
|
// Check if dry-run mode is enabled
|
||||||
if cfg.DryRun {
|
if cfg.DryRun {
|
||||||
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.SetStatus("Initializing experimental proxy mode...")
|
ui.SetStatus("Initializing proxy mode...")
|
||||||
|
|
||||||
// Setup CA certificate for MITM
|
// Setup CA certificate for MITM
|
||||||
caCert, caCertPath, err := f.setupCACertificate()
|
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)
|
return fmt.Errorf("failed to create analyzer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create analysis cache
|
// Create analysis cache and stats collector
|
||||||
cache := interceptors.NewInMemoryAnalysisCache()
|
cache := interceptors.NewInMemoryAnalysisCache()
|
||||||
|
statsCollector := interceptors.NewAnalysisStatsCollector()
|
||||||
|
|
||||||
// Create confirmation channel and start confirmation handler
|
// Create confirmation channel and start confirmation handler
|
||||||
confirmationChan := make(chan *interceptors.ConfirmationRequest, 10)
|
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
|
// Create ecosystem-specific interceptor using factory
|
||||||
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan)
|
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan)
|
||||||
interceptor, err := factory.CreateInterceptor(ecosystem)
|
interceptor, err := factory.CreateInterceptor(ecosystem)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
|
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)
|
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
|
// Run should always end with handleExecutionResultError to ensure the process exits with the correct exit code
|
||||||
// from the execution result.
|
// from the execution result.
|
||||||
return handleExecutionResultError(executionError)
|
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()
|
ticker.Stop()
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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
|
pos += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-15
@@ -64,15 +64,18 @@ func BlockNoExit(config *BlockConfig) error {
|
|||||||
func blockWithExit(config *BlockConfig, exit bool) error {
|
func blockWithExit(config *BlockConfig, exit bool) error {
|
||||||
StopSpinner()
|
StopSpinner()
|
||||||
|
|
||||||
fmt.Println()
|
// We show the block message only in normal mode to avoid repeating information
|
||||||
fmt.Println(Colors.Red("❌ Malicious package blocked!"))
|
// 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 {
|
if config.ShowReference {
|
||||||
printMaliciousPackagesList(config.MalwarePackages)
|
printMaliciousPackagesList(config.MalwarePackages)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if exit {
|
if exit {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,7 @@ func SetStatus(status string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StopSpinner()
|
StopSpinner()
|
||||||
StartSpinnerWithColor(fmt.Sprintf("ℹ️ %s", status), Colors.Green)
|
StartSpinnerWithColor(status, Colors.Dim)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages.
|
// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages.
|
||||||
@@ -101,12 +104,12 @@ func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersi
|
|||||||
StopSpinner()
|
StopSpinner()
|
||||||
|
|
||||||
fmt.Println()
|
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)
|
printMaliciousPackagesList(malwarePackages)
|
||||||
|
|
||||||
fmt.Println()
|
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
|
// Use Scanner on the provided reader to support PTY input routing
|
||||||
scanner := bufio.NewScanner(reader)
|
scanner := bufio.NewScanner(reader)
|
||||||
@@ -128,7 +131,7 @@ func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersi
|
|||||||
|
|
||||||
func ShowWarning(message string) {
|
func ShowWarning(message string) {
|
||||||
// Print colored warning to stderr immediately - it won't be cleared by other output
|
// 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{}) {
|
func Fatalf(msg string, args ...interface{}) {
|
||||||
@@ -141,16 +144,16 @@ func Fatalf(msg string, args ...interface{}) {
|
|||||||
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
||||||
for _, mp := range malwarePackages {
|
for _, mp := range malwarePackages {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("⚠️ ", Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
fmt.Printf(" %s %s\n", Colors.Red("-"),
|
||||||
mp.PackageVersion.GetVersion())))
|
Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
||||||
|
mp.PackageVersion.GetVersion())))
|
||||||
|
|
||||||
if verbosityLevel == VerbosityLevelVerbose {
|
if verbosityLevel == VerbosityLevelVerbose {
|
||||||
fmt.Println(Colors.Yellow(termWidthFormatText(mp.Summary, 80)))
|
fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if mp.ReferenceURL != "" {
|
if mp.ReferenceURL != "" {
|
||||||
fmt.Println()
|
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
||||||
fmt.Println(Colors.Yellow(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
type baseRegistryInterceptor struct {
|
type baseRegistryInterceptor struct {
|
||||||
analyzer analyzer.PackageVersionAnalyzer
|
analyzer analyzer.PackageVersionAnalyzer
|
||||||
cache AnalysisCache
|
cache AnalysisCache
|
||||||
|
statsCollector *AnalysisStatsCollector
|
||||||
confirmationChan chan *ConfirmationRequest
|
confirmationChan chan *ConfirmationRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,17 @@ func (b *baseRegistryInterceptor) analyzePackage(
|
|||||||
Version: packageVersion,
|
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) {
|
if config.IsTrustedPackage(pkgVersion) {
|
||||||
log.Debugf("[%s] Skipping trusted package: %s/%s@%s",
|
log.Debugf("[%s] Skipping trusted package: %s/%s@%s",
|
||||||
ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||||
@@ -108,6 +120,10 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
"reference_url": result.ReferenceURL,
|
"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",
|
message := fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s",
|
||||||
ecosystem.String(),
|
ecosystem.String(),
|
||||||
packageName, packageVersion,
|
packageName, packageVersion,
|
||||||
@@ -126,6 +142,11 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
confirmed, err := b.requestUserConfirmation(ctx, result)
|
confirmed, err := b.requestUserConfirmation(ctx, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%s] Failed to get user confirmation: %v", ctx.RequestID, err)
|
log.Errorf("[%s] Failed to get user confirmation: %v", ctx.RequestID, err)
|
||||||
|
|
||||||
|
if b.statsCollector != nil {
|
||||||
|
b.statsCollector.RecordBlocked(result)
|
||||||
|
}
|
||||||
|
|
||||||
return &proxy.InterceptorResponse{
|
return &proxy.InterceptorResponse{
|
||||||
Action: proxy.ActionBlock,
|
Action: proxy.ActionBlock,
|
||||||
BlockCode: http.StatusForbidden,
|
BlockCode: http.StatusForbidden,
|
||||||
@@ -141,6 +162,10 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
"reference_url": result.ReferenceURL,
|
"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",
|
message := fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s",
|
||||||
ecosystem.String(),
|
ecosystem.String(),
|
||||||
packageName, packageVersion,
|
packageName, packageVersion,
|
||||||
@@ -157,18 +182,30 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
eventlog.LogMalwareConfirmed(packageName, packageVersion, ecosystem.String())
|
eventlog.LogMalwareConfirmed(packageName, packageVersion, ecosystem.String())
|
||||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
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)
|
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
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
|
|
||||||
case analyzer.ActionAllow:
|
case analyzer.ActionAllow:
|
||||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
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)
|
log.Debugf("[%s] Package %s/%s@%s is safe, allowing request", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
eventlog.LogInstallAllowed(packageName, packageVersion, ecosystem.String(), 1)
|
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)
|
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
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
type InterceptorFactory struct {
|
type InterceptorFactory struct {
|
||||||
analyzer analyzer.PackageVersionAnalyzer
|
analyzer analyzer.PackageVersionAnalyzer
|
||||||
cache AnalysisCache
|
cache AnalysisCache
|
||||||
|
statsCollector *AnalysisStatsCollector
|
||||||
confirmationChan chan *ConfirmationRequest
|
confirmationChan chan *ConfirmationRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ type InterceptorFactory struct {
|
|||||||
func NewInterceptorFactory(
|
func NewInterceptorFactory(
|
||||||
analyzer analyzer.PackageVersionAnalyzer,
|
analyzer analyzer.PackageVersionAnalyzer,
|
||||||
cache AnalysisCache,
|
cache AnalysisCache,
|
||||||
|
statsCollector *AnalysisStatsCollector,
|
||||||
confirmationChan chan *ConfirmationRequest,
|
confirmationChan chan *ConfirmationRequest,
|
||||||
) *InterceptorFactory {
|
) *InterceptorFactory {
|
||||||
return &InterceptorFactory{
|
return &InterceptorFactory{
|
||||||
analyzer: analyzer,
|
analyzer: analyzer,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
|
statsCollector: statsCollector,
|
||||||
confirmationChan: confirmationChan,
|
confirmationChan: confirmationChan,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +39,7 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p
|
|||||||
return NewNpmRegistryInterceptor(
|
return NewNpmRegistryInterceptor(
|
||||||
f.analyzer,
|
f.analyzer,
|
||||||
f.cache,
|
f.cache,
|
||||||
|
f.statsCollector,
|
||||||
f.confirmationChan,
|
f.confirmationChan,
|
||||||
), nil
|
), nil
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
|
|||||||
func NewNpmRegistryInterceptor(
|
func NewNpmRegistryInterceptor(
|
||||||
analyzer analyzer.PackageVersionAnalyzer,
|
analyzer analyzer.PackageVersionAnalyzer,
|
||||||
cache AnalysisCache,
|
cache AnalysisCache,
|
||||||
|
statsCollector *AnalysisStatsCollector,
|
||||||
confirmationChan chan *ConfirmationRequest,
|
confirmationChan chan *ConfirmationRequest,
|
||||||
) *NpmRegistryInterceptor {
|
) *NpmRegistryInterceptor {
|
||||||
return &NpmRegistryInterceptor{
|
return &NpmRegistryInterceptor{
|
||||||
baseRegistryInterceptor: baseRegistryInterceptor{
|
baseRegistryInterceptor: baseRegistryInterceptor{
|
||||||
analyzer: analyzer,
|
analyzer: analyzer,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
|
statsCollector: statsCollector,
|
||||||
confirmationChan: confirmationChan,
|
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