2026-06-26 11:19:28 +05:30
|
|
|
# Persistent Proxy Server
|
|
|
|
|
|
|
|
|
|
The persistent proxy server runs PMG's MITM proxy as a long-lived process that
|
|
|
|
|
intercepts **every** supported package manager invocation in an environment via
|
|
|
|
|
standard proxy environment variables, without shims, aliases, or wrapping each
|
|
|
|
|
command with `pmg`. It is built for non-interactive environments, primarily
|
|
|
|
|
CI/CD pipelines (e.g. GitHub Actions), where the environment can be configured
|
|
|
|
|
once for the whole job.
|
|
|
|
|
|
|
|
|
|
It builds on the generic MITM proxy described in [proxy.md](./proxy.md), reusing
|
|
|
|
|
the same interceptor chain, malware analyzer, and certificate manager. The
|
|
|
|
|
difference is the **lifecycle**: instead of PMG starting an ephemeral proxy
|
|
|
|
|
around a single subprocess, the proxy is started once, advertises itself to the
|
|
|
|
|
other `pmg proxy` commands, and serves many package manager processes until it
|
|
|
|
|
is stopped.
|
|
|
|
|
|
|
|
|
|
## Default proxy mode vs. persistent proxy server
|
|
|
|
|
|
|
|
|
|
PMG's default proxy mode (see [proxy.md](./proxy.md)) wraps a single command.
|
|
|
|
|
`pmg npm install` starts an ephemeral proxy, runs `npm` as a child with proxy
|
|
|
|
|
env vars injected, then tears the proxy down. The persistent server decouples
|
|
|
|
|
these steps.
|
|
|
|
|
|
|
|
|
|
| | Default proxy mode | Persistent proxy server |
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
| Invocation | `pmg npm install` (wrapped) | bare `npm install` (no wrapper) |
|
|
|
|
|
| Proxy lifetime | One subprocess | Until `pmg proxy stop` |
|
|
|
|
|
| Who runs the PM | PMG (as a child) | The user / CI directly |
|
|
|
|
|
| Ecosystems served | The one being run | All supported (npm + PyPI) |
|
|
|
|
|
| Confirmation on malware | Interactive prompt (TTY) | Auto-block (non-interactive) |
|
|
|
|
|
| Reporting | At subprocess exit | At `pmg proxy stop` |
|
|
|
|
|
| Target | Local dev | CI/CD pipelines |
|
|
|
|
|
|
|
|
|
|
## How it works
|
|
|
|
|
|
|
|
|
|
The diagram below shows the order of events in a CI job. The `pmg proxy`
|
|
|
|
|
commands run in separate workflow steps and coordinate through the running
|
|
|
|
|
daemon.
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
sequenceDiagram
|
|
|
|
|
participant CI as CI Job
|
|
|
|
|
participant Proxy as Proxy Daemon
|
|
|
|
|
participant PM as Package Manager
|
|
|
|
|
participant Cloud as SafeDep Cloud
|
|
|
|
|
|
|
|
|
|
CI->>Proxy: pmg proxy start --daemon
|
|
|
|
|
Proxy-->>CI: ready (addr, ca path)
|
|
|
|
|
CI->>CI: pmg proxy env (set HTTP_PROXY + CA vars)
|
|
|
|
|
PM->>Proxy: package download (via HTTP_PROXY)
|
|
|
|
|
Proxy->>Proxy: analyze package
|
|
|
|
|
Proxy-->>PM: allow, or 403 block + record event
|
|
|
|
|
Proxy->>Cloud: periodic sync of events (while serving)
|
|
|
|
|
CI->>Proxy: pmg proxy stop --fail-on-violation
|
|
|
|
|
Proxy->>Cloud: final flush of remaining events
|
|
|
|
|
Proxy-->>CI: exit non-zero if anything was blocked
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Usage
|
|
|
|
|
|
|
|
|
|
The persistent server targets non-interactive CI/CD. For local development use
|
|
|
|
|
the default proxy mode (`pmg npm install`), which keeps the interactive malware
|
|
|
|
|
confirmation prompt. The persistent server auto-blocks without prompting.
|
|
|
|
|
|
|
|
|
|
GitHub Actions (raw commands):
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
- run: pmg proxy start --daemon
|
|
|
|
|
- run: pmg proxy env >> "$GITHUB_ENV"
|
|
|
|
|
- run: npm ci
|
|
|
|
|
- run: pmg proxy stop --fail-on-violation
|
|
|
|
|
if: always()
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
GitHub Actions (via the [safedep/pmg action](../action.yml) `server-mode`):
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
- uses: safedep/pmg@v1
|
|
|
|
|
with:
|
|
|
|
|
server-mode: true
|
|
|
|
|
api-key: ${{ secrets.SAFEDEP_API_KEY }}
|
|
|
|
|
tenant-id: ${{ secrets.SAFEDEP_TENANT_ID }}
|
|
|
|
|
|
|
|
|
|
- run: npm ci # intercepted automatically
|
|
|
|
|
|
|
|
|
|
- name: Enforce PMG policy
|
|
|
|
|
if: always()
|
|
|
|
|
run: pmg proxy stop --fail-on-violation
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
In `server-mode`, the action starts the daemon and injects env vars instead of
|
|
|
|
|
installing shims. Because composite actions cannot run an automatic cleanup
|
|
|
|
|
step, the final `pmg proxy stop --fail-on-violation` step is required. It stops
|
|
|
|
|
the proxy (the daemon flushes events to the cloud during shutdown) and fails the
|
|
|
|
|
job on a block.
|
|
|
|
|
|
|
|
|
|
## Commands
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
pmg proxy start # start the proxy (foreground, or detached with --daemon)
|
|
|
|
|
pmg proxy stop # stop the proxy and report the outcome
|
|
|
|
|
pmg proxy env # print env vars that route package managers through it
|
|
|
|
|
pmg proxy status # report whether a proxy is running
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Run `pmg proxy <command> --help` for flags. `--daemon` is **Unix only**: on
|
|
|
|
|
Windows it returns a clear "not supported" error, and the foreground
|
|
|
|
|
`pmg proxy start` still works. To run multiple independent proxies on one host,
|
|
|
|
|
give each a distinct `--state` path and `--port`.
|
|
|
|
|
|
|
|
|
|
## Bind address
|
|
|
|
|
|
|
|
|
|
The proxy binds `127.0.0.1` on a random port by default, reachable only from the
|
|
|
|
|
host (the right choice for CI and local use). Override with `--host`/`--port`, or
|
|
|
|
|
the `proxy.server.listen_host`/`listen_port` config (flags take precedence).
|
|
|
|
|
|
|
|
|
|
Bind a non-loopback address (e.g. `--host 0.0.0.0`) **only** for a deliberately
|
|
|
|
|
hosted deployment: it exposes the MITM proxy to the network, and every client
|
|
|
|
|
routed through it has its HTTPS intercepted and must trust the PMG CA.
|
|
|
|
|
|
|
|
|
|
## Certificate trust
|
|
|
|
|
|
|
|
|
|
The proxy performs TLS MITM, so clients must trust its CA. Trust is delivered
|
|
|
|
|
through **environment variables, not the OS trust store**. `pmg proxy env`
|
|
|
|
|
always emits the cert-path variables pointing at the proxy's CA bundle:
|
|
|
|
|
`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`,
|
|
|
|
|
`YARN_HTTPS_CA_FILE_PATH`. Package managers pick these up from the job
|
|
|
|
|
environment and trust the proxy's CA, with no OS trust-store install required.
|
|
|
|
|
|
|
|
|
|
This is deliberate: whether a tool consults the OS trust store varies by tool,
|
|
|
|
|
version, and config (npm/Node ignore it by default; modern pip can read it;
|
|
|
|
|
`requests`/`certifi` ship their own bundle). The cert-path vars work across all
|
|
|
|
|
of them, and are harmlessly ignored by tools that do read the OS store.
|
|
|
|
|
|
|
|
|
|
As a result `pmg setup cert install` is **not** needed for the persistent proxy.
|
|
|
|
|
If a persisted CA from `pmg setup cert install` exists the proxy reuses it,
|
|
|
|
|
otherwise it generates an ephemeral one. Either way `pmg proxy env` carries the
|
|
|
|
|
trust. OS trust-store install (`pmg setup cert install --system`) is
|
|
|
|
|
intentionally not used: it needs root (breaking container and locked-down
|
|
|
|
|
runners), persistently installs a MITM-capable CA into the machine trust store,
|
|
|
|
|
and still does not remove the need for the env vars.
|
|
|
|
|
|
|
|
|
|
Loopback addresses are always excluded from proxying via `NO_PROXY`
|
|
|
|
|
(`localhost,127.0.0.1,::1`).
|
|
|
|
|
|
|
|
|
|
## Cloud event sync
|
|
|
|
|
|
|
|
|
|
When SafeDep Cloud is enabled, malware-block events must reach the cloud even on
|
|
|
|
|
ephemeral CI runners that are destroyed immediately after the job. The daemon
|
|
|
|
|
owns delivery: it records each blocked package to a durable local event log as
|
|
|
|
|
it happens, syncs pending events to SafeDep Cloud periodically while serving, and
|
|
|
|
|
flushes whatever remains on shutdown.
|
|
|
|
|
|
|
|
|
|
`pmg proxy stop` reports the recorded result (`Synced N event(s) to SafeDep
|
|
|
|
|
Cloud`, or a `Cloud sync failed` line). A flush failure is surfaced but does not
|
|
|
|
|
mask the fail-on-violation exit code.
|
|
|
|
|
|
|
|
|
|
## Fail on violation
|
|
|
|
|
|
|
|
|
|
By default `pmg proxy stop` just stops the proxy and exits `0`. Failing the CI
|
|
|
|
|
job on a policy violation is opt-in via `--fail-on-violation`.
|
|
|
|
|
|
|
|
|
|
- It exits non-zero when any package was blocked.
|
|
|
|
|
- It **fails closed**. If the daemon shut down without writing a verifiable
|
|
|
|
|
final state (e.g. it crashed), `--fail-on-violation` also fails, because a
|
|
|
|
|
security gate must not pass on an unverifiable run.
|
|
|
|
|
|
|
|
|
|
The package manager's own non-zero exit (from the `403` on a blocked download)
|
|
|
|
|
is a separate signal. `--fail-on-violation` gives an authoritative gate from the
|
|
|
|
|
proxy regardless of how the package manager reported the failure.
|
|
|
|
|
|
|
|
|
|
## Limitations
|
|
|
|
|
|
|
|
|
|
- **Unix-only daemon.** `--daemon` is not supported on Windows (foreground mode
|
|
|
|
|
works).
|
|
|
|
|
- **Non-interactive only.** There is no interactive confirmation; flagged
|
|
|
|
|
packages are always auto-blocked. This is intentional for CI.
|
|
|
|
|
- **Single proxy per state file.** Starting a second proxy that points at the
|
|
|
|
|
same state file is refused while one is running.
|
|
|
|
|
- **System-level trust enforcement is out of scope.** The server relies on env
|
|
|
|
|
var propagation. Enforcing interception for `sudo`-scrubbed environments (e.g.
|
2026-07-14 21:34:22 +05:30
|
|
|
via `iptables`) is tracked separately. For system-wide shell shims on Linux,
|
|
|
|
|
see [system-install.md](./system-install.md).
|
2026-06-26 11:19:28 +05:30
|
|
|
|
|
|
|
|
## References
|
|
|
|
|
|
|
|
|
|
- [proxy.md](./proxy.md) is the underlying generic MITM proxy server
|
|
|
|
|
- [config.md](./config.md) is the configuration schema (cloud, proxy, cache dir)
|
|
|
|
|
- [action.yml](../action.yml) is the PMG GitHub Action (`server-mode`)
|