fix: Show error messages on fatal failures #32 (#34)

* fix: Show error messages on fatal failures #32

* test: Add E2E test

* test: fix E2E scripts

* test: fix E2E scripts

* fix: Race condition in concurrent analyzer

* fix: update formatting to ensure docs URL is clickable

Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>

* fix: E2E test

---------

Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>
Co-authored-by: Sahil Bansal <bansalsahil315@gmail.com>
This commit is contained in:
Abhisek Datta
2025-05-17 21:54:47 +05:30
committed by GitHub
co-authored by Sahil Bansal
parent b85f77cfbc
commit 4031219375
12 changed files with 215 additions and 49 deletions
+31
View File
@@ -36,6 +36,37 @@ jobs:
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout Source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Setup Go
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
with:
go-version: 1.24
check-latest: true
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
check-latest: true
- name: Setup PNPM
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
with:
version: 10
- name: Build Binary
run: make
- name: Run E2E Tests
run: chmod +x test/e2e.sh && ./test/e2e.sh
shell: bash
goreleaser-test: goreleaser-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
+1 -1
View File
@@ -39,7 +39,7 @@ var _ Analyzer = &malysisActiveScanAnalyzer{}
func NewMalysisActiveScanAnalyzer(config MalysisActiveScanAnalyzerConfig) (*malysisActiveScanAnalyzer, error) { func NewMalysisActiveScanAnalyzer(config MalysisActiveScanAnalyzerConfig) (*malysisActiveScanAnalyzer, error) {
if config.TenantId == "" || config.ApiKey == "" { if config.TenantId == "" || config.ApiKey == "" {
return nil, fmt.Errorf("active scanning requires SafeDep Cloud authentication credentials") return nil, fmt.Errorf("active scanning requires SafeDep Cloud credentials. See: https://docs.safedep.io/cloud/malware-analysis")
} }
headers := http.Header{} headers := http.Header{}
+13 -7
View File
@@ -1,11 +1,13 @@
package npm package npm
import ( import (
"context"
_ "embed" _ "embed"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/flows"
"github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,12 +17,7 @@ func NewNpmCommand() *cobra.Command {
Short: "Guard npm package manager", Short: "Guard npm package manager",
DisableFlagParsing: true, DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config, err := config.FromContext(cmd.Context()) err := executeNpmFlow(cmd.Context(), args)
if err != nil {
ui.Fatalf("Failed to get config: %s", err)
}
err = executeNpmFlow(cmd.Context(), config, args)
if err != nil { if err != nil {
log.Errorf("Failed to execute npm flow: %s", err) log.Errorf("Failed to execute npm flow: %s", err)
} }
@@ -29,3 +26,12 @@ func NewNpmCommand() *cobra.Command {
}, },
} }
} }
func executeNpmFlow(ctx context.Context, args []string) error {
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultNpmPackageManagerConfig())
if err != nil {
ui.Fatalf("Failed to create npm package manager proxy: %s", err)
}
return flows.Common(packageManager).Run(ctx, args)
}
+13 -7
View File
@@ -1,11 +1,13 @@
package npm package npm
import ( import (
"context"
_ "embed" _ "embed"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/flows"
"github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,12 +17,7 @@ func NewPnpmCommand() *cobra.Command {
Short: "Guard pnpm package manager", Short: "Guard pnpm package manager",
DisableFlagParsing: true, DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config, err := config.FromContext(cmd.Context()) err := executePnpmFlow(cmd.Context(), args)
if err != nil {
ui.Fatalf("Failed to get config: %s", err)
}
err = executePnpmFlow(cmd.Context(), config, args)
if err != nil { if err != nil {
log.Errorf("Failed to execute pnpm flow: %s", err) log.Errorf("Failed to execute pnpm flow: %s", err)
} }
@@ -29,3 +26,12 @@ func NewPnpmCommand() *cobra.Command {
}, },
} }
} }
func executePnpmFlow(ctx context.Context, args []string) error {
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultPnpmPackageManagerConfig())
if err != nil {
ui.Fatalf("Failed to create pnpm package manager proxy: %s", err)
}
return flows.Common(packageManager).Run(ctx, args)
}
+3
View File
@@ -0,0 +1,3 @@
# Development
- [User Interface](./ui.md)
+29
View File
@@ -0,0 +1,29 @@
# User Interface
PMG is an interactive tool. We support multiple interactivity modes such as `silent`, `verbose` etc. to meet different developer experience needs. As such, we need to standardize the UI, UX and interactive messaging guidance for developers.
## Messaging
Two types of messages to users are supported:
1. UI messages
2. Logs
### UI messages
UI messages are displayed in the user interface, currently in the terminal. Following types of messages are supported:
1. **Status updates** - Meant for showing the stage or status of the workflow.
2. **Error messages** - Meant for showing fatal error messages
| Type | Mode | Show? |
| ------ | ------- | ----- |
| Status | Silent | No |
| Status | Verbose | Yes |
| Error | Silent | Yes |
| Error | Verbose | Yes |
### Logs
Logs are by default for inspection and debugging purposes. They are not shown by default but can be configured through verbosity levels or logging to files. Consider logs as something meant for use only when there is an unexpected behavior.
+11 -1
View File
@@ -172,6 +172,8 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// We will fail based on executed command's exit code. This is important
// because other tools (scripts, CI etc.) may depend on this exit code.
return cmd.Run() return cmd.Run()
} }
@@ -204,13 +206,19 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
}() }()
} }
// Queue all packages for analysis
for _, pkg := range packages { for _, pkg := range packages {
jobs <- pkg jobs <- pkg
} }
close(jobs) close(jobs)
analysisResults := []*analyzer.PackageVersionAnalysisResult{} analysisResults := []*analyzer.PackageVersionAnalysisResult{}
// We must wait for the results go routine to collect all results
rwg := sync.WaitGroup{}
rwg.Add(1)
go func() { go func() {
defer rwg.Done()
for result := range results { for result := range results {
analysisResults = append(analysisResults, result) analysisResults = append(analysisResults, result)
} }
@@ -219,8 +227,10 @@ func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
waiter := make(chan struct{}) waiter := make(chan struct{})
go func() { go func() {
wg.Wait() wg.Wait()
close(waiter)
close(results) close(results)
rwg.Wait()
close(waiter)
}() }()
select { select {
+47
View File
@@ -0,0 +1,47 @@
package guard
import (
"context"
"testing"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/stretchr/testify/assert"
)
func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) {
mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
if err != nil {
t.Fatalf("failed to create mq: %v", err)
}
pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil,
[]analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{})
if err != nil {
t.Fatalf("failed to create pg: %v", err)
}
t.Run("should resolve a single known malicious package version", func(t *testing.T) {
r, err := pg.concurrentAnalyzePackages(context.Background(), []*packagev1.PackageVersion{
{
Package: &packagev1.Package{
Name: "nyc-config",
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: "10.0.0",
},
})
if err != nil {
t.Fatalf("failed to analyze packages: %v", err)
}
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())
assert.Equal(t, packagev1.Ecosystem_ECOSYSTEM_NPM, r[0].PackageVersion.GetPackage().GetEcosystem())
assert.NotEmpty(t, r[0].ReferenceURL)
assert.NotEmpty(t, r[0].Summary)
assert.NotNil(t, r[0].Data)
assert.Equal(t, analyzer.ActionBlock, r[0].Action)
})
}
+28 -24
View File
@@ -1,8 +1,7 @@
package npm package flows
import ( import (
"context" "context"
"fmt"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
@@ -11,7 +10,25 @@ import (
"github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/packagemanager"
) )
func executeCommonFlow(ctx context.Context, config config.Config, pm packagemanager.PackageManager, args []string) error { type commonFlow struct {
pm packagemanager.PackageManager
}
// Creates a common flow of execution for all package managers. This should work for most
// of the cases unless a package manager has its own unique requirements. Configuration
// should be passed through the context (Global Config)
func Common(pm packagemanager.PackageManager) *commonFlow {
return &commonFlow{
pm: pm,
}
}
func (f *commonFlow) Run(ctx context.Context, args []string) error {
config, err := config.FromContext(ctx)
if err != nil {
ui.Fatalf("Failed to get config: %s", err)
}
packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig()
packageResolverConfig.IncludeTransitiveDependencies = config.Transitive packageResolverConfig.IncludeTransitiveDependencies = config.Transitive
packageResolverConfig.TransitiveDepth = config.TransitiveDepth packageResolverConfig.TransitiveDepth = config.TransitiveDepth
@@ -19,7 +36,7 @@ func executeCommonFlow(ctx context.Context, config config.Config, pm packagemana
packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed to create npm dependency resolver: %w", err) ui.Fatalf("Failed to create dependency resolver: %s", err)
} }
var analyzers []analyzer.PackageVersionAnalyzer var analyzers []analyzer.PackageVersionAnalyzer
@@ -27,14 +44,14 @@ func executeCommonFlow(ctx context.Context, config config.Config, pm packagemana
if config.Paranoid { if config.Paranoid {
malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig()) malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
if err != nil { if err != nil {
return fmt.Errorf("failed to create malysis active scan analyzer: %w", err) ui.Fatalf("Failed to create malware analyzer: %s", err)
} }
analyzers = append(analyzers, malysisActiveScanAnalyzer) analyzers = append(analyzers, malysisActiveScanAnalyzer)
} else { } else {
malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
if err != nil { if err != nil {
return fmt.Errorf("failed to create malysis query analyzer: %w", err) ui.Fatalf("Failed to create malware analyzer: %s", err)
} }
analyzers = append(analyzers, malysisQueryAnalyzer) analyzers = append(analyzers, malysisQueryAnalyzer)
@@ -50,28 +67,15 @@ func executeCommonFlow(ctx context.Context, config config.Config, pm packagemana
guardConfig := guard.DefaultPackageManagerGuardConfig() guardConfig := guard.DefaultPackageManagerGuardConfig()
guardConfig.DryRun = config.DryRun guardConfig.DryRun = config.DryRun
proxy, err := guard.NewPackageManagerGuard(guardConfig, pm, packageResolver, analyzers, interaction) proxy, err := guard.NewPackageManagerGuard(guardConfig, f.pm, packageResolver, analyzers, interaction)
if err != nil { if err != nil {
return fmt.Errorf("failed to create package manager guard: %w", err) ui.Fatalf("Failed to create package manager guard: %s", err)
} }
return proxy.Run(ctx, args) err = proxy.Run(ctx, args)
}
func executeNpmFlow(ctx context.Context, config config.Config, args []string) error {
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultNpmPackageManagerConfig())
if err != nil { if err != nil {
return fmt.Errorf("failed to create npm package manager: %w", err) ui.Fatalf("pmg: failed to execute command: %s", err)
} }
return executeCommonFlow(ctx, config, packageManager, args) return err
}
func executePnpmFlow(ctx context.Context, config config.Config, args []string) error {
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultPnpmPackageManagerConfig())
if err != nil {
return fmt.Errorf("failed to create pnpm package manager: %w", err)
}
return executeCommonFlow(ctx, config, packageManager, args)
} }
+22 -8
View File
@@ -11,6 +11,10 @@ import (
"github.com/safedep/dry/packageregistry" "github.com/safedep/dry/packageregistry"
) )
// Contract for a function that implements ecosystem specific version
// resolver from a version range specification.
type versionSpecResolver func(version string) string
type dependencyResolverConfig struct { type dependencyResolverConfig struct {
IncludeDevDependencies bool IncludeDevDependencies bool
IncludeTransitiveDependencies bool IncludeTransitiveDependencies bool
@@ -20,19 +24,29 @@ type dependencyResolverConfig struct {
} }
type dependencyResolver struct { type dependencyResolver struct {
client packageregistry.Client client packageregistry.Client
config dependencyResolverConfig config dependencyResolverConfig
mutex sync.Mutex mutex sync.Mutex
versionSpecResolver versionSpecResolver
} }
func newDependencyResolver(client packageregistry.Client, config dependencyResolverConfig) *dependencyResolver { func newDependencyResolver(client packageregistry.Client, config dependencyResolverConfig,
versionSpecResolver versionSpecResolver) *dependencyResolver {
if config.MaxConcurrency <= 0 { if config.MaxConcurrency <= 0 {
config.MaxConcurrency = 10 config.MaxConcurrency = 10
} }
if versionSpecResolver == nil {
// Default version spec resolver
versionSpecResolver = func(version string) string {
return version
}
}
return &dependencyResolver{ return &dependencyResolver{
client: client, client: client,
config: config, config: config,
versionSpecResolver: versionSpecResolver,
} }
} }
@@ -124,10 +138,10 @@ func (r *dependencyResolver) resolvePackageDependenciesConcurrent(
for _, dependency := range dependencies { for _, dependency := range dependencies {
resolvedDependencies = append(resolvedDependencies, &packagev1.PackageVersion{ resolvedDependencies = append(resolvedDependencies, &packagev1.PackageVersion{
Package: &packagev1.Package{ Package: &packagev1.Package{
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, Ecosystem: packageVersion.GetPackage().GetEcosystem(),
Name: dependency.Name, Name: dependency.Name,
}, },
Version: npmCleanVersion(dependency.VersionSpec), Version: r.versionSpecResolver(dependency.VersionSpec),
}) })
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func (r *npmDependencyResolver) ResolveDependencies(ctx context.Context,
TransitiveDepth: r.config.TransitiveDepth, TransitiveDepth: r.config.TransitiveDepth,
FailFast: r.config.FailFast, FailFast: r.config.FailFast,
MaxConcurrency: r.config.MaxConcurrency, MaxConcurrency: r.config.MaxConcurrency,
}) }, npmCleanVersion)
return resolver.resolveDependencies(ctx, packageVersion) return resolver.resolveDependencies(ctx, packageVersion)
} }
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -e
scriptDir=$(dirname "$0")
pmg=$scriptDir/../bin/pmg
echo "Running e2e tests..."
## All these should be successful
$pmg --debug --dry-run npm install express
$pmg --debug --dry-run npm install express
$pmg --debug --dry-run pnpm add express
## All these should fail
! $pmg --debug --dry-run npm install nyc-config@10.0.0 || echo "Command failed as expected"