From 39debb474d9f80f36d6f8e24c05a46fdc15f233a Mon Sep 17 00:00:00 2001 From: Sahilb315 Date: Tue, 23 Jun 2026 15:36:41 +0530 Subject: [PATCH] feat(proxy): add persistent proxy server with start/stop/env/status commands Adds pmg proxy command group for running a long-lived MITM proxy that intercepts all package manager traffic without requiring PMG shims or wrappers. Targets CI/CD pipelines where env vars can be set globally. - pmg proxy start: starts proxy with npm+pypi interceptors, writes state file (pid/addr/ca-cert-path), auto-blocks suspicious packages - pmg proxy stop: sends SIGTERM to the running proxy - pmg proxy env: emits HTTP_PROXY/HTTPS_PROXY/SSL_CERT_FILE etc. as shell exports, or writes directly to $GITHUB_ENV with --gha - pmg proxy status: shows running/stopped status GHA usage: pmg proxy start & pmg proxy env --gha # populates env for all subsequent steps npm install # intercepted automatically, no wrapper needed Also adds .github/workflows/persistent-proxy-e2e.yml to validate the persistent proxy mode end-to-end in CI. --- .github/workflows/persistent-proxy-e2e.yml | 102 +++++++++++++++++ cmd/proxy/env.go | 100 +++++++++++++++++ cmd/proxy/proxy.go | 15 +++ cmd/proxy/start.go | 124 +++++++++++++++++++++ cmd/proxy/status.go | 40 +++++++ cmd/proxy/stop.go | 49 ++++++++ main.go | 2 + 7 files changed, 432 insertions(+) create mode 100644 .github/workflows/persistent-proxy-e2e.yml create mode 100644 cmd/proxy/env.go create mode 100644 cmd/proxy/proxy.go create mode 100644 cmd/proxy/start.go create mode 100644 cmd/proxy/status.go create mode 100644 cmd/proxy/stop.go diff --git a/.github/workflows/persistent-proxy-e2e.yml b/.github/workflows/persistent-proxy-e2e.yml new file mode 100644 index 0000000..2bd4310 --- /dev/null +++ b/.github/workflows/persistent-proxy-e2e.yml @@ -0,0 +1,102 @@ +name: Persistent Proxy E2E + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - 'cmd/proxy/**' + - 'internal/proxystate/**' + - 'internal/flows/cert.go' + +permissions: + contents: read + +jobs: + persistent-proxy-e2e: + name: Persistent Proxy E2E + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + + steps: + - name: Checkout Source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Build PMG + run: make + + - name: Add pmg to PATH + run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH + + - name: Setup PMG + run: pmg setup install + + - name: Start persistent proxy + run: | + pmg proxy start & + # Poll until proxy writes its state file (up to 10s) + for i in $(seq 1 10); do + pmg proxy status && break + sleep 1 + done + + - name: Inject proxy env vars into workflow environment + run: pmg proxy env --gha + + - name: Verify proxy env vars are set + run: | + echo "HTTP_PROXY=$HTTP_PROXY" + echo "NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS" + test -n "$HTTP_PROXY" + test -n "$NODE_EXTRA_CA_CERTS" + test -f "$NODE_EXTRA_CA_CERTS" + + - name: Test: benign package installs successfully + run: | + mkdir benign-test && cd benign-test + npm init -y + npm install lodash@4.17.21 + test -d node_modules/lodash + echo "SUCCESS: lodash installed through proxy" + cd .. && rm -rf benign-test + + - name: Test: malicious package is blocked + run: | + mkdir malicious-test && cd malicious-test + npm init -y + if npm --no-cache --prefer-online install safedep-test-pkg@0.1.3; then + echo "ERROR: safedep-test-pkg was not blocked!" + exit 1 + fi + if [ -d "node_modules/safedep-test-pkg" ]; then + echo "ERROR: safedep-test-pkg found in node_modules!" + exit 1 + fi + echo "SUCCESS: safedep-test-pkg blocked by persistent proxy" + cd .. && rm -rf malicious-test + + - name: Test: pip installs through proxy + run: | + python -m venv venv && source venv/bin/activate + pip install requests==2.32.4 + python -c "import requests; print('pip ok:', requests.__version__)" + deactivate && rm -rf venv + + - name: Stop proxy + if: always() + run: pmg proxy stop || true diff --git a/cmd/proxy/env.go b/cmd/proxy/env.go new file mode 100644 index 0000000..d489155 --- /dev/null +++ b/cmd/proxy/env.go @@ -0,0 +1,100 @@ +package proxy + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/proxystate" + "github.com/spf13/cobra" +) + +func newEnvCommand() *cobra.Command { + var gha bool + + cmd := &cobra.Command{ + Use: "env", + Short: "Print proxy environment variables (use with eval or --gha for GitHub Actions)", + RunE: func(_ *cobra.Command, _ []string) error { + return runEnv(gha) + }, + } + + cmd.Flags().BoolVar(&gha, "gha", false, "Write env vars to $GITHUB_ENV instead of stdout") + return cmd +} + +func runEnv(gha bool) error { + cfg := config.Get() + statePath := proxystate.StatePath(cfg.ConfigDir()) + + state, err := proxystate.Read(statePath) + if err != nil { + return fmt.Errorf("proxy not running — start with 'pmg proxy start' first: %w", err) + } + + proxyURL := fmt.Sprintf("http://%s", state.Addr) + noProxy := "localhost,127.0.0.1,::1" + + vars := []string{ + fmt.Sprintf("HTTP_PROXY=%s", proxyURL), + fmt.Sprintf("HTTPS_PROXY=%s", proxyURL), + fmt.Sprintf("http_proxy=%s", proxyURL), + fmt.Sprintf("https_proxy=%s", proxyURL), + fmt.Sprintf("NO_PROXY=%s", noProxy), + fmt.Sprintf("no_proxy=%s", noProxy), + "NODE_USE_ENV_PROXY=1", + fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", state.CACertPath), + fmt.Sprintf("SSL_CERT_FILE=%s", state.CACertPath), + fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", state.CACertPath), + fmt.Sprintf("PIP_CERT=%s", state.CACertPath), + fmt.Sprintf("PIP_PROXY=%s", proxyURL), + "PIP_RETRIES=0", + fmt.Sprintf("YARN_HTTP_PROXY=%s", proxyURL), + fmt.Sprintf("YARN_HTTPS_PROXY=%s", proxyURL), + fmt.Sprintf("YARN_HTTPS_CA_FILE_PATH=%s", state.CACertPath), + } + + if gha { + return writeGitHubEnv(vars) + } + + return writeShellExports(vars) +} + +func writeShellExports(vars []string) error { + w := bufio.NewWriter(os.Stdout) + for _, kv := range vars { + // Split on first '=' so we can quote only the value, handling paths with spaces. + k, v, _ := strings.Cut(kv, "=") + if _, err := fmt.Fprintf(w, "export %s=%q\n", k, v); err != nil { + return fmt.Errorf("write env var: %w", err) + } + } + return w.Flush() +} + +func writeGitHubEnv(vars []string) error { + ghEnvFile := os.Getenv("GITHUB_ENV") + if ghEnvFile == "" { + return fmt.Errorf("$GITHUB_ENV is not set — are you running in GitHub Actions?") + } + + f, err := os.OpenFile(ghEnvFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open $GITHUB_ENV file: %w", err) + } + defer func() { + _ = f.Close() + }() + + w := bufio.NewWriter(f) + for _, v := range vars { + if _, err := fmt.Fprintf(w, "%s\n", v); err != nil { + return fmt.Errorf("write to $GITHUB_ENV: %w", err) + } + } + return w.Flush() +} diff --git a/cmd/proxy/proxy.go b/cmd/proxy/proxy.go new file mode 100644 index 0000000..34bd529 --- /dev/null +++ b/cmd/proxy/proxy.go @@ -0,0 +1,15 @@ +package proxy + +import "github.com/spf13/cobra" + +func NewProxyCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "proxy", + Short: "Manage the persistent PMG proxy server", + } + cmd.AddCommand(newStartCommand()) + cmd.AddCommand(newStopCommand()) + cmd.AddCommand(newEnvCommand()) + cmd.AddCommand(newStatusCommand()) + return cmd +} diff --git a/cmd/proxy/start.go b/cmd/proxy/start.go new file mode 100644 index 0000000..a475dd6 --- /dev/null +++ b/cmd/proxy/start.go @@ -0,0 +1,124 @@ +package proxy + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/flows" + "github.com/safedep/pmg/internal/proxystate" + pmgproxy "github.com/safedep/pmg/proxy" + "github.com/safedep/pmg/proxy/certmanager" + "github.com/safedep/pmg/proxy/interceptors" + "github.com/spf13/cobra" +) + +func newStartCommand() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start the persistent PMG proxy server (runs in foreground)", + RunE: runStart, + } +} + +func runStart(_ *cobra.Command, _ []string) error { + cfg := config.Get() + statePath := proxystate.StatePath(cfg.ConfigDir()) + + if existing, err := proxystate.Read(statePath); err == nil && existing.IsRunning() { + return fmt.Errorf("proxy already running (pid %d, addr %s) — run 'pmg proxy stop' first", existing.PID, existing.Addr) + } + + caCertPath := filepath.Join(cfg.ConfigDir(), "proxy-ca.pem") + caCert, _, err := flows.SetupCACertificate(cfg.ConfigDir(), caCertPath) + if err != nil { + return fmt.Errorf("setup CA certificate: %w", err) + } + + certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig()) + if err != nil { + return fmt.Errorf("create certificate manager: %w", err) + } + + malysisAnalyzer, err := analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) + if err != nil { + return fmt.Errorf("create analyzer: %w", err) + } + + cache := interceptors.NewInMemoryAnalysisCache() + stats := interceptors.NewAnalysisStatsCollector() + confirmationChan := make(chan *interceptors.ConfirmationRequest, 100) + go autoBlockConfirmations(confirmationChan) + + factory := interceptors.NewInterceptorFactory( + malysisAnalyzer, cache, stats, confirmationChan, interceptors.InterceptorContext{}, + ) + + var interceptorList []pmgproxy.Interceptor + for _, eco := range interceptors.SupportedEcosystems() { + i, ferr := factory.CreateInterceptor(eco) + if ferr != nil { + return fmt.Errorf("create interceptor for %s: %w", eco.String(), ferr) + } + interceptorList = append(interceptorList, i) + } + interceptorList = append(interceptorList, interceptors.NewAuditLoggerInterceptor()) + + proxyConfig := pmgproxy.DefaultProxyConfig() + proxyConfig.CertManager = certMgr + proxyConfig.Interceptors = interceptorList + + server, err := pmgproxy.NewProxyServer(proxyConfig) + if err != nil { + return fmt.Errorf("create proxy server: %w", err) + } + + if err := server.Start(); err != nil { + return fmt.Errorf("start proxy server: %w", err) + } + + state := proxystate.State{ + PID: os.Getpid(), + Addr: server.Address(), + CACertPath: caCertPath, + } + if err := proxystate.Write(statePath, state); err != nil { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Stop(stopCtx) + return fmt.Errorf("write proxy state: %w", err) + } + + log.Infof("PMG persistent proxy running on %s (pid %d)", state.Addr, state.PID) + if _, err := fmt.Fprintf(os.Stderr, "PMG proxy running on %s\nRun: eval $(pmg proxy env) # or: pmg proxy env --gha\n", state.Addr); err != nil { + log.Warnf("failed to write startup message: %v", err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + + close(confirmationChan) + _ = proxystate.Remove(statePath) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return server.Stop(stopCtx) +} + +// autoBlockConfirmations drains the confirmation channel and always denies, +// appropriate for non-interactive CI/CD environments. +func autoBlockConfirmations(ch chan *interceptors.ConfirmationRequest) { + for req := range ch { + log.Warnf("Persistent proxy: auto-blocking suspicious package %s", req.PackageVersion.GetPackage().GetName()) + req.ResponseChan <- false + close(req.ResponseChan) + } +} diff --git a/cmd/proxy/status.go b/cmd/proxy/status.go new file mode 100644 index 0000000..f399ddf --- /dev/null +++ b/cmd/proxy/status.go @@ -0,0 +1,40 @@ +package proxy + +import ( + "fmt" + "os" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/proxystate" + "github.com/spf13/cobra" +) + +func newStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the status of the persistent PMG proxy server", + RunE: runStatus, + } +} + +func runStatus(_ *cobra.Command, _ []string) error { + cfg := config.Get() + statePath := proxystate.StatePath(cfg.ConfigDir()) + + state, err := proxystate.Read(statePath) + if err != nil { + if _, werr := fmt.Fprintln(os.Stdout, "PMG proxy: not running (no state file)"); werr != nil { + return werr + } + return nil + } + + if state.IsRunning() { + _, err = fmt.Fprintf(os.Stdout, "PMG proxy: running (pid %d, addr %s, ca %s)\n", + state.PID, state.Addr, state.CACertPath) + } else { + _, err = fmt.Fprintf(os.Stdout, "PMG proxy: stopped (stale state for pid %d — run 'pmg proxy stop' to clean up)\n", state.PID) + } + + return err +} diff --git a/cmd/proxy/stop.go b/cmd/proxy/stop.go new file mode 100644 index 0000000..53dca76 --- /dev/null +++ b/cmd/proxy/stop.go @@ -0,0 +1,49 @@ +package proxy + +import ( + "fmt" + "os" + "syscall" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/proxystate" + "github.com/spf13/cobra" +) + +func newStopCommand() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the running persistent PMG proxy server", + RunE: runStop, + } +} + +func runStop(_ *cobra.Command, _ []string) error { + cfg := config.Get() + statePath := proxystate.StatePath(cfg.ConfigDir()) + + state, err := proxystate.Read(statePath) + if err != nil { + return fmt.Errorf("no proxy state found — is the proxy running? (%w)", err) + } + + if !state.IsRunning() { + _ = proxystate.Remove(statePath) + return fmt.Errorf("proxy process (pid %d) is not running; state file cleaned up", state.PID) + } + + proc, err := os.FindProcess(state.PID) + if err != nil { + return fmt.Errorf("find proxy process (pid %d): %w", state.PID, err) + } + + if err := proc.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err) + } + + if _, err := fmt.Fprintf(os.Stdout, "Sent SIGTERM to PMG proxy (pid %d, addr %s)\n", state.PID, state.Addr); err != nil { + return fmt.Errorf("write stop message: %w", err) + } + + return nil +} diff --git a/main.go b/main.go index dd6aab9..bf5dda7 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/safedep/pmg/cmd/executors" landlockCmd "github.com/safedep/pmg/cmd/landlock" "github.com/safedep/pmg/cmd/npm" + proxyCmd "github.com/safedep/pmg/cmd/proxy" "github.com/safedep/pmg/cmd/pypi" sandboxCmd "github.com/safedep/pmg/cmd/sandbox" "github.com/safedep/pmg/cmd/setup" @@ -148,6 +149,7 @@ func main() { cmd.AddCommand(pypi.NewUvCommand()) cmd.AddCommand(pypi.NewPoetryCommand()) cmd.AddCommand(executors.NewPipxCommand()) + cmd.AddCommand(proxyCmd.NewProxyCommand()) cmd.AddCommand(version.NewVersionCommand()) cmd.AddCommand(setup.NewSetupCommand()) cmd.AddCommand(setup.NewRemoveCommand())