mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
Merge branch 'main' into feat/experimental-sandbox-support
Signed-off-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
+175
-59
@@ -2,16 +2,20 @@ package flows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/pty"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
"github.com/safedep/pmg/packagemanager"
|
||||
"github.com/safedep/pmg/proxy"
|
||||
@@ -35,6 +39,15 @@ func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.
|
||||
|
||||
// Run executes the proxy-based flow
|
||||
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
|
||||
|
||||
// Get the ecosystem from the package manager
|
||||
ecosystem := f.pm.Ecosystem()
|
||||
|
||||
// Check if proxy mode is supported for this ecosystem
|
||||
if !interceptors.IsSupported(ecosystem) {
|
||||
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
// Check if dry-run mode is enabled
|
||||
@@ -83,24 +96,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
defer close(confirmationChan)
|
||||
|
||||
// Create interaction callbacks for user prompts
|
||||
interaction := guard.PackageManagerGuardInteraction{
|
||||
SetStatus: ui.SetStatus,
|
||||
ClearStatus: ui.ClearStatus,
|
||||
ShowWarning: ui.ShowWarning,
|
||||
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
|
||||
Block: ui.Block,
|
||||
}
|
||||
|
||||
// Get the ecosystem from the package manager
|
||||
ecosystem := f.pm.Ecosystem()
|
||||
|
||||
// Check if proxy mode is supported for this ecosystem
|
||||
if !interceptors.IsSupported(ecosystem) {
|
||||
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
|
||||
// Note: We use a pointer so we can later inject the input reader via SetInput
|
||||
interaction := &guard.PackageManagerGuardInteraction{
|
||||
SetStatus: ui.SetStatus,
|
||||
ClearStatus: ui.ClearStatus,
|
||||
ShowWarning: ui.ShowWarning,
|
||||
Block: ui.Block,
|
||||
}
|
||||
|
||||
// Create ecosystem-specific interceptor using factory
|
||||
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan, interaction)
|
||||
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan)
|
||||
interceptor, err := factory.CreateInterceptor(ecosystem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
|
||||
@@ -128,8 +133,15 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
||||
log.Infof("Proxy server started on %s", proxyAddr)
|
||||
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
|
||||
|
||||
proxyEnv := f.setupEnvForProxy(proxyAddr, caCertPath)
|
||||
|
||||
if !pty.IsInteractiveTerminal() {
|
||||
// Execute the package manager command with proxy environment variables for non PTY or non-interactive TTY
|
||||
return f.executeWithProxyForNonInteractiveTTY(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
|
||||
}
|
||||
|
||||
// Execute the package manager command with proxy environment variables
|
||||
return f.executeWithProxy(ctx, parsedCmd, proxyAddr, caCertPath, confirmationChan, interaction)
|
||||
return f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
|
||||
}
|
||||
|
||||
// setupCACertificate generates or loads a CA certificate for MITM
|
||||
@@ -212,21 +224,11 @@ func (f *proxyFlow) createAndStartProxyServer(
|
||||
return proxyServer, proxyAddr, nil
|
||||
}
|
||||
|
||||
// executeWithProxy executes the package manager command with proxy environment variables
|
||||
func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemanager.ParsedCommand,
|
||||
proxyAddr, caCertPath string, confirmationChan chan *interceptors.ConfirmationRequest,
|
||||
interaction guard.PackageManagerGuardInteraction,
|
||||
) error {
|
||||
// Build proxy URL
|
||||
func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string {
|
||||
proxyURL := fmt.Sprintf("http://%s", proxyAddr)
|
||||
|
||||
// Create command
|
||||
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
|
||||
|
||||
// Set proxy environment variables. This is what tells the executed command to use the proxy for communication.
|
||||
// However, every package manager has its nuances and may require additional environment variables to be set.
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env,
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
|
||||
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
|
||||
@@ -238,40 +240,35 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
|
||||
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
|
||||
)
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// executeWithProxyForNonInteractiveTTY runs the command without PTY (for CI/non-interactive environments)
|
||||
func (f *proxyFlow) executeWithProxyForNonInteractiveTTY(
|
||||
ctx context.Context,
|
||||
parsedCmd *packagemanager.ParsedCommand,
|
||||
env []string,
|
||||
confirmationChan chan *interceptors.ConfirmationRequest,
|
||||
interaction *guard.PackageManagerGuardInteraction,
|
||||
) error {
|
||||
log.Debugf("Executing proxy for non interactive TTY")
|
||||
|
||||
// For non-interactive terminals, we enforce suspicious packages as malicious
|
||||
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
|
||||
cmd.Env = append(env, "CI=true")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
pmName := f.pm.Name()
|
||||
result, err := executor.ApplySandbox(ctx, cmd, pmName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply sandbox: %w", err)
|
||||
}
|
||||
|
||||
defer result.Close()
|
||||
|
||||
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
|
||||
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
|
||||
|
||||
// Start confirmation handler in goroutine. Use confirmation hooks to pause and resume the executed
|
||||
// process to prevent stdout and stderr from being mixed up. Pause / resume is on a best effort basis.
|
||||
// We do not consider it a critical error if pause / resume fails.
|
||||
go interceptors.HandleConfirmationRequests(confirmationChan, interaction, &interceptors.ConfirmationHook{
|
||||
BeforeInteraction: func([]*analyzer.PackageVersionAnalysisResult) error {
|
||||
if err := platformPauseProcess(cmd); err != nil {
|
||||
log.Warnf("Failed to pause process for user interaction: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
AfterInteraction: func([]*analyzer.PackageVersionAnalysisResult, bool) error {
|
||||
if err := platformResumeProcess(cmd); err != nil {
|
||||
log.Warnf("Failed to resume process after user interaction: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
go interceptors.HandleConfirmationRequests(
|
||||
confirmationChan,
|
||||
interaction,
|
||||
nil,
|
||||
)
|
||||
|
||||
// Only run the command if the sandbox didn't already execute it
|
||||
if result.ShouldRun() {
|
||||
@@ -288,3 +285,122 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
|
||||
log.Debugf("Command completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeWithProxy executes the package manager command with proxy environment variables.
|
||||
func (f *proxyFlow) executeWithProxy(
|
||||
ctx context.Context,
|
||||
parsedCmd *packagemanager.ParsedCommand,
|
||||
env []string,
|
||||
confirmationChan chan *interceptors.ConfirmationRequest,
|
||||
interaction *guard.PackageManagerGuardInteraction,
|
||||
) error {
|
||||
log.Debugf("Executing proxy for interactive TTY")
|
||||
|
||||
// Set the confirmation handler to use the interaction's reader
|
||||
// This allows PTY input routing during proxy mode
|
||||
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||||
return ui.GetConfirmationOnMalwareWithReader(malwarePackages, interaction.Reader())
|
||||
}
|
||||
|
||||
sessionConfig := pty.NewSessionConfig(parsedCmd.Command.Exe, parsedCmd.Command.Args, env)
|
||||
|
||||
sess, err := pty.NewSession(ctx, sessionConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pty session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
outputRouter, err := pty.NewOutputRouter(os.Stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output router: %w", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
io.Copy(outputRouter, sess.PtyReader())
|
||||
})
|
||||
|
||||
inputRouter, err := pty.NewInputRouter(sess.PtyWriter())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create input router: %w", err)
|
||||
}
|
||||
|
||||
promptReader, promptWriter := io.Pipe()
|
||||
defer func() {
|
||||
promptWriter.Close()
|
||||
promptReader.Close()
|
||||
}()
|
||||
|
||||
// Note: This goroutine cannot be cleanly cancelled because os.Stdin.Read() is
|
||||
// a blocking syscall that doesn't support timeouts or cancellation. This is a
|
||||
// known limitation. The goroutine will exit when the process terminates, which
|
||||
// is acceptable for a CLI tool. For long-running servers, stdin reading should
|
||||
// be handled differently.
|
||||
go inputRouter.ReadLoop(os.Stdin)
|
||||
|
||||
go interceptors.HandleConfirmationRequests(
|
||||
confirmationChan,
|
||||
interaction,
|
||||
&interceptors.ConfirmationHook{
|
||||
BeforeInteraction: func(_ []*analyzer.PackageVersionAnalysisResult) error {
|
||||
// Pause printing the child output
|
||||
outputRouter.Pause()
|
||||
|
||||
// Restore "Cooked" mode so user can type normally with echo
|
||||
if err := sess.SetCookedMode(); err != nil {
|
||||
return fmt.Errorf("failed to set cooked mode: %w", err)
|
||||
}
|
||||
|
||||
// Force cursor visible (ANSI escape sequence)
|
||||
fmt.Fprint(os.Stdout, "\033[?25h")
|
||||
|
||||
// Switch Input: Route keystrokes to the Prompt Pipe
|
||||
inputRouter.RouteToPrompt(promptWriter)
|
||||
|
||||
// Inject the Reader into the Interaction for the confirmation prompt
|
||||
interaction.SetInput(promptReader)
|
||||
|
||||
return nil
|
||||
},
|
||||
AfterInteraction: func(_ []*analyzer.PackageVersionAnalysisResult, _ bool) error {
|
||||
// Switch input back to PTY
|
||||
inputRouter.RouteToPTY()
|
||||
|
||||
// Restore "Raw" mode for the PTY
|
||||
if err := sess.SetRawMode(); err != nil {
|
||||
return fmt.Errorf("failed to set raw mode: %w", err)
|
||||
}
|
||||
|
||||
// Clear the interaction input (back to default)
|
||||
interaction.SetInput(nil)
|
||||
|
||||
// Flush buffered output and resume live output
|
||||
outputRouter.Resume()
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
err = sess.Wait()
|
||||
|
||||
// Wait for the routers to copy all the remaining data
|
||||
wg.Wait()
|
||||
|
||||
if err != nil {
|
||||
var exitErr *pty.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// Close writer and reader
|
||||
promptWriter.Close()
|
||||
promptReader.Close()
|
||||
|
||||
// Close the session
|
||||
sess.Close()
|
||||
|
||||
os.Exit(exitErr.Code)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user