mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: add Linux system-wide setup
Install shared shims, managed configuration, and login-shell PATH integration so golden images and multi-user hosts can protect package installs for every user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: keep local design documents untracked
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden and simplify Linux system install
Tighten shim detection, profile repair, and install ordering while
trimming over-specific doctor/info hints from the system-install path.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: clarify system-install doctor alias and shim path checks
Use UserBinDir for PATH checks and pass aliases as not required under
system install without treating that as active interception.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: tighten event-log soft-fail warning prefix
Prefix the warning with [pmg] and drop the redundant continuing clause.
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci: add Linux system-install e2e and pin pnpm for add flake
Cover root system setup, PATH/profile.d, managed config, non-root
interception, and remove. Pin pnpm 11.10.0 on the package-manager e2e
job after an integrity crash on pnpm add.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: bump packageManager to pnpm 11.10.0 for e2e
Align package.json with the pnpm version we want in CI so action-setup
stops erroring on a version mismatch after the e2e integrity flake.
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci: use npm init for pnpm e2e to avoid integrity crash
pnpm 11.x `pnpm init` still writes onFail:download; `pnpm add` then
fails after PMG analysis even on 11.10.0. Seed the temp package with
npm init instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: revert packageManager pin to pnpm 11.1.3
The e2e integrity crash is avoided by npm init; the 11.10.0 bump is
no longer needed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden system-install review findings
Require root-owned, non-group/other-writable pmg for --system install;
allow remove without that validation. Doctor checks npm resolution for
PATH precedence, uses ImpliesInterception instead of message matching,
and documents version-manager shadowing. Pass profile bin dir from the
shim manager and note that system config ignores per-user files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden doctor PATH checks and attribute cloud events by OS user
Doctor now verifies every installed package manager against the shim
directory, and system-install validation only requires a safe parent
directory. Cloud sync records username/uid on invocation context for
multi-user hosts sharing one endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: address system-install review findings
- shim: make system executable resolution injectable so tests pass under
umask 002; skip the root-owner test when running as root
- doctor: treat resolution into either the system or per-user shim dir as
intercepted, and collapse the shim-in-PATH check to a single call site
- setup: make remove (both --system and per-user) best-effort with
errors.Join so one failed step no longer strands the other artifact
- shim: allow a group-writable install parent dir (Debian/Ubuntu ship
/usr/local/bin as root:staff 2775) while still rejecting world-writable
and non-root-owned parents
- audit: attribute cloud events to SUDO_USER when running under sudo
- docs: drop the soft-fail event-logging claim (hard-fail is retained)
* ci: normalize /usr/local/bin perms before system-install e2e
The GitHub ubuntu-latest runner ships /usr/local/bin world-writable so
tooling can install without sudo. System install correctly refuses a
world-writable dir for the shared binary (any local user could replace
it and hijack every user's npm/pip). No FHS-compliant distro or Docker
image ships it world-writable — it is always root:root 0755 or
root:staff 2775 — so this normalizes only the anomalous CI runner back
to standard perms and still exercises the real /usr/local/bin path.
* fix: actionable remedy for root-created per-user config dir
A pmg run as root with a preserved HOME (GitHub runners, sudo -E, su
without -) creates the invoking user's ~/.config/safedep as root-owned,
and event-log init then fail-closes every later non-root command.
Make that state self-solvable:
- event-log init permission errors exit with a usefulerror naming the
likely cause and the chown fix instead of a bare fatal
- pmg setup doctor probes event-log dir writability and reports the
same fix via a new per-result Fix override
- document the mechanism and remedy in system-install.md, along with
the binary ownership requirements for --system
- consolidate this branch's doctor tests into doctor_test.go
* fix: resolve per-user paths from root's own home when running as root
Path resolution trusted HOME (and XDG_*), which sudo and su can preserve
from the invoking user (GitHub runners, sudo -E, su without -). Any pmg
run as root then created root-owned ~/.config/safedep inside that user's
home, and event-log init fail-closed every later non-root pmg/npm/pip
run for them. System install made sudo pmg the documented flow, turning
this latent bug into the happy path.
When euid is 0, configDir and cacheDir now resolve from root's passwd
home instead of the environment, so root state lands under /root and
user homes are never touched. PMG_CONFIG_DIR/PMG_CACHE_DIR still win,
non-root resolution is unchanged, and Windows is unaffected (no euid).
Event-log init stays fatal on failure; sudo-run package events are
attributed via SUDO_USER and synced by the exit auto-sync as usual.
E2E: GitHub runners preserve HOME under sudo, so assert that no sudo
pmg run leaks state into the runner's home, and that the managed-config
refusal fails for the documented reason rather than a permission brick.
* fix: triage the unwritable config dir remedy by cause
The chown hint is only correct when another account created files
inside the current user's own home. When a leaked HOME or
XDG_CONFIG_HOME points at another user's home (e.g. sudo -u on GitHub
runners), following it would chown that user's directory and brick
their pmg instead. Classify the failure against the passwd home,
which the leaked environment cannot influence, and prescribe:
- dir inside own home: restore ownership with chown
- dir outside own home: fix the leaked environment, never chown
- explicit PMG_CONFIG_DIR: make it writable
Used by both the fatal event-log error and the doctor check, and the
docs troubleshooting now carries the same two-case triage.
* ci: pin XDG_CONFIG_HOME for the cross-user e2e step; terse doctor fix
GitHub runners export XDG_CONFIG_HOME=/home/runner/.config and it leaks
through sudo -u, so the pmgtest pmg resolved the runner user's config
dir and fail-closed on its runner-owned log file (run 29289868727 shows
the triaged error catching exactly this). Set it inside the login shell
so it wins regardless of how the leak is delivered.
The remedy now returns a full-help and doctor-table pair from a single
triage, and drops the do-not-chown tail from the leak message.
* fix: adapt event-log error to the two-value remedy signature
Belongs with the previous commit; it was left unstaged and e0580b1
does not compile without it.
* fix: fall back to env path resolution when root has no passwd entry
Running as uid 0 without a resolvable root passwd entry (scratch
containers, minimal chroots) panicked at startup on every command,
because the euid-based path resolution had no fallback. Fall back to
env-derived resolution there: without a passwd database there is no
user switching, so the cross-user poisoning that branch prevents
cannot occur.
Also restore the underlying cause in the generic event-log init error
(minimal output hid it after the usefulerror change), and document
that root's per-user data lives under /root regardless of a preserved
HOME.
* fix: gate SUDO_USER trust and root path diversion; add doctor binary check
Address review findings on the system-install PR:
- cloud_sink: honor SUDO_USER for audit attribution only when euid==0.
Without the gate any user could set SUDO_USER and spoof cloud-audit
attribution to another account. Matches the guard in cmd/setup/cert.go.
- config: divert per-user paths to root's passwd home only on an actual
sudo elevation (euid==0 && SUDO_USER set), not for every root euid.
The blanket root diversion ignored HOME/XDG_CONFIG_HOME and silently
stopped reading genuine root users' config (golden Docker images),
regressing two tests that only fail when the suite runs as root.
Genuine root honors the environment as before; su without - leaves no
marker and stays a documented, loud-failing residual.
- doctor: add a system-only check re-validating that the binary the
installed shims exec is still root-owned and non-writable, catching
permission/ownership drift after install.
- shim: fold the duplicated shim-scan loop into firstShimContent.
* fix: harden system dirs at install; keep sudo attribution without passwd
Address remaining review comments:
- shim: force root:root 0755 on the managed system dirs (shim tree and
profile.d) after MkdirAll. A pre-created dir with weaker ownership,
possible under Debian's group-writable /usr/local/lib, would let a
non-root user replace the shims every account executes.
- audit: when SUDO_USER has no passwd entry (minimal containers),
attribute cloud events from sudo's recorded SUDO_USER/SUDO_UID env
instead of falling back to root. Still gated on euid 0.
- setup: reword the root-without---system warning; alias/shim install
follows HOME, so claiming it configures only root's home was wrong.
- shim: skip the non-root-owner validation test on Windows, where file
ownership is not resolvable.
* fix: reject system binaries unreachable by other users; consistent info
The system-install validation checked the binary's own permissions and
the parent's tamper-safety but never reachability: a 0755 root-owned
binary under a 0700 directory (e.g. /root/pmg) passed every check while
every non-root user's shim failed with exit 127. Walk the directory
chain to / and require the search bit for others; doctor's system
binary check inherits this. E2E gains a reject case for a binary under
a non-searchable directory.
setup info: render alias/user-shim/system-shim rows through one
installed-state formatter (location when installed, "not installed"
otherwise) instead of a mix of booleans, paths, and prose.
* fix: stop reowning /etc/profile.d; document group-writable and su gaps
writeSystemProfile chowned/chmod'd /etc/profile.d itself, a shared
system directory pmg does not own, silently overriding any perms a
sysadmin set on it. Secure only the file pmg writes (pmg.sh) via
secureSystemFile, which also forces 0644 explicitly so a restrictive
root umask cannot leave the snippet non-world-readable (which would
drop the shim dir from other users' login-shell PATH).
Docs: add Limitations entries for the group-writable install dir
bypass (validation is defeatable on non-sticky group-writable dirs
like Debian's /usr/local/bin) and the elevation-only scope (su without
- can still poison the caller's home; sudo -u cannot poison another
account). Trim the requireSafeParentDir comment to a pointer.
* refactor: separate unwritable-dir diagnosis from remedy rendering
Address the open review threads on #376:
- rename realUserHomeDir to currentUserHomeDir and fail when the passwd
entry has no home directory
- split UnwritableConfigDirRemedy into classifyUnwritableDir (cause
diagnosis) and pure message rendering so each function has one job
- replace cmd/setup's duplicate pathIsUnderDir with the shared
config.PathWithinDir, now guarding empty inputs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A
* fix: repair umask-clamped modes on system shims and managed config
os.WriteFile and os.MkdirAll honor the process umask, so a hardened root
umask (e.g. 077) produced 0700 shims other users cannot execute and a
0600/0700 managed config non-root pmg runs cannot read - silently
disabling the system-wide policy. Chmod/chown the artifacts explicitly
after writing, with unix regression tests running under umask 077.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A
* refactor: address system-install maintainability review
- move PathWithinDir and the root-owned mkdir/chmod helpers into a new
internal/fsutil package; config no longer exports a generic fs util
- create managed-config directories with MkdirAllRootOwned, which only
sets ownership and mode on directories it creates - drops the
hardcoded safedep parent-dir heuristic and never touches pre-existing
directories
- collapse NewSystemShimManagerForRemove into NewSystemShimManager and
validate the executable in Install, where the action needs it; Remove
works regardless of binary state
- rename ShimConfig.ManageProfile to SystemProfile and document it
- name the Linux-only system paths linuxSystemBinDir/ProfilePath and
document the Unix-only validation semantics
- share the PMG_BIN shim variable name between writeShimScript and
parseShimPMGBin via the shimPMGBinVar constant
- cloud attribution falls back to the effective uid when SUDO_UID is
absent, so sudo-invoked commands are not misattributed to root
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A
---------
Co-authored-by: Sahilb315 <bansalsahil315@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
190 lines
8.0 KiB
Markdown
190 lines
8.0 KiB
Markdown
# 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.
|
|
via `iptables`) is tracked separately. For system-wide shell shims on Linux,
|
|
see [system-install.md](./system-install.md).
|
|
|
|
## 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`)
|