mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* 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>
250 lines
7.6 KiB
Go
250 lines
7.6 KiB
Go
package util
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func emptyOpts() MandatoryDenyOptions {
|
|
return MandatoryDenyOptions{AllowGitConfig: false}
|
|
}
|
|
|
|
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("blocks git hooks unconditionally on both sides", func(t *testing.T) {
|
|
cwd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
home, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
|
|
r := GetMandatoryDenyPatterns(emptyOpts())
|
|
|
|
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("blocks git config when AllowGitConfig is false", func(t *testing.T) {
|
|
cwd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
home, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
|
|
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: false})
|
|
|
|
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("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)
|
|
|
|
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) {
|
|
r := GetMandatoryDenyPatterns(emptyOpts())
|
|
|
|
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)
|
|
})
|
|
}
|