feat: Add support for environment protection (scrubbing) (#327)

* feat: Add support for environment variable protection for sandbox

* chore: Update dangerous env var list

* fix: Split profiles for improved environment protection

* fix: pipx sandbox profile separation

* chore: Show sandbox scrub info on error exit

* fix: Code review fixes

* test: Add e2e for sandbox environment scrubbing
This commit is contained in:
Abhisek Datta
2026-06-11 11:40:33 +05:30
committed by GitHub
parent 7620097613
commit c7244f921a
39 changed files with 1385 additions and 49 deletions
+36 -4
View File
@@ -598,6 +598,16 @@ jobs:
touch ./.env
- name: Run Sandbox E2E Test
env:
E2E_ENV_SEEDED: "1"
GITHUB_TOKEN: pmg-e2e-canary
gh_token: pmg-e2e-canary
AWS_SECRET_ACCESS_KEY: pmg-e2e-canary
OP_SERVICE_ACCOUNT_TOKEN: pmg-e2e-canary
CLOUDFLARE_API_TOKEN: pmg-e2e-canary
TWINE_PASSWORD: pmg-e2e-canary
NPM_TOKEN: pmg-e2e-keep
NODE_AUTH_TOKEN: pmg-e2e-keep
run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js
- name: Run Package Manager E2E Test
@@ -668,10 +678,20 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Run Sandbox E2E Test
run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js
env:
E2E_ENV_SEEDED: "1"
GITHUB_TOKEN: pmg-e2e-canary
gh_token: pmg-e2e-canary
AWS_SECRET_ACCESS_KEY: pmg-e2e-canary
OP_SERVICE_ACCOUNT_TOKEN: pmg-e2e-canary
CLOUDFLARE_API_TOKEN: pmg-e2e-canary
TWINE_PASSWORD: pmg-e2e-canary
NPM_TOKEN: pmg-e2e-keep
NODE_AUTH_TOKEN: pmg-e2e-keep
run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js
- name: Run Package Manager E2E Test
run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/pm-e2e.js
run: pmg --sandbox --sandbox-enforce npm exec -- node test/pm-e2e.js
sandbox-e2e-linux-landlock:
name: Sandbox E2E - Linux (Landlock)
@@ -747,8 +767,20 @@ jobs:
- name: Run Landlock Helper E2E Tests (Go)
run: go test -count=1 -v -run TestLandlockHelper ./sandbox/platform/...
# The npm leaf profile's npm_config_* env allow is what keeps the
# job-level npm_config_cache redirect alive.
- name: Run Sandbox E2E Test
run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js
env:
E2E_ENV_SEEDED: "1"
GITHUB_TOKEN: pmg-e2e-canary
gh_token: pmg-e2e-canary
AWS_SECRET_ACCESS_KEY: pmg-e2e-canary
OP_SERVICE_ACCOUNT_TOKEN: pmg-e2e-canary
CLOUDFLARE_API_TOKEN: pmg-e2e-canary
TWINE_PASSWORD: pmg-e2e-canary
NPM_TOKEN: pmg-e2e-keep
NODE_AUTH_TOKEN: pmg-e2e-keep
run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js
- name: Run Package Manager E2E Test
run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/pm-e2e.js
run: pmg --sandbox --sandbox-enforce npm exec -- node test/pm-e2e.js
+16 -1
View File
@@ -27,7 +27,7 @@ VERSION := "$(shell git describe --tags --abbrev=0)-$(shell git rev-parse --shor
GO_CFLAGS=-X 'github.com/safedep/pmg/internal/version.Commit=$(GITCOMMIT)' -X 'github.com/safedep/pmg/internal/version.Version=$(VERSION)'
GO_LDFLAGS=-ldflags "-w $(GO_CFLAGS)"
.PHONY: all pmg create_bin clean test
.PHONY: all pmg create_bin clean test sandbox-e2e
all: pmg
@@ -43,6 +43,21 @@ clean:
test:
$(GO) test ./...
# Runs the sandbox E2E tests with seeded env canaries, mirroring the
# pmg-e2e.yml sandbox jobs. Requires node and a supported sandbox driver
# (Seatbelt on macOS, Bubblewrap or Landlock on Linux).
sandbox-e2e: pmg
E2E_ENV_SEEDED=1 \
GITHUB_TOKEN=pmg-e2e-canary \
gh_token=pmg-e2e-canary \
AWS_SECRET_ACCESS_KEY=pmg-e2e-canary \
OP_SERVICE_ACCOUNT_TOKEN=pmg-e2e-canary \
CLOUDFLARE_API_TOKEN=pmg-e2e-canary \
TWINE_PASSWORD=pmg-e2e-canary \
NPM_TOKEN=pmg-e2e-keep \
NODE_AUTH_TOKEN=pmg-e2e-keep \
./$(BIN) --sandbox --sandbox-enforce npm exec -- node ./test/sandbox-e2e.js
fmt:
$(GO) fmt ./...
+1 -1
View File
@@ -129,7 +129,7 @@ func executeSetupInfo() error {
policyParts := make([]string, 0, len(pmNames))
for _, name := range pmNames {
ref := sandboxCfg.Policies[name]
ref, _ := sandboxCfg.PolicyFor(name)
status := "disabled"
if ref.Enabled {
status = ref.Profile
+47
View File
@@ -150,6 +150,52 @@ type DependencyCooldownConfig struct {
Days int `mapstructure:"days"`
}
// legacyProfileAliases maps old default profile names, keyed by package
// manager, to their per-PM leaf profiles. When npm-restrictive and
// pypi-restrictive became pure bases with no environment allows (and
// pnpm-restrictive was renamed to pnpm), existing config files kept the old
// mappings (config merge preserves user values), so the old defaults are
// re-mapped at read time.
var legacyProfileAliases = map[string]map[string]string{
"npm-restrictive": {
"npm": "npm",
"yarn": "yarn",
"bun": "bun",
},
"pnpm-restrictive": {
"pnpm": "pnpm",
},
"pypi-restrictive": {
"pip": "pip",
"pip3": "pip",
"pipx": "pipx",
"poetry": "poetry",
"uv": "uv",
},
}
// PolicyFor returns the sandbox policy reference for a package manager,
// re-mapping legacy default profiles to their per-PM leaf profiles. The
// re-mapping is skipped when a policy template overrides the legacy name,
// since the user's custom template must keep winning as it did before the
// profile split.
func (s *SandboxConfig) PolicyFor(pmName string) (SandboxPolicyRef, bool) {
ref, exists := s.Policies[pmName]
if !exists {
return SandboxPolicyRef{}, false
}
if leaves, legacy := legacyProfileAliases[ref.Profile]; legacy {
if _, overridden := s.PolicyTemplates[ref.Profile]; !overridden {
if leaf, ok := leaves[pmName]; ok {
ref.Profile = leaf
}
}
}
return ref, true
}
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
// a profile name to a path.
type SandboxPolicyTemplate struct {
@@ -298,6 +344,7 @@ const (
SandboxAllowExec SandboxAllowType = "exec"
SandboxAllowNetConnect SandboxAllowType = "net-connect"
SandboxAllowNetBind SandboxAllowType = "net-bind"
SandboxAllowEnv SandboxAllowType = "env"
)
// SandboxAllowOverride represents a single --sandbox-allow flag value.
+15 -11
View File
@@ -111,14 +111,16 @@ sandbox:
# Per-package-manager sandbox policies
# Each package manager can have its own policy to account for unique security characteristics
policies:
# npm ecosystem. npm-restrictive is a built-in profile.
# npm ecosystem. npm-restrictive is the shared base profile; each package
# manager maps to a leaf profile that re-allows only its own environment
# variables.
npm:
enabled: true
profile: npm-restrictive # Built-in profile, template name, or path to custom YAML
profile: npm # Built-in profile, template name, or path to custom YAML
pnpm:
enabled: true
profile: pnpm-restrictive
profile: pnpm
npx:
enabled: true
@@ -130,32 +132,34 @@ sandbox:
yarn:
enabled: true
profile: npm-restrictive
profile: yarn
bun:
enabled: true
profile: npm-restrictive
profile: bun
# PyPI ecosystem. pypi-restrictive is a built-in profile.
# PyPI ecosystem. pypi-restrictive is the shared base profile; each
# package manager maps to a leaf profile that re-allows only its own
# environment variables.
pip:
enabled: true
profile: pypi-restrictive
profile: pip
pip3:
enabled: true
profile: pypi-restrictive
profile: pip
pipx:
enabled: true
profile: pypi-restrictive
profile: pipx
poetry:
enabled: true
profile: pypi-restrictive
profile: poetry
uv:
enabled: true
profile: pypi-restrictive
profile: uv
# Dependency cooldown blocks installation of package versions published within
# a configurable time window.
+25 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"unicode"
"github.com/safedep/dry/log"
)
@@ -16,6 +17,7 @@ var validSandboxAllowTypes = map[SandboxAllowType]bool{
SandboxAllowExec: true,
SandboxAllowNetConnect: true,
SandboxAllowNetBind: true,
SandboxAllowEnv: true,
}
// parseSandboxAllowOverrides parses raw --sandbox-allow flag values into validated overrides.
@@ -69,7 +71,7 @@ func parseSingleOverride(raw string) (SandboxAllowOverride, error) {
}
if !validSandboxAllowTypes[allowType] {
return SandboxAllowOverride{}, fmt.Errorf("unknown type %q, valid types: read, write, exec, net-connect, net-bind", typStr)
return SandboxAllowOverride{}, fmt.Errorf("unknown type %q, valid types: read, write, exec, net-connect, net-bind, env", typStr)
}
resolved, err := validateAndResolveValue(allowType, value)
@@ -95,11 +97,33 @@ func validateAndResolveValue(typ SandboxAllowType, value string) (string, error)
return validateNetConnect(value)
case SandboxAllowNetBind:
return validateNetBind(value)
case SandboxAllowEnv:
return validateEnvName(value)
default:
return "", fmt.Errorf("unhandled type: %s", typ)
}
}
// validateEnvName validates an env allow value. The value is an environment
// variable name or name glob (e.g. NPM_TOKEN, npm_config_*) and is kept
// verbatim. Unlike filesystem/exec values it is NOT path-resolved, since it
// matches a variable name and not a filesystem location. Matching is
// case-insensitive at scrub time, so the value is not normalized here.
// Whitespace and control characters are rejected because the value is echoed
// back in logs and audit events, and backslash and separators because they
// have no place in a variable name or glob.
func validateEnvName(value string) (string, error) {
invalid := strings.ContainsAny(value, "=/\\") ||
strings.ContainsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) || unicode.IsControl(r)
})
if invalid {
return "", fmt.Errorf("invalid env variable name %q (expected a name or name glob, e.g. NPM_TOKEN or npm_config_*)", value)
}
return value, nil
}
// resolveFilesystemPath resolves a filesystem path for read/write overrides.
// Supports glob patterns. Resolves relative paths to absolute via CWD.
func resolveFilesystemPath(value string) (string, error) {
+42
View File
@@ -94,6 +94,48 @@ func TestParseSandboxAllowOverrides_ValidFormats(t *testing.T) {
}
}
func TestParseSandboxAllowOverrides_Env(t *testing.T) {
tests := []struct {
name string
raw string
expectedValue string
}{
{name: "exact name", raw: "env=NPM_TOKEN", expectedValue: "NPM_TOKEN"},
{name: "glob name kept verbatim", raw: "env=npm_config_*", expectedValue: "npm_config_*"},
{name: "not path resolved", raw: "env=AWS_PROFILE", expectedValue: "AWS_PROFILE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
overrides, err := parseSandboxAllowOverrides([]string{tt.raw})
require.NoError(t, err)
require.Len(t, overrides, 1)
assert.Equal(t, SandboxAllowEnv, overrides[0].Type)
// Value is kept verbatim, with no CWD/path resolution.
assert.Equal(t, tt.expectedValue, overrides[0].Value)
})
}
}
func TestParseSandboxAllowOverrides_EnvInvalid(t *testing.T) {
invalid := []string{
"env=NPM/TOKEN",
"env=FOO=BAR",
"env=HAS SPACE",
"env=HAS\tTAB",
"env=HAS\nNEWLINE",
"env=HAS\rRETURN",
"env=BACK\\SLASH",
"env=CTRL\x07CHAR",
}
for _, raw := range invalid {
_, err := parseSandboxAllowOverrides([]string{raw})
assert.Error(t, err, "expected error for %q", raw)
}
}
func TestParseSandboxAllowOverrides_MultipleValues(t *testing.T) {
raw := []string{
"write=./.gitignore",
+134
View File
@@ -0,0 +1,134 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSandboxConfigPolicyFor(t *testing.T) {
tests := []struct {
name string
policies map[string]SandboxPolicyRef
policyTemplates map[string]SandboxPolicyTemplate
pmName string
wantProfile string
wantExists bool
}{
{
name: "legacy default re-mapped for npm",
policies: map[string]SandboxPolicyRef{"npm": {Enabled: true, Profile: "npm-restrictive"}},
pmName: "npm",
wantProfile: "npm",
wantExists: true,
},
{
name: "legacy default re-mapped for yarn",
policies: map[string]SandboxPolicyRef{"yarn": {Enabled: true, Profile: "npm-restrictive"}},
pmName: "yarn",
wantProfile: "yarn",
wantExists: true,
},
{
name: "legacy default re-mapped for bun",
policies: map[string]SandboxPolicyRef{"bun": {Enabled: true, Profile: "npm-restrictive"}},
pmName: "bun",
wantProfile: "bun",
wantExists: true,
},
{
name: "custom profile kept verbatim",
policies: map[string]SandboxPolicyRef{"npm": {Enabled: true, Profile: "my-corp-npm"}},
pmName: "npm",
wantProfile: "my-corp-npm",
wantExists: true,
},
{
name: "legacy pnpm-restrictive re-mapped for pnpm",
policies: map[string]SandboxPolicyRef{"pnpm": {Enabled: true, Profile: "pnpm-restrictive"}},
pmName: "pnpm",
wantProfile: "pnpm",
wantExists: true,
},
{
name: "legacy profile for unrelated package manager kept verbatim",
policies: map[string]SandboxPolicyRef{"pip": {Enabled: true, Profile: "npm-restrictive"}},
pmName: "pip",
wantProfile: "npm-restrictive",
wantExists: true,
},
{
name: "legacy pypi-restrictive re-mapped for pip",
policies: map[string]SandboxPolicyRef{"pip": {Enabled: true, Profile: "pypi-restrictive"}},
pmName: "pip",
wantProfile: "pip",
wantExists: true,
},
{
name: "legacy pypi-restrictive re-mapped for pip3",
policies: map[string]SandboxPolicyRef{"pip3": {Enabled: true, Profile: "pypi-restrictive"}},
pmName: "pip3",
wantProfile: "pip",
wantExists: true,
},
{
name: "legacy pypi-restrictive re-mapped for pipx",
policies: map[string]SandboxPolicyRef{"pipx": {Enabled: true, Profile: "pypi-restrictive"}},
pmName: "pipx",
wantProfile: "pipx",
wantExists: true,
},
{
name: "legacy pypi-restrictive re-mapped for poetry",
policies: map[string]SandboxPolicyRef{"poetry": {Enabled: true, Profile: "pypi-restrictive"}},
pmName: "poetry",
wantProfile: "poetry",
wantExists: true,
},
{
name: "legacy pypi-restrictive re-mapped for uv",
policies: map[string]SandboxPolicyRef{"uv": {Enabled: true, Profile: "pypi-restrictive"}},
pmName: "uv",
wantProfile: "uv",
wantExists: true,
},
{
name: "template override disables re-mapping",
policies: map[string]SandboxPolicyRef{"npm": {Enabled: true, Profile: "npm-restrictive"}},
policyTemplates: map[string]SandboxPolicyTemplate{
"npm-restrictive": {Path: "./custom-npm.yml"},
},
pmName: "npm",
wantProfile: "npm-restrictive",
wantExists: true,
},
{
name: "missing package manager",
policies: map[string]SandboxPolicyRef{},
pmName: "npm",
wantExists: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := SandboxConfig{Policies: tt.policies, PolicyTemplates: tt.policyTemplates}
ref, exists := cfg.PolicyFor(tt.pmName)
assert.Equal(t, tt.wantExists, exists)
if tt.wantExists {
assert.Equal(t, tt.wantProfile, ref.Profile)
}
})
}
}
func TestSandboxConfigPolicyForDoesNotMutateConfig(t *testing.T) {
cfg := SandboxConfig{
Policies: map[string]SandboxPolicyRef{"npm": {Enabled: true, Profile: "npm-restrictive"}},
}
_, _ = cfg.PolicyFor("npm")
assert.Equal(t, "npm-restrictive", cfg.Policies["npm"].Profile)
}
+58 -1
View File
@@ -46,6 +46,54 @@ read=./.env` is enough to read `${CWD}/.env`. Suppression is exact post-expansio
like `${HOME}/**` do not opt out of `${HOME}/.aws`. The unnamed absolute form stays denied.
`.git/hooks` does not accept opt-outs because hooks can execute arbitrary code.
### Environment Variable Protection
Many supply chain attacks steal credentials from the **process environment** rather than from files
(e.g. `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `NPM_TOKEN`, `TWINE_PASSWORD`). When the sandbox is
enabled, PMG scrubs a default-deny list of credential-bearing variables from the package manager
child process before it is spawned. The built-in list is an explicit, curated set of **known** secret
names. See [`DANGEROUS_ENV_VARS`](../sandbox/util/dangerous.go). There are deliberately no generic
`*_TOKEN` / `*_SECRET` catch-alls in the default, because broad wildcards there would risk clipping
legitimate build variables.
Scrubbing is platform-independent (it filters the environment regardless of the OS sandbox driver)
and runs as the last step before launch, after project overlays and `--sandbox-allow` overrides are
merged. Scrubbed variable **names** (never values) are logged at info level. Run with `--debug` to
see what was removed.
The shared base profiles (`npm-restrictive`, `pypi-restrictive`) allow no environment variables.
Each package manager's leaf profile (`npm`, `yarn`, `bun`, `pnpm`, `npx`, `pip`, `pipx`, `uv`,
`poetry`) re-allows only the variables that package manager legitimately needs via an
`environment.allow` block, so package managers keep working:
```yaml
environment:
# Re-permit only what this package manager needs; everything else in the
# default deny list stays scrubbed. allow always wins over deny.
allow:
- NPM_TOKEN
- npm_config_*
# Optionally scrub more than the default. Glob patterns are supported here
# (they are intentionally not in the built-in default).
deny:
- MY_CUSTOM_SECRET
- "*_TOKEN"
```
**Accepted trade-off**: a leaf profile re-allows its own package manager's auth token, so a
malicious package executed during `npm install` can read `NPM_TOKEN`, but not a yarn or bun token, a
PyPI token, AWS key, or other cloud/secret-manager credential, which stay scrubbed. The reverse
holds for the other profiles. This is deliberate: the package manager needs its own auth token to
function.
Configs written before the profile split may still map npm, yarn, or bun to `npm-restrictive`, pnpm
to `pnpm-restrictive`, or pip, pip3, poetry, or uv to `pypi-restrictive`. PMG re-maps these legacy
defaults to the per-package-manager leaf profile at load time, unless a custom policy template
overrides the legacy name, in which case the template wins as before.
Matching is on the variable name, case-insensitive, and supports the same glob syntax as filesystem
rules. A small set of core variables (`PATH`, `HOME`, `LC_*`, `TZ`, ...) is never scrubbed.
## Requirements
- Linux kernel 5.13+ with Landlock enabled (default, no external dependencies)
@@ -162,6 +210,9 @@ pmg --sandbox-allow net-connect=npm.internal.corp:443 npm install @corp/private-
# Allow a dev server to bind to a local port
pmg --sandbox-allow net-bind=127.0.0.1:3000 npx some-dev-tool
# Re-allow a sensitive environment variable that the profile scrubs by default
pmg --sandbox-allow env=AWS_PROFILE aws-cdk-using-package install
# Multiple overrides
pmg \
--sandbox-allow write=./.gitignore \
@@ -169,7 +220,13 @@ pmg \
npm install some-package
```
Supported types: `read`, `write`, `exec`, `net-connect`, `net-bind`.
Supported types: `read`, `write`, `exec`, `net-connect`, `net-bind`, `env`.
For `env`, the value is an environment variable **name** or name glob (e.g. `NPM_TOKEN`,
`npm_config_*`) and is kept verbatim. It is not path-resolved. It is an allow-only override that
re-permits a variable the profile would otherwise scrub; allow always wins, so there is no deny list
to edit. If a command fails with an auth error, re-run with `--debug` and look for a "scrubbed" log
line naming the variable, then re-allow it with `--sandbox-allow env=NAME`.
Overrides are non-persistent (apply to current invocation only) and logged in the event log for
auditing. An override adds the path to the allow list and removes an exact match entry from the
+1 -1
View File
@@ -49,7 +49,7 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
if cfg.Config.Sandbox.Enabled {
if policyRef, exists := cfg.Config.Sandbox.Policies[f.pm.Name()]; exists {
if policyRef, exists := cfg.Config.Sandbox.PolicyFor(f.pm.Name()); exists {
reportData.SandboxProfile = policyRef.Profile
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
if cfg.Config.Sandbox.Enabled {
if policyRef, exists := cfg.Config.Sandbox.Policies[f.pm.Name()]; exists {
if policyRef, exists := cfg.Config.Sandbox.PolicyFor(f.pm.Name()); exists {
reportData.SandboxProfile = policyRef.Profile
}
}
+9 -7
View File
@@ -18,14 +18,16 @@ type ChildExitError struct {
Code int // child's exit code (or 128+signum for signal termination)
Signaled bool // true if terminated by a signal (Ctrl+C, SIGTERM, …)
PMName string // package manager name, for the dim one-liner
Scrubbed int // env vars scrubbed by the sandbox, for the dim hint
}
func (e *ChildExitError) Error() string {
return fmt.Sprintf("%s exited with code %d", e.PMName, e.Code)
}
func (e *ChildExitError) ExitCode() int { return e.Code }
func (e *ChildExitError) Transparent() bool { return true }
func (e *ChildExitError) IsSignaled() bool { return e.Signaled }
func (e *ChildExitError) ExitCode() int { return e.Code }
func (e *ChildExitError) Transparent() bool { return true }
func (e *ChildExitError) IsSignaled() bool { return e.Signaled }
func (e *ChildExitError) ScrubbedEnvCount() int { return e.Scrubbed }
// classify turns a package-manager execution error into either a transparent
// child exit or a visible PMG error, and is the only place the fork lives. It is
@@ -33,13 +35,13 @@ func (e *ChildExitError) IsSignaled() bool { return e.Signaled }
// child's own non-zero exit loud — a restrictive policy routinely denies benign
// operations and causation cannot be inferred from a denial. Only a failure on
// PMG's side of the boundary (the tool never produced an exit status) is loud.
func classify(err error, pmName string) error {
func classify(err error, pmName string, scrubbedEnv int) error {
if err == nil {
return nil
}
code, signaled, resolved := extractExit(err)
return decideExit(err, code, signaled, resolved, pmName)
return decideExit(err, code, signaled, resolved, pmName, scrubbedEnv)
}
// extractExit pulls the exit code and signal status from a process error.
@@ -63,11 +65,11 @@ func extractExit(err error) (code int, signaled bool, resolved bool) {
return -1, false, false
}
func decideExit(err error, code int, signaled, resolved bool, pmName string) error {
func decideExit(err error, code int, signaled, resolved bool, pmName string, scrubbedEnv int) error {
if !resolved {
return visibleExecError(err)
}
return &ChildExitError{Code: code, Signaled: signaled, PMName: pmName}
return &ChildExitError{Code: code, Signaled: signaled, PMName: pmName, Scrubbed: scrubbedEnv}
}
// visibleExecError is the loud error for a genuine PMG-side failure: the package
+13 -4
View File
@@ -55,7 +55,7 @@ func TestDecideExit(t *testing.T) {
runErr := errors.New("npm failed")
t.Run("plain child exit becomes a transparent ChildExitError", func(t *testing.T) {
err := decideExit(runErr, 1, false, true, "npm")
err := decideExit(runErr, 1, false, true, "npm", 0)
var ce *ChildExitError
require.True(t, errors.As(err, &ce))
@@ -63,10 +63,19 @@ func TestDecideExit(t *testing.T) {
assert.True(t, ce.Transparent())
assert.False(t, ce.IsSignaled())
assert.Equal(t, "npm", ce.PMName)
assert.Equal(t, 0, ce.ScrubbedEnvCount())
})
t.Run("scrubbed env count is carried for the exit hint", func(t *testing.T) {
err := decideExit(runErr, 1, false, true, "npm", 3)
var ce *ChildExitError
require.True(t, errors.As(err, &ce))
assert.Equal(t, 3, ce.ScrubbedEnvCount())
})
t.Run("signaled child exit is transparent and signaled", func(t *testing.T) {
err := decideExit(runErr, 130, true, true, "npm")
err := decideExit(runErr, 130, true, true, "npm", 0)
var ce *ChildExitError
require.True(t, errors.As(err, &ce))
@@ -75,7 +84,7 @@ func TestDecideExit(t *testing.T) {
})
t.Run("unresolved exit is a visible launch failure", func(t *testing.T) {
err := decideExit(runErr, -1, false, false, "npm")
err := decideExit(runErr, -1, false, false, "npm", 0)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
@@ -94,7 +103,7 @@ func TestClassifyTransparentChildExit(t *testing.T) {
childErr := exec.Command("sh", "-c", "exit 1").Run()
require.Error(t, childErr)
err := classify(childErr, "npm")
err := classify(childErr, "npm", 0)
var ce *ChildExitError
require.True(t, errors.As(err, &ce))
+2 -2
View File
@@ -130,7 +130,7 @@ func runDirect(cmd *exec.Cmd, result *sandbox.ExecutionResult, pmName string) er
if err := cmd.Run(); err != nil {
executor.ObserveViolations(result, err)
return classify(err, pmName)
return classify(err, pmName, result.ScrubbedEnvCount())
}
log.Debugf("Command completed successfully")
@@ -244,7 +244,7 @@ func runPTY(
if sessionError != nil {
executor.ObserveViolations(result, sessionError)
return classify(sessionError, pmName)
return classify(sessionError, pmName, result.ScrubbedEnvCount())
}
return nil
+17
View File
@@ -14,6 +14,14 @@ type transparentExit interface {
IsSignaled() bool
}
// scrubbedEnvReporter is optionally satisfied by a transparent exit error to
// surface how many environment variables the sandbox scrubbed from the failed
// run. Kept separate from transparentExit so older implementations still
// classify as transparent.
type scrubbedEnvReporter interface {
ScrubbedEnvCount() int
}
type exitDecision struct {
transparent bool
notice bool
@@ -34,6 +42,15 @@ func classifyExit(err error) exitDecision {
if !te.IsSignaled() && verbosityLevel != VerbosityLevelSilent {
d.notice = true
d.message = "↳ pmg: " + te.Error()
// Env scrubbing produces no sandbox violation and the child's own
// error (e.g. a registry 401) does not point at the cause, so hint at
// it here. Names are not printed; they are at info level via --debug.
if sr, ok := te.(scrubbedEnvReporter); ok && sr.ScrubbedEnvCount() > 0 {
d.message += fmt.Sprintf(
"\n↳ pmg: sandbox scrubbed %d env var(s) (names via --debug, re-allow with --sandbox-allow env=NAME)",
sr.ScrubbedEnvCount())
}
}
return d
}
+46 -4
View File
@@ -14,12 +14,16 @@ type fakeChildExit struct {
code int
signaled bool
pmName string
scrubbed int
}
func (e *fakeChildExit) Error() string { return fmt.Sprintf("%s exited with code %d", e.pmName, e.code) }
func (e *fakeChildExit) ExitCode() int { return e.code }
func (e *fakeChildExit) Transparent() bool { return true }
func (e *fakeChildExit) IsSignaled() bool { return e.signaled }
func (e *fakeChildExit) Error() string {
return fmt.Sprintf("%s exited with code %d", e.pmName, e.code)
}
func (e *fakeChildExit) ExitCode() int { return e.code }
func (e *fakeChildExit) Transparent() bool { return true }
func (e *fakeChildExit) IsSignaled() bool { return e.signaled }
func (e *fakeChildExit) ScrubbedEnvCount() int { return e.scrubbed }
func withVerbosity(t *testing.T, level VerbosityLevel) {
t.Helper()
@@ -51,6 +55,44 @@ func TestClassifyExit(t *testing.T) {
assert.True(t, d.notice)
})
t.Run("scrubbed env vars append a dim hint line", func(t *testing.T) {
withVerbosity(t, VerbosityLevelNormal)
d := classifyExit(&fakeChildExit{code: 1, pmName: "npm", scrubbed: 3})
assert.True(t, d.notice)
assert.Contains(t, d.message, "↳ pmg: npm exited with code 1\n")
assert.Contains(t, d.message, "sandbox scrubbed 3 env var(s)")
assert.Contains(t, d.message, "--sandbox-allow env=NAME")
})
t.Run("zero scrubbed env vars keep the notice to one line", func(t *testing.T) {
withVerbosity(t, VerbosityLevelNormal)
d := classifyExit(&fakeChildExit{code: 1, pmName: "npm"})
assert.NotContains(t, d.message, "scrubbed")
assert.NotContains(t, d.message, "\n")
})
t.Run("scrubbed hint is suppressed on signal exits", func(t *testing.T) {
withVerbosity(t, VerbosityLevelNormal)
d := classifyExit(&fakeChildExit{code: 130, signaled: true, pmName: "npm", scrubbed: 3})
assert.False(t, d.notice)
assert.Empty(t, d.message)
})
t.Run("scrubbed hint is suppressed in silent mode", func(t *testing.T) {
withVerbosity(t, VerbosityLevelSilent)
d := classifyExit(&fakeChildExit{code: 1, pmName: "npm", scrubbed: 3})
assert.False(t, d.notice)
assert.Empty(t, d.message)
})
t.Run("signal termination is silent but still mirrors the code", func(t *testing.T) {
withVerbosity(t, VerbosityLevelNormal)
+44 -1
View File
@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/dry/usefulerror"
@@ -15,6 +16,7 @@ import (
"github.com/safedep/pmg/internal/audit"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
"github.com/safedep/pmg/sandbox/util"
)
type applySandboxConfig struct {
@@ -74,7 +76,7 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
// This is to avoid running the command without sandbox protection.
// To bypass sandbox for a specific package manager, users should explicitly
// disable for the package manager in the config.
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
policyRef, exists := cfg.Config.Sandbox.PolicyFor(pmName)
if !exists {
return nil, usefulerror.NewUsefulError().
WithCode(errcodes.NotFound).
@@ -164,11 +166,19 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
log.Debugf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
// Scrub sensitive environment variables before the child is spawned. This
// runs after overlay and runtime overrides are merged into the policy so
// user allowances are honored, and is platform-independent (it filters the
// env slice regardless of the OS sandbox driver).
scrubbed := scrubEnv(cmd, policy)
result, err := sb.Execute(ctx, cmd, policy)
if err != nil {
return nil, fmt.Errorf("failed to setup sandbox: %w", err)
}
result.SetScrubbedEnvCount(scrubbed)
return result, nil
}
@@ -205,10 +215,43 @@ func applyRuntimeOverrides(policy *sandbox.SandboxPolicy, overrides []config.San
// Enable AllowNetworkBind so the translator emits bind rules.
// Without this, AllowBind entries would be ignored on some platforms.
policy.AllowNetworkBind = utils.PtrTo(true)
case config.SandboxAllowEnv:
// Allow-wins: appending to Allow un-scrubs the variable regardless
// of whether it was denied by the built-in list or a profile deny
// glob, so (unlike the filesystem cases) there is no deny list to
// remove an exact match from.
log.Infof("Sandbox override: allowing environment variable %s", override.Value)
policy.Environment.Allow = append(policy.Environment.Allow, override.Value)
}
}
}
// scrubEnv removes sensitive environment variables from cmd.Env per the
// resolved policy's environment section and returns how many were removed.
// It runs after project overlay and runtime overrides are merged into the
// policy, so user allowances take effect. A nil cmd.Env would mean "inherit
// the parent environment", which would defeat scrubbing, so it is populated
// from os.Environ() first.
func scrubEnv(cmd *exec.Cmd, policy *sandbox.SandboxPolicy) int {
if cmd.Env == nil {
cmd.Env = os.Environ()
}
result := util.ScrubEnv(cmd.Env, util.EnvScrubOptions{
Allow: policy.Environment.Allow,
Deny: policy.Environment.Deny,
})
cmd.Env = result.Env
if len(result.Removed) > 0 {
log.Infof("Sandbox: scrubbed %d sensitive environment variable(s) from %s: %s",
len(result.Removed), policy.Name, strings.Join(result.Removed, ", "))
}
return len(result.Removed)
}
// removeExactMatch removes entries from the slice that exactly match the given value.
// Glob patterns and wildcards in the slice are never matched. Only literal string
// equality is used. This keeps broad deny rules intact while allowing targeted overrides.
+69 -1
View File
@@ -2,6 +2,7 @@ package executor
import (
"os"
"os/exec"
"path/filepath"
"testing"
@@ -57,6 +58,74 @@ func TestApplyRuntimeOverrides_Exec(t *testing.T) {
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/curl")
}
func TestApplyRuntimeOverrides_Env(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Environment: sandbox.EnvironmentPolicy{
Allow: []string{"NPM_TOKEN"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowEnv, Value: "AWS_PROFILE", Raw: "env=AWS_PROFILE"},
})
assert.Contains(t, policy.Environment.Allow, "NPM_TOKEN")
assert.Contains(t, policy.Environment.Allow, "AWS_PROFILE")
}
func TestScrubEnv_RemovesDeniedKeepsAllowed(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Environment: sandbox.EnvironmentPolicy{
Allow: []string{"NPM_TOKEN"},
},
}
cmd := &exec.Cmd{Env: []string{
"PATH=/usr/bin",
"NPM_TOKEN=keep-me",
"AWS_SECRET_ACCESS_KEY=scrub-me",
"GITHUB_TOKEN=scrub-me-too",
}}
scrubbed := scrubEnv(cmd, policy)
assert.Equal(t, 2, scrubbed)
assert.Contains(t, cmd.Env, "PATH=/usr/bin")
assert.Contains(t, cmd.Env, "NPM_TOKEN=keep-me")
assert.NotContains(t, cmd.Env, "AWS_SECRET_ACCESS_KEY=scrub-me")
assert.NotContains(t, cmd.Env, "GITHUB_TOKEN=scrub-me-too")
}
func TestScrubEnv_AllowOverrideUnscrubs(t *testing.T) {
policy := &sandbox.SandboxPolicy{Name: "test"}
// Simulate a --sandbox-allow env=AWS_SESSION_TOKEN override having been merged.
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowEnv, Value: "AWS_SESSION_TOKEN", Raw: "env=AWS_SESSION_TOKEN"},
})
cmd := &exec.Cmd{Env: []string{"AWS_SESSION_TOKEN=kept"}}
scrubbed := scrubEnv(cmd, policy)
assert.Equal(t, 0, scrubbed)
assert.Contains(t, cmd.Env, "AWS_SESSION_TOKEN=kept")
}
func TestScrubEnv_NilEnvPopulatedThenScrubbed(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "should-be-scrubbed")
t.Setenv("PMG_ENV_SCRUB_MARKER", "kept")
policy := &sandbox.SandboxPolicy{Name: "test"}
cmd := &exec.Cmd{Env: nil}
scrubEnv(cmd, policy)
require.NotNil(t, cmd.Env)
assert.Contains(t, cmd.Env, "PMG_ENV_SCRUB_MARKER=kept")
assert.NotContains(t, cmd.Env, "GITHUB_TOKEN=should-be-scrubbed")
}
func TestApplyRuntimeOverrides_NetConnect(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Network: sandbox.NetworkPolicy{
@@ -250,7 +319,6 @@ func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testi
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
}
func TestApplyProjectOverlayAppendsEntries(t *testing.T) {
dir := t.TempDir()
repo := "/repo/example"
+20 -4
View File
@@ -19,9 +19,10 @@ type SandboxPolicy struct {
// These fields are affected by inheritance and are merged with the parent policy.
// Any new values added here should be handled in the MergeWithParent method.
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
Network NetworkPolicy `yaml:"network" json:"network"`
Process ProcessPolicy `yaml:"process" json:"process"`
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
Network NetworkPolicy `yaml:"network" json:"network"`
Process ProcessPolicy `yaml:"process" json:"process"`
Environment EnvironmentPolicy `yaml:"environment" json:"environment"`
// The boolean fields are pointers to allow for nil values so that the YAML parser
// can set the values from the child policy if present. We can differentiate between
@@ -63,6 +64,15 @@ type ProcessPolicy struct {
DenyExec []string `yaml:"deny_exec" json:"deny_exec"`
}
// EnvironmentPolicy controls which environment variables are scrubbed from the
// child process. Deny extends the built-in util.DANGEROUS_ENV_VARS list; Allow
// suppresses matching denies (allow wins). Patterns are case-insensitive name
// globs. Enforcement happens in sandbox/util.ScrubEnv at process spawn.
type EnvironmentPolicy struct {
Allow []string `yaml:"allow" json:"allow"`
Deny []string `yaml:"deny" json:"deny"`
}
// Validate validates the sandbox policy for correctness before inheritance resolution.
// Returns an error if the policy is invalid.
// Note: Validation for "at least one rule" check is deferred to ValidateResolved(),
@@ -94,7 +104,9 @@ func (p *SandboxPolicy) ValidateResolved() error {
len(p.Network.AllowOutbound) > 0 ||
len(p.Network.DenyOutbound) > 0 ||
len(p.Process.AllowExec) > 0 ||
len(p.Process.DenyExec) > 0
len(p.Process.DenyExec) > 0 ||
len(p.Environment.Allow) > 0 ||
len(p.Environment.Deny) > 0
if !hasRules {
return fmt.Errorf("policy must define at least one access rule (after inheritance resolution)")
@@ -134,6 +146,10 @@ func (child *SandboxPolicy) MergeWithParent(parent *SandboxPolicy) {
child.Process.AllowExec = unionStringSlices(parent.Process.AllowExec, child.Process.AllowExec)
child.Process.DenyExec = unionStringSlices(parent.Process.DenyExec, child.Process.DenyExec)
// Union environment lists
child.Environment.Allow = unionStringSlices(parent.Environment.Allow, child.Environment.Allow)
child.Environment.Deny = unionStringSlices(parent.Environment.Deny, child.Environment.Deny)
// Set boolean fields by duplicating the parent value if not present in the child.
if child.AllowPTY == nil {
child.AllowPTY = utils.PtrTo(utils.SafelyGetValue(parent.AllowPTY))
+55
View File
@@ -0,0 +1,55 @@
package sandbox
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMergeWithParent_Environment(t *testing.T) {
parent := &SandboxPolicy{
Environment: EnvironmentPolicy{
Allow: []string{"NPM_TOKEN"},
Deny: []string{"PARENT_SECRET"},
},
}
child := &SandboxPolicy{
Environment: EnvironmentPolicy{
Allow: []string{"NODE_AUTH_TOKEN"},
Deny: []string{"CHILD_SECRET"},
},
}
child.MergeWithParent(parent)
assert.Equal(t, []string{"NPM_TOKEN", "NODE_AUTH_TOKEN"}, child.Environment.Allow)
assert.Equal(t, []string{"PARENT_SECRET", "CHILD_SECRET"}, child.Environment.Deny)
}
// An environment-only policy is a valid policy: EnvironmentPolicy is an
// enforceable section, so it counts toward the "at least one access rule"
// check.
func TestValidateResolved_EnvironmentOnlyPolicy(t *testing.T) {
p := &SandboxPolicy{
Name: "env-only",
PackageManagers: []string{"npm"},
Environment: EnvironmentPolicy{Deny: []string{"*_TOKEN"}},
}
assert.NoError(t, p.ValidateResolved())
}
func TestResolveProfile_DeepCopiesEnvironment(t *testing.T) {
r, err := newDefaultProfileRegistry()
assert.NoError(t, err)
resolved, err := r.ResolveProfile("npm", ResolveOptions{})
assert.NoError(t, err)
// Mutating the resolved copy must not corrupt the registry-cached policy.
resolved.Environment.Allow = append(resolved.Environment.Allow, "MUTATED")
again, err := r.ResolveProfile("npm", ResolveOptions{})
assert.NoError(t, err)
assert.NotContains(t, again.Environment.Allow, "MUTATED")
}
+19
View File
@@ -0,0 +1,19 @@
name: bun
description: Profile for bun, extending npm-restrictive with bun environment variables
inherits: npm-restrictive
package_managers:
- bun
environment:
# Bun authenticates via BUN_AUTH_TOKEN and also reads .npmrc with env
# interpolation and npm_config_* conventions. Sibling tokens
# (YARN_NPM_AUTH_*) stay scrubbed.
allow:
- BUN_AUTH_TOKEN
- NPM_TOKEN
- NPM_AUTH_TOKEN
- NODE_AUTH_TOKEN
- npm_config_*
- NPM_CONFIG_*
- NODE_EXTRA_CA_CERTS
+8
View File
@@ -93,6 +93,14 @@ network:
deny_outbound:
- "*:*"
environment:
# This profile is the shared base for the npm ecosystem and deliberately
# allows no environment variables: everything in the built-in
# DANGEROUS_ENV_VARS list is scrubbed. Each package manager's leaf profile
# (npm, yarn, bun, pnpm, npx) re-allows only the variables that
# package manager needs for auth, registry config, and TLS.
allow: []
process:
allow_exec:
- /usr/bin/node
+21
View File
@@ -0,0 +1,21 @@
name: npm
description: Profile for npm, extending npm-restrictive with npm environment variables
inherits: npm-restrictive
package_managers:
- npm
environment:
# The npm-restrictive base allows no environment variables. Re-allow only
# what npm needs for auth, registry config, and TLS.
#
# Accepted trade-off: a malicious JS package executed during install can read
# the npm publishing token below, but NOT yarn/bun tokens, PyPI tokens, AWS
# keys, or other cloud/secret-manager credentials, which remain scrubbed.
allow:
- NPM_TOKEN
- NPM_AUTH_TOKEN
- NODE_AUTH_TOKEN
- npm_config_*
- NPM_CONFIG_*
- NODE_EXTRA_CA_CERTS
+13
View File
@@ -16,6 +16,19 @@ allow_pty: true
# npx generators and dev servers frequently need to bind to localhost ports
allow_network_bind: true
environment:
# The npm-restrictive base allows no environment variables. npx and pnpx
# execute npm-ecosystem packages and use the npm auth and config
# conventions. Sibling tokens (YARN_NPM_AUTH_*, BUN_AUTH_TOKEN) stay
# scrubbed.
allow:
- NPM_TOKEN
- NPM_AUTH_TOKEN
- NODE_AUTH_TOKEN
- npm_config_*
- NPM_CONFIG_*
- NODE_EXTRA_CA_CERTS
filesystem:
# Add write permissions for common generator outputs
allow_write:
+15
View File
@@ -0,0 +1,15 @@
name: pip
description: Profile for pip and pip3, extending pypi-restrictive with pip environment variables
inherits: pypi-restrictive
package_managers:
- pip
- pip3
environment:
# The pypi-restrictive base allows no environment variables. pip needs its
# own config namespace for index auth, mirrors, and TLS (e.g. PIP_INDEX_URL,
# PIP_CERT). Sibling tool credentials (UV_PUBLISH_TOKEN, POETRY_*) and
# TWINE_* stay scrubbed.
allow:
- PIP_*
+22 -1
View File
@@ -15,17 +15,38 @@ allow_pty: true
# pipx-executed tools may need to bind to localhost ports (e.g., dev servers)
allow_network_bind: true
environment:
# The pypi-restrictive base allows no environment variables. pipx delegates
# to pip inside its venvs, so it needs the pip config namespace for index
# auth and TLS. Sibling tool credentials (UV_PUBLISH_TOKEN, POETRY_*) and
# TWINE_* stay scrubbed.
allow:
- PIP_*
filesystem:
allow_read:
# pipx installs and manages packages in ~/.local/pipx
# pipx venv homes: ~/.local/pipx is the legacy default. pipx >= 1.5
# defaults PIPX_HOME to platformdirs locations when the legacy dir does
# not exist: ~/.local/share/pipx on Linux, ~/Library/Application Support/pipx
# on macOS.
- ${HOME}/.local/pipx/**
- ${HOME}/.local/share/pipx/**
- ${HOME}/Library/Application Support/pipx/**
- ${HOME}/.local/bin/**
# pipx run caches ephemeral venvs here
- ${HOME}/.cache/pipx/**
- ${HOME}/Library/Caches/pipx/**
# Add write permissions for pipx-specific paths
allow_write:
- ${CWD}/**
- ${HOME}/.local/pipx/**
- ${HOME}/.local/share/pipx/**
- ${HOME}/Library/Application Support/pipx/**
- ${HOME}/.local/bin/**
- ${HOME}/.cache/pipx/**
- ${HOME}/Library/Caches/pipx/**
# Additional deny rules for extra security
deny_write:
@@ -1,10 +1,23 @@
name: pnpm-restrictive
description: Profile for pnpm with write access to current directory
name: pnpm
description: Profile for pnpm, extending npm-restrictive with pnpm write paths and environment variables
inherits: npm-restrictive
package_managers:
- pnpm
environment:
# The npm-restrictive base allows no environment variables. pnpm uses the
# npm auth and config conventions (.npmrc with env interpolation,
# npm_config_*). Sibling tokens (YARN_NPM_AUTH_*, BUN_AUTH_TOKEN) stay
# scrubbed.
allow:
- NPM_TOKEN
- NPM_AUTH_TOKEN
- NODE_AUTH_TOKEN
- npm_config_*
- NPM_CONFIG_*
- NODE_EXTRA_CA_CERTS
filesystem:
allow_write:
# pnpm needs write access here
+17
View File
@@ -0,0 +1,17 @@
name: poetry
description: Profile for poetry, extending pypi-restrictive with poetry environment variables
inherits: pypi-restrictive
package_managers:
- poetry
environment:
# The pypi-restrictive base allows no environment variables. poetry needs
# its own config namespace and can delegate to pip. Sibling tool credentials
# (UV_PUBLISH_TOKEN) and TWINE_* stay scrubbed.
#
# Accepted trade-off: POETRY_* re-allows POETRY_PYPI_TOKEN_PYPI and
# POETRY_HTTP_BASIC_PYPI_PASSWORD, poetry's own publishing credentials.
allow:
- POETRY_*
- PIP_*
+10
View File
@@ -3,6 +3,7 @@ description: Restrictive sandbox policy for PyPI ecosystem (pip, poetry, uv)
package_managers:
- pip
- pip3
- pipx
- poetry
- uv
@@ -71,6 +72,15 @@ network:
deny_outbound:
- "*:*"
environment:
# This profile is the shared base for the PyPI ecosystem and deliberately
# allows no environment variables: everything in the built-in
# DANGEROUS_ENV_VARS list is scrubbed. Each package manager's leaf profile
# (pip, uv, poetry) re-allows only the variables that package manager needs.
# TWINE_* is allowed nowhere: twine is not a package manager PMG wraps, so
# its publishing credentials stay scrubbed during installs.
allow: []
process:
allow_exec:
- /usr/bin/python*
+17
View File
@@ -0,0 +1,17 @@
name: uv
description: Profile for uv, extending pypi-restrictive with uv environment variables
inherits: pypi-restrictive
package_managers:
- uv
environment:
# The pypi-restrictive base allows no environment variables. uv needs its
# own config namespace and honors pip env conventions via the uv pip
# interface. Sibling tool credentials (POETRY_*) and TWINE_* stay scrubbed.
#
# Accepted trade-off: UV_* re-allows UV_PUBLISH_TOKEN, uv's own publishing
# credential.
allow:
- UV_*
- PIP_*
+20
View File
@@ -0,0 +1,20 @@
name: yarn
description: Profile for yarn, extending npm-restrictive with yarn environment variables
inherits: npm-restrictive
package_managers:
- yarn
environment:
# Yarn berry authenticates via YARN_NPM_AUTH_*. Yarn classic reads .npmrc
# with env interpolation and npm_config_* conventions, so the shared npm
# auth set is also needed. Sibling tokens (BUN_AUTH_TOKEN) stay scrubbed.
allow:
- YARN_NPM_AUTH_TOKEN
- YARN_NPM_AUTH_IDENT
- NPM_TOKEN
- NPM_AUTH_TOKEN
- NODE_AUTH_TOKEN
- npm_config_*
- NPM_CONFIG_*
- NODE_EXTRA_CA_CERTS
+80
View File
@@ -0,0 +1,80 @@
package sandbox
import (
"strings"
"testing"
"github.com/safedep/pmg/sandbox/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestProfileEnvContract pins the env protection contract as a regression
// test: npm-restrictive and pypi-restrictive are pure bases that allow
// nothing, each package manager's leaf profile re-allows only its own auth
// variables (plus the shared config conventions of its ecosystem), and
// sibling tokens stay scrubbed alongside other ecosystems' and cloud
// credentials. Every probe entry is on the built-in deny list, so anything
// not explicitly expected as kept must be scrubbed. This catches accidental
// over-broad environment.allow entries.
func TestProfileEnvContract(t *testing.T) {
r, err := newDefaultProfileRegistry()
require.NoError(t, err)
env := []string{
"NPM_TOKEN=x",
"NODE_AUTH_TOKEN=x",
"YARN_NPM_AUTH_TOKEN=x",
"BUN_AUTH_TOKEN=x",
"TWINE_PASSWORD=x",
"UV_PUBLISH_TOKEN=x",
"POETRY_PYPI_TOKEN_PYPI=x",
"AWS_SECRET_ACCESS_KEY=x",
"GITHUB_TOKEN=x",
"OP_SERVICE_ACCOUNT_TOKEN=x",
"CLOUDFLARE_API_TOKEN=x",
}
tests := []struct {
profile string
wantKept []string
}{
{profile: "npm-restrictive", wantKept: []string{}},
{profile: "pypi-restrictive", wantKept: []string{}},
{profile: "npm", wantKept: []string{"NPM_TOKEN", "NODE_AUTH_TOKEN"}},
{profile: "yarn", wantKept: []string{"NPM_TOKEN", "NODE_AUTH_TOKEN", "YARN_NPM_AUTH_TOKEN"}},
{profile: "bun", wantKept: []string{"NPM_TOKEN", "NODE_AUTH_TOKEN", "BUN_AUTH_TOKEN"}},
{profile: "pnpm", wantKept: []string{"NPM_TOKEN", "NODE_AUTH_TOKEN"}},
{profile: "npx", wantKept: []string{"NPM_TOKEN", "NODE_AUTH_TOKEN"}},
{profile: "pip", wantKept: []string{}},
{profile: "pipx", wantKept: []string{}},
{profile: "uv", wantKept: []string{"UV_PUBLISH_TOKEN"}},
{profile: "poetry", wantKept: []string{"POETRY_PYPI_TOKEN_PYPI"}},
}
for _, tt := range tests {
t.Run(tt.profile, func(t *testing.T) {
policy, err := r.ResolveProfile(tt.profile, ResolveOptions{})
require.NoError(t, err)
result := util.ScrubEnv(env, util.EnvScrubOptions{
Allow: policy.Environment.Allow,
Deny: policy.Environment.Deny,
})
kept := map[string]bool{}
for _, name := range tt.wantKept {
kept[name] = true
}
for _, entry := range env {
name, _, _ := strings.Cut(entry, "=")
if kept[name] {
assert.Contains(t, result.Env, entry, "%s should keep %s", tt.profile, name)
} else {
assert.Contains(t, result.Removed, name, "%s should scrub %s", tt.profile, name)
}
}
})
}
}
+7
View File
@@ -81,6 +81,13 @@ func expandPolicyPaths(p *SandboxPolicy, opts ResolveOptions) (*SandboxPolicy, e
AllowBind: append([]string(nil), p.Network.AllowBind...),
}
// Environment entries are variable-name globs, not paths, so they are
// deep-copied without expansion so the caller can safely mutate the result.
out.Environment = EnvironmentPolicy{
Allow: append([]string(nil), p.Environment.Allow...),
Deny: append([]string(nil), p.Environment.Deny...),
}
out.PackageManagers = append([]string(nil), p.PackageManagers...)
return &out, nil
+20 -2
View File
@@ -57,8 +57,9 @@ type violationReporter interface {
// additional metadata (e.g., exit codes, resource usage, violation events).
// Callers must call Close() after cmd.Run() completes to clean up resources.
type ExecutionResult struct {
executed bool
sandbox Sandbox
executed bool
sandbox Sandbox
scrubbedEnvCount int
}
// ExecutionResultOpt is a function that can be used to configure an ExecutionResult.
@@ -93,6 +94,23 @@ func (r *ExecutionResult) ShouldRun() bool {
return !r.executed
}
// SetScrubbedEnvCount records how many environment variables were scrubbed
// from the child process per the resolved environment policy.
func (r *ExecutionResult) SetScrubbedEnvCount(count int) {
r.scrubbedEnvCount = count
}
// ScrubbedEnvCount returns how many environment variables were scrubbed from
// the child process. Used to hint at scrubbing as a possible cause when the
// child fails.
func (r *ExecutionResult) ScrubbedEnvCount() int {
if r == nil {
return 0
}
return r.scrubbedEnvCount
}
// BestEffortViolation returns sandbox-specific best-effort violation details.
// Implementations may use platform logs or other weak signals, so callers
// should treat the result as advisory.
+20
View File
@@ -0,0 +1,20 @@
package sandbox
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExecutionResultScrubbedEnvCount(t *testing.T) {
r := NewExecutionResult()
assert.Equal(t, 0, r.ScrubbedEnvCount())
r.SetScrubbedEnvCount(3)
assert.Equal(t, 3, r.ScrubbedEnvCount())
}
func TestExecutionResultScrubbedEnvCountNilReceiver(t *testing.T) {
var r *ExecutionResult
assert.Equal(t, 0, r.ScrubbedEnvCount())
}
+156
View File
@@ -24,6 +24,162 @@ var DANGEROUS_FILES = []string{
".config/gh",
}
// DANGEROUS_ENV_VARS are credential-bearing environment variables scrubbed from
// the child process by default when the sandbox is enabled (see ScrubEnv). This
// is an explicit, curated list of known secret names. There are deliberately
// no generic "*_TOKEN" / "*_SECRET" catch-alls here, because broad wildcards in
// the default would risk clipping legitimate build variables for every user.
// The matcher (ScrubEnv) does support glob patterns, so users who want broader
// coverage opt into it per profile via environment.deny.
//
// Matching is case-insensitive (see ScrubEnv). A package manager's own
// publishing token is intentionally left scrubbable here and re-allowed per
// ecosystem in the profile's environment.allow (e.g. npm re-allows NPM_TOKEN).
var DANGEROUS_ENV_VARS = []string{
// Cloud providers
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_SECURITY_TOKEN",
"AZURE_CLIENT_SECRET",
"AZURE_CLIENT_ID",
"AZURE_TENANT_ID",
"ARM_CLIENT_SECRET",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CREDENTIALS",
"GOOGLE_OAUTH_ACCESS_TOKEN",
"GCP_SERVICE_ACCOUNT_KEY",
"CLOUDSDK_AUTH_ACCESS_TOKEN",
"DIGITALOCEAN_ACCESS_TOKEN",
"DIGITALOCEAN_TOKEN",
"CLOUDFLARE_API_TOKEN",
"CLOUDFLARE_API_KEY",
"HEROKU_API_KEY",
"FLY_API_TOKEN",
"RAILWAY_TOKEN",
"VERCEL_TOKEN",
"NETLIFY_AUTH_TOKEN",
// Package registry / publishing tokens
"NPM_TOKEN",
"NPM_AUTH_TOKEN",
"NODE_AUTH_TOKEN",
"NPM_CONFIG__AUTH",
"YARN_NPM_AUTH_TOKEN",
"YARN_NPM_AUTH_IDENT",
"BUN_AUTH_TOKEN",
"TWINE_USERNAME",
"TWINE_PASSWORD",
"PYPI_TOKEN",
"UV_PUBLISH_TOKEN",
"FLIT_PASSWORD",
"POETRY_PYPI_TOKEN_PYPI",
"POETRY_HTTP_BASIC_PYPI_PASSWORD",
"ANACONDA_API_TOKEN",
"GEM_HOST_API_KEY",
"RUBYGEMS_API_KEY",
"CARGO_REGISTRY_TOKEN",
"COMPOSER_AUTH",
"HEX_API_KEY",
"NUGET_API_KEY",
"CONAN_LOGIN_PASSWORD",
"CONAN_PASSWORD",
"DENO_AUTH_TOKENS",
"EXPO_TOKEN",
"JFROG_ACCESS_TOKEN",
"ARTIFACTORY_ACCESS_TOKEN",
"ARTIFACTORY_API_KEY",
"ARTIFACTORY_PASSWORD",
// VCS / CI
"GITHUB_TOKEN",
"GH_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITLAB_TOKEN",
"CI_JOB_TOKEN",
"CIRCLE_TOKEN",
"BUILDKITE_AGENT_TOKEN",
"BUILDKITE_API_TOKEN",
"AZURE_DEVOPS_EXT_PAT",
"SYSTEM_ACCESSTOKEN",
// Secrets managers
"VAULT_TOKEN",
"CONSUL_HTTP_TOKEN",
"NOMAD_TOKEN",
"OP_SERVICE_ACCOUNT_TOKEN",
"OP_CONNECT_TOKEN",
"BW_SESSION",
"BWS_ACCESS_TOKEN",
"DOPPLER_TOKEN",
"INFISICAL_TOKEN",
// Infrastructure as code
"TFE_TOKEN",
"TF_API_TOKEN",
"PULUMI_ACCESS_TOKEN",
// Misc high-value
"DOCKER_PASSWORD",
"DOCKER_AUTH_CONFIG",
"SNYK_TOKEN",
"CODECOV_TOKEN",
"SONAR_TOKEN",
"SENTRY_AUTH_TOKEN",
"DATADOG_API_KEY",
"DD_API_KEY",
"DD_APP_KEY",
"NEW_RELIC_API_KEY",
"SLACK_BOT_TOKEN",
"STRIPE_SECRET_KEY",
"STRIPE_API_KEY",
"TWILIO_AUTH_TOKEN",
"SENDGRID_API_KEY",
"FIREBASE_TOKEN",
"SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_ACCESS_TOKEN",
// AI providers
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"AZURE_OPENAI_API_KEY",
"HUGGING_FACE_HUB_TOKEN",
"HF_TOKEN",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"COHERE_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"XAI_API_KEY",
}
// ProtectedEnvVars are core process variables never scrubbed, regardless of
// deny patterns. They are matched case-insensitively as globs (see ScrubEnv),
// so "LC_*" covers the whole locale family. This is a safety net so that a
// profile opting into a broad deny glob (e.g. "*_TOKEN") cannot break process
// startup. The built-in DANGEROUS_ENV_VARS list never touches these names.
var ProtectedEnvVars = []string{
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"PWD",
"OLDPWD",
"TERM",
"TMPDIR",
"TEMP",
"TMP",
"LANG",
"LC_*",
"TZ",
"DISPLAY",
"HOSTNAME",
"NODE_ENV",
}
// MandatoryDenyOptions configures GetMandatoryDenyPatterns. AllowRead and
// AllowWrite must be already expanded (post-ExpandVariables); the function
// does not call ExpandVariables itself.
+108
View File
@@ -0,0 +1,108 @@
package util
import (
"regexp"
"strings"
"sync"
)
// EnvScrubOptions configures ScrubEnv. Allow and Deny are variable-name glob
// patterns sourced from the resolved sandbox policy's environment section
// (already merged with inheritance, project overlay, and --sandbox-allow env=
// overrides by the caller). Deny extends the built-in DANGEROUS_ENV_VARS;
// Allow suppresses any matching deny (allow wins).
type EnvScrubOptions struct {
Allow []string
Deny []string
}
// EnvScrubResult is the outcome of ScrubEnv. Env holds the kept "KEY=VALUE"
// entries; Removed holds the NAMES (never values) of scrubbed variables, for
// audit logging.
type EnvScrubResult struct {
Env []string
Removed []string
}
// ScrubEnv removes sensitive variables from env. A variable is removed iff its
// name matches the effective deny set (built-in DANGEROUS_ENV_VARS plus
// opts.Deny) AND does not match opts.Allow AND is not a ProtectedEnvVars entry.
// Matching is on the variable name (left of the first '=') and is
// case-insensitive glob (see GlobToRegex). Removal (not blanking) is
// intentional: absence is the cleanest "not set" signal for downstream tools.
func ScrubEnv(env []string, opts EnvScrubOptions) EnvScrubResult {
deny := make([]string, 0, len(DANGEROUS_ENV_VARS)+len(opts.Deny))
deny = append(deny, DANGEROUS_ENV_VARS...)
deny = append(deny, opts.Deny...)
kept := make([]string, 0, len(env))
var removed []string
for _, entry := range env {
name := entry
if i := strings.IndexByte(entry, '='); i >= 0 {
name = entry[:i]
}
if shouldScrubEnvVar(name, deny, opts.Allow) {
removed = append(removed, name)
continue
}
kept = append(kept, entry)
}
return EnvScrubResult{Env: kept, Removed: removed}
}
// shouldScrubEnvVar reports whether a variable named name should be removed.
// Protected variables and allow matches are kept; otherwise a deny match
// scrubs. Allow wins over deny by construction (checked first).
func shouldScrubEnvVar(name string, deny, allow []string) bool {
if matchAnyEnvPattern(name, ProtectedEnvVars) {
return false
}
if matchAnyEnvPattern(name, allow) {
return false
}
return matchAnyEnvPattern(name, deny)
}
func matchAnyEnvPattern(name string, patterns []string) bool {
for _, pattern := range patterns {
if envNameRegex(pattern).MatchString(name) {
return true
}
}
return false
}
var (
envRegexMu sync.Mutex
envRegexCache = map[string]*regexp.Regexp{}
)
// envNameRegex compiles pattern into a case-insensitive anchored regex for
// matching environment variable names, caching the result. GlobToRegex escapes
// all regex specials, so compilation does not fail in practice; on the
// unexpected error we fall back to a literal case-insensitive name match so a
// deny pattern is never silently dropped.
func envNameRegex(pattern string) *regexp.Regexp {
envRegexMu.Lock()
defer envRegexMu.Unlock()
if re, ok := envRegexCache[pattern]; ok {
return re
}
re, err := regexp.Compile("(?i)" + GlobToRegex(pattern))
if err != nil {
re = regexp.MustCompile("(?i)^" + regexp.QuoteMeta(pattern) + "$")
}
envRegexCache[pattern] = re
return re
}
+96
View File
@@ -0,0 +1,96 @@
package util
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScrubEnv(t *testing.T) {
tests := []struct {
name string
env []string
opts EnvScrubOptions
wantKept []string
wantRemoved []string
}{
{
name: "built-in deny scrubs known secret",
env: []string{"PATH=/usr/bin", "AWS_SECRET_ACCESS_KEY=abc"},
wantKept: []string{"PATH=/usr/bin"},
wantRemoved: []string{"AWS_SECRET_ACCESS_KEY"},
},
{
name: "case-insensitive match",
env: []string{"aws_secret_access_key=abc"},
wantKept: []string{},
wantRemoved: []string{"aws_secret_access_key"},
},
{
name: "allow suppresses built-in deny",
env: []string{"NPM_TOKEN=secret"},
opts: EnvScrubOptions{Allow: []string{"NPM_TOKEN"}},
wantKept: []string{"NPM_TOKEN=secret"},
wantRemoved: nil,
},
{
name: "profile deny glob scrubs",
env: []string{"MY_CUSTOM_TOKEN=x", "OTHER=y"},
opts: EnvScrubOptions{Deny: []string{"*_TOKEN"}},
wantKept: []string{"OTHER=y"},
wantRemoved: []string{"MY_CUSTOM_TOKEN"},
},
{
name: "allow glob wins over profile deny glob",
env: []string{"npm_config_registry=x", "npm_config_token=y"},
opts: EnvScrubOptions{Deny: []string{"*_TOKEN", "npm_config_*"}, Allow: []string{"npm_config_*"}},
wantKept: []string{"npm_config_registry=x", "npm_config_token=y"},
wantRemoved: nil,
},
{
name: "protected essential never scrubbed even under broad deny",
env: []string{"PATH=/usr/bin", "HOME=/home/u", "LC_ALL=en_US.UTF-8"},
opts: EnvScrubOptions{Deny: []string{"*"}},
wantKept: []string{"PATH=/usr/bin", "HOME=/home/u", "LC_ALL=en_US.UTF-8"},
wantRemoved: nil,
},
{
name: "non-sensitive variables are kept",
env: []string{"FOO=bar", "EDITOR=vim"},
wantKept: []string{"FOO=bar", "EDITOR=vim"},
wantRemoved: nil,
},
{
name: "entry without equals is treated as a name",
env: []string{"GITHUB_TOKEN", "PLAINNAME"},
wantKept: []string{"PLAINNAME"},
wantRemoved: []string{"GITHUB_TOKEN"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScrubEnv(tt.env, tt.opts)
assert.Equal(t, tt.wantKept, got.Env)
assert.Equal(t, tt.wantRemoved, got.Removed)
})
}
}
func TestScrubEnv_RemovesEntirelyNotBlanked(t *testing.T) {
got := ScrubEnv([]string{"GITHUB_TOKEN=secret"}, EnvScrubOptions{})
require.Empty(t, got.Env)
assert.NotContains(t, got.Env, "GITHUB_TOKEN=")
assert.Equal(t, []string{"GITHUB_TOKEN"}, got.Removed)
}
func TestScrubEnv_NoCatchAllsInBuiltinList(t *testing.T) {
// A novel token name must NOT be scrubbed by the default list (catch-alls
// are opt-in per profile, never built in).
got := ScrubEnv([]string{"SOME_RANDOM_TOKEN=x"}, EnvScrubOptions{})
assert.Equal(t, []string{"SOME_RANDOM_TOKEN=x"}, got.Env)
assert.Empty(t, got.Removed)
}
+70
View File
@@ -327,6 +327,76 @@ test('ALLOW: Network DNS resolution', () => {
}
});
// ============================================
// ENVIRONMENT PROTECTION (ENV SCRUBBING)
// ============================================
// The sandbox scrubs deny-listed credential env vars from the child process
// (see sandbox/util/dangerous.go) while the npm leaf profile re-allows npm's
// own auth vars. CI seeds canary values before invoking pmg and sets
// E2E_ENV_SEEDED=1. Without seeding (e.g. a plain local run), scrub tests
// still assert absence and keep tests are skipped. To run fully seeded
// locally:
//
// E2E_ENV_SEEDED=1 GITHUB_TOKEN=pmg-e2e-canary gh_token=pmg-e2e-canary \
// AWS_SECRET_ACCESS_KEY=pmg-e2e-canary OP_SERVICE_ACCOUNT_TOKEN=pmg-e2e-canary \
// CLOUDFLARE_API_TOKEN=pmg-e2e-canary TWINE_PASSWORD=pmg-e2e-canary \
// NPM_TOKEN=pmg-e2e-keep NODE_AUTH_TOKEN=pmg-e2e-keep \
// pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js
console.log('\n--- Environment protection tests ---\n');
const envSeeded = process.env.E2E_ENV_SEEDED === '1';
// Deny-listed credential variables that must never reach the sandboxed child.
// gh_token (lowercase) pins case-insensitive matching.
const scrubbedVars = [
'GITHUB_TOKEN',
'gh_token',
'AWS_SECRET_ACCESS_KEY',
'OP_SERVICE_ACCOUNT_TOKEN',
'CLOUDFLARE_API_TOKEN',
'TWINE_PASSWORD',
];
for (const name of scrubbedVars) {
test(`BLOCK: env var ${name} is scrubbed`, () => {
if (process.env[name] !== undefined) {
console.log(` ❌ FAIL: ${name} is present in the sandboxed environment`);
return false;
}
const note = envSeeded ? 'seeded value scrubbed' : 'not present';
console.log(` ✅ PASS: ${name} absent (${note})`);
return true;
});
}
// npm's own auth tokens are re-allowed by the npm leaf profile.
for (const name of ['NPM_TOKEN', 'NODE_AUTH_TOKEN']) {
test(`ALLOW: env var ${name} is kept`, () => {
if (!envSeeded) {
console.log(` ⚠️ SKIP: ${name} not seeded (run with E2E_ENV_SEEDED=1, see header)`);
return true;
}
if (process.env[name] === 'pmg-e2e-keep') {
console.log(` ✅ PASS: ${name} kept`);
return true;
}
console.log(` ❌ FAIL: ${name} missing or altered (got: ${process.env[name]})`);
return false;
});
}
// Core process variables are protected and never scrubbed.
for (const name of ['PATH', 'HOME']) {
test(`ALLOW: protected env var ${name} is present`, () => {
if (process.env[name]) {
console.log(` ✅ PASS: ${name} present`);
return true;
}
console.log(` ❌ FAIL: ${name} missing`);
return false;
});
}
// ============================================
// SUMMARY
// ============================================