feat: Add support for Linux Sandbox using Bubblewrap (#120)

* feat: Add support for bubblewrap sandbox

* fix: Glob pattern expansion limit for linux

* fix: Bug in glob pattern expansion for bwrap

* fix: README on trust

* fix: Multiple bubblewrap translator fix

* test: Add E2E for linux sandbox

* fix: Refactor bwrap sandbox to use common dangerous files

* fix: Path test case

* fix: Non-existent path handling bug

* refactor: Misc cleanup

* fix: Avoid bind mount for non-existentent deny protection

* fix: Off by one bug in path depth handling

* ci: Disable AppArmor on GHA runner

* fix: Disable apparmor userns restrictions
This commit is contained in:
Abhisek Datta
2026-01-15 20:12:12 +05:30
committed by GitHub
parent b97a4c2ee5
commit 80a1747e3e
13 changed files with 2495 additions and 56 deletions
+58
View File
@@ -443,3 +443,61 @@ jobs:
- name: Run Sandbox E2E Test
run: pmg --sandbox npm exec -- node test/sandbox-e2e.js
sandbox-e2e-linux:
name: Sandbox E2E - Linux (Bubblewrap)
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- name: Checkout Source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Setup Go
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
with:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
check-latest: true
- name: Install Bubblewrap
run: sudo apt-get update && sudo apt-get install -y bubblewrap
- name: Verify Bubblewrap Installation
run: bwrap --version
- name: Build PMG
run: make
- name: Add pmg to PATH
run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH
- name: Setup PMG
run: pmg setup install
- name: Create Test Directories for Sandbox Permissions Tests
run: mkdir -p ~/.aws ~/.gcloud ~/.kube ~/.ssh ~/.gnupg ~/.docker
- name: Create Test Files for Sandbox Permissions Tests
run: |
touch ~/.aws/credentials
touch ~/.gcloud/credentials.json
touch ~/.kube/config
touch ~/.ssh/id_rsa
touch ~/.gnupg/pubring.kbx
touch ~/.docker/config.json
- name: Disable AppArmor for Bubblewrap
run: |
sudo systemctl stop apparmor
sudo systemctl disable apparmor
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Run Sandbox E2E Test
run: pmg --sandbox --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js
+4
View File
@@ -17,6 +17,10 @@ See [example](https://safedep.io/malicious-npm-package-express-cookie-parser/)
- Blocks malicious packages at install time
- No configuration required, just install and use
- Maintains package installation event log for transparency and audit trail
- Enforces least privilege and defense in depth using OS native sandboxing
PMG guarantees its own artifact integrity using GitHub and npm attestations. Users can cryptographically prove that the binary they run
matches the source code they reviewed, eliminating the risk of tampered or malicious builds. See [why and how to trust PMG](docs/trust.md).
## PMG in Action
+7 -1
View File
@@ -57,8 +57,14 @@ trusted_packages:
#
# Currently supported platforms:
# - macOS (using Seatbelt sandbox-exec)
# - Linux (planned: Bubblewrap or seccomp-bpf)
# - Linux (using Bubblewrap with namespace isolation)
# - Windows (planned)
#
# Platform-specific limitations:
# - Linux: Filesystem permissions use coarse-grained bind mounts. Glob patterns (e.g., *.txt)
# are expanded at policy translation time, but entire directories may be mounted rather than
# individual matching files. This is less precise than macOS regex-based filtering.
# - macOS: Network filtering is limited (all-or-nothing for most policies).
sandbox:
# Enable sandbox mode (opt-in, default: false for backward compatibility)
enabled: false
+81 -5
View File
@@ -5,6 +5,31 @@ PMG sandbox design goal is to protect against unknown supply chain attacks using
We do not want to re-invent sandbox and likely rely on OS native sandbox primitives. This is at the cost of developer experience,
where we have to work within the limitations of the sandbox implementations that we use.
## Requirements
- Bubblewrap on Linux
- Seatbelt on MacOS
<details>
<summary>Bubblewrap Installation on Linux</summary>
For Debian-based Linux distributions, you can install Bubblewrap with the following command:
```bash
sudo apt install bubblewrap
```
For Arch Linux, you can install Bubblewrap with the following command:
```bash
sudo pacman -S bubblewrap
```
For other Linux distributions, you can install Bubblewrap from the package manager of your choice.
See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installation) for more details.
</details>
## Usage
- Make sure sandbox is enabled in your `config.yml` file.
@@ -99,11 +124,39 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
## Supported Platforms
| Platform | Supported | Implementation |
| -------- | --------- | ---------------------------------- |
| MacOS | Yes | Seatbelt sandbox-exec |
| Linux | No | Bubblewrap / seccomp-bpf (planned) |
| Windows | No | Not yet supported |
| Platform | Supported | Implementation |
| -------- | --------- | ----------------------------------- |
| MacOS | Yes | Seatbelt sandbox-exec |
| Linux | Yes | Bubblewrap with namespace isolation |
| Windows | No | Not yet supported |
### Platform-Specific Limitations
<details>
<summary>Linux (Bubblewrap)</summary>
**Filesystem permissions are coarse-grained**: [Bubblewrap](https://github.com/containers/bubblewrap) uses bind mounts for filesystem isolation.
To prevent `Argument list too long` errors with large directory trees, PMG automatically uses
coarse-grained fallback strategies when glob patterns match many files.
**Fallback Behavior:**
- **Small patterns** (< 100 matches): Individual files are mounted (fine-grained, most precise)
- **Large patterns** (> 100 matches): Parent directory is mounted (coarse-grained, scalable)
- **Threshold**: 100 paths per pattern triggers coarse-grained fallback
**Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific
filtering is not enforced.
</details>
<details>
<summary>macOS (Seatbelt)</summary>
**Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained `host:port` filtering is not enforced.
</details>
## Concepts
@@ -176,6 +229,29 @@ Use `log(1)` to filter the log file by the log tag or generic `PMG_SBX_` prefix.
log show --last 5m --predicate 'message ENDSWITH "PMG_SBX_"' --style compact
```
### Linux
Linux sandbox implementation uses Bubblewrap for namespace-based isolation. Enable debug logging to see translated sandbox arguments:
```bash
APP_LOG_LEVEL=debug APP_LOG_FILE=/tmp/pmg-debug.log pmg --sandbox --sandbox-profile=npm-restrictive npm install express
```
Review the debug log to see the translated `bwrap` command-line arguments:
```bash
grep "Bubblewrap arguments" /tmp/pmg-debug.log
```
To debug sandbox violations, you can manually test commands with increased verbosity by running the sandbox command directly:
```bash
# Extract the bwrap command from debug logs and run with --verbose
bwrap --verbose [arguments...] -- npm install express
```
**Note**: Unlike macOS, Bubblewrap does not provide real-time violation logging. Policy violations typically manifest as `EACCES` (Permission denied) errors.
## References
- https://github.com/anthropic-experimental/sandbox-runtime
+2 -2
View File
@@ -10,7 +10,7 @@ The assertion in [2] cannot be *implicit*. If so, it breaks the entire security
## Security Goals
- Adopt software supply chain security best practices so that PMG users can *verify* and only then trust PMG
- PMG is open source, built in public and reviewed by the community for trust in code
- PMG is open source, built in public and reviewed by the community for verifiable source of truth
- PMG leverages GitHub build attestation to verify the integrity of the PMG binary with source provenance
- PMG npm package has build attestation to verify the integrity of the PMG binary and build environment with source provenance
- PMG security model is multi-layered without single point of failure
@@ -48,7 +48,7 @@ Install verified binary for your platform:
gh release download $RELEASE_TAG -R safedep/pmg --dir ./pmg-$RELEASE_TAG
```
Install the platform specific binary from `./$pmg-$RELEASE_TAG`. To see binary specific attestation metadata, run:
Install the platform specific binary from `./pmg-$RELEASE_TAG`. To see binary specific attestation metadata, run:
```bash
gh attestation verify pmg_Linux_x86_64.tar.gz -R safedep/pmg --format json
+13 -2
View File
@@ -56,7 +56,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
policy, err = registry.GetProfile(cfg.SandboxProfileOverride)
if err != nil {
return nil, fmt.Errorf("failed to load override sandbox policy %s: %w", cfg.SandboxProfileOverride, err)
return nil, usefulerror.Useful().
WithCode("sandbox_policy_load_failed").
WithHumanError(fmt.Sprintf("failed to load override sandbox policy %s: %s", cfg.SandboxProfileOverride, err)).
WithHelp("Please check the sandbox profile path and try again.").
WithAdditionalHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
Wrap(fmt.Errorf("failed to load override sandbox policy %s: %w", cfg.SandboxProfileOverride, err))
}
} else {
log.Debugf("Looking up sandbox policy for %s", pmName)
@@ -68,6 +73,7 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
if !exists {
return nil, usefulerror.Useful().
WithCode("sandbox_policy_not_configured").
WithHumanError(fmt.Sprintf("no sandbox policy configured for %s", pmName)).
WithHelp("Please configure a sandbox policy for this package manager in the config file.").
WithAdditionalHelp("See https://github.com/safedep/pmg/blob/main/docs/sandbox.md for more information.").
@@ -123,7 +129,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
}
if !sb.IsAvailable() {
return nil, fmt.Errorf("sandbox %s is required but not available", sb.Name())
return nil, usefulerror.Useful().
WithCode("sandbox_not_available").
WithHumanError(fmt.Sprintf("sandbox %s is required but not available", sb.Name())).
WithHelp("Please install the sandbox provider and try again.").
WithAdditionalHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
Wrap(fmt.Errorf("sandbox %s is required but not available", sb.Name()))
}
log.Debugf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
+216
View File
@@ -0,0 +1,216 @@
//go:build linux
// +build linux
package platform
import (
"os"
)
// bubblewrapConfig contains configuration for Bubblewrap sandbox behavior.
// This allows for tuning sandbox isolation without hardcoding magic values
// throughout the translator.
type bubblewrapConfig struct {
// Essential system paths that are always mounted read-only for package managers
// to function. These paths provide access to system libraries, binaries, and
// runtime dependencies.
essentialSystemPaths []string
// Essential device files that must be accessible in the sandbox.
// These are critical for basic I/O operations and random number generation.
essentialDevices []string
// Proc filesystem paths to mount. The /proc filesystem provides runtime
// information about processes, system resources, and kernel parameters.
procPaths []string
// Maximum depth for glob pattern expansion. Limits filesystem traversal
// to prevent excessive scanning of deep directory trees.
// Set to 0 for unlimited depth (not recommended).
maxGlobDepth int
// Maximum number of paths to expand from a single glob pattern.
// Prevents memory exhaustion from patterns matching huge directory trees.
maxGlobPaths int
// Glob fallback threshold for coarse-grained binding.
// When glob expansion yields more than this many paths, fallback to binding
// the parent directory instead of individual files for scalability.
globFallbackThreshold int
// Total argument limit for bwrap command.
// Warns when total arguments exceed this limit (approaching ARG_MAX).
totalArgsLimit int
// Whether to unshare the network namespace by default if policy has no network rules.
// When true and no network rules specified, completely isolates network access.
unshareNetworkByDefault bool
// Whether to unshare the PID namespace. Isolates process tree visibility.
// Recommended for security but may break some package managers that inspect processes.
unsharePID bool
// Whether to unshare the IPC namespace. Isolates System V IPC and POSIX message queues.
unshareIPC bool
// Whether to create a new session (setsid). Detaches from terminal session.
newSession bool
// Whether to die when parent process exits. Ensures cleanup of orphaned sandboxes.
dieWithParent bool
// Seccomp filter configuration
seccomp seccompConfig
// Maximum depth to scan for mandatory deny patterns (e.g., .env files in subdirectories)
// Set to 0 to only check literal paths, higher values scan subdirectories.
mandatoryDenyScanDepth int
}
// seccompConfig contains seccomp-bpf filter settings
type seccompConfig struct {
// Whether to enable seccomp filtering
enabled bool
// Path to seccomp filter file (BPF bytecode)
// If empty, uses built-in default filter
filterPath string
// Syscalls to deny (blocklist approach)
// Common dangerous syscalls: ptrace, kexec_load, module_init, etc.
deniedSyscalls []string
}
// newDefaultBubblewrapConfig creates a bubblewrap config with safe default values.
// These defaults are based on:
// - Common Linux filesystem layouts
// - Anthropic Sandbox Runtime implementation patterns
//
// This config is for maintaining safe defaults for the sandbox. Future enhancements
// will allow the user to override the config through policy or sandbox config available at PMG level.
func newDefaultBubblewrapConfig() *bubblewrapConfig {
return &bubblewrapConfig{
// Essential system paths (read-only)
// Based on Filesystem Hierarchy Standard (FHS)
essentialSystemPaths: []string{
"/usr", // User binaries, libraries, documentation
"/lib", // Essential shared libraries
"/lib64", // 64-bit libraries (on x86_64 systems)
"/bin", // Essential command binaries (may be symlink to /usr/bin)
"/sbin", // System binaries (may be symlink to /usr/sbin)
"/etc", // System configuration files (read-only access needed for DNS, etc.)
"/opt", // Optional application software packages
"/var/lib", // Variable state information (package databases, etc.)
"/sys", // Sysfs - kernel and device information
},
// Essential device files
// Required for basic I/O, randomness, and null device operations
essentialDevices: []string{
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/full",
"/dev/tty", // For terminal operations
},
// Proc filesystem paths
// Provides process and system information
procPaths: []string{
"/proc", // Full proc filesystem
},
// Glob expansion limits
// Conservative defaults to prevent DoS via huge glob patterns
maxGlobDepth: 5, // Scan up to 5 directory levels
maxGlobPaths: 10000, // Maximum 10k paths per glob pattern
globFallbackThreshold: 100, // Fallback to parent dir above 100 paths
totalArgsLimit: 8000, // Total bwrap argument safety limit
// Network isolation (default: isolate network if no rules)
unshareNetworkByDefault: true,
// Process/IPC isolation
unsharePID: true, // Isolate PID namespace
unshareIPC: true, // Isolate IPC namespace
newSession: true, // Create new session
dieWithParent: true, // Cleanup on parent exit
// Seccomp configuration
seccomp: seccompConfig{
enabled: false, // Disabled by default (Phase 4 enhancement)
filterPath: "", // Use built-in filter when enabled
deniedSyscalls: []string{
// Dangerous syscalls that should be blocked
"ptrace", // Process tracing (debugging/injection)
"kexec_load", // Load new kernel
"module_init", // Load kernel modules
"reboot", // System reboot
"swapon", // Enable swap
"swapoff", // Disable swap
"mount", // Mount filesystems
"umount", // Unmount filesystems
"pivot_root", // Change root filesystem
"chroot", // Change root directory
"unshare", // Create new namespaces (prevent nested sandboxing)
"setns", // Join existing namespace
"acct", // Process accounting
"add_key", // Add key to kernel keyring
"request_key", // Request key from kernel
"keyctl", // Manipulate kernel keyring
"ioperm", // Set port I/O permissions
"iopl", // Set I/O privilege level
"perf_event_open", // Performance monitoring
},
},
// Scan depth for finding dangerous files in project directories
mandatoryDenyScanDepth: 3,
}
}
// shouldUnshareNetwork determines whether to isolate network based on policy.
// Returns true if network should be completely isolated (--unshare-net).
func (c *bubblewrapConfig) shouldUnshareNetwork(hasAllowRules bool, hasDenyAll bool) bool {
// If there are allow rules, don't isolate network
// Note: bubblewrap can't do per-host filtering, so allow rules mean "allow network"
// The allow_outbound rules serve as documentation of intended access
if hasAllowRules {
return false
}
// No allow rules - check if we should deny all
if hasDenyAll {
return true
}
// No allow rules and no deny-all - use default behavior
return c.unshareNetworkByDefault
}
// getEssentialSystemPaths returns essential system paths for read-only binding.
// Filters out paths that don't exist on this system (e.g., /lib64 on 32-bit).
func (c *bubblewrapConfig) getEssentialSystemPaths() []string {
existingPaths := make([]string, 0, len(c.essentialSystemPaths))
for _, path := range c.essentialSystemPaths {
if _, err := os.Stat(path); err == nil {
existingPaths = append(existingPaths, path)
}
}
return existingPaths
}
// getEssentialDevices returns essential device files for binding.
// Filters out devices that don't exist on this system.
func (c *bubblewrapConfig) getEssentialDevices() []string {
existingDevices := make([]string, 0, len(c.essentialDevices))
for _, device := range c.essentialDevices {
if _, err := os.Stat(device); err == nil {
existingDevices = append(existingDevices, device)
}
}
return existingDevices
}
+110
View File
@@ -0,0 +1,110 @@
//go:build linux
// +build linux
package platform
import (
"context"
"fmt"
"os/exec"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
)
// bubblewrapSandbox implements the Sandbox interface using Bubblewrap (bwrap) on Linux.
// Bubblewrap is a low-level unprivileged sandboxing tool that uses Linux namespaces
// to isolate processes with controlled access to filesystem, network, and IPC resources.
//
// This implementation follows the CLI-wrapper pattern (like Seatbelt on macOS):
// - Modifies the cmd in place by wrapping it with `bwrap` CLI
// - Returns ExecutionResult with executed=false
// - Caller must call cmd.Run() to execute the sandboxed command
type bubblewrapSandbox struct {
config *bubblewrapConfig
translator *bubblewrapPolicyTranslator
}
// newBubblewrapSandbox creates a new Bubblewrap sandbox instance with default configuration.
func newBubblewrapSandbox() (*bubblewrapSandbox, error) {
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
return &bubblewrapSandbox{
config: config,
translator: translator,
}, nil
}
// Execute prepares a command to run in the Bubblewrap sandbox with the given policy.
// It translates the PMG policy to bwrap CLI arguments and wraps the command execution.
//
// This implementation modifies the cmd in place and does NOT execute it.
// Returns ExecutionResult with executed=false, indicating the caller must run cmd.Run().
func (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
bwrapPath, err := exec.LookPath("bwrap")
if err != nil {
return nil, usefulerror.Useful().
WithCode("bubblewrap_not_found").
WithHumanError("Bubblewrap binary not found").
WithHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
Wrap(fmt.Errorf("bubblewrap binary not found: %w", err))
}
bwrapArgs, err := b.translator.translate(policy)
if err != nil {
return nil, fmt.Errorf("failed to translate sandbox policy to bubblewrap arguments: %w", err)
}
log.Debugf("Bubblewrap arguments: %v", bwrapArgs)
originalPath := cmd.Path
originalArgs := cmd.Args
// Build bwrap command: bwrap [bwrap-args] -- <original-command> <original-args>
// The "--" separator is important to distinguish bwrap args from command args
cmd.Path = bwrapPath
cmd.Args = []string{"bwrap"}
// Add all translated bwrap arguments
cmd.Args = append(cmd.Args, bwrapArgs...)
// Add separator
cmd.Args = append(cmd.Args, "--")
// Add original command
cmd.Args = append(cmd.Args, originalPath)
// Add original arguments (skip argv[0] which is the command itself)
if len(originalArgs) > 1 {
cmd.Args = append(cmd.Args, originalArgs[1:]...)
}
log.Debugf("Sandboxed command: %s %v", cmd.Path, cmd.Args)
return sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(b)), nil
}
// Name returns the name of this sandbox implementation.
func (b *bubblewrapSandbox) Name() string {
return "bubblewrap"
}
// IsAvailable returns true if bubblewrap (bwrap) is available on this system.
// Checks by attempting to locate the bwrap binary in PATH.
func (b *bubblewrapSandbox) IsAvailable() bool {
_, err := exec.LookPath("bwrap")
return err == nil
}
// Close cleans up any resources allocated by the sandbox.
// For Bubblewrap, there are no temporary files to clean up (unlike Seatbelt),
// since all configuration is passed via CLI arguments.
//
// This method is idempotent and safe to call multiple times.
func (b *bubblewrapSandbox) Close() error {
// Bubblewrap doesn't create temporary files like Seatbelt does,
// so there's nothing to clean up. All isolation is via CLI args.
return nil
}
+320
View File
@@ -0,0 +1,320 @@
//go:build linux
// +build linux
package platform
import (
"context"
"os/exec"
"testing"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBubblewrapSandboxCreation(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
assert.NotNil(t, sb)
assert.Equal(t, "bubblewrap", sb.Name())
}
func TestBubblewrapSandboxIsAvailable(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
// This test will pass if bwrap is installed, skip if not
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
assert.True(t, sb.IsAvailable())
}
func TestBubblewrapSandboxExecute(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test policy",
PackageManagers: []string{"test"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr", "/lib", "/bin"},
AllowWrite: []string{"/tmp"},
},
Network: sandbox.NetworkPolicy{
AllowOutbound: []string{"*:*"},
},
}
// Create a simple command to wrap
cmd := exec.Command("/bin/echo", "hello")
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
require.NotNil(t, result)
// Result should indicate caller must run the command
assert.True(t, result.ShouldRun(), "Bubblewrap should return executed=false")
// Command should be modified to use bwrap
assert.Equal(t, "bwrap", cmd.Args[0])
assert.Contains(t, cmd.Args, "/bin/echo")
assert.Contains(t, cmd.Args, "hello")
// Cleanup
err = result.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxExecuteCommandWrapping(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test policy",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr"},
},
}
// Create a command with multiple arguments
originalCmd := "/usr/bin/node"
originalArgs := []string{"/usr/bin/node", "--version"}
cmd := exec.Command(originalCmd, originalArgs[1:]...)
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
// Verify command structure
// bwrap [bwrap-args] -- /usr/bin/node --version
assert.Contains(t, cmd.Args, "bwrap")
assert.Contains(t, cmd.Args, "--") // Separator
assert.Contains(t, cmd.Args, originalCmd)
assert.Contains(t, cmd.Args, "--version")
// Find the separator and verify structure
separatorIdx := -1
for i, arg := range cmd.Args {
if arg == "--" {
separatorIdx = i
break
}
}
assert.NotEqual(t, -1, separatorIdx, "Should have -- separator")
// After separator should be the original command and args
afterSeparator := cmd.Args[separatorIdx+1:]
assert.Equal(t, originalCmd, afterSeparator[0])
assert.Equal(t, "--version", afterSeparator[1])
// Cleanup
err = result.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxExecuteWithPTY(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test with PTY",
PackageManagers: []string{"npm"},
AllowPTY: utils.PtrTo(true),
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr"},
},
}
cmd := exec.Command("/bin/echo", "test")
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
// Should have PTY-related arguments
argsStr := ""
for _, arg := range cmd.Args {
argsStr += arg + " "
}
assert.Contains(t, argsStr, "/dev/pts")
assert.Contains(t, argsStr, "/dev/ptmx")
// Cleanup
err = result.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxExecuteWithNetworkIsolation(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test with network isolation",
PackageManagers: []string{"npm"},
Network: sandbox.NetworkPolicy{
DenyOutbound: []string{"*:*"},
},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr"},
},
}
cmd := exec.Command("/bin/echo", "test")
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
// Should have network isolation
argsStr := ""
for _, arg := range cmd.Args {
argsStr += arg + " "
}
assert.Contains(t, argsStr, "--unshare-net")
// Cleanup
err = result.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxClose(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
// Close should be idempotent
err = sb.Close()
assert.NoError(t, err)
err = sb.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxExecutionResult(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
PackageManagers: []string{"test"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr"},
},
}
cmd := exec.Command("/bin/echo", "test")
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
// Verify ExecutionResult properties
assert.True(t, result.ShouldRun(), "Bubblewrap uses CLI wrapper, should return executed=false")
// Close should succeed
err = result.Close()
assert.NoError(t, err)
// Multiple closes should be safe
err = result.Close()
assert.NoError(t, err)
}
func TestBubblewrapSandboxTranslationError(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
// Create a policy with invalid patterns (shouldn't cause translation error)
policy := &sandbox.SandboxPolicy{
Name: "test",
PackageManagers: []string{"test"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr"},
},
}
cmd := exec.Command("/bin/echo", "test")
ctx := context.Background()
// Should succeed even with complex patterns
result, err := sb.Execute(ctx, cmd, policy)
assert.NoError(t, err)
assert.NotNil(t, result)
// Cleanup
if result != nil {
_ = result.Close()
}
}
func TestBubblewrapSandboxEssentialBindMounts(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
if !sb.IsAvailable() {
t.Skip("bubblewrap (bwrap) is not installed on this system")
}
policy := &sandbox.SandboxPolicy{
Name: "test",
PackageManagers: []string{"test"},
// Minimal policy - should still get essential mounts
Filesystem: sandbox.FilesystemPolicy{},
}
cmd := exec.Command("/bin/echo", "test")
ctx := context.Background()
result, err := sb.Execute(ctx, cmd, policy)
require.NoError(t, err)
argsStr := ""
for _, arg := range cmd.Args {
argsStr += arg + " "
}
// Should have essential system paths
assert.Contains(t, argsStr, "/usr")
// Should have essential devices
assert.Contains(t, argsStr, "/dev/null")
// Should have proc filesystem
assert.Contains(t, argsStr, "--proc")
// Should have tmpdir
assert.Contains(t, argsStr, "--bind")
// Cleanup
err = result.Close()
assert.NoError(t, err)
}
@@ -0,0 +1,706 @@
//go:build linux
// +build linux
package platform
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/util"
)
// bubblewrapPolicyTranslator translates PMG SandboxPolicy to Bubblewrap (bwrap) CLI arguments.
//
// Bubblewrap uses command-line arguments instead of profile files (like Seatbelt).
// The translator generates arguments for:
// - Filesystem bind mounts (--bind, --ro-bind, --dev-bind)
// - Network isolation (--unshare-net)
// - Process isolation (--unshare-pid, --unshare-ipc)
// - Device access (--dev-bind /dev/null, etc.)
// - Essential system permissions
type bubblewrapPolicyTranslator struct {
config *bubblewrapConfig
}
// newBubblewrapPolicyTranslator creates a new translator with the given config.
func newBubblewrapPolicyTranslator(config *bubblewrapConfig) *bubblewrapPolicyTranslator {
return &bubblewrapPolicyTranslator{
config: config,
}
}
// translate converts a PMG SandboxPolicy to bwrap CLI arguments.
// Returns a slice of arguments to pass to the bwrap command.
func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([]string, error) {
args := []string{}
// 1. Add essential system permissions (filesystem, devices, proc)
systemArgs, err := t.addEssentialSystemPermissions()
if err != nil {
return nil, fmt.Errorf("failed to add essential system permissions: %w", err)
}
args = append(args, systemArgs...)
// 2. Add isolation namespaces
isolationArgs := t.addIsolationNamespaces(policy)
args = append(args, isolationArgs...)
// 3. Add filesystem rules (allow read, allow write, deny patterns)
filesystemArgs, err := t.translateFilesystem(policy)
if err != nil {
return nil, fmt.Errorf("failed to translate filesystem rules: %w", err)
}
args = append(args, filesystemArgs...)
// 4. Add PTY support if needed
if utils.SafelyGetValue(policy.AllowPTY) {
ptyArgs := t.addPTYSupport()
args = append(args, ptyArgs...)
}
// 5. Add tmpdir support (package managers need writable temp directory)
tmpdirArgs := t.addTmpdirSupport()
args = append(args, tmpdirArgs...)
// 6. Check total argument limit and log warning if exceeded
// Do not fail, let bwrap fail naturally if it does.
if len(args) > t.config.totalArgsLimit {
log.Warnf("Total bwrap arguments (%d) exceeds safety limit (%d), sandbox may fail with 'Argument list too long' error",
len(args), t.config.totalArgsLimit)
}
log.Debugf("Translated policy '%s' to %d bwrap arguments (limit: %d)", policy.Name, len(args), t.config.totalArgsLimit)
return args, nil
}
// addEssentialSystemPermissions adds bind mounts for essential system paths and devices
// that package managers need to function properly.
func (t *bubblewrapPolicyTranslator) addEssentialSystemPermissions() ([]string, error) {
args := []string{}
// Add essential system paths (read-only)
for _, path := range t.config.getEssentialSystemPaths() {
args = append(args, "--ro-bind-try", path, path)
}
// Add essential device files
for _, device := range t.config.getEssentialDevices() {
args = append(args, "--dev-bind-try", device, device)
}
// Add proc filesystem (read-only for safety)
for _, procPath := range t.config.procPaths {
args = append(args, "--proc", procPath)
}
return args, nil
}
// addIsolationNamespaces adds namespace isolation arguments based on policy and config.
func (t *bubblewrapPolicyTranslator) addIsolationNamespaces(policy *sandbox.SandboxPolicy) []string {
args := []string{}
// Network isolation
hasAllowRules := len(policy.Network.AllowOutbound) > 0
hasDenyAll := false
for _, pattern := range policy.Network.DenyOutbound {
if pattern == "*:*" {
hasDenyAll = true
break
}
}
if t.config.shouldUnshareNetwork(hasAllowRules, hasDenyAll) {
args = append(args, "--unshare-net")
log.Debugf("Network isolated (--unshare-net)")
} else {
log.Debugf("Network allowed (no --unshare-net)")
}
// PID namespace isolation
if t.config.unsharePID {
args = append(args, "--unshare-pid")
}
// IPC namespace isolation
if t.config.unshareIPC {
args = append(args, "--unshare-ipc")
}
// New session
if t.config.newSession {
args = append(args, "--new-session")
}
// Die with parent
if t.config.dieWithParent {
args = append(args, "--die-with-parent")
}
return args
}
// translateFilesystem converts filesystem policy rules to bwrap bind mount arguments.
//
// Bubblewrap filesystem isolation works via bind mounts:
// - --ro-bind: Read-only bind mount
// - --bind: Read-write bind mount
// - --dev-bind: Device file bind mount
// - --tmpfs: Temporary file system mount (used to hide specific files/directories)
// - Paths not mounted are inaccessible (deny-by-default)
//
// Strategy:
// 1. Start with essential system paths (added separately)
// 2. Add user-specified allow_read paths FIRST (read-only bind mounts)
// This establishes the base filesystem view (e.g., "/" for full access)
// 3. Add user-specified allow_write paths SECOND (read-write bind mounts)
// These OVERRIDE earlier read-only binds (bwrap: later mounts win)
// 4. Handle deny patterns by mounting /dev/null or read-only for directories
// 5. Add mandatory deny patterns
func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPolicy) ([]string, error) {
args := []string{}
// Track paths we've already bound for read and write to avoid duplicates
// Bubblewrap later mounts win, so we need to track both read and write bound paths.
readBoundPaths := make(map[string]bool)
writeBoundPaths := make(map[string]bool)
// Add essential system paths to bound paths (already handled separately)
for _, path := range t.config.getEssentialSystemPaths() {
readBoundPaths[path] = true
}
// Mark tmpdir as already bound (will be handled by addTmpdirSupport())
// This prevents conflicts from policy patterns like /tmp/**
tmpDir := os.TempDir()
writeBoundPaths[tmpDir] = true
// 1. Process allow_read rules FIRST (read-only bind mounts)
// This establishes the base read-only filesystem view (including "/" if specified)
for _, pattern := range policy.Filesystem.AllowRead {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
log.Warnf("Failed to expand variables in allow_read pattern '%s': %v", pattern, err)
continue
}
// Glob chars are handled by the processReadRule function.
readArgs, err := t.processReadRule(expanded, readBoundPaths)
if err != nil {
log.Warnf("Failed to process allow_read rule '%s': %v", expanded, err)
continue
}
args = append(args, readArgs...)
}
// 2. Process allow_write rules SECOND (read-write bind mounts)
// These OVERRIDE earlier read-only binds (bwrap: later mounts win)
// Use a separate map so we don't skip paths that need write access
writeBoundPaths[tmpDir] = true // tmpdir handled by addTmpdirSupport
for _, pattern := range policy.Filesystem.AllowWrite {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
log.Warnf("Failed to expand variables in allow_write pattern '%s': %v", pattern, err)
continue
}
// Glob chars are handled by the processWriteRule function.
writeArgs, err := t.processWriteRule(expanded, writeBoundPaths)
if err != nil {
log.Warnf("Failed to process allow_write rule '%s': %v", expanded, err)
continue
}
args = append(args, writeArgs...)
}
// 3. Process deny_write rules (mount /dev/null to prevent access)
allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig)
denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...)
// Add mandatory deny patterns (credentials - these get completely hidden)
mandatoryDenies := util.GetMandatoryDenyPatterns(allowGitConfig)
denyPatterns = append(denyPatterns, mandatoryDenies...)
for _, pattern := range denyPatterns {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
log.Warnf("Failed to expand variables in deny pattern '%s': %v", pattern, err)
continue
}
denyArgs, err := t.processDenyRule(expanded)
if err != nil {
log.Debugf("Deny rule '%s' skipped: %v", expanded, err)
continue
}
args = append(args, denyArgs...)
}
// 4. Process mandatory credential directories - completely hide them with tmpfs
// This blocks both read AND write access (more secure than read-only mount)
hiddenDirs := make(map[string]bool)
for _, pattern := range mandatoryDenies {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
continue
}
var dirsToHide []string
if util.ContainsGlob(expanded) {
matches, err := filepath.Glob(expanded)
if err != nil {
continue
}
dirsToHide = matches
} else {
dirsToHide = []string{expanded}
}
for _, dir := range dirsToHide {
if hiddenDirs[dir] {
continue
}
if info, err := os.Stat(dir); err == nil && info.IsDir() {
args = append(args, "--tmpfs", dir)
hiddenDirs[dir] = true
log.Debugf("Hiding credential directory '%s' with tmpfs", dir)
}
}
}
// 5. Process deny_exec rules (mount /dev/null over executables)
for _, exePath := range policy.Process.DenyExec {
expanded, err := util.ExpandVariables(exePath)
if err != nil {
log.Warnf("Failed to expand variables in deny_exec pattern '%s': %v", exePath, err)
continue
}
// Handle glob patterns (e.g., /usr/bin/python*)
if util.ContainsGlob(expanded) {
matches, err := filepath.Glob(expanded)
if err != nil {
log.Warnf("Failed to expand deny_exec glob '%s': %v", expanded, err)
continue
}
for _, match := range matches {
if info, err := os.Stat(match); err == nil && !info.IsDir() {
args = append(args, "--ro-bind", "/dev/null", match)
log.Debugf("Blocked execution of '%s'", match)
}
}
} else {
if info, err := os.Stat(expanded); err == nil && !info.IsDir() {
args = append(args, "--ro-bind", "/dev/null", expanded)
log.Debugf("Blocked execution of '%s'", expanded)
}
}
}
return args, nil
}
// processReadRule handles a single allow_read rule, expanding globs and creating ro-bind mounts.
func (t *bubblewrapPolicyTranslator) processReadRule(path string, boundPaths map[string]bool) ([]string, error) {
args := []string{}
// Check if path contains glob pattern
if util.ContainsGlob(path) {
// Check if the base directory is already bound
baseDir := t.extractParentDir(path)
if boundPaths[baseDir] {
log.Debugf("Skipping pattern '%s' - base directory '%s' already bound", path, baseDir)
return args, nil
}
// Expand glob pattern to concrete paths with fallback detection
paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
}
if useFallback {
// Coarse-grained: bind parent directory
for _, parentDir := range paths {
if !boundPaths[parentDir] {
args = append(args, "--ro-bind-try", parentDir, parentDir)
boundPaths[parentDir] = true
log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-only)", parentDir)
}
}
} else {
// Fine-grained: bind individual paths
for _, p := range paths {
if !boundPaths[p] {
args = append(args, "--ro-bind-try", p, p)
boundPaths[p] = true
}
}
}
} else {
// Literal path - create read-only bind
if !boundPaths[path] {
args = append(args, "--ro-bind-try", path, path)
boundPaths[path] = true
}
}
return args, nil
}
// processWriteRule handles a single allow_write rule, expanding globs and creating rw-bind mounts.
func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths map[string]bool) ([]string, error) {
args := []string{}
// Check if path contains glob pattern
if util.ContainsGlob(path) {
// Check if the base directory is already bound (e.g., /tmp already bound, skip /tmp/**)
baseDir := t.extractParentDir(path)
if boundPaths[baseDir] {
log.Debugf("Skipping pattern '%s' - base directory '%s' already bound", path, baseDir)
return args, nil
}
// Expand glob pattern to concrete paths with fallback detection
paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
}
if useFallback {
// Coarse-grained: bind parent directory
for _, parentDir := range paths {
if !boundPaths[parentDir] {
args = append(args, "--bind-try", parentDir, parentDir)
boundPaths[parentDir] = true
log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-write)", parentDir)
} else {
// Path already bound, skip (likely already bound as read-only from essential paths)
log.Debugf("Parent directory '%s' already bound, skipping duplicate bind", parentDir)
}
}
} else {
// Fine-grained: bind individual paths
for _, p := range paths {
// Check if path exists - if not, bind parent directory instead
// This allows creating new directories (e.g., node_modules/** when node_modules doesn't exist)
pathToBind := p
if _, err := os.Stat(p); os.IsNotExist(err) {
parentDir := filepath.Dir(p)
if parentDir != "" && parentDir != "." && parentDir != "/" {
pathToBind = parentDir
log.Debugf("Path '%s' doesn't exist, binding parent '%s' as writable to allow creation", p, parentDir)
}
}
if !boundPaths[pathToBind] {
args = append(args, "--bind-try", pathToBind, pathToBind)
boundPaths[pathToBind] = true
} else {
// Path already bound, add another bind to upgrade to read-write
// bwrap: later mounts override earlier ones
args = append(args, "--bind-try", pathToBind, pathToBind)
log.Debugf("Path '%s' already bound, adding write bind to override", pathToBind)
}
}
}
} else {
// Literal path - create read-write bind
if !boundPaths[path] {
args = append(args, "--bind-try", path, path)
boundPaths[path] = true
}
}
return args, nil
}
// processDenyRule handles deny rules by mounting /dev/null to prevent file access.
// This technique is borrowed from Anthropic's sandbox-runtime.
func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, error) {
args := []string{}
// For glob patterns, expand and deny each path
if util.ContainsGlob(path) {
// For deny rules, we scan for existing files matching the pattern
// Note: For deny rules, we ignore the fallback indicator since we want to
// deny all matched paths individually for maximum security
paths, _, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths)
if err != nil {
// If glob expansion fails, it's not critical for deny rules
return args, nil
}
for _, p := range paths {
info, err := os.Stat(p)
if err == nil {
if info.IsDir() {
// For directories, mount as read-only to prevent writes
// This overrides any previous writable bind of parent directories
args = append(args, "--ro-bind-try", p, p)
log.Debugf("Deny rule: mounted directory '%s' as read-only", p)
} else {
// For files, mount /dev/null to prevent access
args = append(args, "--ro-bind", "/dev/null", p)
}
}
}
} else {
// For literal paths, check if they exist
if info, err := os.Stat(path); err == nil {
if info.IsDir() {
// For directories, mount as read-only to prevent writes
// This overrides any previous writable bind of parent directories
args = append(args, "--ro-bind-try", path, path)
log.Debugf("Deny rule: mounted directory '%s' as read-only", path)
} else {
// File exists - mount /dev/null over it
args = append(args, "--ro-bind", "/dev/null", path)
}
} else if os.IsNotExist(err) {
// File doesn't exist - skip it
// IMPORTANT: We cannot use --ro-bind /dev/null for non-existent paths because
// bwrap creates the file on the host filesystem as a mount point, which leaves
// empty files (.env, .aws, etc.) in the user's directory after sandbox exits.
// Non-existent files are harmless (no secrets to leak), and blocking creation
// in writable directories isn't critical since an attacker creating an empty
// .env is not a security threat.
log.Debugf("Deny rule: skipping non-existent path '%s' (bwrap would create empty file as mount point)", path)
}
}
return args, nil
}
// findFirstNonExistentPath walks up the directory tree to find the first path component
// that doesn't exist. This allows us to block file creation by mounting /dev/null.
//
// Example: If /home/user/.env doesn't exist but /home/user does, returns /home/user/.env
func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) string {
path = filepath.Clean(path)
// Walk up the tree
for path != "/" && path != "." {
if _, err := os.Stat(path); os.IsNotExist(err) {
// Check if parent exists
parent := filepath.Dir(path)
if _, err := os.Stat(parent); err == nil {
// Parent exists, this is the first non-existent path
return path
}
}
path = filepath.Dir(path)
}
return ""
}
// expandGlobPattern expands a glob pattern to a list of concrete paths.
// Implements depth limiting and path count limiting to prevent DoS.
// Returns (paths, useFallback, error) where useFallback indicates if
// coarse-grained parent directory fallback should be used.
func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, bool, error) {
// Handle ** globstar patterns specially
if strings.Contains(pattern, "**") {
paths, err := t.expandGlobstarPattern(pattern, maxDepth, maxPaths)
if err != nil {
return nil, false, err
}
// Check if we should use fallback
if len(paths) > t.config.globFallbackThreshold {
log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
pattern, len(paths), t.config.globFallbackThreshold)
parentDir := t.extractParentDir(pattern)
return []string{parentDir}, true, nil
}
return paths, false, nil
}
// Use filepath.Glob for simple patterns (*, ?, [])
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, false, fmt.Errorf("glob expansion failed: %w", err)
}
// Check fallback threshold before applying maxPaths limit
if len(matches) > t.config.globFallbackThreshold {
log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
pattern, len(matches), t.config.globFallbackThreshold)
parentDir := t.extractParentDir(pattern)
return []string{parentDir}, true, nil
}
// Limit number of matches (shouldn't happen if fallback threshold < maxPaths)
if len(matches) > maxPaths {
log.Warnf("Glob pattern '%s' matched %d paths, limiting to %d", pattern, len(matches), maxPaths)
matches = matches[:maxPaths]
}
return matches, false, nil
}
// expandGlobstarPattern expands patterns containing ** (recursive glob).
// This requires custom implementation since filepath.Glob doesn't support **.
func (t *bubblewrapPolicyTranslator) expandGlobstarPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) {
// Split pattern at **
parts := strings.Split(pattern, "**")
if len(parts) != 2 {
return nil, fmt.Errorf("only one ** globstar supported per pattern")
}
basePath := strings.TrimSuffix(parts[0], "/")
suffix := strings.TrimPrefix(parts[1], "/")
// If base path is empty, it would walk from root which is prohibitively expensive.
// Skip such patterns to prevent filesystem scan timeouts.
if basePath == "" {
log.Debugf("Skipping globstar pattern '%s' with empty base path (would walk from root)", pattern)
return []string{}, nil
}
// Expand base path variables
var err error
basePath, err = util.ExpandVariables(basePath)
if err != nil {
return nil, fmt.Errorf("failed to expand base path: %w", err)
}
// Check if base path exists
if _, err := os.Stat(basePath); os.IsNotExist(err) {
// Base path doesn't exist yet, return just the base
return []string{basePath}, nil
}
matches := []string{}
// Walk the directory tree with depth limiting
err = t.walkWithDepthLimit(basePath, suffix, maxDepth, maxPaths, &matches)
if err != nil {
return nil, fmt.Errorf("failed to walk directory tree: %w", err)
}
return matches, nil
}
// walkWithDepthLimit walks a directory tree with depth limiting.
func (t *bubblewrapPolicyTranslator) walkWithDepthLimit(root string, suffix string, maxDepth int, maxPaths int, matches *[]string) error {
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Skip paths we can't access
return nil
}
// Calculate depth relative to root
relPath, err := filepath.Rel(root, path)
if err != nil {
return nil
}
// When relPath is "." (the root itself), depth should be 0
// strings.Split(".", "/") returns ["."] with length 1, causing off-by-one error
depth := 0
if relPath != "." {
depth = len(strings.Split(relPath, string(filepath.Separator)))
}
// Enforce depth limit
if maxDepth > 0 && depth > maxDepth {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// Match suffix
if suffix == "" || strings.HasSuffix(path, suffix) {
*matches = append(*matches, path)
// Enforce path count limit
if len(*matches) >= maxPaths {
return filepath.SkipAll
}
}
return nil
})
return err
}
// extractParentDir extracts the parent directory from a glob pattern.
// This is used for coarse-grained fallback when glob expansion yields too many paths.
//
// Examples:
// - ${CWD}/node_modules/** → ${CWD}/node_modules
// - ${HOME}/.cache/pnpm/** → ${HOME}/.cache/pnpm
// - /tmp/*.txt → /tmp
// - /usr/lib/**/*.so → /usr/lib
// - ${CWD}/package.json.* → ${CWD}
func (t *bubblewrapPolicyTranslator) extractParentDir(pattern string) string {
// Remove trailing /** or /*
pattern = strings.TrimSuffix(pattern, "/**")
pattern = strings.TrimSuffix(pattern, "/*")
// Remove any remaining glob characters and find the parent directory
idx := strings.IndexAny(pattern, "*?[")
if idx >= 0 {
// Glob found - truncate at glob character and get the directory
pattern = pattern[:idx]
// Get the directory containing the file/pattern
pattern = filepath.Dir(pattern)
}
// Clean up trailing separator
pattern = strings.TrimSuffix(pattern, string(filepath.Separator))
// If pattern is now empty or just a separator, default to current directory
if pattern == "" || pattern == string(filepath.Separator) {
return "."
}
return pattern
}
// addPTYSupport adds arguments for pseudo-terminal support.
// Required for interactive package manager commands.
func (t *bubblewrapPolicyTranslator) addPTYSupport() []string {
args := []string{}
// Bind /dev/pts for PTY allocation
args = append(args, "--dev-bind-try", "/dev/pts", "/dev/pts")
// Bind /dev/ptmx for PTY master
args = append(args, "--dev-bind-try", "/dev/ptmx", "/dev/ptmx")
return args
}
// addTmpdirSupport adds arguments for temporary directory access.
// Package managers need writable temp space for downloads, extraction, etc.
func (t *bubblewrapPolicyTranslator) addTmpdirSupport() []string {
args := []string{}
tmpDir := os.TempDir()
// Bind tmp directory as writable
// Use --bind instead of --bind-try to ensure it's available
args = append(args, "--bind", tmpDir, tmpDir)
return args
}
@@ -0,0 +1,917 @@
//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/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
}
+2 -4
View File
@@ -4,13 +4,11 @@
package platform
import (
"errors"
"github.com/safedep/pmg/sandbox"
)
// NewSandbox creates a platform-specific sandbox instance for Linux.
// TODO: Implement Bubblewrap or seccomp-bpf based sandbox.
// Uses Bubblewrap (bwrap) for filesystem, network, and process isolation.
func NewSandbox() (sandbox.Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Linux")
return newBubblewrapSandbox()
}
+59 -42
View File
@@ -6,6 +6,27 @@ const os = require('os');
const home = os.homedir();
const results = { passed: 0, failed: 0, tests: [] };
// Helper to test if a directory is blocked (either EPERM or empty via tmpfs)
function isDirectoryBlocked(dirPath) {
try {
const contents = fs.readdirSync(dirPath);
// On Linux/bwrap, tmpfs makes directory empty (credentials hidden)
// On macOS/seatbelt, access is denied (EPERM)
if (contents.length === 0) {
return { blocked: true, reason: 'empty via tmpfs' };
}
return { blocked: false, reason: 'contents readable' };
} catch (e) {
if (e.code === 'EPERM') {
return { blocked: true, reason: 'EPERM' };
}
if (e.code === 'ENOENT') {
return { blocked: true, reason: 'does not exist', skip: true };
}
return { blocked: true, reason: e.code, skip: true };
}
}
function test(name, fn) {
try {
const result = fn();
@@ -26,66 +47,62 @@ console.log('--- Tests that SHOULD be BLOCKED ---\n');
// Test 1: Read ~/.ssh (should be blocked)
test('BLOCK: Read ~/.ssh directory', () => {
try {
fs.readdirSync(path.join(home, '.ssh'));
console.log(' ❌ FAIL: Could read ~/.ssh');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.ssh blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.ssh - ${e.code} (may not exist)`);
return true; // ENOENT is okay if dir doesn't exist
const result = isDirectoryBlocked(path.join(home, '.ssh'));
if (result.skip) {
console.log(` ⚠️ SKIP: ~/.ssh - ${result.reason}`);
return true;
}
if (result.blocked) {
console.log(` ✅ PASS: ~/.ssh blocked (${result.reason})`);
return true;
}
console.log(' ❌ FAIL: Could read ~/.ssh contents');
return false;
});
// Test 2: Read ~/.aws (should be blocked)
test('BLOCK: Read ~/.aws directory', () => {
try {
fs.readdirSync(path.join(home, '.aws'));
console.log(' ❌ FAIL: Could read ~/.aws');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.aws blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.aws - ${e.code} (may not exist)`);
const result = isDirectoryBlocked(path.join(home, '.aws'));
if (result.skip) {
console.log(` ⚠️ SKIP: ~/.aws - ${result.reason}`);
return true;
}
if (result.blocked) {
console.log(` ✅ PASS: ~/.aws blocked (${result.reason})`);
return true;
}
console.log(' ❌ FAIL: Could read ~/.aws contents');
return false;
});
// Test 3: Read ~/.kube (should be blocked)
test('BLOCK: Read ~/.kube/config', () => {
try {
fs.readFileSync(path.join(home, '.kube', 'config'));
console.log(' ❌ FAIL: Could read ~/.kube/config');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.kube/config blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.kube/config - ${e.code} (may not exist)`);
test('BLOCK: Read ~/.kube directory', () => {
const result = isDirectoryBlocked(path.join(home, '.kube'));
if (result.skip) {
console.log(` ⚠️ SKIP: ~/.kube - ${result.reason}`);
return true;
}
if (result.blocked) {
console.log(` ✅ PASS: ~/.kube blocked (${result.reason})`);
return true;
}
console.log(' ❌ FAIL: Could read ~/.kube contents');
return false;
});
// Test 4: Read ~/.gcloud (should be blocked)
test('BLOCK: Read ~/.gcloud directory', () => {
try {
fs.readdirSync(path.join(home, '.gcloud'));
console.log(' ❌ FAIL: Could read ~/.gcloud');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.gcloud blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.gcloud - ${e.code} (may not exist)`);
const result = isDirectoryBlocked(path.join(home, '.gcloud'));
if (result.skip) {
console.log(` ⚠️ SKIP: ~/.gcloud - ${result.reason}`);
return true;
}
if (result.blocked) {
console.log(` ✅ PASS: ~/.gcloud blocked (${result.reason})`);
return true;
}
console.log(' ❌ FAIL: Could read ~/.gcloud contents');
return false;
});
// Test 5: Write to /etc (should be blocked)