mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user