mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat/sandbox allow explicit dangerous pattern override (#239)
* feat(sandbox): allow opt-out of mandatory deny via explicit allow rules
Mandatory deny patterns (.env, .aws, .ssh, .gcloud, .kube, .gnupg,
.docker/config.json, .git/config) can now be opted out by listing the
exact literal post-expansion path in policy filesystem.allow_read /
allow_write, OR via --sandbox-allow read=... / write=... at runtime.
Both channels are treated at par.
Suppression is exact-match. Listing the CWD-absolute or HOME-absolute
form of a dangerous file additionally suppresses its **/<file> glob
sibling on the same direction so a single opt-out is sufficient.
Broad globs (${CWD}/**) and relative paths in user allow lists do not
suppress. The unnamed absolute form remains denied. .git/hooks is
unconditional and never suppressible (arbitrary code execution risk).
GetMandatoryDenyPatterns now returns split DenyRead / DenyWrite
slices and reports SuppressedRead / SuppressedWrite for audit. Both
translators emit per-direction deny rules and log.Warnf each
suppression. On Linux/bubblewrap, the tmpfs hide is restricted to the
intersection of DenyRead and DenyWrite; one-sided suppression falls
back to /dev/null (write) or the user's allow_read --ro-bind (read).
bwrap has no primitive that allows writes while denying reads, so
write-only opt-outs warn that the read-side mandatory deny is
unenforceable.
Updates docs/sandbox.md to document the opt-out, exact-match
semantics, and the Linux platform limitation. Updates pmg-e2e.yml to
create ./.env so the sandbox e2e test exercises the BLOCK case.
Closes #232
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: Code review fixes
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b56a8e2a43
commit
d6755d3f44
+216
-73
@@ -6,101 +6,244 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetMandatoryDenyPatterns(t *testing.T) {
|
||||
t.Run("always blocks dangerous files", func(t *testing.T) {
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
func emptyOpts() MandatoryDenyOptions {
|
||||
return MandatoryDenyOptions{AllowGitConfig: false}
|
||||
}
|
||||
|
||||
// Should contain patterns for each dangerous file
|
||||
assert.Contains(t, patterns, "**/.env")
|
||||
assert.Contains(t, patterns, "**/.ssh")
|
||||
assert.Contains(t, patterns, "**/.aws")
|
||||
assert.Contains(t, patterns, "**/.gcloud")
|
||||
assert.Contains(t, patterns, "**/.kube")
|
||||
assert.Contains(t, patterns, "**/.gnupg")
|
||||
assert.Contains(t, patterns, "**/.docker/config.json")
|
||||
func TestGetMandatoryDenyPatterns_NoAllowList(t *testing.T) {
|
||||
t.Run("blocks dangerous file globs on both sides", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(emptyOpts())
|
||||
|
||||
for _, p := range []string{
|
||||
"**/.env", "**/.env.*", "**/.ssh", "**/.aws", "**/.azure",
|
||||
"**/.gcloud", "**/.config/gcloud", "**/.kube", "**/.gnupg",
|
||||
"**/.docker/config.json", "**/.netrc", "**/.git-credentials",
|
||||
"**/.pgpass", "**/.config/gh",
|
||||
} {
|
||||
assert.Contains(t, r.DenyRead, p, "DenyRead missing %s", p)
|
||||
assert.Contains(t, r.DenyWrite, p, "DenyWrite missing %s", p)
|
||||
}
|
||||
|
||||
assert.Empty(t, r.SuppressedRead)
|
||||
assert.Empty(t, r.SuppressedWrite)
|
||||
})
|
||||
|
||||
t.Run("always blocks git hooks in CWD and HOME", func(t *testing.T) {
|
||||
t.Run("blocks git hooks unconditionally on both sides", func(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
assert.NoError(t, err)
|
||||
|
||||
require.NoError(t, err)
|
||||
home, err := os.UserHomeDir()
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
r := GetMandatoryDenyPatterns(emptyOpts())
|
||||
|
||||
// Should block git hooks in CWD
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks/**"))
|
||||
|
||||
// Should block git hooks in HOME
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".git/hooks"))
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".git/hooks/**"))
|
||||
})
|
||||
|
||||
t.Run("blocks git config when allowGitConfig is false", func(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
assert.NoError(t, err)
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
assert.NoError(t, err)
|
||||
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
|
||||
// Should block git config in CWD and HOME
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".git/config"))
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".git/config"))
|
||||
})
|
||||
|
||||
t.Run("allows git config when allowGitConfig is true", func(t *testing.T) {
|
||||
patterns := GetMandatoryDenyPatterns(true)
|
||||
|
||||
// Should NOT block git config
|
||||
for _, pattern := range patterns {
|
||||
assert.NotContains(t, pattern, ".git/config")
|
||||
for _, p := range []string{
|
||||
filepath.Join(cwd, ".git/hooks"),
|
||||
filepath.Join(cwd, ".git/hooks/**"),
|
||||
filepath.Join(home, ".git/hooks"),
|
||||
filepath.Join(home, ".git/hooks/**"),
|
||||
} {
|
||||
assert.Contains(t, r.DenyRead, p)
|
||||
assert.Contains(t, r.DenyWrite, p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("includes CWD-relative patterns", func(t *testing.T) {
|
||||
t.Run("blocks git config when AllowGitConfig is false", func(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
assert.NoError(t, err)
|
||||
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
|
||||
// Should include absolute paths in CWD
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".env"))
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".ssh"))
|
||||
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
|
||||
})
|
||||
|
||||
t.Run("includes HOME-relative patterns", func(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
home, err := os.UserHomeDir()
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: false})
|
||||
|
||||
// Should include absolute paths in HOME
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".env"))
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".ssh"))
|
||||
assert.Contains(t, patterns, filepath.Join(home, ".aws"))
|
||||
assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".git/config"))
|
||||
assert.Contains(t, r.DenyWrite, filepath.Join(home, ".git/config"))
|
||||
assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".git/config"))
|
||||
assert.Contains(t, r.DenyRead, filepath.Join(home, ".git/config"))
|
||||
})
|
||||
|
||||
t.Run("includes glob patterns for env variants", func(t *testing.T) {
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
t.Run("omits git config when AllowGitConfig is true", func(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should include pattern for .env.* files
|
||||
assert.Contains(t, patterns, "**/.env.*")
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: true})
|
||||
|
||||
cwdGitConfig := filepath.Join(cwd, ".git/config")
|
||||
homeGitConfig := filepath.Join(home, ".git/config")
|
||||
|
||||
assert.NotContains(t, r.DenyRead, cwdGitConfig)
|
||||
assert.NotContains(t, r.DenyRead, homeGitConfig)
|
||||
assert.NotContains(t, r.DenyWrite, cwdGitConfig)
|
||||
assert.NotContains(t, r.DenyWrite, homeGitConfig)
|
||||
})
|
||||
|
||||
t.Run("includes CWD-absolute and HOME-absolute forms", func(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
r := GetMandatoryDenyPatterns(emptyOpts())
|
||||
|
||||
assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env"))
|
||||
assert.Contains(t, r.DenyRead, filepath.Join(home, ".env"))
|
||||
assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".aws"))
|
||||
assert.Contains(t, r.DenyWrite, filepath.Join(home, ".aws"))
|
||||
})
|
||||
|
||||
t.Run("does not use global globs for git operations", func(t *testing.T) {
|
||||
patterns := GetMandatoryDenyPatterns(false)
|
||||
r := GetMandatoryDenyPatterns(emptyOpts())
|
||||
|
||||
// Should NOT contain global globs for git hooks/config
|
||||
// This allows legitimate git operations in temp directories (e.g., npx cloning repos)
|
||||
assert.NotContains(t, patterns, "**/.git/hooks")
|
||||
assert.NotContains(t, patterns, "**/.git/hooks/**")
|
||||
assert.NotContains(t, patterns, "**/.git/config")
|
||||
for _, side := range [][]string{r.DenyRead, r.DenyWrite} {
|
||||
assert.NotContains(t, side, "**/.git/hooks")
|
||||
assert.NotContains(t, side, "**/.git/hooks/**")
|
||||
assert.NotContains(t, side, "**/.git/config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMandatoryDenyPatterns_Suppression(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
cwdEnv := filepath.Join(cwd, ".env")
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
globEnv := filepath.Join("**", ".env")
|
||||
homeAws := filepath.Join(home, ".aws")
|
||||
cwdGitConfig := filepath.Join(cwd, ".git/config")
|
||||
|
||||
t.Run("CWD-absolute form suppresses CWD form and glob form on same direction", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{cwdEnv},
|
||||
})
|
||||
|
||||
assert.NotContains(t, r.DenyRead, cwdEnv)
|
||||
assert.Contains(t, r.SuppressedRead, cwdEnv)
|
||||
|
||||
assert.NotContains(t, r.DenyRead, globEnv)
|
||||
assert.Contains(t, r.SuppressedRead, globEnv)
|
||||
|
||||
assert.Contains(t, r.DenyRead, homeEnv)
|
||||
|
||||
assert.Contains(t, r.DenyWrite, cwdEnv)
|
||||
assert.Contains(t, r.DenyWrite, globEnv)
|
||||
assert.Contains(t, r.DenyWrite, homeEnv)
|
||||
assert.Empty(t, r.SuppressedWrite)
|
||||
})
|
||||
|
||||
t.Run("HOME-absolute form suppresses HOME form and glob form on same direction", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{homeAws},
|
||||
})
|
||||
|
||||
homeAwsGlob := filepath.Join("**", ".aws")
|
||||
cwdAws := filepath.Join(cwd, ".aws")
|
||||
|
||||
assert.NotContains(t, r.DenyRead, homeAws)
|
||||
assert.Contains(t, r.SuppressedRead, homeAws)
|
||||
|
||||
assert.NotContains(t, r.DenyRead, homeAwsGlob)
|
||||
assert.Contains(t, r.SuppressedRead, homeAwsGlob)
|
||||
|
||||
assert.Contains(t, r.DenyRead, cwdAws)
|
||||
})
|
||||
|
||||
t.Run("glob form suppressed only when listed", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{globEnv},
|
||||
})
|
||||
|
||||
assert.NotContains(t, r.DenyRead, globEnv)
|
||||
assert.Contains(t, r.SuppressedRead, globEnv)
|
||||
|
||||
assert.Contains(t, r.DenyRead, cwdEnv)
|
||||
assert.Contains(t, r.DenyRead, homeEnv)
|
||||
})
|
||||
|
||||
t.Run("read-side suppression does not affect write side", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{cwdEnv},
|
||||
AllowWrite: []string{},
|
||||
})
|
||||
|
||||
assert.NotContains(t, r.DenyRead, cwdEnv)
|
||||
assert.Contains(t, r.DenyWrite, cwdEnv)
|
||||
})
|
||||
|
||||
t.Run("write-side suppression does not affect read side", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowWrite: []string{cwdEnv},
|
||||
})
|
||||
|
||||
assert.NotContains(t, r.DenyWrite, cwdEnv)
|
||||
assert.Contains(t, r.DenyRead, cwdEnv)
|
||||
})
|
||||
|
||||
t.Run("broad glob does NOT suppress", func(t *testing.T) {
|
||||
broad := filepath.Join(cwd, "**")
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{broad},
|
||||
})
|
||||
|
||||
assert.Empty(t, r.SuppressedRead)
|
||||
assert.Contains(t, r.DenyRead, cwdEnv)
|
||||
})
|
||||
|
||||
t.Run("relative path in allow list does NOT suppress absolute form", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{".env"}, // post-Clean stays as ".env"
|
||||
})
|
||||
|
||||
assert.Contains(t, r.DenyRead, cwdEnv)
|
||||
assert.Empty(t, r.SuppressedRead)
|
||||
})
|
||||
|
||||
t.Run("git config CWD form suppressible via allow_write", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowGitConfig: false,
|
||||
AllowWrite: []string{cwdGitConfig},
|
||||
})
|
||||
|
||||
assert.NotContains(t, r.DenyWrite, cwdGitConfig)
|
||||
assert.Contains(t, r.SuppressedWrite, cwdGitConfig)
|
||||
})
|
||||
|
||||
t.Run("git hooks NEVER suppressed", func(t *testing.T) {
|
||||
cwdHooks := filepath.Join(cwd, ".git/hooks")
|
||||
cwdHooksGlob := filepath.Join(cwd, ".git/hooks/**")
|
||||
homeHooks := filepath.Join(home, ".git/hooks")
|
||||
homeHooksGlob := filepath.Join(home, ".git/hooks/**")
|
||||
|
||||
hookPaths := []string{cwdHooks, cwdHooksGlob, homeHooks, homeHooksGlob}
|
||||
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: hookPaths,
|
||||
AllowWrite: hookPaths,
|
||||
})
|
||||
|
||||
for _, p := range hookPaths {
|
||||
assert.Contains(t, r.DenyRead, p)
|
||||
assert.Contains(t, r.DenyWrite, p)
|
||||
assert.NotContains(t, r.SuppressedRead, p)
|
||||
assert.NotContains(t, r.SuppressedWrite, p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple suppressions accumulate", func(t *testing.T) {
|
||||
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
|
||||
AllowRead: []string{cwdEnv, globEnv},
|
||||
AllowWrite: []string{cwdEnv},
|
||||
})
|
||||
|
||||
// AllowWrite lists only cwdEnv; the absolute form auto-suppresses the
|
||||
// **/.env glob on that side too.
|
||||
assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedRead)
|
||||
assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedWrite)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user