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
|
touch ./.env
|
||||||
|
|
||||||
- name: Run Sandbox E2E Test
|
- 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
|
run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js
|
||||||
|
|
||||||
- name: Run Package Manager E2E Test
|
- name: Run Package Manager E2E Test
|
||||||
@@ -668,10 +678,20 @@ jobs:
|
|||||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
|
||||||
- name: Run Sandbox E2E Test
|
- 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
|
- 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:
|
sandbox-e2e-linux-landlock:
|
||||||
name: Sandbox E2E - Linux (Landlock)
|
name: Sandbox E2E - Linux (Landlock)
|
||||||
@@ -747,8 +767,20 @@ jobs:
|
|||||||
- name: Run Landlock Helper E2E Tests (Go)
|
- name: Run Landlock Helper E2E Tests (Go)
|
||||||
run: go test -count=1 -v -run TestLandlockHelper ./sandbox/platform/...
|
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
|
- 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
|
- 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_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)"
|
GO_LDFLAGS=-ldflags "-w $(GO_CFLAGS)"
|
||||||
|
|
||||||
.PHONY: all pmg create_bin clean test
|
.PHONY: all pmg create_bin clean test sandbox-e2e
|
||||||
|
|
||||||
all: pmg
|
all: pmg
|
||||||
|
|
||||||
@@ -43,6 +43,21 @@ clean:
|
|||||||
test:
|
test:
|
||||||
$(GO) 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:
|
fmt:
|
||||||
$(GO) fmt ./...
|
$(GO) fmt ./...
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ func executeSetupInfo() error {
|
|||||||
|
|
||||||
policyParts := make([]string, 0, len(pmNames))
|
policyParts := make([]string, 0, len(pmNames))
|
||||||
for _, name := range pmNames {
|
for _, name := range pmNames {
|
||||||
ref := sandboxCfg.Policies[name]
|
ref, _ := sandboxCfg.PolicyFor(name)
|
||||||
status := "disabled"
|
status := "disabled"
|
||||||
if ref.Enabled {
|
if ref.Enabled {
|
||||||
status = ref.Profile
|
status = ref.Profile
|
||||||
|
|||||||
@@ -150,6 +150,52 @@ type DependencyCooldownConfig struct {
|
|||||||
Days int `mapstructure:"days"`
|
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
|
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
|
||||||
// a profile name to a path.
|
// a profile name to a path.
|
||||||
type SandboxPolicyTemplate struct {
|
type SandboxPolicyTemplate struct {
|
||||||
@@ -298,6 +344,7 @@ const (
|
|||||||
SandboxAllowExec SandboxAllowType = "exec"
|
SandboxAllowExec SandboxAllowType = "exec"
|
||||||
SandboxAllowNetConnect SandboxAllowType = "net-connect"
|
SandboxAllowNetConnect SandboxAllowType = "net-connect"
|
||||||
SandboxAllowNetBind SandboxAllowType = "net-bind"
|
SandboxAllowNetBind SandboxAllowType = "net-bind"
|
||||||
|
SandboxAllowEnv SandboxAllowType = "env"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SandboxAllowOverride represents a single --sandbox-allow flag value.
|
// SandboxAllowOverride represents a single --sandbox-allow flag value.
|
||||||
|
|||||||
+15
-11
@@ -111,14 +111,16 @@ sandbox:
|
|||||||
# Per-package-manager sandbox policies
|
# Per-package-manager sandbox policies
|
||||||
# Each package manager can have its own policy to account for unique security characteristics
|
# Each package manager can have its own policy to account for unique security characteristics
|
||||||
policies:
|
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:
|
npm:
|
||||||
enabled: true
|
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:
|
pnpm:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pnpm-restrictive
|
profile: pnpm
|
||||||
|
|
||||||
npx:
|
npx:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -130,32 +132,34 @@ sandbox:
|
|||||||
|
|
||||||
yarn:
|
yarn:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: npm-restrictive
|
profile: yarn
|
||||||
|
|
||||||
bun:
|
bun:
|
||||||
enabled: true
|
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:
|
pip:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: pip
|
||||||
|
|
||||||
pip3:
|
pip3:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: pip
|
||||||
|
|
||||||
pipx:
|
pipx:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: pipx
|
||||||
|
|
||||||
poetry:
|
poetry:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: poetry
|
||||||
|
|
||||||
uv:
|
uv:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: uv
|
||||||
|
|
||||||
# Dependency cooldown blocks installation of package versions published within
|
# Dependency cooldown blocks installation of package versions published within
|
||||||
# a configurable time window.
|
# a configurable time window.
|
||||||
|
|||||||
+25
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,7 @@ var validSandboxAllowTypes = map[SandboxAllowType]bool{
|
|||||||
SandboxAllowExec: true,
|
SandboxAllowExec: true,
|
||||||
SandboxAllowNetConnect: true,
|
SandboxAllowNetConnect: true,
|
||||||
SandboxAllowNetBind: true,
|
SandboxAllowNetBind: true,
|
||||||
|
SandboxAllowEnv: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSandboxAllowOverrides parses raw --sandbox-allow flag values into validated overrides.
|
// parseSandboxAllowOverrides parses raw --sandbox-allow flag values into validated overrides.
|
||||||
@@ -69,7 +71,7 @@ func parseSingleOverride(raw string) (SandboxAllowOverride, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !validSandboxAllowTypes[allowType] {
|
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)
|
resolved, err := validateAndResolveValue(allowType, value)
|
||||||
@@ -95,11 +97,33 @@ func validateAndResolveValue(typ SandboxAllowType, value string) (string, error)
|
|||||||
return validateNetConnect(value)
|
return validateNetConnect(value)
|
||||||
case SandboxAllowNetBind:
|
case SandboxAllowNetBind:
|
||||||
return validateNetBind(value)
|
return validateNetBind(value)
|
||||||
|
case SandboxAllowEnv:
|
||||||
|
return validateEnvName(value)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unhandled type: %s", typ)
|
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.
|
// resolveFilesystemPath resolves a filesystem path for read/write overrides.
|
||||||
// Supports glob patterns. Resolves relative paths to absolute via CWD.
|
// Supports glob patterns. Resolves relative paths to absolute via CWD.
|
||||||
func resolveFilesystemPath(value string) (string, error) {
|
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) {
|
func TestParseSandboxAllowOverrides_MultipleValues(t *testing.T) {
|
||||||
raw := []string{
|
raw := []string{
|
||||||
"write=./.gitignore",
|
"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.
|
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.
|
`.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
|
## Requirements
|
||||||
|
|
||||||
- Linux kernel 5.13+ with Landlock enabled (default, no external dependencies)
|
- 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
|
# Allow a dev server to bind to a local port
|
||||||
pmg --sandbox-allow net-bind=127.0.0.1:3000 npx some-dev-tool
|
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
|
# Multiple overrides
|
||||||
pmg \
|
pmg \
|
||||||
--sandbox-allow write=./.gitignore \
|
--sandbox-allow write=./.gitignore \
|
||||||
@@ -169,7 +220,13 @@ pmg \
|
|||||||
npm install some-package
|
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
|
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
|
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
|
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
|
||||||
|
|
||||||
if 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
|
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
|
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
|
||||||
|
|
||||||
if 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
|
reportData.SandboxProfile = policyRef.Profile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type ChildExitError struct {
|
|||||||
Code int // child's exit code (or 128+signum for signal termination)
|
Code int // child's exit code (or 128+signum for signal termination)
|
||||||
Signaled bool // true if terminated by a signal (Ctrl+C, SIGTERM, …)
|
Signaled bool // true if terminated by a signal (Ctrl+C, SIGTERM, …)
|
||||||
PMName string // package manager name, for the dim one-liner
|
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 {
|
func (e *ChildExitError) Error() string {
|
||||||
@@ -26,6 +27,7 @@ func (e *ChildExitError) Error() string {
|
|||||||
func (e *ChildExitError) ExitCode() int { return e.Code }
|
func (e *ChildExitError) ExitCode() int { return e.Code }
|
||||||
func (e *ChildExitError) Transparent() bool { return true }
|
func (e *ChildExitError) Transparent() bool { return true }
|
||||||
func (e *ChildExitError) IsSignaled() bool { return e.Signaled }
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
code, signaled, resolved := extractExit(err)
|
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.
|
// 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
|
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 {
|
if !resolved {
|
||||||
return visibleExecError(err)
|
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
|
// 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")
|
runErr := errors.New("npm failed")
|
||||||
|
|
||||||
t.Run("plain child exit becomes a transparent ChildExitError", func(t *testing.T) {
|
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
|
var ce *ChildExitError
|
||||||
require.True(t, errors.As(err, &ce))
|
require.True(t, errors.As(err, &ce))
|
||||||
@@ -63,10 +63,19 @@ func TestDecideExit(t *testing.T) {
|
|||||||
assert.True(t, ce.Transparent())
|
assert.True(t, ce.Transparent())
|
||||||
assert.False(t, ce.IsSignaled())
|
assert.False(t, ce.IsSignaled())
|
||||||
assert.Equal(t, "npm", ce.PMName)
|
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) {
|
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
|
var ce *ChildExitError
|
||||||
require.True(t, errors.As(err, &ce))
|
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) {
|
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)
|
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
@@ -94,7 +103,7 @@ func TestClassifyTransparentChildExit(t *testing.T) {
|
|||||||
childErr := exec.Command("sh", "-c", "exit 1").Run()
|
childErr := exec.Command("sh", "-c", "exit 1").Run()
|
||||||
require.Error(t, childErr)
|
require.Error(t, childErr)
|
||||||
|
|
||||||
err := classify(childErr, "npm")
|
err := classify(childErr, "npm", 0)
|
||||||
|
|
||||||
var ce *ChildExitError
|
var ce *ChildExitError
|
||||||
require.True(t, errors.As(err, &ce))
|
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 {
|
if err := cmd.Run(); err != nil {
|
||||||
executor.ObserveViolations(result, err)
|
executor.ObserveViolations(result, err)
|
||||||
return classify(err, pmName)
|
return classify(err, pmName, result.ScrubbedEnvCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("Command completed successfully")
|
log.Debugf("Command completed successfully")
|
||||||
@@ -244,7 +244,7 @@ func runPTY(
|
|||||||
|
|
||||||
if sessionError != nil {
|
if sessionError != nil {
|
||||||
executor.ObserveViolations(result, sessionError)
|
executor.ObserveViolations(result, sessionError)
|
||||||
return classify(sessionError, pmName)
|
return classify(sessionError, pmName, result.ScrubbedEnvCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ type transparentExit interface {
|
|||||||
IsSignaled() bool
|
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 {
|
type exitDecision struct {
|
||||||
transparent bool
|
transparent bool
|
||||||
notice bool
|
notice bool
|
||||||
@@ -34,6 +42,15 @@ func classifyExit(err error) exitDecision {
|
|||||||
if !te.IsSignaled() && verbosityLevel != VerbosityLevelSilent {
|
if !te.IsSignaled() && verbosityLevel != VerbosityLevelSilent {
|
||||||
d.notice = true
|
d.notice = true
|
||||||
d.message = "↳ pmg: " + te.Error()
|
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
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ type fakeChildExit struct {
|
|||||||
code int
|
code int
|
||||||
signaled bool
|
signaled bool
|
||||||
pmName string
|
pmName string
|
||||||
|
scrubbed int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *fakeChildExit) Error() string { return fmt.Sprintf("%s exited with code %d", e.pmName, e.code) }
|
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) ExitCode() int { return e.code }
|
||||||
func (e *fakeChildExit) Transparent() bool { return true }
|
func (e *fakeChildExit) Transparent() bool { return true }
|
||||||
func (e *fakeChildExit) IsSignaled() bool { return e.signaled }
|
func (e *fakeChildExit) IsSignaled() bool { return e.signaled }
|
||||||
|
func (e *fakeChildExit) ScrubbedEnvCount() int { return e.scrubbed }
|
||||||
|
|
||||||
func withVerbosity(t *testing.T, level VerbosityLevel) {
|
func withVerbosity(t *testing.T, level VerbosityLevel) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -51,6 +55,44 @@ func TestClassifyExit(t *testing.T) {
|
|||||||
assert.True(t, d.notice)
|
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) {
|
t.Run("signal termination is silent but still mirrors the code", func(t *testing.T) {
|
||||||
withVerbosity(t, VerbosityLevelNormal)
|
withVerbosity(t, VerbosityLevelNormal)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/dry/usefulerror"
|
"github.com/safedep/dry/usefulerror"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
"github.com/safedep/pmg/sandbox"
|
"github.com/safedep/pmg/sandbox"
|
||||||
"github.com/safedep/pmg/sandbox/platform"
|
"github.com/safedep/pmg/sandbox/platform"
|
||||||
|
"github.com/safedep/pmg/sandbox/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type applySandboxConfig struct {
|
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.
|
// This is to avoid running the command without sandbox protection.
|
||||||
// To bypass sandbox for a specific package manager, users should explicitly
|
// To bypass sandbox for a specific package manager, users should explicitly
|
||||||
// disable for the package manager in the config.
|
// disable for the package manager in the config.
|
||||||
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
|
policyRef, exists := cfg.Config.Sandbox.PolicyFor(pmName)
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, usefulerror.NewUsefulError().
|
return nil, usefulerror.NewUsefulError().
|
||||||
WithCode(errcodes.NotFound).
|
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)
|
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)
|
result, err := sb.Execute(ctx, cmd, policy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to setup sandbox: %w", err)
|
return nil, fmt.Errorf("failed to setup sandbox: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.SetScrubbedEnvCount(scrubbed)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +215,43 @@ func applyRuntimeOverrides(policy *sandbox.SandboxPolicy, overrides []config.San
|
|||||||
// Enable AllowNetworkBind so the translator emits bind rules.
|
// Enable AllowNetworkBind so the translator emits bind rules.
|
||||||
// Without this, AllowBind entries would be ignored on some platforms.
|
// Without this, AllowBind entries would be ignored on some platforms.
|
||||||
policy.AllowNetworkBind = utils.PtrTo(true)
|
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.
|
// 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
|
// 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.
|
// equality is used. This keeps broad deny rules intact while allowing targeted overrides.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package executor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -57,6 +58,74 @@ func TestApplyRuntimeOverrides_Exec(t *testing.T) {
|
|||||||
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/curl")
|
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) {
|
func TestApplyRuntimeOverrides_NetConnect(t *testing.T) {
|
||||||
policy := &sandbox.SandboxPolicy{
|
policy := &sandbox.SandboxPolicy{
|
||||||
Network: sandbox.NetworkPolicy{
|
Network: sandbox.NetworkPolicy{
|
||||||
@@ -250,7 +319,6 @@ func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testi
|
|||||||
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
|
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestApplyProjectOverlayAppendsEntries(t *testing.T) {
|
func TestApplyProjectOverlayAppendsEntries(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
repo := "/repo/example"
|
repo := "/repo/example"
|
||||||
|
|||||||
+17
-1
@@ -22,6 +22,7 @@ type SandboxPolicy struct {
|
|||||||
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
|
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
|
||||||
Network NetworkPolicy `yaml:"network" json:"network"`
|
Network NetworkPolicy `yaml:"network" json:"network"`
|
||||||
Process ProcessPolicy `yaml:"process" json:"process"`
|
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
|
// 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
|
// 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"`
|
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.
|
// Validate validates the sandbox policy for correctness before inheritance resolution.
|
||||||
// Returns an error if the policy is invalid.
|
// Returns an error if the policy is invalid.
|
||||||
// Note: Validation for "at least one rule" check is deferred to ValidateResolved(),
|
// 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.AllowOutbound) > 0 ||
|
||||||
len(p.Network.DenyOutbound) > 0 ||
|
len(p.Network.DenyOutbound) > 0 ||
|
||||||
len(p.Process.AllowExec) > 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 {
|
if !hasRules {
|
||||||
return fmt.Errorf("policy must define at least one access rule (after inheritance resolution)")
|
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.AllowExec = unionStringSlices(parent.Process.AllowExec, child.Process.AllowExec)
|
||||||
child.Process.DenyExec = unionStringSlices(parent.Process.DenyExec, child.Process.DenyExec)
|
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.
|
// Set boolean fields by duplicating the parent value if not present in the child.
|
||||||
if child.AllowPTY == nil {
|
if child.AllowPTY == nil {
|
||||||
child.AllowPTY = utils.PtrTo(utils.SafelyGetValue(parent.AllowPTY))
|
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:
|
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:
|
process:
|
||||||
allow_exec:
|
allow_exec:
|
||||||
- /usr/bin/node
|
- /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
|
# npx generators and dev servers frequently need to bind to localhost ports
|
||||||
allow_network_bind: true
|
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:
|
filesystem:
|
||||||
# Add write permissions for common generator outputs
|
# Add write permissions for common generator outputs
|
||||||
allow_write:
|
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)
|
# pipx-executed tools may need to bind to localhost ports (e.g., dev servers)
|
||||||
allow_network_bind: true
|
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:
|
filesystem:
|
||||||
allow_read:
|
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/pipx/**
|
||||||
|
- ${HOME}/.local/share/pipx/**
|
||||||
|
- ${HOME}/Library/Application Support/pipx/**
|
||||||
- ${HOME}/.local/bin/**
|
- ${HOME}/.local/bin/**
|
||||||
|
|
||||||
|
# pipx run caches ephemeral venvs here
|
||||||
|
- ${HOME}/.cache/pipx/**
|
||||||
|
- ${HOME}/Library/Caches/pipx/**
|
||||||
|
|
||||||
# Add write permissions for pipx-specific paths
|
# Add write permissions for pipx-specific paths
|
||||||
allow_write:
|
allow_write:
|
||||||
- ${CWD}/**
|
- ${CWD}/**
|
||||||
- ${HOME}/.local/pipx/**
|
- ${HOME}/.local/pipx/**
|
||||||
|
- ${HOME}/.local/share/pipx/**
|
||||||
|
- ${HOME}/Library/Application Support/pipx/**
|
||||||
- ${HOME}/.local/bin/**
|
- ${HOME}/.local/bin/**
|
||||||
|
- ${HOME}/.cache/pipx/**
|
||||||
|
- ${HOME}/Library/Caches/pipx/**
|
||||||
|
|
||||||
# Additional deny rules for extra security
|
# Additional deny rules for extra security
|
||||||
deny_write:
|
deny_write:
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
name: pnpm-restrictive
|
name: pnpm
|
||||||
description: Profile for pnpm with write access to current directory
|
description: Profile for pnpm, extending npm-restrictive with pnpm write paths and environment variables
|
||||||
inherits: npm-restrictive
|
inherits: npm-restrictive
|
||||||
|
|
||||||
package_managers:
|
package_managers:
|
||||||
- pnpm
|
- 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:
|
filesystem:
|
||||||
allow_write:
|
allow_write:
|
||||||
# pnpm needs write access here
|
# 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:
|
package_managers:
|
||||||
- pip
|
- pip
|
||||||
- pip3
|
- pip3
|
||||||
|
- pipx
|
||||||
- poetry
|
- poetry
|
||||||
- uv
|
- uv
|
||||||
|
|
||||||
@@ -71,6 +72,15 @@ network:
|
|||||||
deny_outbound:
|
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:
|
process:
|
||||||
allow_exec:
|
allow_exec:
|
||||||
- /usr/bin/python*
|
- /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...),
|
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...)
|
out.PackageManagers = append([]string(nil), p.PackageManagers...)
|
||||||
|
|
||||||
return &out, nil
|
return &out, nil
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type violationReporter interface {
|
|||||||
type ExecutionResult struct {
|
type ExecutionResult struct {
|
||||||
executed bool
|
executed bool
|
||||||
sandbox Sandbox
|
sandbox Sandbox
|
||||||
|
scrubbedEnvCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionResultOpt is a function that can be used to configure an ExecutionResult.
|
// ExecutionResultOpt is a function that can be used to configure an ExecutionResult.
|
||||||
@@ -93,6 +94,23 @@ func (r *ExecutionResult) ShouldRun() bool {
|
|||||||
return !r.executed
|
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.
|
// BestEffortViolation returns sandbox-specific best-effort violation details.
|
||||||
// Implementations may use platform logs or other weak signals, so callers
|
// Implementations may use platform logs or other weak signals, so callers
|
||||||
// should treat the result as advisory.
|
// 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",
|
".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
|
// MandatoryDenyOptions configures GetMandatoryDenyPatterns. AllowRead and
|
||||||
// AllowWrite must be already expanded (post-ExpandVariables); the function
|
// AllowWrite must be already expanded (post-ExpandVariables); the function
|
||||||
// does not call ExpandVariables itself.
|
// 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
|
// SUMMARY
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user