diff --git a/README.md b/README.md index e5c1305..0d8706b 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ matches the source code they reviewed, eliminating the risk of tampered or malic ## PMG in Action -pmg in action +pmg in action ## TL;DR -Install `pmg` using Homebrew: +Install `pmg` using your favorite package manager: ```shell # MacOS/Linux with Homebrew @@ -63,6 +63,7 @@ uv pip install - Malicious package identification using [SafeDep Cloud](https://docs.safedep.io/cloud/malware-analysis) with realtime threat detection - Deep dependency analysis and transitive dependency resolution - Fast and efficient package verification +- Defense in depth using OS native sandboxing - Seamless integration with existing package managers - Automated shell integration with cross-shell support - Package installation tracking and event logging diff --git a/docs/demo/.gitignore b/docs/demo/.gitignore new file mode 100644 index 0000000..e8625c1 --- /dev/null +++ b/docs/demo/.gitignore @@ -0,0 +1,3 @@ +package.json +package-lock.json +node_modules diff --git a/docs/demo/build.sh b/docs/demo/build.sh new file mode 100755 index 0000000..0fe168e --- /dev/null +++ b/docs/demo/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Check if vhs is installed +if ! command -v vhs &> /dev/null; then + echo "vhs could not be found" + exit 1 +fi + +# Switch to the script directory +cd "$(dirname "$0")" + +# Enumerate all .tape files in the current directory +for file in *.tape; do + vhs "$file" +done \ No newline at end of file diff --git a/docs/demo/pmg-intro.gif b/docs/demo/pmg-intro.gif new file mode 100644 index 0000000..c139db2 Binary files /dev/null and b/docs/demo/pmg-intro.gif differ diff --git a/docs/demo/pmg-intro.tape b/docs/demo/pmg-intro.tape new file mode 100644 index 0000000..6f21dae --- /dev/null +++ b/docs/demo/pmg-intro.tape @@ -0,0 +1,24 @@ +Output pmg-intro.gif + +Set FontSize 28 +Set Width 1400 +Set Height 1000 + +Set WindowBar Colorful + +Type "pmg setup install" +Enter +Sleep 2s + +Enter + +Type "source ~/.pmg.rc" +Enter +Sleep 2s + +Enter + +Type "npm install safedep-test-pkg" +Enter +Sleep 5s + diff --git a/docs/demo/pmg-sandbox.gif b/docs/demo/pmg-sandbox.gif new file mode 100644 index 0000000..3c2e1de Binary files /dev/null and b/docs/demo/pmg-sandbox.gif differ diff --git a/docs/demo/pmg-sandbox.tape b/docs/demo/pmg-sandbox.tape new file mode 100644 index 0000000..b3ac812 --- /dev/null +++ b/docs/demo/pmg-sandbox.tape @@ -0,0 +1,36 @@ +Output pmg-sandbox.gif + +Set FontSize 28 +Set Width 1400 +Set Height 1000 + +Set WindowBar Colorful + +Hide +Type "export CI=true" +Enter + +Type "clear" +Enter + +Sleep 0.5s +Show + +Type "pmg setup install" +Enter +Sleep 2s + +Enter + +Type "source ~/.pmg.rc" +Enter +Sleep 2s + +Enter + +Type `npm exec -- \` Enter +Type `node -e "require('child_process').execSync('curl')" \` Enter +Type ` 2>&1 | head -n 8` Enter + +Sleep 5s + diff --git a/guard/guard.go b/guard/guard.go index 5b7810d..2eabe59 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -19,6 +19,7 @@ import ( "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/sandbox/executor" + "github.com/safedep/pmg/usefulerror" ) type PackageManagerGuardInteraction struct { @@ -254,7 +255,21 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package }() if result.ShouldRun() { - return cmd.Run() + err := cmd.Run() + if err != nil { + humanError := "Failed to execute package manager command" + if exitErr, ok := err.(*exec.ExitError); ok { + humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode()) + } + + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError(humanError). + WithHelp("Check the package manager command and its arguments"). + Wrap(err) + } + + return nil } return nil diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index d2faaa8..71e6148 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -55,8 +55,6 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema 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) - ui.SetStatus("Running in dry-run mode (proxy mode)") - ui.ClearStatus() return nil } @@ -102,7 +100,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema SetStatus: ui.SetStatus, ClearStatus: ui.ClearStatus, ShowWarning: ui.ShowWarning, - Block: ui.Block, + Block: ui.BlockNoExit, } // Create ecosystem-specific interceptor using factory @@ -307,7 +305,7 @@ func (f *proxyFlow) executeWithProxyForNonInteractiveTTY( err = cmd.Run() if err != nil { - return fmt.Errorf("failed to execute %s: %w", f.pm.Name(), err) + return f.handlePackageManagerExecutionError(err) } } @@ -454,8 +452,32 @@ func (f *proxyFlow) executeWithProxy( } if sessionError != nil { - return fmt.Errorf("failed to wait for session: %w", sessionError) + return f.handlePackageManagerExecutionError(sessionError) } return nil } + +func (f *proxyFlow) handlePackageManagerExecutionError(err error) error { + if exitErr, ok := err.(*exec.ExitError); ok { + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())). + WithHelp("Check the package manager command and its arguments"). + Wrap(err) + } + + if sessionError, ok := err.(*pty.ExitError); ok { + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", sessionError.Code)). + WithHelp("Check the package manager command and its arguments"). + Wrap(sessionError.Err) + } + + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError("Failed to execute package manager command"). + WithHelp("Check the package manager command and its arguments"). + Wrap(err) +} diff --git a/internal/ui/error.go b/internal/ui/error.go index 24aea37..12dc76c 100644 --- a/internal/ui/error.go +++ b/internal/ui/error.go @@ -33,13 +33,9 @@ func ErrorExit(err error) { } // printMinimalError prints error in minimal two-line format: -// Line 1: Error code (red background) + message (red) -// Line 2: Actionable hint with arrow prefix (dimmed) func printMinimalError(code, message, hint string) { - // Line 1: Error code + message fmt.Printf("%s %s\n", Colors.ErrorCode(" %s ", code), Colors.Red(message)) - // Line 2: Actionable hint with arrow (only if meaningful) if hint != "" && hint != "No additional help is available for this error." { fmt.Printf(" %s %s\n", Colors.Dim("→"), Colors.Dim(hint)) } diff --git a/internal/ui/error_convert.go b/internal/ui/error_convert.go index 2385e32..4e80d4e 100644 --- a/internal/ui/error_convert.go +++ b/internal/ui/error_convert.go @@ -76,7 +76,6 @@ var errorMatchers = []errorMatcher{ WithCode(usefulerror.ErrCodeLifecycle). WithHumanError(fmt.Sprintf("Command failed with exit code %d", exitCode)). WithHelp("Check command output above"). - WithAdditionalHelp("Run with PMG_DEBUG=true for more details"). Wrap(err) }, }, diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 156c556..702b909 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -54,6 +54,14 @@ func ClearStatus() { } func Block(config *BlockConfig) error { + return blockWithExit(config, true) +} + +func BlockNoExit(config *BlockConfig) error { + return blockWithExit(config, false) +} + +func blockWithExit(config *BlockConfig, exit bool) error { StopSpinner() fmt.Println() @@ -64,7 +72,10 @@ func Block(config *BlockConfig) error { } fmt.Println() - os.Exit(1) + + if exit { + os.Exit(1) + } return nil } diff --git a/packagemanager/errors.go b/packagemanager/errors.go index d7f5f32..0e21465 100644 --- a/packagemanager/errors.go +++ b/packagemanager/errors.go @@ -4,44 +4,51 @@ import ( "github.com/safedep/pmg/usefulerror" ) +const ( + errDependencyResolutionFailed = "DependencyResolutionFailed" + errPackageParseFailed = "PackageParseFailed" + errPackageAuthorNotFound = "PackageAuthorNotFound" + errGitHubRateLimitExceeded = "GitHubRateLimitExceeded" +) + var ( ErrPackageNotFound = usefulerror.Useful(). - WithCode("package_not_found"). + WithCode(usefulerror.ErrCodeNotFound). WithHumanError("The requested package could not be found."). WithHelp("Please check the package name and try again.") ErrFailedToFetchPackage = usefulerror.Useful(). - WithCode("fetch_failed"). + WithCode(usefulerror.ErrCodeNetwork). WithHumanError("Failed to retrieve the requested package."). - WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + WithHelp("Check your network connection and try again."). Msg("failed to fetch package") ErrFailedToResolveVersion = usefulerror.Useful(). - WithCode("resolve_version_failed"). + WithCode(usefulerror.ErrCodeNetwork). WithHumanError("Failed to resolve the requested package version."). - WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + WithHelp("Check your network connection and try again."). Msg("failed to resolve package version") ErrFailedToResolveDependencies = usefulerror.Useful(). - WithCode("resolve_failed"). + WithCode(errDependencyResolutionFailed). WithHumanError("Failed to resolve dependencies."). - WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + WithHelp("Check your network connection and try again."). Msg("failed to resolve dependencies") ErrFailedToParsePackage = usefulerror.Useful(). - WithCode("parse_failed"). + WithCode(errPackageParseFailed). WithHumanError("The package data could not be processed."). WithHelp("The package may be corrupted or in an unsupported format."). Msg("failed to parse package") ErrAuthorNotFound = usefulerror.Useful(). - WithCode("author_not_found"). + WithCode(errPackageAuthorNotFound). WithHumanError("The package author information could not be found."). WithHelp("This may be due to incomplete package metadata or network issues."). Msg("author not found") ErrGitHubRateLimitExceeded = usefulerror.Useful(). - WithCode("github_rate_limit"). + WithCode(errGitHubRateLimitExceeded). WithHumanError("GitHub API rate limit has been exceeded."). WithHelp("Wait for the rate limit to reset or configure authentication to increase your rate limit."). Msg("github api rate limit exceeded") diff --git a/proxy/interceptors/base_registry.go b/proxy/interceptors/base_registry.go index 19f3407..337c8fc 100644 --- a/proxy/interceptors/base_registry.go +++ b/proxy/interceptors/base_registry.go @@ -10,7 +10,6 @@ import ( "github.com/safedep/dry/log" "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/config" - "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/eventlog" "github.com/safedep/pmg/proxy" ) @@ -21,7 +20,6 @@ type baseRegistryInterceptor struct { analyzer analyzer.PackageVersionAnalyzer cache AnalysisCache confirmationChan chan *ConfirmationRequest - interaction guard.PackageManagerGuardInteraction } var _ proxy.Interceptor = (*baseRegistryInterceptor)(nil) diff --git a/proxy/interceptors/base_registry_test.go b/proxy/interceptors/base_registry_test.go index 57bd818..44e8d01 100644 --- a/proxy/interceptors/base_registry_test.go +++ b/proxy/interceptors/base_registry_test.go @@ -8,7 +8,6 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/guard" "github.com/safedep/pmg/proxy" "github.com/stretchr/testify/assert" ) @@ -119,11 +118,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { confirmationChan := make(chan *ConfirmationRequest, 1) - interaction := guard.PackageManagerGuardInteraction{} base := &baseRegistryInterceptor{ confirmationChan: confirmationChan, - interaction: interaction, } parsedURL, _ := url.Parse("https://registry.npmjs.org/test") diff --git a/usefulerror/codes.go b/usefulerror/codes.go index 37bd80f..36e5f20 100644 --- a/usefulerror/codes.go +++ b/usefulerror/codes.go @@ -4,13 +4,14 @@ package usefulerror // We will use a human friendly format for the error codes and not align with posix error codes. // Keep this minimal. Reuse first before adding new ones. const ( - ErrCodeInvalidArgument = "InvalidArgument" - ErrCodePermissionDenied = "PermissionDenied" - ErrCodeNotFound = "NotFound" - ErrCodeTimeout = "Timeout" - ErrCodeCanceled = "Canceled" - ErrCodeUnexpectedEOF = "UnexpectedEOF" - ErrCodeUnknown = "Unknown" - ErrCodeLifecycle = "Lifecycle" - ErrCodeNetwork = "Network" + ErrCodeInvalidArgument = "InvalidArgument" + ErrCodePermissionDenied = "PermissionDenied" + ErrCodeNotFound = "NotFound" + ErrCodeTimeout = "Timeout" + ErrCodeCanceled = "Canceled" + ErrCodeUnexpectedEOF = "UnexpectedEOF" + ErrCodeUnknown = "Unknown" + ErrCodeLifecycle = "Lifecycle" + ErrCodeNetwork = "Network" + ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed" )