Files
pmg/internal/flows/proxy_flow.go
T
987bda5d6a feat: Add dependency cooldown for npm packages (#200)
* feat: Add dependency cooldown for npm packages

Strip recently-published package versions from npm registry metadata
responses so npm's resolver naturally falls back to older versions.
Overrides the Accept header to force full packument responses (which
include the "time" field needed for publish-date checks).

Reports cooldown blocks only when all versions are stripped (remaining == 0),
matching npm's --min-release-age behavior for silent fallback.

* fix: Report oldest version in cooldown block (shortest wait)

When all versions are blocked by cooldown, report the oldest version
since it exits the cooldown window first — giving the user the
shortest wait time instead of the longest.

* fix: Handle resp.Body.Close error return for errcheck linter

* test: Add dependency cooldown assertions to template config tests

* fix: config template for dependency cooldown

* fix: Prevent npm from caching cooldown-stripped metadata responses

* fix: Restore body on ReadAll failure and log Close errors in response modifier

* fix: Close response body before replacing to prevent connection leak

* fix: Correct daysLeft ceiling math and update ContentLength on error recovery

* fix: Clear Status on status code change and update ContentLength in modifier error path

* refactor: address review comments on dependency cooldown PR

- Make NpmCooldownHandler and constructor package-private
- Pass cooldown days as parameter instead of reading config internally
- Convert standalone functions to methods on npmCooldownHandler
- Set Accept-Encoding: identity to prevent gzip responses breaking JSON parsing
- Return 503 with descriptive message when upstream body read fails

* fix: log errors in stripCooldownVersions instead of swallowing them

* fix: Config preserve fallback defaults

* fix: Code review fixes

* fix: correct cooldown tip to show wait time instead of incorrect trusted_packages advice

* fix: prevent integer overflow in cooldown duration calculation with large days values

* refactor: deduplicate CooldownBlock into internal/models, fix misleading variable names

- Move CooldownBlock struct to internal/models to eliminate duplication
  between proxy/interceptors and internal/ui packages
- Simplify proxy_flow.go by using direct assignment instead of field copy
- Rename latestStripped/latestDate to oldestVer/oldestDate for clarity

* fix: Dependency Cooldown Check Encapsulation (#207)

* fix: Encapsulate cooldown check

* feat: Add --skip-dependency-cooldown override

* fix: Code review fixes

---------

Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
2026-04-08 21:04:17 +05:30

522 lines
17 KiB
Go

package flows
import (
"context"
"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"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors"
"github.com/safedep/pmg/sandbox/executor"
"github.com/safedep/pmg/usefulerror"
)
type proxyFlow struct {
pm packagemanager.PackageManager
packageResolver packagemanager.PackageResolver
}
// ProxyFlow creates a new proxy-based flow for package manager protection
func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.PackageResolver) *proxyFlow {
return &proxyFlow{
pm: pm,
packageResolver: packageResolver,
}
}
// Run executes the proxy-based flow
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
// Check if we have a supported ecosystem else fail fast
ecosystem := f.pm.Ecosystem()
if !interceptors.IsSupported(ecosystem) {
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
}
// Configure sandbox based on command type and enforcement policy
config.ConfigureSandbox(parsedCmd.IsInstallationCommand())
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
if cfg.DryRun {
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)
reportData.Outcome = ui.OutcomeDryRun
ui.Report(reportData)
return nil
}
// Setup CA certificate for MITM
caCert, caCertPath, err := f.setupCACertificate()
if err != nil {
return fmt.Errorf("failed to setup CA certificate for proxy mode: %w", err)
}
defer func() {
// Clean up temporary CA certificate file
if caCertPath != "" {
if err := os.Remove(caCertPath); err != nil {
log.Errorf("Failed to remove CA certificate file: %v", err)
}
}
}()
// Create certificate manager
certMgr, err := f.createCertificateManager(caCert)
if err != nil {
return fmt.Errorf("failed to create certificate manager: %w", err)
}
// Create analyzer
malysisAnalyzer, err := f.createAnalyzer()
if err != nil {
return fmt.Errorf("failed to create analyzer: %w", err)
}
// Create analysis cache and stats collector
cache := interceptors.NewInMemoryAnalysisCache()
statsCollector := interceptors.NewAnalysisStatsCollector()
// Create confirmation channel and start confirmation handler
confirmationChan := make(chan *interceptors.ConfirmationRequest, 10)
defer close(confirmationChan)
// Create interaction callbacks for user prompts
// 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.BlockNoExit,
}
// Create ecosystem-specific interceptor using factory
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan)
interceptor, err := factory.CreateInterceptor(ecosystem)
if err != nil {
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
}
log.Debugf("Created %s interceptor for ecosystem %s", interceptor.Name(), ecosystem.String())
// Create and start proxy server
proxyServer, proxyAddr, err := f.createAndStartProxyServer(certMgr, []proxy.Interceptor{
interceptor,
interceptors.NewAuditLoggerInterceptor(),
})
if err != nil {
return fmt.Errorf("failed to start proxy server: %w", err)
}
// Ensure proxy is stopped on exit
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := proxyServer.Stop(shutdownCtx); err != nil {
log.Errorf("Failed to stop proxy server: %v", err)
}
}()
ui.ClearStatus()
log.Infof("Proxy server started on %s", proxyAddr)
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
proxyEnv := f.setupEnvForProxy(proxyAddr, caCertPath)
var executionError error
if pty.IsInteractiveTerminal() {
// Execute the package manager command with proxy environment variables
executionError = f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
} else {
// Execute the package manager command with proxy environment variables for non PTY or non-interactive TTY
executionError = f.executeWithProxyForNonInteractiveTTY(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()
reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks()
// 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
// from the execution result.
return handleExecutionResultError(executionError)
}
// handleExecutionResultError handles the error from the execution result.
func handleExecutionResultError(err error) error {
if err == nil {
return nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("failed to execute command: %w", err)
}
// setupCACertificate generates CA for MITM and writes proxy bundle for child package managers.
func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) {
log.Debugf("Generating CA certificate for proxy MITM")
// Generate CA certificate
caConfig := certmanager.DefaultCertManagerConfig()
caCert, err := certmanager.GenerateCAWithSystemCA(caConfig)
if err != nil {
return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err)
}
// Write CA certificate to temporary file for package managers to trust
tempDir := os.TempDir()
caCertPath := filepath.Join(tempDir, fmt.Sprintf("pmg-ca-cert-%d.pem", os.Getpid()))
if err := os.WriteFile(caCertPath, caCert.Certificate, 0o600); err != nil {
return nil, "", fmt.Errorf("failed to write CA certificate to %s: %w", caCertPath, err)
}
log.Debugf("CA certificate written to %s", caCertPath)
return caCert, caCertPath, nil
}
// createCertificateManager creates a certificate manager with the given CA certificate
func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (certmanager.CertificateManager, error) {
caConfig := certmanager.DefaultCertManagerConfig()
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, caConfig)
if err != nil {
return nil, fmt.Errorf("failed to create certificate manager: %w", err)
}
return certMgr, nil
}
// createAnalyzer creates the malysis query analyzer
func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) {
log.Debugf("Creating malysis query analyzer")
return analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
}
// createAndStartProxyServer creates and starts the proxy server with the given interceptor
func (f *proxyFlow) createAndStartProxyServer(
certMgr certmanager.CertificateManager,
interceptorsList []proxy.Interceptor,
) (proxy.ProxyServer, string, error) {
proxyConfig := proxy.DefaultProxyConfig()
proxyConfig.CertManager = certMgr
proxyConfig.Interceptors = interceptorsList
proxyServer, err := proxy.NewProxyServer(proxyConfig)
if err != nil {
return nil, "", fmt.Errorf("failed to create proxy server: %w", err)
}
if err := proxyServer.Start(); err != nil {
return nil, "", fmt.Errorf("failed to start proxy server: %w", err)
}
proxyAddr := proxyServer.Address()
if proxyAddr == "" {
return nil, "", fmt.Errorf("proxy server started but address is empty")
}
return proxyServer, proxyAddr, nil
}
func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string {
proxyURL := fmt.Sprintf("http://%s", proxyAddr)
noProxyList := "localhost,127.0.0.1,[::1]"
env := os.Environ()
env = append(env,
"NODE_USE_ENV_PROXY=1",
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
fmt.Sprintf("NO_PROXY=%s", noProxyList),
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
fmt.Sprintf("http_proxy=%s", proxyURL),
fmt.Sprintf("https_proxy=%s", proxyURL),
fmt.Sprintf("no_proxy=%s", noProxyList),
fmt.Sprintf("SSL_CERT_FILE=%s", caCertPath),
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caCertPath),
fmt.Sprintf("PIP_CERT=%s", caCertPath),
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
"PIP_RETRIES=0",
)
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
go interceptors.HandleConfirmationRequests(
confirmationChan,
interaction,
nil,
)
result, err := executor.ApplySandbox(ctx, cmd, f.pm.Name())
if err != nil {
return fmt.Errorf("failed to apply sandbox: %w", err)
}
defer func() {
err := result.Close()
if err != nil {
log.Errorf("failed to close sandbox: %v", err)
}
}()
// Only run the command if the sandbox didn't already execute it
if result.ShouldRun() {
log.Debugf("Running command with args: %s: %v", cmd.Path, cmd.Args[1:])
err = cmd.Run()
if err != nil {
return f.handlePackageManagerExecutionError(err)
}
}
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())
}
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
result, err := executor.ApplySandbox(ctx, cmd, f.pm.Name())
if err != nil {
return fmt.Errorf("failed to apply sandbox: %w", err)
}
if !result.ShouldRun() {
return usefulerror.Useful().
Wrap(fmt.Errorf("sandbox not supported for PTY sessions")).
WithHumanError("Sandbox executed command cannot be used with PTY session. Please use non-interactive TTY mode instead.")
}
// Extract the command executable and arguments from the sandboxed command
// for use to create the PTY session.
cmdExe := cmd.Path
cmdArgs := cmd.Args[1:]
log.Debugf("Running command with args: %s: %v", cmdExe, cmdArgs)
// Create the PTY session with the sandbox command
// This is not compatible with sandbox that executes the command directly within the sandbox
// because internally we use ptyx.Spawn() to create the process with PTY support.
sessionConfig := pty.NewSessionConfig(cmdExe, cmdArgs, 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() {
if _, err := io.Copy(outputRouter, sess.PtyReader()); err != nil {
log.Errorf("failed to copy output: %v", err)
}
})
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
},
},
)
// sessionError may contain the exit code of the command if the command exited with a non-zero code.
sessionError := sess.Wait()
// Wait for the routers to copy all the remaining data
wg.Wait()
if err := promptReader.Close(); err != nil {
log.Errorf("failed to close prompt reader: %v", err)
}
if err := promptWriter.Close(); err != nil {
log.Errorf("failed to close prompt writer: %v", err)
}
if err := sess.Close(); err != nil {
log.Errorf("failed to close session: %v", err)
}
if sessionError != nil {
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)
}