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
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
"github.com/safedep/pmg/sandbox/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -915,3 +916,128 @@ func argSliceToString(args []string) string {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func TestBubblewrapMandatoryDenySuppression(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("read-side opt-out preserves real ro-bind and skips tmpfs and /dev/null", func(t *testing.T) {
|
||||
// Real .env in an isolated CWD so processDenyRule does not skip the
|
||||
// path as non-existent.
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600))
|
||||
|
||||
origCwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origCwd)
|
||||
})
|
||||
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Name: "test",
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowRead: []string{envPath},
|
||||
},
|
||||
}
|
||||
args := translateForTest(t, policy)
|
||||
|
||||
assertNoTmpfsAt(t, args, envPath)
|
||||
// /dev/null overlay would mask reads; allow_read --ro-bind already
|
||||
// denies writes via EROFS, so the mandatory write deny is redundant.
|
||||
assertNoDevNullMount(t, args, envPath)
|
||||
assertReadBind(t, args, envPath)
|
||||
})
|
||||
|
||||
t.Run("user deny_write still wins for paths also in allow_read", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600))
|
||||
|
||||
origCwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origCwd)
|
||||
})
|
||||
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Name: "test",
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowRead: []string{envPath},
|
||||
DenyWrite: []string{envPath},
|
||||
},
|
||||
}
|
||||
args := translateForTest(t, policy)
|
||||
|
||||
assertDevNullMount(t, args, envPath)
|
||||
})
|
||||
|
||||
t.Run("write-side opt-out skips both tmpfs and /dev/null for that path", func(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Name: "test",
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowWrite: []string{filepath.Join(cwd, ".env")},
|
||||
},
|
||||
}
|
||||
args := translateForTest(t, policy)
|
||||
|
||||
assertNoTmpfsAt(t, args, filepath.Join(cwd, ".env"))
|
||||
assertNoDevNullMount(t, args, filepath.Join(cwd, ".env"))
|
||||
})
|
||||
|
||||
t.Run("no opt-out: tmpfs fires for the path", func(t *testing.T) {
|
||||
// tmpfs only fires for paths that exist on the host; assert at the
|
||||
// GetMandatoryDenyPatterns level instead of the translator output.
|
||||
r := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{})
|
||||
assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env"))
|
||||
assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".env"))
|
||||
})
|
||||
}
|
||||
|
||||
func translateForTest(t *testing.T, policy *sandbox.SandboxPolicy) []string {
|
||||
t.Helper()
|
||||
tr := newBubblewrapPolicyTranslator(newDefaultBubblewrapConfig())
|
||||
args, err := tr.translate(policy)
|
||||
require.NoError(t, err)
|
||||
return args
|
||||
}
|
||||
|
||||
func assertNoTmpfsAt(t *testing.T, args []string, path string) {
|
||||
t.Helper()
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--tmpfs" && args[i+1] == path {
|
||||
t.Fatalf("expected no --tmpfs at %q, but found one", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDevNullMount(t *testing.T, args []string, path string) {
|
||||
t.Helper()
|
||||
for i := 0; i+2 < len(args); i++ {
|
||||
if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected /dev/null mount at %q, not found in args: %v", path, args)
|
||||
}
|
||||
|
||||
func assertNoDevNullMount(t *testing.T, args []string, path string) {
|
||||
t.Helper()
|
||||
for i := 0; i+2 < len(args); i++ {
|
||||
if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path {
|
||||
t.Fatalf("expected no /dev/null mount at %q, but found one", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertReadBind(t *testing.T, args []string, path string) {
|
||||
t.Helper()
|
||||
for i := 0; i+2 < len(args); i++ {
|
||||
if (args[i] == "--ro-bind" || args[i] == "--ro-bind-try") && args[i+1] == path && args[i+2] == path {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected --ro-bind %q %q, not found in args: %v", path, path, args)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user