From ed59693f88973dd84889f4afd89ab560df6220bc Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Thu, 8 Jan 2026 13:40:22 +0530 Subject: [PATCH] feat: Sandbox implementation with seatbelt --- config/cobra.go | 4 + config/config.go | 27 +++ config/config.template.yml | 67 ++++++++ guard/guard.go | 7 + internal/flows/proxy_flow.go | 7 + sandbox/apply.go | 79 +++++++++ sandbox/policy.go | 142 ++++++++++++++++ sandbox/profiles/README.md | 65 +++++++ sandbox/profiles/npm-restrictive.yml | 73 ++++++++ sandbox/profiles/pypi-restrictive.yml | 60 +++++++ sandbox/registry.go | 148 ++++++++++++++++ sandbox/sandbox.go | 47 ++++++ sandbox/sandbox_darwin.go | 61 +++++++ sandbox/sandbox_linux.go | 13 ++ sandbox/sandbox_unsupported.go | 11 ++ sandbox/sandbox_windows.go | 13 ++ sandbox/seatbelt/seatbelt_darwin.go | 84 +++++++++ sandbox/seatbelt/translator_darwin.go | 234 ++++++++++++++++++++++++++ sandbox/util/variables.go | 67 ++++++++ sandbox/variable.go | 26 +++ 20 files changed, 1235 insertions(+) create mode 100644 sandbox/apply.go create mode 100644 sandbox/policy.go create mode 100644 sandbox/profiles/README.md create mode 100644 sandbox/profiles/npm-restrictive.yml create mode 100644 sandbox/profiles/pypi-restrictive.yml create mode 100644 sandbox/registry.go create mode 100644 sandbox/sandbox.go create mode 100644 sandbox/sandbox_darwin.go create mode 100644 sandbox/sandbox_linux.go create mode 100644 sandbox/sandbox_unsupported.go create mode 100644 sandbox/sandbox_windows.go create mode 100644 sandbox/seatbelt/seatbelt_darwin.go create mode 100644 sandbox/seatbelt/translator_darwin.go create mode 100644 sandbox/util/variables.go create mode 100644 sandbox/variable.go diff --git a/config/cobra.go b/config/cobra.go index da04fc0..2a734ae 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -21,4 +21,8 @@ func ApplyCobraFlags(cmd *cobra.Command) { globalConfig.Config.SkipEventLogging, "Skip event logging") cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode", 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") } diff --git a/config/config.go b/config/config.go index 00acf5a..ee74dcd 100644 --- a/config/config.go +++ b/config/config.go @@ -59,6 +59,33 @@ type Config struct { // ExperimentalProxyMode enables experimental proxy-based package interception. // When enabled, PMG starts a proxy server and intercepts package manager requests in real-time. 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. diff --git a/config/config.template.yml b/config/config.template.yml index 6afbae7..0728086 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -46,3 +46,70 @@ experimental_proxy_mode: false trusted_packages: - purl: pkg:npm/@safedep/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 diff --git a/guard/guard.go b/guard/guard.go index 2d43343..4608d2d 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -17,6 +17,7 @@ import ( "github.com/safedep/pmg/internal/eventlog" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" + "github.com/safedep/pmg/sandbox" ) type PackageManagerGuardInteraction struct { @@ -220,6 +221,12 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package cmd.Stdout = os.Stdout 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 // because other tools (scripts, CI etc.) may depend on this exit code. return cmd.Run() diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 238fbf9..d6b0f1e 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -17,6 +17,7 @@ import ( "github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy/certmanager" "github.com/safedep/pmg/proxy/interceptors" + "github.com/safedep/pmg/sandbox" ) type proxyFlow struct { @@ -241,6 +242,12 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana cmd.Stdout = os.Stdout 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("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath) diff --git a/sandbox/apply.go b/sandbox/apply.go new file mode 100644 index 0000000..746f266 --- /dev/null +++ b/sandbox/apply.go @@ -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 +} diff --git a/sandbox/policy.go b/sandbox/policy.go new file mode 100644 index 0000000..90a4a24 --- /dev/null +++ b/sandbox/policy.go @@ -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 +} diff --git a/sandbox/profiles/README.md b/sandbox/profiles/README.md new file mode 100644 index 0000000..e0ca451 --- /dev/null +++ b/sandbox/profiles/README.md @@ -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) diff --git a/sandbox/profiles/npm-restrictive.yml b/sandbox/profiles/npm-restrictive.yml new file mode 100644 index 0000000..0268dab --- /dev/null +++ b/sandbox/profiles/npm-restrictive.yml @@ -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* diff --git a/sandbox/profiles/pypi-restrictive.yml b/sandbox/profiles/pypi-restrictive.yml new file mode 100644 index 0000000..0028542 --- /dev/null +++ b/sandbox/profiles/pypi-restrictive.yml @@ -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 diff --git a/sandbox/registry.go b/sandbox/registry.go new file mode 100644 index 0000000..f0f492b --- /dev/null +++ b/sandbox/registry.go @@ -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() +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go new file mode 100644 index 0000000..a5b75da --- /dev/null +++ b/sandbox/sandbox.go @@ -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() +} diff --git a/sandbox/sandbox_darwin.go b/sandbox/sandbox_darwin.go new file mode 100644 index 0000000..ae3f78e --- /dev/null +++ b/sandbox/sandbox_darwin.go @@ -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() +} diff --git a/sandbox/sandbox_linux.go b/sandbox/sandbox_linux.go new file mode 100644 index 0000000..9f644d9 --- /dev/null +++ b/sandbox/sandbox_linux.go @@ -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)") +} diff --git a/sandbox/sandbox_unsupported.go b/sandbox/sandbox_unsupported.go new file mode 100644 index 0000000..2b266b8 --- /dev/null +++ b/sandbox/sandbox_unsupported.go @@ -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") +} diff --git a/sandbox/sandbox_windows.go b/sandbox/sandbox_windows.go new file mode 100644 index 0000000..1070cb7 --- /dev/null +++ b/sandbox/sandbox_windows.go @@ -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)") +} diff --git a/sandbox/seatbelt/seatbelt_darwin.go b/sandbox/seatbelt/seatbelt_darwin.go new file mode 100644 index 0000000..d2dc90b --- /dev/null +++ b/sandbox/seatbelt/seatbelt_darwin.go @@ -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 + 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 +} diff --git a/sandbox/seatbelt/translator_darwin.go b/sandbox/seatbelt/translator_darwin.go new file mode 100644 index 0000000..02ab180 --- /dev/null +++ b/sandbox/seatbelt/translator_darwin.go @@ -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 +} diff --git a/sandbox/util/variables.go b/sandbox/util/variables.go new file mode 100644 index 0000000..e09083b --- /dev/null +++ b/sandbox/util/variables.go @@ -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 +} diff --git a/sandbox/variable.go b/sandbox/variable.go new file mode 100644 index 0000000..2f0dbf0 --- /dev/null +++ b/sandbox/variable.go @@ -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) +}