Merge branch 'main' into feat/experimental-sandbox-support

Signed-off-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
Abhisek Datta
2026-01-12 11:13:04 +05:30
committed by GitHub
36 changed files with 1377 additions and 347 deletions
+175 -59
View File
@@ -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
}