mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for bubblewrap sandbox
This commit is contained in:
@@ -57,8 +57,14 @@ trusted_packages:
|
|||||||
#
|
#
|
||||||
# Currently supported platforms:
|
# Currently supported platforms:
|
||||||
# - macOS (using Seatbelt sandbox-exec)
|
# - macOS (using Seatbelt sandbox-exec)
|
||||||
# - Linux (planned: Bubblewrap or seccomp-bpf)
|
# - Linux (using Bubblewrap with namespace isolation)
|
||||||
# - Windows (planned)
|
# - 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:
|
sandbox:
|
||||||
# Enable sandbox mode (opt-in, default: false for backward compatibility)
|
# Enable sandbox mode (opt-in, default: false for backward compatibility)
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
+39
-5
@@ -99,11 +99,22 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
|
|||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
| Platform | Supported | Implementation |
|
| Platform | Supported | Implementation |
|
||||||
| -------- | --------- | ---------------------------------- |
|
| -------- | --------- | ----------------------------------- |
|
||||||
| MacOS | Yes | Seatbelt sandbox-exec |
|
| MacOS | Yes | Seatbelt sandbox-exec |
|
||||||
| Linux | No | Bubblewrap / seccomp-bpf (planned) |
|
| Linux | Yes | Bubblewrap with namespace isolation |
|
||||||
| Windows | No | Not yet supported |
|
| Windows | No | Not yet supported |
|
||||||
|
|
||||||
|
### Platform-Specific Limitations
|
||||||
|
|
||||||
|
**Linux (Bubblewrap)**:
|
||||||
|
- **Filesystem permissions are coarse-grained**: Linux sandbox uses bind mounts for filesystem isolation. When you specify a glob pattern like `${CWD}/*.txt`, the pattern is expanded to matching files at policy translation time, but Bubblewrap mounts entire directories rather than individual files. This means filesystem access control is at the directory level, not file-pattern level.
|
||||||
|
- **Example**: A policy allowing `${CWD}/node_modules/**` will mount the entire `node_modules` directory tree, not selectively filter files by pattern.
|
||||||
|
- **Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific filtering is not enforced in the initial implementation.
|
||||||
|
|
||||||
|
**macOS (Seatbelt)**:
|
||||||
|
- **Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained host:port filtering is not consistently enforced across all connection types.
|
||||||
|
- **Filesystem permissions are precise**: Uses regex-based pattern matching, allowing file-level access control.
|
||||||
|
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
@@ -176,6 +187,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
|
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
|
## References
|
||||||
|
|
||||||
- https://github.com/anthropic-experimental/sandbox-runtime
|
- https://github.com/anthropic-experimental/sandbox-runtime
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Mandatory deny file patterns (overrides user policy)
|
||||||
|
// These files are always protected regardless of user configuration.
|
||||||
|
mandatoryDenyPatterns []string
|
||||||
|
|
||||||
|
// 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 (FHS - Filesystem Hierarchy Standard)
|
||||||
|
// - Anthropic Sandbox Runtime implementation patterns
|
||||||
|
// - Flatpak's bubblewrap usage
|
||||||
|
// - Chrome/Docker seccomp profiles
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mandatory deny patterns
|
||||||
|
// These files are ALWAYS protected, regardless of user policy
|
||||||
|
mandatoryDenyPatterns: getMandatoryDenyPatterns(),
|
||||||
|
|
||||||
|
// Scan depth for finding dangerous files in project directories
|
||||||
|
mandatoryDenyScanDepth: 3, // Check up to 3 levels deep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMandatoryDenyPatterns returns file patterns that must always be denied write access.
|
||||||
|
// These patterns protect credentials, secrets, and security-critical files.
|
||||||
|
func getMandatoryDenyPatterns() []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
patterns := []string{
|
||||||
|
// Environment files (secrets, API keys)
|
||||||
|
".env",
|
||||||
|
".env.*", // .env.local, .env.production, etc.
|
||||||
|
|
||||||
|
// Cloud provider credentials
|
||||||
|
".aws", // AWS credentials
|
||||||
|
".aws/**", // AWS config files
|
||||||
|
".gcloud", // Google Cloud credentials
|
||||||
|
".gcloud/**", // GCloud config
|
||||||
|
".azure", // Azure credentials
|
||||||
|
".azure/**", // Azure config
|
||||||
|
".config/gcloud", // Alternative GCloud location
|
||||||
|
|
||||||
|
// SSH keys
|
||||||
|
".ssh", // SSH directory
|
||||||
|
".ssh/**", // All SSH files
|
||||||
|
"id_rsa", // SSH private key
|
||||||
|
"id_ed25519", // Ed25519 SSH key
|
||||||
|
"id_ecdsa", // ECDSA SSH key
|
||||||
|
|
||||||
|
// GPG/PGP keys
|
||||||
|
".gnupg",
|
||||||
|
".gnupg/**",
|
||||||
|
|
||||||
|
// Kubernetes credentials
|
||||||
|
".kube",
|
||||||
|
".kube/**",
|
||||||
|
".kubeconfig",
|
||||||
|
|
||||||
|
// Docker credentials
|
||||||
|
".docker/config.json",
|
||||||
|
|
||||||
|
// Git security-critical files
|
||||||
|
".git/hooks", // Git hooks (can execute arbitrary code)
|
||||||
|
".git/hooks/**", // All git hooks
|
||||||
|
".gitconfig", // Global git config
|
||||||
|
|
||||||
|
// Shell configurations (backdoor risk)
|
||||||
|
".bashrc",
|
||||||
|
".bash_profile",
|
||||||
|
".zshrc",
|
||||||
|
".zprofile",
|
||||||
|
".profile",
|
||||||
|
|
||||||
|
// NPM/Node credentials
|
||||||
|
".npmrc", // May contain auth tokens (read-only is fine, write is dangerous)
|
||||||
|
|
||||||
|
// Python credentials
|
||||||
|
".pypirc", // PyPI credentials
|
||||||
|
|
||||||
|
// Database credentials
|
||||||
|
".pgpass", // PostgreSQL password file
|
||||||
|
".my.cnf", // MySQL config
|
||||||
|
".mysql_history", // MySQL command history (may contain passwords)
|
||||||
|
|
||||||
|
// Browser/session data
|
||||||
|
".mozilla",
|
||||||
|
".chrome",
|
||||||
|
".chromium",
|
||||||
|
|
||||||
|
// Additional credential stores
|
||||||
|
".netrc", // Network authentication
|
||||||
|
".docker", // Docker configs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add home-directory-prefixed versions for absolute path matching
|
||||||
|
homePrefixed := make([]string, 0, len(patterns))
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
homePrefixed = append(homePrefixed, filepath.Join(home, pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(patterns, homePrefixed...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 policy explicitly denies all network ("*:*"), isolate
|
||||||
|
if hasDenyAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no allow rules and default is to isolate, unshare
|
||||||
|
if !hasAllowRules && c.unshareNetworkByDefault {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, allow network (no --unshare-net)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,109 @@
|
|||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Translate PMG policy to bwrap arguments
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Store original command details
|
||||||
|
originalPath := cmd.Path
|
||||||
|
originalArgs := cmd.Args
|
||||||
|
|
||||||
|
// Find bwrap binary
|
||||||
|
bwrapPath, err := exec.LookPath("bwrap")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bubblewrap binary not found: %w (install with: apt install bubblewrap)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 execution result with this sandbox instance for cleanup
|
||||||
|
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,480 @@
|
|||||||
|
//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...)
|
||||||
|
|
||||||
|
log.Debugf("Translated policy '%s' to %d bwrap arguments", policy.Name, len(args))
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// Use --ro-bind-try which doesn't fail if path doesn't exist
|
||||||
|
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
|
||||||
|
// - Paths not mounted are inaccessible (deny-by-default)
|
||||||
|
//
|
||||||
|
// Strategy:
|
||||||
|
// 1. Start with essential system paths (added separately)
|
||||||
|
// 2. Add user-specified allow_read paths (read-only bind mounts)
|
||||||
|
// 3. Add user-specified allow_write paths (read-write bind mounts)
|
||||||
|
// 4. Handle deny patterns by mounting /dev/null (prevents creation)
|
||||||
|
// 5. Add mandatory deny patterns
|
||||||
|
func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPolicy) ([]string, error) {
|
||||||
|
args := []string{}
|
||||||
|
|
||||||
|
// Track paths we've already bound to avoid duplicates
|
||||||
|
boundPaths := make(map[string]bool)
|
||||||
|
|
||||||
|
// Add essential system paths to bound paths (already handled separately)
|
||||||
|
for _, path := range t.config.getEssentialSystemPaths() {
|
||||||
|
boundPaths[path] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Process allow_read rules (read-only bind mounts)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
readArgs, err := t.processReadRule(expanded, boundPaths)
|
||||||
|
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 (read-write bind mounts)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
writeArgs, err := t.processWriteRule(expanded, boundPaths)
|
||||||
|
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 creation)
|
||||||
|
allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig)
|
||||||
|
denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...)
|
||||||
|
|
||||||
|
// Add mandatory deny patterns
|
||||||
|
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 {
|
||||||
|
// Deny rules failing is not critical (file may not exist yet)
|
||||||
|
log.Debugf("Deny rule '%s' skipped: %v", expanded, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args = append(args, denyArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Expand glob pattern to concrete paths
|
||||||
|
paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create read-only bind for each expanded path
|
||||||
|
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) {
|
||||||
|
// Expand glob pattern to concrete paths
|
||||||
|
paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create read-write bind for each expanded path
|
||||||
|
for _, p := range paths {
|
||||||
|
if !boundPaths[p] {
|
||||||
|
args = append(args, "--bind-try", p, p)
|
||||||
|
boundPaths[p] = true
|
||||||
|
} else {
|
||||||
|
// Path already bound as read-only, upgrade to read-write
|
||||||
|
// This is a limitation of the simple approach - we'd need to track
|
||||||
|
// and replace the previous bind. For now, log warning.
|
||||||
|
log.Warnf("Path '%s' already bound, cannot upgrade to read-write", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 creation.
|
||||||
|
// 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
|
||||||
|
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 {
|
||||||
|
// Mount /dev/null to prevent access
|
||||||
|
args = append(args, "--ro-bind", "/dev/null", p)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For literal paths, check if they exist
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
// File exists - mount /dev/null over it
|
||||||
|
args = append(args, "--ro-bind", "/dev/null", path)
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
// File doesn't exist - find first non-existent ancestor and block it
|
||||||
|
nonExistentPath := t.findFirstNonExistentPath(path)
|
||||||
|
if nonExistentPath != "" {
|
||||||
|
args = append(args, "--ro-bind", "/dev/null", nonExistentPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) {
|
||||||
|
// Handle ** globstar patterns specially
|
||||||
|
if strings.Contains(pattern, "**") {
|
||||||
|
return t.expandGlobstarPattern(pattern, maxDepth, maxPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use filepath.Glob for simple patterns (*, ?, [])
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("glob expansion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit number of matches
|
||||||
|
if len(matches) > maxPaths {
|
||||||
|
log.Warnf("Glob pattern '%s' matched %d paths, limiting to %d", pattern, len(matches), maxPaths)
|
||||||
|
matches = matches[:maxPaths]
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, 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, start from root
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
relPath, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,669 @@
|
|||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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)
|
||||||
|
|
||||||
|
// Mandatory deny patterns
|
||||||
|
assert.NotEmpty(t, config.mandatoryDenyPatterns)
|
||||||
|
assert.Contains(t, config.mandatoryDenyPatterns, ".env")
|
||||||
|
assert.Contains(t, config.mandatoryDenyPatterns, ".ssh")
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a test file that exists
|
||||||
|
testFile := filepath.Join(tmpDir, "existing.txt")
|
||||||
|
require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644))
|
||||||
|
|
||||||
|
// Create a path that doesn't exist
|
||||||
|
nonExistentPath := filepath.Join(tmpDir, ".env")
|
||||||
|
|
||||||
|
policy := &sandbox.SandboxPolicy{
|
||||||
|
Filesystem: sandbox.FilesystemPolicy{
|
||||||
|
DenyWrite: []string{
|
||||||
|
testFile, // Existing file
|
||||||
|
nonExistentPath, // Non-existent file
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Non-existent file should also be blocked
|
||||||
|
assert.Contains(t, argsStr, nonExistentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/safedep/pmg/sandbox"
|
"github.com/safedep/pmg/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewSandbox creates a platform-specific sandbox instance for Linux.
|
// 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) {
|
func NewSandbox() (sandbox.Sandbox, error) {
|
||||||
return nil, errors.New("sandbox not yet implemented for Linux")
|
return newBubblewrapSandbox()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user