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
|
- name: Run Sandbox E2E Test
|
||||||
run: pmg --sandbox npm exec -- node test/sandbox-e2e.js
|
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
|
- Blocks malicious packages at install time
|
||||||
- No configuration required, just install and use
|
- No configuration required, just install and use
|
||||||
- Maintains package installation event log for transparency and audit trail
|
- 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
|
## PMG in Action
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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,
|
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.
|
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
|
## Usage
|
||||||
|
|
||||||
- Make sure sandbox is enabled in your `config.yml` file.
|
- 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
|
## 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
|
||||||
|
|
||||||
|
<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
|
## 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
|
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
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ The assertion in [2] cannot be *implicit*. If so, it breaks the entire security
|
|||||||
## Security Goals
|
## Security Goals
|
||||||
|
|
||||||
- Adopt software supply chain security best practices so that PMG users can *verify* and only then trust PMG
|
- 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 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 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
|
- 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
|
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
|
```bash
|
||||||
gh attestation verify pmg_Linux_x86_64.tar.gz -R safedep/pmg --format json
|
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)
|
policy, err = registry.GetProfile(cfg.SandboxProfileOverride)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
log.Debugf("Looking up sandbox policy for %s", pmName)
|
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]
|
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, usefulerror.Useful().
|
return nil, usefulerror.Useful().
|
||||||
|
WithCode("sandbox_policy_not_configured").
|
||||||
WithHumanError(fmt.Sprintf("no sandbox policy configured for %s", pmName)).
|
WithHumanError(fmt.Sprintf("no sandbox policy configured for %s", pmName)).
|
||||||
WithHelp("Please configure a sandbox policy for this package manager in the config file.").
|
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.").
|
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() {
|
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)
|
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
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-42
@@ -6,6 +6,27 @@ const os = require('os');
|
|||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
const results = { passed: 0, failed: 0, tests: [] };
|
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) {
|
function test(name, fn) {
|
||||||
try {
|
try {
|
||||||
const result = fn();
|
const result = fn();
|
||||||
@@ -26,66 +47,62 @@ console.log('--- Tests that SHOULD be BLOCKED ---\n');
|
|||||||
|
|
||||||
// Test 1: Read ~/.ssh (should be blocked)
|
// Test 1: Read ~/.ssh (should be blocked)
|
||||||
test('BLOCK: Read ~/.ssh directory', () => {
|
test('BLOCK: Read ~/.ssh directory', () => {
|
||||||
try {
|
const result = isDirectoryBlocked(path.join(home, '.ssh'));
|
||||||
fs.readdirSync(path.join(home, '.ssh'));
|
if (result.skip) {
|
||||||
console.log(' ❌ FAIL: Could read ~/.ssh');
|
console.log(` ⚠️ SKIP: ~/.ssh - ${result.reason}`);
|
||||||
return false;
|
return true;
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
|
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 2: Read ~/.aws (should be blocked)
|
||||||
test('BLOCK: Read ~/.aws directory', () => {
|
test('BLOCK: Read ~/.aws directory', () => {
|
||||||
try {
|
const result = isDirectoryBlocked(path.join(home, '.aws'));
|
||||||
fs.readdirSync(path.join(home, '.aws'));
|
if (result.skip) {
|
||||||
console.log(' ❌ FAIL: Could read ~/.aws');
|
console.log(` ⚠️ SKIP: ~/.aws - ${result.reason}`);
|
||||||
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)`);
|
|
||||||
return true;
|
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 3: Read ~/.kube (should be blocked)
|
||||||
test('BLOCK: Read ~/.kube/config', () => {
|
test('BLOCK: Read ~/.kube directory', () => {
|
||||||
try {
|
const result = isDirectoryBlocked(path.join(home, '.kube'));
|
||||||
fs.readFileSync(path.join(home, '.kube', 'config'));
|
if (result.skip) {
|
||||||
console.log(' ❌ FAIL: Could read ~/.kube/config');
|
console.log(` ⚠️ SKIP: ~/.kube - ${result.reason}`);
|
||||||
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)`);
|
|
||||||
return true;
|
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 4: Read ~/.gcloud (should be blocked)
|
||||||
test('BLOCK: Read ~/.gcloud directory', () => {
|
test('BLOCK: Read ~/.gcloud directory', () => {
|
||||||
try {
|
const result = isDirectoryBlocked(path.join(home, '.gcloud'));
|
||||||
fs.readdirSync(path.join(home, '.gcloud'));
|
if (result.skip) {
|
||||||
console.log(' ❌ FAIL: Could read ~/.gcloud');
|
console.log(` ⚠️ SKIP: ~/.gcloud - ${result.reason}`);
|
||||||
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)`);
|
|
||||||
return true;
|
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)
|
// Test 5: Write to /etc (should be blocked)
|
||||||
|
|||||||
Reference in New Issue
Block a user