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.
This commit is contained in:
Sahilb315
2026-06-23 15:36:41 +05:30
parent 5bd756e3fb
commit 39debb474d
7 changed files with 432 additions and 0 deletions
+102
View File
@@ -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
+100
View File
@@ -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()
}
+15
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+40
View File
@@ -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
}
+49
View File
@@ -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
}
+2
View File
@@ -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())