mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user