mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract
Network lockdown needs the PMG proxy's address, which is only known at
spawn time. Thread an ExecutionContext from the proxy flow through the
runner and executor into every sandbox driver, and enforce the
network_via_proxy_only fail-closed contract: lockdown without a running
loopback proxy, or on a driver that cannot enforce it, is a hard error —
never a silent fallback to unrestricted network.
- sandbox.ExecutionContext{ProxyAddr} + 4-arg Sandbox.Execute
- sandbox.ValidateLockdown validates the proxy address (loopback only)
with usefulerror code SandboxRequiresProxy
- Seatbelt validates lockdown before translation (translation itself
lands next); bubblewrap and landlock reject lockdown as unsupported
until Linux enforcement is implemented
- executor.WithExecutionContext, runner.ExecuteOptions.SandboxProxyAddr,
proxy flow passes the live proxy address
- ApplySandbox also validates centrally before invoking the driver
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): require numeric in-range proxy port in ValidateLockdown
The validated port string is embedded into generated sandbox profiles,
so service names, zero, and out-of-range ports are refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): fail closed on Seatbelt lockdown until translation lands
A lockdown policy that passed proxy validation would silently receive
the pre-lockdown network rules from the translator. Reject it until the
lockdown profile translation is implemented, keeping the window between
plumbing and enforcement fail-closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* chore: review feedback — drop redundant comment, simplify stub help text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
381 lines
13 KiB
Go
381 lines
13 KiB
Go
package flows
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"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/audit"
|
|
"github.com/safedep/pmg/internal/localstore"
|
|
"github.com/safedep/pmg/internal/runner"
|
|
"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"
|
|
)
|
|
|
|
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) (runErr 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() || parsedCmd.MayDownloadPackages())
|
|
|
|
cfg := config.Get()
|
|
|
|
// When install_only is enabled, skip proxy for known non-download commands
|
|
// and user-defined skip commands
|
|
if cfg.Config.Proxy.InstallOnly {
|
|
if !parsedCmd.MayDownloadPackages() {
|
|
log.Debugf("Skipping proxy for non-download command (install_only=true)")
|
|
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun)
|
|
}
|
|
|
|
if cmds, ok := cfg.Config.Proxy.SkipCommands[f.pm.Name()]; ok && len(cmds) > 0 {
|
|
if packagemanager.IsFirstNonFlagArgInList(parsedCmd.Command.Args, cmds) {
|
|
log.Debugf("Skipping proxy for user-defined skip command (install_only=true)")
|
|
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.PolicyFor(f.pm.Name()); exists {
|
|
reportData.SandboxProfile = policyRef.Profile
|
|
}
|
|
}
|
|
|
|
if cfg.SandboxProfileOverride != "" {
|
|
reportData.SandboxProfile = cfg.SandboxProfileOverride
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
audit.LogInstallStarted(f.pm.Name(), args)
|
|
|
|
sessionCompleted := false
|
|
defer func() {
|
|
if sessionCompleted {
|
|
return
|
|
}
|
|
|
|
// On early error returns (e.g. CA cert, analyzer init), reportData.Outcome
|
|
// is still the default (Success). Override to Error for these cases.
|
|
if runErr != nil && reportData.Outcome == ui.OutcomeSuccess {
|
|
reportData.Outcome = ui.OutcomeError
|
|
}
|
|
|
|
audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy)
|
|
}()
|
|
|
|
// Check if dry-run mode is enabled
|
|
if cfg.DryRun {
|
|
log.Infof("Dry-run mode: Would execute %s with 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)
|
|
}
|
|
|
|
localDB := localstore.NewManager(cfg)
|
|
defer func() {
|
|
if cerr := localDB.Close(); cerr != nil {
|
|
log.Warnf("failed to close localdb: %v", cerr)
|
|
}
|
|
}()
|
|
|
|
// Analyzer with an optional persistent cache. Cache failures degrade to
|
|
// running uncached and never block the install.
|
|
malysisAnalyzer, err := BuildMalysisAnalyzer(ctx, cfg, localDB)
|
|
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,
|
|
}
|
|
|
|
// Extract pinned versions from install targets so cooldown handlers can
|
|
// report when a user's explicitly requested version was blocked.
|
|
pinnedVersions := make(map[string]string)
|
|
for _, target := range parsedCmd.InstallTargets {
|
|
if target.IsExplicitVersion {
|
|
pinnedVersions[target.PackageVersion.GetPackage().GetName()] = target.PackageVersion.GetVersion()
|
|
}
|
|
}
|
|
|
|
// Package managers with run-specific proxy routing (Go's user-configurable
|
|
// GOPROXY) contribute extra child env vars and dynamic MITM hosts.
|
|
routing := &packagemanager.ProxyRouting{}
|
|
if provider, ok := f.pm.(packagemanager.ProxyRoutingProvider); ok {
|
|
routing, err = provider.ProxyRouting(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve proxy routing for %s: %w", f.pm.Name(), err)
|
|
}
|
|
}
|
|
|
|
// Create ecosystem-specific interceptor using factory
|
|
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan, interceptors.InterceptorContext{
|
|
PinnedVersions: pinnedVersions,
|
|
GoProxyBaseURLs: routing.MITMHosts,
|
|
})
|
|
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())
|
|
|
|
executionError := runner.ExecuteWithOptions(ctx, parsedCmd, runner.ExecuteOptions{
|
|
PackageManagerName: f.pm.Name(),
|
|
DryRun: cfg.DryRun,
|
|
SandboxProxyAddr: proxyAddr,
|
|
Mode: runner.ExecutionModeAuto,
|
|
EnvOverrides: append(packagemanager.EnvVarForProxy(proxyAddr, caCertPath), routing.ExtraEnv...),
|
|
DirectEnvOverrides: ciEnvOverride(),
|
|
BeforeDirectRun: func() error {
|
|
log.Debugf("Executing proxy for non interactive TTY")
|
|
|
|
interaction.GetConfirmationOnMalware = func(_ []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
go interceptors.HandleConfirmationRequests(confirmationChan, interaction, nil)
|
|
return nil
|
|
},
|
|
PreparePTYSession: func(runtime *runner.PTYRuntime) error {
|
|
log.Debugf("Executing proxy for interactive TTY")
|
|
|
|
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
|
return ui.GetConfirmationOnMalwareWithReader(malwarePackages, interaction.Reader())
|
|
}
|
|
|
|
go interceptors.HandleConfirmationRequests(
|
|
confirmationChan,
|
|
interaction,
|
|
&interceptors.ConfirmationHook{
|
|
BeforeInteraction: func(_ []*analyzer.PackageVersionAnalysisResult) error {
|
|
runtime.OutputRouter.Pause()
|
|
|
|
if err := runtime.Session.SetCookedMode(); err != nil {
|
|
return fmt.Errorf("failed to set cooked mode: %w", err)
|
|
}
|
|
|
|
if _, err := fmt.Fprint(os.Stdout, "\033[?25h"); err != nil {
|
|
log.Warnf("failed to force cursor visible: %v", err)
|
|
}
|
|
|
|
runtime.InputRouter.RouteToPrompt(runtime.PromptWriter)
|
|
interaction.SetInput(runtime.PromptReader)
|
|
|
|
return nil
|
|
},
|
|
AfterInteraction: func(_ []*analyzer.PackageVersionAnalysisResult, _ bool) error {
|
|
runtime.InputRouter.RouteToPTY()
|
|
|
|
if err := runtime.Session.SetRawMode(); err != nil {
|
|
return fmt.Errorf("failed to set raw mode: %w", err)
|
|
}
|
|
|
|
interaction.SetInput(nil)
|
|
runtime.OutputRouter.Resume()
|
|
|
|
return nil
|
|
},
|
|
},
|
|
)
|
|
|
|
return nil
|
|
},
|
|
})
|
|
|
|
// 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()
|
|
reportData.AdvisoryMessage = cfg.Config.AdvisoryMessage
|
|
|
|
// Set outcome based on execution result using shared inference logic
|
|
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
|
|
|
// Emit session complete before report/exit — handleExecutionResultError may call
|
|
// os.Exit which skips defers, so we must emit the session summary here.
|
|
audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy)
|
|
sessionCompleted = true
|
|
|
|
// 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 returns the execution error so RunE can route it
|
|
// through ui.ExitFromCommandError, the single exit point. A transparent
|
|
// *runner.ChildExitError survives the %w wrap (errors.As unwraps it) and is
|
|
// passed through with the child's exit code; everything else keeps the visible
|
|
// PMG error framing.
|
|
func handleExecutionResultError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("failed to execute command: %w", err)
|
|
}
|
|
|
|
func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) {
|
|
dir := config.Get().ConfigDir()
|
|
outputPath := certmanager.EphemeralProxyCABundlePath()
|
|
cert, _, err := SetupCACertificate(dir, outputPath)
|
|
return cert, outputPath, err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
|
proxyConfig.BlockMessageRenderer = presenter.BlockMessage
|
|
|
|
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
|
|
}
|
|
|
|
// ciEnvOverride forces CI=true for non-interactive runs so package managers
|
|
// behave non-interactively. It respects an explicitly set CI value (including
|
|
// CI=false) so we don't clobber the user's intent. See issue #335.
|
|
func ciEnvOverride() []string {
|
|
if _, ok := os.LookupEnv("CI"); ok {
|
|
return nil
|
|
}
|
|
return []string{"CI=true"}
|
|
}
|