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