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>
1044 lines
29 KiB
Go
1044 lines
29 KiB
Go
//go:build linux
|
|
// +build linux
|
|
|
|
package platform
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
func TestBubblewrapTranslatorBasicTranslation(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{
|
|
Name: "test",
|
|
Description: "test policy",
|
|
PackageManagers: []string{"npm"},
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{"/tmp"},
|
|
AllowWrite: []string{"/tmp"},
|
|
},
|
|
Network: sandbox.NetworkPolicy{
|
|
AllowOutbound: []string{"registry.npmjs.org:443"},
|
|
},
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, args)
|
|
|
|
// Convert to string for easier assertion
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Essential system paths should be mounted read-only
|
|
assert.Contains(t, argsStr, "--ro-bind-try")
|
|
assert.Contains(t, argsStr, "/usr")
|
|
assert.Contains(t, argsStr, "/lib")
|
|
|
|
// Essential devices should be mounted
|
|
assert.Contains(t, argsStr, "--dev-bind-try")
|
|
assert.Contains(t, argsStr, "/dev/null")
|
|
|
|
// Proc filesystem should be mounted
|
|
assert.Contains(t, argsStr, "--proc")
|
|
assert.Contains(t, argsStr, "/proc")
|
|
|
|
// User-specified paths should be mounted
|
|
assert.Contains(t, argsStr, "/tmp")
|
|
|
|
// Network should be allowed (no --unshare-net)
|
|
assert.NotContains(t, argsStr, "--unshare-net")
|
|
|
|
// Process isolation should be enabled
|
|
assert.Contains(t, argsStr, "--unshare-pid")
|
|
assert.Contains(t, argsStr, "--unshare-ipc")
|
|
|
|
// Die with parent
|
|
assert.Contains(t, argsStr, "--die-with-parent")
|
|
}
|
|
|
|
func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
policy *sandbox.SandboxPolicy
|
|
assert func(t *testing.T, args []string, err error)
|
|
}{
|
|
{
|
|
name: "simple read-only path",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{"/usr/local"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
// Should have read-only bind for the path
|
|
assert.Contains(t, argsStr, "--ro-bind-try")
|
|
assert.Contains(t, argsStr, "/usr/local")
|
|
},
|
|
},
|
|
{
|
|
name: "simple read-write path",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowWrite: []string{"/tmp/test"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
// Should have read-write bind for the path
|
|
assert.Contains(t, argsStr, "--bind-try")
|
|
assert.Contains(t, argsStr, "/tmp/test")
|
|
},
|
|
},
|
|
{
|
|
name: "variable expansion in paths",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{"${HOME}/.npmrc"},
|
|
AllowWrite: []string{"${CWD}/node_modules"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
cwd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
// Variables should be expanded
|
|
assert.Contains(t, argsStr, homeDir+"/.npmrc")
|
|
assert.Contains(t, argsStr, cwd+"/node_modules")
|
|
},
|
|
},
|
|
{
|
|
name: "deny write with /dev/null mount",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
DenyWrite: []string{"/etc/passwd"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should mount /dev/null over denied path if it exists
|
|
// Since /etc/passwd exists, it should be blocked
|
|
if _, err := os.Stat("/etc/passwd"); err == nil {
|
|
assert.Contains(t, argsStr, "--ro-bind")
|
|
assert.Contains(t, argsStr, "/dev/null")
|
|
assert.Contains(t, argsStr, "/etc/passwd")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "multiple paths",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{
|
|
"/usr/bin",
|
|
"/usr/lib",
|
|
"/var/log",
|
|
},
|
|
AllowWrite: []string{
|
|
"/tmp/output",
|
|
"/var/tmp/cache",
|
|
},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// All read paths should be present
|
|
assert.Contains(t, argsStr, "/usr/bin")
|
|
assert.Contains(t, argsStr, "/usr/lib")
|
|
assert.Contains(t, argsStr, "/var/log")
|
|
|
|
// All write paths should be present
|
|
assert.Contains(t, argsStr, "/tmp/output")
|
|
assert.Contains(t, argsStr, "/var/tmp/cache")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(tt.policy)
|
|
tt.assert(t, args, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBubblewrapTranslatorGlobPatterns(t *testing.T) {
|
|
// Create a temporary directory structure for testing glob expansion
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test files
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir1"), 0755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir2"), 0755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("test"), 0644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file2.log"), []byte("test"), 0644))
|
|
|
|
cases := []struct {
|
|
name string
|
|
policy *sandbox.SandboxPolicy
|
|
assert func(t *testing.T, args []string, err error)
|
|
}{
|
|
{
|
|
name: "glob pattern with *",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{tmpDir + "/*.txt"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should expand to concrete file
|
|
assert.Contains(t, argsStr, "file1.txt")
|
|
// Should NOT match .log files
|
|
assert.NotContains(t, argsStr, "file2.log")
|
|
},
|
|
},
|
|
{
|
|
name: "glob pattern with ** (recursive)",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowWrite: []string{tmpDir + "/**"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should include the base directory
|
|
assert.Contains(t, argsStr, tmpDir)
|
|
// Should include subdirectories
|
|
assert.Contains(t, argsStr, "subdir1")
|
|
assert.Contains(t, argsStr, "subdir2")
|
|
},
|
|
},
|
|
{
|
|
name: "non-existent glob pattern",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{"/nonexistent/path/**"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should still include the base path (even if doesn't exist)
|
|
assert.Contains(t, argsStr, "/nonexistent/path")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(tt.policy)
|
|
tt.assert(t, args, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBubblewrapTranslatorNetworkIsolation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
policy *sandbox.SandboxPolicy
|
|
assert func(t *testing.T, args []string, err error)
|
|
}{
|
|
{
|
|
name: "network allowed with allow rules",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Network: sandbox.NetworkPolicy{
|
|
AllowOutbound: []string{"registry.npmjs.org:443"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should NOT have --unshare-net (network allowed)
|
|
assert.NotContains(t, argsStr, "--unshare-net")
|
|
},
|
|
},
|
|
{
|
|
name: "network isolated with deny all",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Network: sandbox.NetworkPolicy{
|
|
DenyOutbound: []string{"*:*"},
|
|
},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should have --unshare-net (network denied)
|
|
assert.Contains(t, argsStr, "--unshare-net")
|
|
},
|
|
},
|
|
{
|
|
name: "network isolated by default when no rules",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Network: sandbox.NetworkPolicy{},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
argsStr := argSliceToString(args)
|
|
|
|
// With default config (unshareNetworkByDefault: true), should isolate
|
|
assert.Contains(t, argsStr, "--unshare-net")
|
|
},
|
|
},
|
|
{
|
|
name: "network allowed when config disables default isolation",
|
|
policy: &sandbox.SandboxPolicy{
|
|
Network: sandbox.NetworkPolicy{},
|
|
},
|
|
assert: func(t *testing.T, args []string, err error) {
|
|
require.NoError(t, err)
|
|
// This test needs a custom config, so we can't assert here
|
|
// Just verify no error
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(tt.policy)
|
|
tt.assert(t, args, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBubblewrapTranslatorPTYSupport(t *testing.T) {
|
|
t.Run("PTY disabled by default", func(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{
|
|
AllowPTY: utils.PtrTo(false),
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should NOT have PTY device bindings
|
|
assert.NotContains(t, argsStr, "/dev/pts")
|
|
assert.NotContains(t, argsStr, "/dev/ptmx")
|
|
})
|
|
|
|
t.Run("PTY enabled when requested", func(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{
|
|
AllowPTY: utils.PtrTo(true),
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should have PTY device bindings
|
|
assert.Contains(t, argsStr, "/dev/pts")
|
|
assert.Contains(t, argsStr, "/dev/ptmx")
|
|
assert.Contains(t, argsStr, "--dev-bind-try")
|
|
})
|
|
}
|
|
|
|
func TestBubblewrapTranslatorMandatoryDenies(t *testing.T) {
|
|
// Create temp directory with some dangerous files
|
|
tmpDir := t.TempDir()
|
|
sshDir := filepath.Join(tmpDir, ".ssh")
|
|
require.NoError(t, os.MkdirAll(sshDir, 0700))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("fake key"), 0600))
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
// Even with broad write permissions...
|
|
AllowWrite: []string{tmpDir + "/**"},
|
|
},
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Mandatory deny patterns should be present
|
|
// Note: The actual paths depend on the current working directory and home
|
|
// We just verify that /dev/null mounting is used
|
|
assert.Contains(t, argsStr, "--ro-bind")
|
|
assert.Contains(t, argsStr, "/dev/null")
|
|
}
|
|
|
|
func TestBubblewrapTranslatorGitConfigDeny(t *testing.T) {
|
|
t.Run("git config denied by default", func(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{
|
|
AllowGitConfig: utils.PtrTo(false),
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
_, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
// Git config should be in deny patterns
|
|
// (we can't easily assert the exact args without creating a .git directory)
|
|
})
|
|
|
|
t.Run("git config allowed when explicitly set", func(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{
|
|
AllowGitConfig: utils.PtrTo(true),
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
_, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
// Should succeed without adding git config to deny patterns
|
|
})
|
|
}
|
|
|
|
func TestBubblewrapConfigDefaults(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
|
|
// Essential system paths
|
|
assert.NotEmpty(t, config.essentialSystemPaths)
|
|
assert.Contains(t, config.essentialSystemPaths, "/usr")
|
|
assert.Contains(t, config.essentialSystemPaths, "/lib")
|
|
|
|
// Essential devices
|
|
assert.NotEmpty(t, config.essentialDevices)
|
|
assert.Contains(t, config.essentialDevices, "/dev/null")
|
|
assert.Contains(t, config.essentialDevices, "/dev/random")
|
|
|
|
// Glob limits
|
|
assert.Equal(t, 5, config.maxGlobDepth)
|
|
assert.Equal(t, 10000, config.maxGlobPaths)
|
|
|
|
// Isolation settings
|
|
assert.True(t, config.unshareNetworkByDefault)
|
|
assert.True(t, config.unsharePID)
|
|
assert.True(t, config.unshareIPC)
|
|
assert.True(t, config.dieWithParent)
|
|
}
|
|
|
|
func TestBubblewrapConfigEssentialPaths(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
|
|
// Get essential system paths (filters out non-existent)
|
|
paths := config.getEssentialSystemPaths()
|
|
assert.NotEmpty(t, paths)
|
|
|
|
// All returned paths should exist
|
|
for _, path := range paths {
|
|
_, err := os.Stat(path)
|
|
assert.NoError(t, err, "Essential path %s should exist", path)
|
|
}
|
|
}
|
|
|
|
func TestBubblewrapConfigEssentialDevices(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
|
|
// Get essential devices (filters out non-existent)
|
|
devices := config.getEssentialDevices()
|
|
assert.NotEmpty(t, devices)
|
|
|
|
// All returned devices should exist
|
|
for _, device := range devices {
|
|
_, err := os.Stat(device)
|
|
assert.NoError(t, err, "Essential device %s should exist", device)
|
|
}
|
|
}
|
|
|
|
func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
|
|
t.Run("existing file is blocked with /dev/null", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file that exists
|
|
testFile := filepath.Join(tmpDir, "existing.txt")
|
|
require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644))
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
DenyWrite: []string{testFile},
|
|
},
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Existing file should be mounted with /dev/null
|
|
assert.Contains(t, argsStr, "--ro-bind")
|
|
assert.Contains(t, argsStr, "/dev/null")
|
|
assert.Contains(t, argsStr, testFile)
|
|
})
|
|
|
|
t.Run("non-existent file is skipped to avoid creating empty files", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Non-existent file - should be skipped because using --ro-bind /dev/null
|
|
// on non-existent paths causes bwrap to create the file as a mount point
|
|
nonExistentPath := filepath.Join(tmpDir, ".env")
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowWrite: []string{tmpDir}, // Allow writes to tmpDir
|
|
DenyWrite: []string{nonExistentPath},
|
|
},
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Non-existent file should NOT be in args
|
|
// Using --ro-bind /dev/null on non-existent paths creates empty files
|
|
assert.NotContains(t, argsStr, nonExistentPath)
|
|
})
|
|
|
|
t.Run("existing directory is mounted read-only", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a directory
|
|
testDir := filepath.Join(tmpDir, "secrets")
|
|
require.NoError(t, os.MkdirAll(testDir, 0755))
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
DenyWrite: []string{testDir},
|
|
},
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Existing directory should be mounted read-only
|
|
assert.Contains(t, argsStr, "--ro-bind-try")
|
|
assert.Contains(t, argsStr, testDir)
|
|
})
|
|
}
|
|
|
|
func TestBubblewrapTranslatorTmpdirSupport(t *testing.T) {
|
|
policy := &sandbox.SandboxPolicy{}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Tmpdir should be mounted as writable
|
|
tmpDir := os.TempDir()
|
|
assert.Contains(t, argsStr, "--bind")
|
|
assert.Contains(t, argsStr, tmpDir)
|
|
}
|
|
|
|
func TestExpandGlobstarPattern(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a directory structure
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir1", "subdir"), 0755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir2"), 0755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("test"), 0644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "dir1", "file2.txt"), []byte("test"), 0644))
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
cases := []struct {
|
|
name string
|
|
pattern string
|
|
maxDepth int
|
|
maxPaths int
|
|
assert func(t *testing.T, matches []string, err error)
|
|
}{
|
|
{
|
|
name: "simple globstar",
|
|
pattern: tmpDir + "/**",
|
|
maxDepth: 3,
|
|
maxPaths: 100,
|
|
assert: func(t *testing.T, matches []string, err error) {
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, matches)
|
|
// Should include base directory
|
|
assert.Contains(t, matches, tmpDir)
|
|
},
|
|
},
|
|
{
|
|
name: "globstar with depth limit",
|
|
pattern: tmpDir + "/**",
|
|
maxDepth: 1,
|
|
maxPaths: 100,
|
|
assert: func(t *testing.T, matches []string, err error) {
|
|
require.NoError(t, err)
|
|
// Should be limited by depth
|
|
for _, match := range matches {
|
|
rel, err := filepath.Rel(tmpDir, match)
|
|
require.NoError(t, err)
|
|
depth := len(filepath.SplitList(rel))
|
|
assert.LessOrEqual(t, depth, 2) // Base + 1 level
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "globstar with count limit",
|
|
pattern: tmpDir + "/**",
|
|
maxDepth: 10,
|
|
maxPaths: 2,
|
|
assert: func(t *testing.T, matches []string, err error) {
|
|
require.NoError(t, err)
|
|
// Should be limited by count
|
|
assert.LessOrEqual(t, len(matches), 2)
|
|
},
|
|
},
|
|
{
|
|
name: "non-existent base path",
|
|
pattern: "/nonexistent/path/**",
|
|
maxDepth: 3,
|
|
maxPaths: 100,
|
|
assert: func(t *testing.T, matches []string, err error) {
|
|
require.NoError(t, err)
|
|
// Should return the base path even if it doesn't exist
|
|
assert.Contains(t, matches, "/nonexistent/path")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
matches, err := translator.expandGlobstarPattern(tt.pattern, tt.maxDepth, tt.maxPaths)
|
|
tt.assert(t, matches, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindFirstNonExistentPath(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a directory structure
|
|
existingDir := filepath.Join(tmpDir, "existing")
|
|
require.NoError(t, os.MkdirAll(existingDir, 0755))
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "file in existing directory",
|
|
path: filepath.Join(existingDir, "nonexistent.txt"),
|
|
expected: filepath.Join(existingDir, "nonexistent.txt"),
|
|
},
|
|
{
|
|
name: "nested non-existent path",
|
|
path: filepath.Join(existingDir, "deep", "nested", "file.txt"),
|
|
expected: filepath.Join(existingDir, "deep"),
|
|
},
|
|
{
|
|
name: "completely non-existent path",
|
|
path: "/totally/nonexistent/path/file.txt",
|
|
expected: "", // No parent exists, can't block creation
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := translator.findFirstNonExistentPath(tt.path)
|
|
if tt.expected == "" {
|
|
// For completely non-existent paths, we might get empty or a high-level path
|
|
// Just verify no panic
|
|
assert.True(t, true)
|
|
} else {
|
|
assert.Equal(t, tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths
|
|
func TestGlobFallbackThreshold(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create 150 files (exceeds threshold of 100)
|
|
for i := 0; i < 150; i++ {
|
|
filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
|
|
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Name: "test-fallback",
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{tmpDir + "/*.txt"},
|
|
},
|
|
}
|
|
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should bind parent directory (tmpDir), not individual files
|
|
assert.Contains(t, argsStr, tmpDir)
|
|
|
|
// Should NOT contain individual file paths (fallback to parent dir)
|
|
assert.NotContains(t, argsStr, "file1.txt")
|
|
assert.NotContains(t, argsStr, "file50.txt")
|
|
assert.NotContains(t, argsStr, "file100.txt")
|
|
|
|
// Verify total argument count is reasonable (coarse-grained fallback should prevent explosion)
|
|
assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
|
|
}
|
|
|
|
// TestGlobFallbackThresholdGlobstar tests fallback with ** globstar patterns
|
|
func TestGlobFallbackThresholdGlobstar(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create deep directory structure with 200 files (exceeds threshold)
|
|
for i := 0; i < 10; i++ {
|
|
subDir := filepath.Join(tmpDir, fmt.Sprintf("dir%d", i))
|
|
require.NoError(t, os.MkdirAll(subDir, 0755))
|
|
for j := 0; j < 20; j++ {
|
|
filePath := filepath.Join(subDir, fmt.Sprintf("file%d.txt", j))
|
|
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
|
|
}
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Name: "test-fallback-globstar",
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowWrite: []string{tmpDir + "/**"},
|
|
},
|
|
}
|
|
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should bind parent directory (tmpDir)
|
|
assert.Contains(t, argsStr, tmpDir)
|
|
|
|
// Should NOT contain individual subdirectory paths (fallback to parent)
|
|
assert.NotContains(t, argsStr, "dir1")
|
|
assert.NotContains(t, argsStr, "dir5")
|
|
|
|
// Verify total argument count is reasonable
|
|
assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
|
|
}
|
|
|
|
// TestGlobNoFallbackSmallPattern tests that small patterns don't trigger fallback
|
|
func TestGlobNoFallbackSmallPattern(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create only 10 files (below threshold)
|
|
for i := 0; i < 10; i++ {
|
|
filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
|
|
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
policy := &sandbox.SandboxPolicy{
|
|
Name: "test-no-fallback",
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: []string{tmpDir + "/*.txt"},
|
|
},
|
|
}
|
|
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err)
|
|
|
|
argsStr := argSliceToString(args)
|
|
|
|
// Should bind individual files (no fallback)
|
|
assert.Contains(t, argsStr, "file0.txt")
|
|
assert.Contains(t, argsStr, "file5.txt")
|
|
}
|
|
|
|
// TestTotalArgsLimit verifies global argument limit warning
|
|
func TestTotalArgsLimit(t *testing.T) {
|
|
// Create policy with many patterns that would exceed limit
|
|
policy := &sandbox.SandboxPolicy{
|
|
Name: "test-args-limit",
|
|
Filesystem: sandbox.FilesystemPolicy{
|
|
AllowRead: make([]string, 1000), // 1000 patterns
|
|
},
|
|
}
|
|
|
|
// Fill with literal paths to avoid glob expansion
|
|
for i := 0; i < 1000; i++ {
|
|
policy.Filesystem.AllowRead[i] = fmt.Sprintf("/tmp/path%d", i)
|
|
}
|
|
|
|
config := newDefaultBubblewrapConfig()
|
|
config.totalArgsLimit = 500 // Set low for testing
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
|
|
args, err := translator.translate(policy)
|
|
require.NoError(t, err) // Should not error, just warn
|
|
|
|
// Verify args were generated despite exceeding limit
|
|
assert.Greater(t, len(args), config.totalArgsLimit)
|
|
}
|
|
|
|
// TestExtractParentDir tests the extractParentDir helper function
|
|
func TestExtractParentDir(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
pattern string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "double star pattern",
|
|
pattern: "/home/user/node_modules/**",
|
|
expected: "/home/user/node_modules",
|
|
},
|
|
{
|
|
name: "single star pattern",
|
|
pattern: "/tmp/*.txt",
|
|
expected: "/tmp",
|
|
},
|
|
{
|
|
name: "middle glob",
|
|
pattern: "/usr/lib/*.so",
|
|
expected: "/usr/lib",
|
|
},
|
|
{
|
|
name: "complex glob",
|
|
pattern: "/home/user/.cache/**/*.log",
|
|
expected: "/home/user/.cache",
|
|
},
|
|
{
|
|
name: "file-level glob with dot",
|
|
pattern: "/home/user/project/package.json.*",
|
|
expected: "/home/user/project",
|
|
},
|
|
{
|
|
name: "file-level glob in CWD",
|
|
pattern: "/home/user/project/*.lock",
|
|
expected: "/home/user/project",
|
|
},
|
|
{
|
|
name: "question mark glob",
|
|
pattern: "/tmp/file?.txt",
|
|
expected: "/tmp",
|
|
},
|
|
{
|
|
name: "bracket glob",
|
|
pattern: "/usr/lib/lib[abc].so",
|
|
expected: "/usr/lib",
|
|
},
|
|
{
|
|
name: "no glob",
|
|
pattern: "/home/user/file.txt",
|
|
expected: "/home/user/file.txt",
|
|
},
|
|
{
|
|
name: "trailing double star",
|
|
pattern: "/home/user/cache/**",
|
|
expected: "/home/user/cache",
|
|
},
|
|
{
|
|
name: "trailing single star",
|
|
pattern: "/var/log/*",
|
|
expected: "/var/log",
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := newDefaultBubblewrapConfig()
|
|
translator := newBubblewrapPolicyTranslator(config)
|
|
result := translator.extractParentDir(tt.pattern)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to convert arg slice to string for easier assertion
|
|
func argSliceToString(args []string) string {
|
|
result := ""
|
|
for _, arg := range args {
|
|
result += arg + " "
|
|
}
|
|
|
|
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)
|
|
}
|