mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Sandbox implementation with seatbelt
This commit is contained in:
@@ -21,4 +21,8 @@ func ApplyCobraFlags(cmd *cobra.Command) {
|
|||||||
globalConfig.Config.SkipEventLogging, "Skip event logging")
|
globalConfig.Config.SkipEventLogging, "Skip event logging")
|
||||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode",
|
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode",
|
||||||
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)")
|
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)")
|
||||||
|
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox",
|
||||||
|
globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)")
|
||||||
|
cmd.PersistentFlags().StringVar(&globalConfig.Config.Sandbox.ViolationMode, "sandbox-violation-mode",
|
||||||
|
globalConfig.Config.Sandbox.ViolationMode, "How to handle sandbox policy violations: block, warn, or allow")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,33 @@ type Config struct {
|
|||||||
// ExperimentalProxyMode enables experimental proxy-based package interception.
|
// ExperimentalProxyMode enables experimental proxy-based package interception.
|
||||||
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
|
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
|
||||||
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
|
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
|
||||||
|
|
||||||
|
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
|
||||||
|
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
|
||||||
|
Sandbox SandboxConfig `mapstructure:"sandbox"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxConfig configures the sandbox system for isolating package manager processes.
|
||||||
|
type SandboxConfig struct {
|
||||||
|
// Enabled enables sandbox mode (opt-in by default for backward compatibility).
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
|
||||||
|
// ViolationMode defines how policy violations are handled (block, warn, or allow).
|
||||||
|
ViolationMode string `mapstructure:"violation_mode"`
|
||||||
|
|
||||||
|
// Policies maps package manager names to their sandbox policy references.
|
||||||
|
// Key is package manager name (e.g., "npm", "pip"), value is policy reference.
|
||||||
|
Policies map[string]SandboxPolicyRef `mapstructure:"policies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxPolicyRef references a sandbox policy for a specific package manager.
|
||||||
|
type SandboxPolicyRef struct {
|
||||||
|
// Enabled enables sandboxing for this specific package manager.
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
|
||||||
|
// Profile is the name of a built-in profile (e.g., "npm-restrictive")
|
||||||
|
// or an absolute path to a custom YAML policy file.
|
||||||
|
Profile string `mapstructure:"profile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
|
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
|
||||||
|
|||||||
@@ -46,3 +46,70 @@ experimental_proxy_mode: false
|
|||||||
trusted_packages:
|
trusted_packages:
|
||||||
- purl: pkg:npm/@safedep/pmg
|
- purl: pkg:npm/@safedep/pmg
|
||||||
reason: "PMG is a trusted package for PMG"
|
reason: "PMG is a trusted package for PMG"
|
||||||
|
|
||||||
|
# Sandbox configuration (EXPERIMENTAL)
|
||||||
|
# When enabled, package managers run in sandboxed environments with restricted
|
||||||
|
# filesystem, network, and process execution access. This provides defense-in-depth
|
||||||
|
# protection against malicious install scripts and supply chain attacks.
|
||||||
|
#
|
||||||
|
# Currently supported platforms:
|
||||||
|
# - macOS (using Seatbelt sandbox-exec)
|
||||||
|
# - Linux (coming soon: Bubblewrap or seccomp-bpf)
|
||||||
|
# - Windows (coming soon)
|
||||||
|
sandbox:
|
||||||
|
# Enable sandbox mode (opt-in, default: false for backward compatibility)
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# How to handle policy violations: block | warn | allow
|
||||||
|
# - block: Prevent execution on policy violation (recommended)
|
||||||
|
# - warn: Log warning but allow execution
|
||||||
|
# - allow: Allow all operations (disables sandbox)
|
||||||
|
violation_mode: block
|
||||||
|
|
||||||
|
# Per-package-manager sandbox policies
|
||||||
|
# Each package manager can have its own policy to account for unique security characteristics
|
||||||
|
policies:
|
||||||
|
# npm ecosystem
|
||||||
|
npm:
|
||||||
|
enabled: true
|
||||||
|
profile: npm-restrictive # Built-in profile or path to custom YAML
|
||||||
|
|
||||||
|
pnpm:
|
||||||
|
enabled: true
|
||||||
|
profile: npm-restrictive
|
||||||
|
|
||||||
|
yarn:
|
||||||
|
enabled: true
|
||||||
|
profile: npm-restrictive
|
||||||
|
|
||||||
|
bun:
|
||||||
|
enabled: true
|
||||||
|
profile: npm-restrictive
|
||||||
|
|
||||||
|
# PyPI ecosystem
|
||||||
|
pip:
|
||||||
|
enabled: true
|
||||||
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
pip3:
|
||||||
|
enabled: true
|
||||||
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
poetry:
|
||||||
|
enabled: true
|
||||||
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
uv:
|
||||||
|
enabled: true
|
||||||
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# 1. Enable sandbox globally: set sandbox.enabled to true
|
||||||
|
# 2. Enable via CLI flag: pmg --sandbox npm install lodash
|
||||||
|
# 3. Use custom profile: pmg --sandbox-profile=/path/to/policy.yml npm install
|
||||||
|
#
|
||||||
|
# Built-in profiles:
|
||||||
|
# - npm-restrictive: Balanced security for npm/pnpm/yarn/bun
|
||||||
|
# - pypi-restrictive: Balanced security for pip/poetry/uv
|
||||||
|
#
|
||||||
|
# See sandbox/profiles/ directory for profile definitions
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/safedep/pmg/internal/eventlog"
|
"github.com/safedep/pmg/internal/eventlog"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
|
"github.com/safedep/pmg/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PackageManagerGuardInteraction struct {
|
type PackageManagerGuardInteraction struct {
|
||||||
@@ -220,6 +221,12 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package
|
|||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// Apply sandbox if enabled
|
||||||
|
pmName := g.packageManager.Name()
|
||||||
|
if err := sandbox.ApplySandbox(ctx, cmd, pmName, ""); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// We will fail based on executed command's exit code. This is important
|
// We will fail based on executed command's exit code. This is important
|
||||||
// because other tools (scripts, CI etc.) may depend on this exit code.
|
// because other tools (scripts, CI etc.) may depend on this exit code.
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
"github.com/safedep/pmg/proxy/certmanager"
|
"github.com/safedep/pmg/proxy/certmanager"
|
||||||
"github.com/safedep/pmg/proxy/interceptors"
|
"github.com/safedep/pmg/proxy/interceptors"
|
||||||
|
"github.com/safedep/pmg/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
type proxyFlow struct {
|
type proxyFlow struct {
|
||||||
@@ -241,6 +242,12 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
|
|||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// Apply sandbox if enabled (sandbox preserves proxy environment variables already set on cmd.Env)
|
||||||
|
pmName := f.pm.Name()
|
||||||
|
if err := sandbox.ApplySandbox(ctx, cmd, pmName, "proxy mode"); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
|
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
|
||||||
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
|
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled.
|
||||||
|
// This is a helper function used by both guard and proxy flows to avoid code duplication.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: Context for the sandbox execution
|
||||||
|
// - cmd: The exec.Cmd to be sandboxed (will be modified in place)
|
||||||
|
// - pmName: Package manager name (e.g., "npm", "pip")
|
||||||
|
// - mode: Optional mode description for logging (e.g., "proxy mode", empty for default)
|
||||||
|
//
|
||||||
|
// Returns an error if sandbox setup fails, or nil if sandbox is not enabled/available.
|
||||||
|
// Gracefully degrades with warnings if sandbox is unavailable on the platform.
|
||||||
|
func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, mode string) error {
|
||||||
|
cfg := config.Get()
|
||||||
|
|
||||||
|
// Check if sandbox is enabled globally
|
||||||
|
if !cfg.Config.Sandbox.Enabled {
|
||||||
|
return nil // Sandbox disabled, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sandbox policy exists for this package manager
|
||||||
|
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
|
||||||
|
if !exists || !policyRef.Enabled {
|
||||||
|
log.Debugf("No sandbox policy enabled for %s", pmName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the sandbox policy
|
||||||
|
registry := NewProfileRegistry()
|
||||||
|
policy, err := registry.GetProfile(policyRef.Profile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load sandbox policy %s: %w", policyRef.Profile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the policy applies to this package manager
|
||||||
|
if !policy.AppliesToPackageManager(pmName) {
|
||||||
|
log.Warnf("Sandbox policy %s does not apply to %s", policy.Name, pmName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create platform-specific sandbox
|
||||||
|
sb, err := NewSandbox()
|
||||||
|
if err != nil {
|
||||||
|
// Sandbox not available on this platform - log warning and continue
|
||||||
|
log.Warnf("Sandbox not available on this platform: %v", err)
|
||||||
|
log.Warnf("Continuing without sandbox protection")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sb.IsAvailable() {
|
||||||
|
log.Warnf("Sandbox %s not available, running without sandbox", sb.Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build log message with optional mode suffix
|
||||||
|
logMsg := fmt.Sprintf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
|
||||||
|
if mode != "" {
|
||||||
|
logMsg += fmt.Sprintf(" (%s)", mode)
|
||||||
|
}
|
||||||
|
log.Infof(logMsg)
|
||||||
|
|
||||||
|
// Execute sandbox setup (modifies cmd in place)
|
||||||
|
// Note: The sandbox preserves any environment variables already set on cmd.Env
|
||||||
|
if err := sb.Execute(ctx, cmd, policy); err != nil {
|
||||||
|
return fmt.Errorf("failed to setup sandbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SandboxPolicy represents a parsed and validated sandbox policy that defines
|
||||||
|
// filesystem, network, and process execution restrictions for package managers.
|
||||||
|
type SandboxPolicy struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
PackageManagers []string `yaml:"package_managers" json:"package_managers"`
|
||||||
|
ViolationMode string `yaml:"violation_mode" json:"violation_mode"`
|
||||||
|
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
|
||||||
|
Network NetworkPolicy `yaml:"network" json:"network"`
|
||||||
|
Process ProcessPolicy `yaml:"process" json:"process"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesystemPolicy defines allowed and denied filesystem access patterns.
|
||||||
|
// Deny rules have higher priority than allow rules.
|
||||||
|
type FilesystemPolicy struct {
|
||||||
|
AllowRead []string `yaml:"allow_read" json:"allow_read"`
|
||||||
|
AllowWrite []string `yaml:"allow_write" json:"allow_write"`
|
||||||
|
DenyRead []string `yaml:"deny_read" json:"deny_read"`
|
||||||
|
DenyWrite []string `yaml:"deny_write" json:"deny_write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkPolicy defines allowed and denied network access patterns.
|
||||||
|
// Patterns are in the format "host:port" or "*:*" for wildcards.
|
||||||
|
type NetworkPolicy struct {
|
||||||
|
AllowOutbound []string `yaml:"allow_outbound" json:"allow_outbound"`
|
||||||
|
DenyOutbound []string `yaml:"deny_outbound" json:"deny_outbound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessPolicy defines allowed and denied process execution patterns.
|
||||||
|
// Patterns can be specific paths or glob patterns.
|
||||||
|
type ProcessPolicy struct {
|
||||||
|
AllowExec []string `yaml:"allow_exec" json:"allow_exec"`
|
||||||
|
DenyExec []string `yaml:"deny_exec" json:"deny_exec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViolationMode represents how sandbox policy violations are handled.
|
||||||
|
type ViolationMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ViolationModeBlock blocks execution when a policy violation is detected (default).
|
||||||
|
ViolationModeBlock ViolationMode = iota
|
||||||
|
// ViolationModeWarn logs a warning but allows execution to continue.
|
||||||
|
ViolationModeWarn
|
||||||
|
// ViolationModeAllow allows all operations (effectively disables the sandbox).
|
||||||
|
ViolationModeAllow
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the violation mode.
|
||||||
|
func (v ViolationMode) String() string {
|
||||||
|
switch v {
|
||||||
|
case ViolationModeBlock:
|
||||||
|
return "block"
|
||||||
|
case ViolationModeWarn:
|
||||||
|
return "warn"
|
||||||
|
case ViolationModeAllow:
|
||||||
|
return "allow"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseViolationMode parses a violation mode string into a ViolationMode value.
|
||||||
|
func ParseViolationMode(s string) (ViolationMode, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "block":
|
||||||
|
return ViolationModeBlock, nil
|
||||||
|
case "warn":
|
||||||
|
return ViolationModeWarn, nil
|
||||||
|
case "allow":
|
||||||
|
return ViolationModeAllow, nil
|
||||||
|
default:
|
||||||
|
return ViolationModeBlock, fmt.Errorf("invalid violation mode: %s (must be block, warn, or allow)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the sandbox policy for correctness.
|
||||||
|
// Returns an error if the policy is invalid.
|
||||||
|
func (p *SandboxPolicy) Validate() error {
|
||||||
|
if p.Name == "" {
|
||||||
|
return fmt.Errorf("policy name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.PackageManagers) == 0 {
|
||||||
|
return fmt.Errorf("policy must specify at least one package manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate violation mode
|
||||||
|
if p.ViolationMode != "" {
|
||||||
|
if _, err := ParseViolationMode(p.ViolationMode); err != nil {
|
||||||
|
return fmt.Errorf("invalid violation mode: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that at least some rules are defined
|
||||||
|
hasRules := len(p.Filesystem.AllowRead) > 0 ||
|
||||||
|
len(p.Filesystem.AllowWrite) > 0 ||
|
||||||
|
len(p.Filesystem.DenyRead) > 0 ||
|
||||||
|
len(p.Filesystem.DenyWrite) > 0 ||
|
||||||
|
len(p.Network.AllowOutbound) > 0 ||
|
||||||
|
len(p.Network.DenyOutbound) > 0 ||
|
||||||
|
len(p.Process.AllowExec) > 0 ||
|
||||||
|
len(p.Process.DenyExec) > 0
|
||||||
|
|
||||||
|
if !hasRules {
|
||||||
|
return fmt.Errorf("policy must define at least one access rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetViolationMode returns the parsed violation mode for the policy.
|
||||||
|
// Returns ViolationModeBlock if not specified or invalid.
|
||||||
|
func (p *SandboxPolicy) GetViolationMode() ViolationMode {
|
||||||
|
if p.ViolationMode == "" {
|
||||||
|
return ViolationModeBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
mode, err := ParseViolationMode(p.ViolationMode)
|
||||||
|
if err != nil {
|
||||||
|
return ViolationModeBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppliesToPackageManager returns true if this policy applies to the given package manager.
|
||||||
|
func (p *SandboxPolicy) AppliesToPackageManager(pm string) bool {
|
||||||
|
pmLower := strings.ToLower(pm)
|
||||||
|
for _, supported := range p.PackageManagers {
|
||||||
|
if strings.ToLower(supported) == pmLower {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# PMG Sandbox Profiles
|
||||||
|
|
||||||
|
This directory contains built-in sandbox policies for PMG package managers.
|
||||||
|
|
||||||
|
## Available Profiles
|
||||||
|
|
||||||
|
### npm-restrictive
|
||||||
|
|
||||||
|
Restrictive policy for the npm ecosystem (npm, pnpm, yarn, bun).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Allows read access to current directory, package manager configs, and caches
|
||||||
|
- Restricts write access to `node_modules/` and lockfiles only
|
||||||
|
- Blocks access to sensitive files (`~/.ssh`, `~/.aws`, `.env` files)
|
||||||
|
- Allows network access to npm registries only
|
||||||
|
- Permits Node.js and git execution, blocks shell and curl/wget
|
||||||
|
|
||||||
|
**Use when:** You want balanced protection for npm package installations
|
||||||
|
|
||||||
|
### pypi-restrictive
|
||||||
|
|
||||||
|
Restrictive policy for the PyPI ecosystem (pip, pip3, poetry, uv).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Allows read access to current directory, pip configs, and caches
|
||||||
|
- Restricts write access to virtual environments and package caches
|
||||||
|
- Blocks access to sensitive files (`~/.ssh`, `~/.aws`, `.env` files)
|
||||||
|
- Allows network access to PyPI registries only
|
||||||
|
- Permits Python, compilers (for native extensions), and git
|
||||||
|
|
||||||
|
**Use when:** You want balanced protection for pip package installations
|
||||||
|
|
||||||
|
## Custom Policies
|
||||||
|
|
||||||
|
You can create custom sandbox policies by:
|
||||||
|
|
||||||
|
1. Copying one of the built-in profiles
|
||||||
|
2. Modifying the rules to suit your needs
|
||||||
|
3. Referencing the custom profile in your PMG config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sandbox:
|
||||||
|
enabled: true
|
||||||
|
policies:
|
||||||
|
npm:
|
||||||
|
enabled: true
|
||||||
|
profile: /path/to/custom-npm-policy.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Policy Schema
|
||||||
|
|
||||||
|
See the [Policy Schema Documentation](../policy.go) for details on the YAML structure.
|
||||||
|
|
||||||
|
### Supported Variables
|
||||||
|
|
||||||
|
- `${HOME}`: User home directory
|
||||||
|
- `${CWD}`: Current working directory
|
||||||
|
- `${PM_CACHE}`: Package manager cache directory
|
||||||
|
- `${TMPDIR}`: Temporary directory
|
||||||
|
|
||||||
|
### Violation Modes
|
||||||
|
|
||||||
|
- `block`: Block execution on policy violation (recommended)
|
||||||
|
- `warn`: Log warning but allow execution
|
||||||
|
- `allow`: Allow all operations (disables sandbox)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: npm-restrictive
|
||||||
|
description: Restrictive sandbox policy for npm ecosystem (npm, pnpm, yarn, bun)
|
||||||
|
package_managers:
|
||||||
|
- npm
|
||||||
|
- pnpm
|
||||||
|
- yarn
|
||||||
|
- bun
|
||||||
|
|
||||||
|
violation_mode: block
|
||||||
|
|
||||||
|
filesystem:
|
||||||
|
allow_read:
|
||||||
|
- ${CWD}/**
|
||||||
|
- ${HOME}/.npmrc
|
||||||
|
- ${HOME}/.yarnrc
|
||||||
|
- ${HOME}/.yarnrc.yml
|
||||||
|
- ${HOME}/.bundle
|
||||||
|
- ${PM_CACHE}/**
|
||||||
|
- /usr/local/**
|
||||||
|
- /Library/**
|
||||||
|
- /System/Library/**
|
||||||
|
- /private/var/**
|
||||||
|
|
||||||
|
allow_write:
|
||||||
|
- ${CWD}/node_modules/**
|
||||||
|
- ${PM_CACHE}/**
|
||||||
|
- ${CWD}/package-lock.json
|
||||||
|
- ${CWD}/yarn.lock
|
||||||
|
- ${CWD}/pnpm-lock.yaml
|
||||||
|
- ${CWD}/bun.lockb
|
||||||
|
- ${TMPDIR}/**
|
||||||
|
|
||||||
|
deny_read:
|
||||||
|
- ${HOME}/.ssh/**
|
||||||
|
- ${HOME}/.aws/**
|
||||||
|
- ${HOME}/.gcloud/**
|
||||||
|
- ${HOME}/.kube/**
|
||||||
|
- "**/.env"
|
||||||
|
- "**/.env.*"
|
||||||
|
- ${HOME}/.docker/config.json
|
||||||
|
|
||||||
|
deny_write:
|
||||||
|
- ${HOME}/.ssh/**
|
||||||
|
- ${HOME}/.aws/**
|
||||||
|
- /etc/**
|
||||||
|
- /usr/**
|
||||||
|
- /bin/**
|
||||||
|
- /sbin/**
|
||||||
|
|
||||||
|
network:
|
||||||
|
allow_outbound:
|
||||||
|
- registry.npmjs.org:443
|
||||||
|
- registry.yarnpkg.com:443
|
||||||
|
- npm.pkg.github.com:443
|
||||||
|
- github.com:443
|
||||||
|
|
||||||
|
deny_outbound:
|
||||||
|
- "*:*"
|
||||||
|
|
||||||
|
process:
|
||||||
|
allow_exec:
|
||||||
|
- /usr/bin/node
|
||||||
|
- /usr/local/bin/node
|
||||||
|
- ${PM_CACHE}/**
|
||||||
|
- /usr/bin/git
|
||||||
|
- /usr/local/bin/git
|
||||||
|
|
||||||
|
deny_exec:
|
||||||
|
- /usr/bin/curl
|
||||||
|
- /usr/bin/wget
|
||||||
|
- /bin/bash
|
||||||
|
- /bin/sh
|
||||||
|
- /usr/bin/python*
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: pypi-restrictive
|
||||||
|
description: Restrictive sandbox policy for PyPI ecosystem (pip, poetry, uv)
|
||||||
|
package_managers:
|
||||||
|
- pip
|
||||||
|
- pip3
|
||||||
|
- poetry
|
||||||
|
- uv
|
||||||
|
|
||||||
|
violation_mode: block
|
||||||
|
|
||||||
|
filesystem:
|
||||||
|
allow_read:
|
||||||
|
- ${CWD}/**
|
||||||
|
- ${HOME}/.config/pip/**
|
||||||
|
- ${HOME}/.pip/**
|
||||||
|
- ${HOME}/.poetry/**
|
||||||
|
- ${PM_CACHE}/**
|
||||||
|
- /usr/local/**
|
||||||
|
- /Library/**
|
||||||
|
- /System/Library/**
|
||||||
|
|
||||||
|
allow_write:
|
||||||
|
- ${CWD}/.venv/**
|
||||||
|
- ${CWD}/venv/**
|
||||||
|
- ${PM_CACHE}/**
|
||||||
|
- ${HOME}/.local/lib/python*/**
|
||||||
|
- ${TMPDIR}/**
|
||||||
|
|
||||||
|
deny_read:
|
||||||
|
- ${HOME}/.ssh/**
|
||||||
|
- ${HOME}/.aws/**
|
||||||
|
- ${HOME}/.gcloud/**
|
||||||
|
- "**/.env"
|
||||||
|
|
||||||
|
deny_write:
|
||||||
|
- ${HOME}/.ssh/**
|
||||||
|
- /etc/**
|
||||||
|
- /usr/**
|
||||||
|
|
||||||
|
network:
|
||||||
|
allow_outbound:
|
||||||
|
- pypi.org:443
|
||||||
|
- files.pythonhosted.org:443
|
||||||
|
- github.com:443
|
||||||
|
|
||||||
|
deny_outbound:
|
||||||
|
- "*:*"
|
||||||
|
|
||||||
|
process:
|
||||||
|
allow_exec:
|
||||||
|
- /usr/bin/python*
|
||||||
|
- /usr/local/bin/python*
|
||||||
|
- /usr/bin/gcc
|
||||||
|
- /usr/bin/clang
|
||||||
|
- /usr/bin/git
|
||||||
|
|
||||||
|
deny_exec:
|
||||||
|
- /usr/bin/curl
|
||||||
|
- /usr/bin/wget
|
||||||
|
- /bin/bash
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed profiles/*.yml
|
||||||
|
var profilesFS embed.FS
|
||||||
|
|
||||||
|
// defaultProfileRegistry implements ProfileRegistry with support for
|
||||||
|
// built-in embedded profiles and custom user-provided profiles.
|
||||||
|
type defaultProfileRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
profiles map[string]*SandboxPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDefaultProfileRegistry creates a new profile registry and loads built-in profiles.
|
||||||
|
func newDefaultProfileRegistry() *defaultProfileRegistry {
|
||||||
|
registry := &defaultProfileRegistry{
|
||||||
|
profiles: make(map[string]*SandboxPolicy),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load built-in profiles from embedded filesystem
|
||||||
|
if err := registry.loadBuiltinProfiles(); err != nil {
|
||||||
|
// Log error but don't fail - graceful degradation
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to load built-in sandbox profiles: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBuiltinProfiles loads all built-in YAML profiles from the embedded filesystem.
|
||||||
|
func (r *defaultProfileRegistry) loadBuiltinProfiles() error {
|
||||||
|
entries, err := profilesFS.ReadDir("profiles")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read profiles directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePath := filepath.Join("profiles", entry.Name())
|
||||||
|
data, err := profilesFS.ReadFile(profilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read profile %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := parsePolicy(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse profile %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := policy.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid profile %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.profiles[policy.Name] = policy
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile retrieves a policy by name.
|
||||||
|
// First checks built-in profiles, then attempts to load as a custom file path.
|
||||||
|
func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) {
|
||||||
|
// Check if it's a built-in profile
|
||||||
|
r.mu.RLock()
|
||||||
|
if policy, exists := r.profiles[name]; exists {
|
||||||
|
r.mu.RUnlock()
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
// Not a built-in profile - try to load as custom file
|
||||||
|
if fileExists(name) {
|
||||||
|
return r.LoadCustomProfile(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile and file does not exist)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCustomProfile loads a policy from a custom YAML file path.
|
||||||
|
func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read custom profile %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := parsePolicy(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse custom profile %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := policy.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid custom profile %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the custom profile for future use
|
||||||
|
r.mu.Lock()
|
||||||
|
r.profiles[policy.Name] = policy
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles returns the names of all built-in profiles.
|
||||||
|
func (r *defaultProfileRegistry) ListProfiles() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
profiles := make([]string, 0, len(r.profiles))
|
||||||
|
for name := range r.profiles {
|
||||||
|
profiles = append(profiles, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePolicy parses a YAML policy file into a SandboxPolicy struct.
|
||||||
|
func parsePolicy(data []byte) (*SandboxPolicy, error) {
|
||||||
|
var policy SandboxPolicy
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &policy); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists checks if a file exists and is not a directory.
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sandbox represents a platform-specific sandbox executor that isolates
|
||||||
|
// package manager processes with controlled access to filesystem, network,
|
||||||
|
// and process execution resources.
|
||||||
|
type Sandbox interface {
|
||||||
|
// Execute runs a command in the sandbox with the given policy.
|
||||||
|
// The command may be modified in place (e.g., wrapped with sandbox-exec).
|
||||||
|
// Returns an error if the sandbox setup fails.
|
||||||
|
Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error
|
||||||
|
|
||||||
|
// Name returns the sandbox implementation name (e.g., "seatbelt", "bubblewrap").
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// IsAvailable returns true if the sandbox is available and functional on this platform.
|
||||||
|
IsAvailable() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSandbox creates a new platform-specific sandbox instance.
|
||||||
|
// The implementation is selected at compile time using build tags.
|
||||||
|
// Returns an error if the sandbox is not available on the current platform.
|
||||||
|
func NewSandbox() (Sandbox, error) {
|
||||||
|
return newPlatformSandbox()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileRegistry manages built-in and custom sandbox policies.
|
||||||
|
type ProfileRegistry interface {
|
||||||
|
// GetProfile retrieves a policy by name.
|
||||||
|
// Name can be a built-in profile (e.g., "npm-restrictive") or a path to a custom YAML file.
|
||||||
|
GetProfile(name string) (*SandboxPolicy, error)
|
||||||
|
|
||||||
|
// LoadCustomProfile loads a policy from a custom YAML file path.
|
||||||
|
LoadCustomProfile(path string) (*SandboxPolicy, error)
|
||||||
|
|
||||||
|
// ListProfiles returns the names of all built-in profiles.
|
||||||
|
ListProfiles() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileRegistry creates a new profile registry with built-in policies.
|
||||||
|
func NewProfileRegistry() ProfileRegistry {
|
||||||
|
return newDefaultProfileRegistry()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/sandbox/seatbelt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// darwinSandboxAdapter adapts the seatbelt implementation to the Sandbox interface.
|
||||||
|
type darwinSandboxAdapter struct {
|
||||||
|
seatbelt *seatbelt.SeatbeltSandbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPlatformSandbox creates a platform-specific sandbox instance for macOS.
|
||||||
|
// Uses Seatbelt (sandbox-exec) for process isolation.
|
||||||
|
func newPlatformSandbox() (Sandbox, error) {
|
||||||
|
sb, err := seatbelt.NewSeatbeltSandbox()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &darwinSandboxAdapter{seatbelt: sb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *darwinSandboxAdapter) Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error {
|
||||||
|
// Convert sandbox.SandboxPolicy to seatbelt.SandboxPolicy
|
||||||
|
seatbeltPolicy := &seatbelt.SandboxPolicy{
|
||||||
|
Name: policy.Name,
|
||||||
|
Description: policy.Description,
|
||||||
|
PackageManagers: policy.PackageManagers,
|
||||||
|
ViolationMode: policy.ViolationMode,
|
||||||
|
Filesystem: seatbelt.FilesystemPolicy{
|
||||||
|
AllowRead: policy.Filesystem.AllowRead,
|
||||||
|
AllowWrite: policy.Filesystem.AllowWrite,
|
||||||
|
DenyRead: policy.Filesystem.DenyRead,
|
||||||
|
DenyWrite: policy.Filesystem.DenyWrite,
|
||||||
|
},
|
||||||
|
Network: seatbelt.NetworkPolicy{
|
||||||
|
AllowOutbound: policy.Network.AllowOutbound,
|
||||||
|
DenyOutbound: policy.Network.DenyOutbound,
|
||||||
|
},
|
||||||
|
Process: seatbelt.ProcessPolicy{
|
||||||
|
AllowExec: policy.Process.AllowExec,
|
||||||
|
DenyExec: policy.Process.DenyExec,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.seatbelt.Execute(ctx, cmd, seatbeltPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *darwinSandboxAdapter) Name() string {
|
||||||
|
return d.seatbelt.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *darwinSandboxAdapter) IsAvailable() bool {
|
||||||
|
return d.seatbelt.IsAvailable()
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// newPlatformSandbox creates a platform-specific sandbox instance for Linux.
|
||||||
|
// Currently not implemented - returns an error.
|
||||||
|
// Future implementations will use Bubblewrap or seccomp-bpf.
|
||||||
|
func newPlatformSandbox() (Sandbox, error) {
|
||||||
|
return nil, errors.New("sandbox not yet implemented for Linux (coming soon: Bubblewrap or seccomp-bpf)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !darwin && !linux && !windows
|
||||||
|
// +build !darwin,!linux,!windows
|
||||||
|
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// newPlatformSandbox returns an error on unsupported platforms.
|
||||||
|
func newPlatformSandbox() (Sandbox, error) {
|
||||||
|
return nil, fmt.Errorf("sandbox is not supported on this platform")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// newPlatformSandbox creates a platform-specific sandbox instance for Windows.
|
||||||
|
// Currently not implemented - returns an error.
|
||||||
|
// Future implementations will use AppContainer or Job Objects.
|
||||||
|
func newPlatformSandbox() (Sandbox, error) {
|
||||||
|
return nil, errors.New("sandbox not yet implemented for Windows (coming soon: AppContainer or Job Objects)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package seatbelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeatbeltSandbox implements the Sandbox interface using macOS Seatbelt (sandbox-exec).
|
||||||
|
type SeatbeltSandbox struct {
|
||||||
|
translator *PolicyTranslator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeatbeltSandbox creates a new Seatbelt sandbox instance.
|
||||||
|
func NewSeatbeltSandbox() (*SeatbeltSandbox, error) {
|
||||||
|
return &SeatbeltSandbox{
|
||||||
|
translator: NewPolicyTranslator(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs a command in the Seatbelt sandbox with the given policy.
|
||||||
|
// It translates the PMG policy to Seatbelt Profile Language (.sb) and wraps
|
||||||
|
// the command execution with sandbox-exec.
|
||||||
|
func (s *SeatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error {
|
||||||
|
// Translate PMG policy to Seatbelt profile
|
||||||
|
sbProfile, err := s.translator.Translate(policy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to translate sandbox policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Seatbelt profile to temporary file
|
||||||
|
tmpFile, err := os.CreateTemp("", "pmg-sandbox-*.sb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary sandbox profile: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(sbProfile); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return fmt.Errorf("failed to write sandbox profile: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
log.Debugf("Seatbelt profile written to %s", tmpFile.Name())
|
||||||
|
log.Debugf("Seatbelt profile content:\n%s", sbProfile)
|
||||||
|
|
||||||
|
// Modify command to run via sandbox-exec
|
||||||
|
originalPath := cmd.Path
|
||||||
|
originalArgs := cmd.Args
|
||||||
|
|
||||||
|
// sandbox-exec -f <profile> <command> <args...>
|
||||||
|
cmd.Path = "/usr/bin/sandbox-exec"
|
||||||
|
cmd.Args = []string{
|
||||||
|
"sandbox-exec",
|
||||||
|
"-f", tmpFile.Name(),
|
||||||
|
originalPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append 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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of this sandbox implementation.
|
||||||
|
func (s *SeatbeltSandbox) Name() string {
|
||||||
|
return "seatbelt"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAvailable returns true if sandbox-exec is available on this system.
|
||||||
|
func (s *SeatbeltSandbox) IsAvailable() bool {
|
||||||
|
_, err := exec.LookPath("sandbox-exec")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package seatbelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/sandbox/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyTranslator translates PMG sandbox policies to Seatbelt Profile Language (.sb).
|
||||||
|
type PolicyTranslator struct{}
|
||||||
|
|
||||||
|
// NewPolicyTranslator creates a new policy translator.
|
||||||
|
func NewPolicyTranslator() *PolicyTranslator {
|
||||||
|
return &PolicyTranslator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxPolicy represents a parsed sandbox policy (defined here to avoid import cycle).
|
||||||
|
type SandboxPolicy struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
PackageManagers []string
|
||||||
|
ViolationMode string
|
||||||
|
Filesystem FilesystemPolicy
|
||||||
|
Network NetworkPolicy
|
||||||
|
Process ProcessPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilesystemPolicy struct {
|
||||||
|
AllowRead []string
|
||||||
|
AllowWrite []string
|
||||||
|
DenyRead []string
|
||||||
|
DenyWrite []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkPolicy struct {
|
||||||
|
AllowOutbound []string
|
||||||
|
DenyOutbound []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessPolicy struct {
|
||||||
|
AllowExec []string
|
||||||
|
DenyExec []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate converts a PMG SandboxPolicy to Seatbelt Profile Language.
|
||||||
|
func (t *PolicyTranslator) Translate(policy *SandboxPolicy) (string, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Header
|
||||||
|
sb.WriteString("(version 1)\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(";; PMG Sandbox Policy: %s\n", policy.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf(";; %s\n", policy.Description))
|
||||||
|
sb.WriteString(";; Generated by PMG sandbox system\n\n")
|
||||||
|
|
||||||
|
// Default policy: deny by default for maximum security
|
||||||
|
sb.WriteString("(deny default)\n\n")
|
||||||
|
|
||||||
|
// Allow basic system operations required for any process
|
||||||
|
sb.WriteString(";; Basic system access\n")
|
||||||
|
sb.WriteString("(allow process-fork)\n")
|
||||||
|
sb.WriteString("(allow process-exec-interpreter)\n")
|
||||||
|
sb.WriteString("(allow sysctl-read)\n")
|
||||||
|
sb.WriteString("(allow mach-lookup)\n")
|
||||||
|
sb.WriteString("(allow mach-register)\n")
|
||||||
|
sb.WriteString("(allow ipc-posix-shm)\n")
|
||||||
|
sb.WriteString("(allow signal)\n\n")
|
||||||
|
|
||||||
|
// Filesystem rules
|
||||||
|
if err := t.translateFilesystem(policy, &sb); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to translate filesystem rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network rules
|
||||||
|
if err := t.translateNetwork(policy, &sb); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to translate network rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process execution rules
|
||||||
|
if err := t.translateProcess(policy, &sb); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to translate process rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateFilesystem translates filesystem access rules.
|
||||||
|
func (t *PolicyTranslator) translateFilesystem(policy *SandboxPolicy, sb *strings.Builder) error {
|
||||||
|
sb.WriteString(";; Filesystem access\n")
|
||||||
|
|
||||||
|
// Expand and add allow read rules
|
||||||
|
for _, pattern := range policy.Filesystem.AllowRead {
|
||||||
|
expanded, err := util.ExpandVariables(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle glob patterns vs literal paths
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
// For glob patterns, use subpath with the base directory
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Expand and add allow write rules
|
||||||
|
for _, pattern := range policy.Filesystem.AllowWrite {
|
||||||
|
expanded, err := util.ExpandVariables(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Deny rules have higher priority (applied after allow)
|
||||||
|
// Note: Seatbelt evaluates rules in order, so denies after allows will override
|
||||||
|
for _, pattern := range policy.Filesystem.DenyRead {
|
||||||
|
expanded, err := util.ExpandVariables(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range policy.Filesystem.DenyWrite {
|
||||||
|
expanded, err := util.ExpandVariables(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateNetwork translates network access rules.
|
||||||
|
func (t *PolicyTranslator) translateNetwork(policy *SandboxPolicy, sb *strings.Builder) error {
|
||||||
|
sb.WriteString(";; Network access\n")
|
||||||
|
|
||||||
|
// If there are allow outbound rules, allow network-outbound generally
|
||||||
|
// (Seatbelt doesn't support fine-grained host:port filtering in all cases)
|
||||||
|
// Note: This is a limitation of Seatbelt - for more fine-grained control,
|
||||||
|
// consider using a network filtering solution or firewall rules
|
||||||
|
if len(policy.Network.AllowOutbound) > 0 {
|
||||||
|
sb.WriteString(";; Network outbound allowed to specific hosts\n")
|
||||||
|
sb.WriteString(";; Note: Seatbelt has limited host-based filtering, consider using firewall rules for strict control\n")
|
||||||
|
sb.WriteString("(allow network-outbound)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If deny outbound includes "*:*", block all network
|
||||||
|
for _, pattern := range policy.Network.DenyOutbound {
|
||||||
|
if pattern == "*:*" {
|
||||||
|
sb.WriteString(";; Network outbound denied\n")
|
||||||
|
sb.WriteString("(deny network-outbound)\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateProcess translates process execution rules.
|
||||||
|
func (t *PolicyTranslator) translateProcess(policy *SandboxPolicy, sb *strings.Builder) error {
|
||||||
|
sb.WriteString(";; Process execution\n")
|
||||||
|
|
||||||
|
// Add allow exec rules
|
||||||
|
for _, exePath := range policy.Process.AllowExec {
|
||||||
|
expanded, err := util.ExpandVariables(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand exec path %s: %w", exePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
// For glob patterns, use subpath to allow anything under that directory
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow process-exec* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(allow process-exec* (literal \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Add deny exec rules
|
||||||
|
for _, exePath := range policy.Process.DenyExec {
|
||||||
|
expanded, err := util.ExpandVariables(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand exec path %s: %w", exePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if util.ContainsGlob(expanded) {
|
||||||
|
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny process-exec* (subpath \"%s\"))\n", baseDir))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\"))\n", expanded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExpandVariables expands known variables in a path or pattern.
|
||||||
|
// Supported variables:
|
||||||
|
// - ${HOME}: User home directory
|
||||||
|
// - ${CWD}: Current working directory
|
||||||
|
// - ${TMPDIR}: Temporary directory
|
||||||
|
func ExpandVariables(pattern string) (string, error) {
|
||||||
|
result := pattern
|
||||||
|
|
||||||
|
// Get home directory
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current working directory
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get temp directory
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
|
||||||
|
// Replace variables
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"${HOME}", home,
|
||||||
|
"${CWD}", cwd,
|
||||||
|
"${TMPDIR}", tmpDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = replacer.Replace(result)
|
||||||
|
|
||||||
|
// Clean up path (resolve .., ., etc.)
|
||||||
|
result = filepath.Clean(result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsGlob returns true if the pattern contains glob wildcards.
|
||||||
|
func ContainsGlob(pattern string) bool {
|
||||||
|
return strings.Contains(pattern, "*") ||
|
||||||
|
strings.Contains(pattern, "?") ||
|
||||||
|
strings.Contains(pattern, "[")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandPathList expands variables in a list of paths/patterns.
|
||||||
|
func ExpandPathList(patterns []string) ([]string, error) {
|
||||||
|
result := make([]string, 0, len(patterns))
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
expanded, err := ExpandVariables(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import "github.com/safedep/pmg/sandbox/util"
|
||||||
|
|
||||||
|
// ExpandVariables expands known variables in a path or pattern.
|
||||||
|
// This is a convenience wrapper around util.ExpandVariables.
|
||||||
|
//
|
||||||
|
// Supported variables:
|
||||||
|
// - ${HOME}: User home directory
|
||||||
|
// - ${CWD}: Current working directory
|
||||||
|
// - ${TMPDIR}: Temporary directory
|
||||||
|
func ExpandVariables(pattern string) (string, error) {
|
||||||
|
return util.ExpandVariables(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandPathList expands variables in a list of paths/patterns.
|
||||||
|
// This is a convenience wrapper around util.ExpandPathList.
|
||||||
|
func ExpandPathList(patterns []string) ([]string, error) {
|
||||||
|
return util.ExpandPathList(patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsGlob returns true if the pattern contains glob wildcards.
|
||||||
|
// This is a convenience wrapper around util.ContainsGlob.
|
||||||
|
func ContainsGlob(pattern string) bool {
|
||||||
|
return util.ContainsGlob(pattern)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user