fix: Fix sandbox policy generator for MacOS min permissions

This commit is contained in:
Abhisek Datta
2026-01-12 20:49:43 +05:30
parent d48ba68847
commit efca7ff711
13 changed files with 1597 additions and 68 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Sandbox
Design goal for sandbox in PMG context is to protect against unknown supply chain attacks using principles of least privilege.
Design goal for sandbox in PMG context is to protect against unknown supply chain attacks using principle of least privilege.
We do not want to re-invent sandbox and likely rely on OS native sandbox primitives. This is at the cost of developer experience,
where we have to work within the limitations of the sandbox implementations that we use.
+14 -1
View File
@@ -61,7 +61,8 @@ func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa
}
log.Debugf("Seatbelt profile written to %s", s.tempProfilePath)
log.Debugf("Seatbelt profile content:\n%s", sbProfile)
debugLogPolicyContent(sbProfile)
// Modify command to run via sandbox-exec
originalPath := cmd.Path
@@ -113,3 +114,15 @@ func (s *seatbeltSandbox) Close() error {
return nil
}
// debugLogPolicyContent logs the policy content when explicitly debugging is enabled.
func debugLogPolicyContent(content string) {
filePath := os.Getenv("PMG_SANDBOX_DEBUG_LOG_SEATBELT_POLICY_CONTENT")
if filePath == "" {
return
}
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil {
log.Warnf("failed to write seatbelt policy content to %s: %v", filePath, err)
}
}
+396 -40
View File
@@ -5,16 +5,136 @@ package platform
import (
"fmt"
"math/rand"
"path/filepath"
"strings"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/util"
)
type seatbeltPolicyTranslator struct{}
type seatbeltPolicyTranslator struct {
logTag string
}
// generateLogTag generates a unique log tag for tracking sandbox violations
func generateLogTag() string {
randomStr := fmt.Sprintf("%x", rand.Uint64())
return fmt.Sprintf("PMG_SBX_%s", randomStr[:12])
}
func newSeatbeltPolicyTranslator() *seatbeltPolicyTranslator {
return &seatbeltPolicyTranslator{}
return &seatbeltPolicyTranslator{
logTag: generateLogTag(),
}
}
// extractBaseDir extracts the base directory from a path that may contain glob patterns.
// For glob patterns, it returns the deepest directory path before any glob characters appear.
// Examples:
// - "/path/to/**" -> "/path/to"
// - "/path/to/*.txt" -> "/path/to"
// - "/path/*/subdir" -> "/path"
// - "/tmp/test[123].txt" -> "/tmp"
// - "/path/to/file" -> "/path/to/file" (no glob, return as-is)
// - "/*.txt" -> "/" (root directory)
func extractBaseDir(pattern string) string {
if !util.ContainsGlob(pattern) {
return pattern
}
// Split the path into components
components := strings.Split(pattern, string(filepath.Separator))
// Find the first component that contains a glob character
baseComponents := []string{}
for _, component := range components {
if util.ContainsGlob(component) {
// Stop before the component with glob
break
}
baseComponents = append(baseComponents, component)
}
// Join the base components back together
basePath := strings.Join(baseComponents, string(filepath.Separator))
// Handle edge cases
if basePath == "" {
// Pattern started with glob (e.g., "*.txt" or if it's an absolute path like "/*.txt", basePath would be "")
if strings.HasPrefix(pattern, string(filepath.Separator)) {
// Absolute path starting with glob at root (e.g., "/*.txt")
return string(filepath.Separator)
}
// Relative path starting with glob (e.g., "*.txt")
return "."
}
return basePath
}
// getAncestorDirectories returns all ancestor directories for a path, up to (but not including) root.
// Example: /private/tmp/test/file.txt -> ["/private/tmp/test", "/private/tmp", "/private"]
func getAncestorDirectories(pathStr string) []string {
ancestors := []string{}
currentPath := filepath.Dir(pathStr)
// Walk up the directory tree until we reach root
for currentPath != string(filepath.Separator) && currentPath != "." {
ancestors = append(ancestors, currentPath)
parentPath := filepath.Dir(currentPath)
// Break if we've reached the top (filepath.Dir returns the same path for root)
if parentPath == currentPath {
break
}
currentPath = parentPath
}
return ancestors
}
// generateMoveBlockingRules generates deny rules for file movement (file-write-unlink) to protect paths.
// This prevents bypassing read or write restrictions by moving files/directories.
//
// For each protected path pattern:
// - Blocks moving/renaming the path itself (via subpath or regex)
// - Blocks moving ancestor directories to prevent bypass
//
// Attack scenario this prevents:
//
// Policy denies write to /sensitive/file
// Attacker tries: mv /sensitive /tmp/renamed && echo "data" > /tmp/renamed/file && mv /tmp/renamed /sensitive
// This blocks the initial "mv /sensitive" operation
func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
rules := []string{}
for _, pathPattern := range pathPatterns {
if util.ContainsGlob(pathPattern) {
// For glob patterns, use regex matching for precise pattern enforcement
regexPattern := util.GlobToRegex(pathPattern)
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (regex \"%s\") (with message \"%s\"))", regexPattern, logTag))
// Also block moving the base directory to prevent bypass
baseDir := extractBaseDir(pathPattern)
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", baseDir, logTag))
// Block moving ancestor directories
for _, ancestorDir := range getAncestorDirectories(baseDir) {
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, logTag))
}
} else {
// For literal paths, use subpath matching
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", pathPattern, logTag))
// Block moving ancestor directories
for _, ancestorDir := range getAncestorDirectories(pathPattern) {
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, logTag))
}
}
}
return rules
}
func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (string, error) {
@@ -27,20 +147,169 @@ func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (str
sb.WriteString(";; Generated by PMG sandbox system\n\n")
// Default policy: deny by default for maximum security
sb.WriteString("(deny default)\n\n")
// Add log tag to track what gets denied by the default rule
sb.WriteString(fmt.Sprintf("(deny default (with message \"%s\"))\n\n", t.logTag))
// Allow basic system operations required for any process
sb.WriteString(";; Basic system access\n")
// Essential system permissions - based on Chrome/Chromium sandbox policy
// These are the minimum permissions needed for stable process execution
sb.WriteString(";; Essential system permissions\n")
sb.WriteString(";; Based on Chrome/Chromium sandbox for stable process execution\n\n")
// Process permissions
sb.WriteString(";; Process permissions\n")
sb.WriteString("(allow process-exec)\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")
sb.WriteString(";; Allow reading file metadata for getcwd() and similar operations\n")
sb.WriteString("(allow file-read-metadata)\n")
sb.WriteString(";; Allow reading system configuration and libraries needed for process execution\n")
sb.WriteString("(allow process-info* (target same-sandbox))\n")
sb.WriteString("(allow signal (target same-sandbox))\n")
sb.WriteString("(allow mach-priv-task-port (target same-sandbox))\n\n")
// User preferences
sb.WriteString(";; User preferences\n")
sb.WriteString("(allow user-preference-read)\n\n")
// Mach IPC - specific services only (no wildcard for security)
sb.WriteString(";; Mach IPC - specific services only\n")
sb.WriteString("(allow mach-lookup\n")
sb.WriteString(" (global-name \"com.apple.audio.systemsoundserver\")\n")
sb.WriteString(" (global-name \"com.apple.distributed_notifications@Uv3\")\n")
sb.WriteString(" (global-name \"com.apple.FontObjectsServer\")\n")
sb.WriteString(" (global-name \"com.apple.fonts\")\n")
sb.WriteString(" (global-name \"com.apple.logd\")\n")
sb.WriteString(" (global-name \"com.apple.lsd.mapdb\")\n")
sb.WriteString(" (global-name \"com.apple.PowerManagement.control\")\n")
sb.WriteString(" (global-name \"com.apple.system.logger\")\n")
sb.WriteString(" (global-name \"com.apple.system.notification_center\")\n")
sb.WriteString(" (global-name \"com.apple.trustd.agent\")\n")
sb.WriteString(" (global-name \"com.apple.system.opendirectoryd.libinfo\")\n")
sb.WriteString(" (global-name \"com.apple.system.opendirectoryd.membership\")\n")
sb.WriteString(" (global-name \"com.apple.bsd.dirhelper\")\n")
sb.WriteString(" (global-name \"com.apple.securityd.xpc\")\n")
sb.WriteString(" (global-name \"com.apple.coreservices.launchservicesd\")\n")
sb.WriteString(")\n\n")
// POSIX IPC
sb.WriteString(";; POSIX IPC\n")
sb.WriteString("(allow ipc-posix-shm) ; Shared memory\n")
sb.WriteString("(allow ipc-posix-sem) ; Semaphores for Python multiprocessing\n\n")
// IOKit operations
sb.WriteString(";; IOKit operations\n")
sb.WriteString("(allow iokit-open\n")
sb.WriteString(" (iokit-registry-entry-class \"IOSurfaceRootUserClient\")\n")
sb.WriteString(" (iokit-registry-entry-class \"RootDomainUserClient\")\n")
sb.WriteString(" (iokit-user-client-class \"IOSurfaceSendRight\")\n")
sb.WriteString(")\n")
sb.WriteString("(allow iokit-get-properties)\n\n")
// Specific safe system socket
sb.WriteString(";; Specific safe system socket\n")
sb.WriteString("(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))\n\n")
// sysctl - specific sysctls only
sb.WriteString(";; sysctl - specific sysctls only\n")
sb.WriteString("(allow sysctl-read\n")
// Hardware info
sb.WriteString(" (sysctl-name \"hw.activecpu\")\n")
sb.WriteString(" (sysctl-name \"hw.busfrequency_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.byteorder\")\n")
sb.WriteString(" (sysctl-name \"hw.cacheconfig\")\n")
sb.WriteString(" (sysctl-name \"hw.cachelinesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.cpufamily\")\n")
sb.WriteString(" (sysctl-name \"hw.cpufrequency\")\n")
sb.WriteString(" (sysctl-name \"hw.cpufrequency_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.cputype\")\n")
sb.WriteString(" (sysctl-name \"hw.l1dcachesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.l1icachesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.l2cachesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.l3cachesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.logicalcpu\")\n")
sb.WriteString(" (sysctl-name \"hw.logicalcpu_max\")\n")
sb.WriteString(" (sysctl-name \"hw.machine\")\n")
sb.WriteString(" (sysctl-name \"hw.memsize\")\n")
sb.WriteString(" (sysctl-name \"hw.ncpu\")\n")
sb.WriteString(" (sysctl-name \"hw.nperflevels\")\n")
sb.WriteString(" (sysctl-name \"hw.packages\")\n")
sb.WriteString(" (sysctl-name \"hw.pagesize_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.pagesize\")\n")
sb.WriteString(" (sysctl-name \"hw.physicalcpu\")\n")
sb.WriteString(" (sysctl-name \"hw.physicalcpu_max\")\n")
sb.WriteString(" (sysctl-name \"hw.tbfrequency_compat\")\n")
sb.WriteString(" (sysctl-name \"hw.vectorunit\")\n")
// Kernel info
sb.WriteString(" (sysctl-name \"kern.argmax\")\n")
sb.WriteString(" (sysctl-name \"kern.bootargs\")\n")
sb.WriteString(" (sysctl-name \"kern.hostname\")\n")
sb.WriteString(" (sysctl-name \"kern.maxfiles\")\n")
sb.WriteString(" (sysctl-name \"kern.maxfilesperproc\")\n")
sb.WriteString(" (sysctl-name \"kern.maxproc\")\n")
sb.WriteString(" (sysctl-name \"kern.ngroups\")\n")
sb.WriteString(" (sysctl-name \"kern.osproductversion\")\n")
sb.WriteString(" (sysctl-name \"kern.osrelease\")\n")
sb.WriteString(" (sysctl-name \"kern.ostype\")\n")
sb.WriteString(" (sysctl-name \"kern.osvariant_status\")\n")
sb.WriteString(" (sysctl-name \"kern.osversion\")\n")
sb.WriteString(" (sysctl-name \"kern.secure_kernel\")\n")
sb.WriteString(" (sysctl-name \"kern.tcsm_available\")\n")
sb.WriteString(" (sysctl-name \"kern.tcsm_enable\")\n")
sb.WriteString(" (sysctl-name \"kern.usrstack64\")\n")
sb.WriteString(" (sysctl-name \"kern.version\")\n")
sb.WriteString(" (sysctl-name \"kern.willshutdown\")\n")
// machdep info
sb.WriteString(" (sysctl-name \"machdep.cpu.brand_string\")\n")
sb.WriteString(" (sysctl-name \"machdep.ptrauth_enabled\")\n")
// Security info
sb.WriteString(" (sysctl-name \"security.mac.lockdown_mode_state\")\n")
// Other
sb.WriteString(" (sysctl-name \"sysctl.proc_cputype\")\n")
sb.WriteString(" (sysctl-name \"vm.loadavg\")\n")
// Prefixes for more sysctls
sb.WriteString(" (sysctl-name-prefix \"hw.optional.arm\")\n")
sb.WriteString(" (sysctl-name-prefix \"hw.optional.arm.\")\n")
sb.WriteString(" (sysctl-name-prefix \"hw.optional.armv8_\")\n")
sb.WriteString(" (sysctl-name-prefix \"hw.perflevel\")\n")
sb.WriteString(" (sysctl-name-prefix \"kern.proc.all\")\n")
sb.WriteString(" (sysctl-name-prefix \"kern.proc.pgrp.\")\n")
sb.WriteString(" (sysctl-name-prefix \"kern.proc.pid.\")\n")
sb.WriteString(" (sysctl-name-prefix \"machdep.cpu.\")\n")
sb.WriteString(" (sysctl-name-prefix \"net.routetable.\")\n")
sb.WriteString(")\n\n")
// V8 thread calculations
sb.WriteString(";; V8 thread calculations\n")
sb.WriteString("(allow sysctl-write\n")
sb.WriteString(" (sysctl-name \"kern.tcsm_enable\")\n")
sb.WriteString(")\n\n")
// Distributed notifications
sb.WriteString(";; Distributed notifications\n")
sb.WriteString("(allow distributed-notification-post)\n\n")
// Specific mach-lookup for security
sb.WriteString(";; Specific mach-lookup for security\n")
sb.WriteString("(allow mach-lookup (global-name \"com.apple.SecurityServer\"))\n\n")
// Device file I/O
sb.WriteString(";; Device file I/O\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/null\"))\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/zero\"))\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/random\"))\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/urandom\"))\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/dtracehelper\"))\n")
sb.WriteString("(allow file-ioctl (literal \"/dev/tty\"))\n\n")
sb.WriteString("(allow file-ioctl file-read-data file-write-data\n")
sb.WriteString(" (require-all\n")
sb.WriteString(" (literal \"/dev/null\")\n")
sb.WriteString(" (vnode-type CHARACTER-DEVICE)\n")
sb.WriteString(" )\n")
sb.WriteString(")\n\n")
// File metadata
sb.WriteString(";; File metadata for getcwd() and similar\n")
sb.WriteString("(allow file-read-metadata)\n\n")
// System configuration and libraries
sb.WriteString(";; System configuration and libraries\n")
sb.WriteString("(allow file-read* (subpath \"/dev\"))\n")
sb.WriteString("(allow file-read* (subpath \"/etc\"))\n\n")
@@ -59,6 +328,20 @@ func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (str
return "", fmt.Errorf("failed to translate process rules: %w", err)
}
// PTY support (optional)
if policy.AllowPTY {
sb.WriteString(";; Pseudo-terminal (PTY) support\n")
sb.WriteString("(allow pseudo-tty)\n")
sb.WriteString("(allow file-ioctl\n")
sb.WriteString(" (literal \"/dev/ptmx\")\n")
sb.WriteString(" (regex #\"^/dev/ttys\")\n")
sb.WriteString(")\n")
sb.WriteString("(allow file-read* file-write*\n")
sb.WriteString(" (literal \"/dev/ptmx\")\n")
sb.WriteString(" (regex #\"^/dev/ttys\")\n")
sb.WriteString(")\n\n")
}
return sb.String(), nil
}
@@ -73,11 +356,10 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
// Handle glob patterns vs literal paths
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
// For glob patterns, use subpath with the base directory
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", baseDir))
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow file-read* (regex \"%s\"))\n", regexPattern))
} else {
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", expanded))
}
@@ -85,6 +367,20 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
sb.WriteString("\n")
// Auto-allow TMPDIR parent on macOS when write restrictions are enabled
// This is necessary because package managers need temp file access
hasWriteRestrictions := len(policy.Filesystem.AllowWrite) > 0
if hasWriteRestrictions {
tmpdirParents := util.GetTmpdirParent()
if len(tmpdirParents) > 0 {
sb.WriteString(";; Auto-allow TMPDIR parent on macOS\n")
for _, parent := range tmpdirParents {
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", parent))
}
sb.WriteString("\n")
}
}
// Expand and add allow write rules
for _, pattern := range policy.Filesystem.AllowWrite {
expanded, err := util.ExpandVariables(pattern)
@@ -92,9 +388,10 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", baseDir))
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow file-write* (regex \"%s\"))\n", regexPattern))
} else {
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", expanded))
}
@@ -104,31 +401,79 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
// Deny rules have higher priority (applied after allow)
// Note: Seatbelt evaluates rules in order, so denies after allows will override
expandedDenyRead := []string{}
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)
}
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", baseDir))
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-read* (regex \"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", expanded))
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
}
expandedDenyRead = append(expandedDenyRead, expanded)
}
// Add file movement protection for deny read paths
if len(expandedDenyRead) > 0 {
sb.WriteString("\n;; Prevent bypassing read restrictions via file movement\n")
for _, rule := range generateMoveBlockingRules(expandedDenyRead, t.logTag) {
sb.WriteString(rule + "\n")
}
}
sb.WriteString("\n")
expandedDenyWrite := []string{}
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)
}
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", baseDir))
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex \"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", expanded))
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
sb.WriteString("\n")
// Add mandatory deny patterns for security (credentials, git hooks, etc.)
sb.WriteString(";; Mandatory security denies (credentials, git hooks, etc.)\n")
mandatoryDenies := util.GetMandatoryDenyPatterns(policy.AllowGitConfig)
for _, pattern := range mandatoryDenies {
// Expand variables if needed
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand mandatory deny pattern %s: %w", pattern, err)
}
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex \"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
sb.WriteString("\n")
// Add file movement protection for all deny write paths (user + mandatory)
if len(expandedDenyWrite) > 0 {
sb.WriteString(";; Prevent bypassing write restrictions via file movement\n")
for _, rule := range generateMoveBlockingRules(expandedDenyWrite, t.logTag) {
sb.WriteString(rule + "\n")
}
}
@@ -141,6 +486,15 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Network access\n")
// Check if deny all is present
denyAll := false
for _, pattern := range policy.Network.DenyOutbound {
if pattern == "*:*" {
denyAll = true
break
}
}
// 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,
@@ -149,16 +503,17 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic
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")
} else if denyAll {
// If there are no allow rules but deny all is set, explicitly deny network
// This handles the case where user wants to completely block network access
sb.WriteString(";; Network outbound denied (no allowed hosts specified)\n")
sb.WriteString("(deny 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
}
}
// Note: We don't add an explicit deny rule when both allow and deny_all are present
// because the default (deny default) at the top of the profile handles blocking
// everything that isn't explicitly allowed. Adding an explicit deny here would
// override the allow rule above, breaking network access entirely.
sb.WriteString("\n")
@@ -177,9 +532,9 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic
}
if util.ContainsGlob(expanded) {
// For glob patterns, use subpath to allow anything under that directory
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(allow process-exec* (subpath \"%s\"))\n", baseDir))
// For glob patterns, use regex matching for precise control
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow process-exec* (regex \"%s\"))\n", regexPattern))
} else {
sb.WriteString(fmt.Sprintf("(allow process-exec* (literal \"%s\"))\n", expanded))
}
@@ -195,10 +550,11 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic
}
if util.ContainsGlob(expanded) {
baseDir := strings.TrimSuffix(expanded, "/**")
sb.WriteString(fmt.Sprintf("(deny process-exec* (subpath \"%s\"))\n", baseDir))
// For glob patterns, use regex matching for precise control
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny process-exec* (regex \"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
} else {
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\"))\n", expanded))
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
}
}
@@ -0,0 +1,469 @@
//go:build darwin
// +build darwin
package platform
import (
"fmt"
"testing"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
)
func TestSeatbeltTranslatorDarwinCommonTranslation(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
DenyRead: []string{"/private/var"},
DenyWrite: []string{"/private/var"},
},
Network: sandbox.NetworkPolicy{
AllowOutbound: []string{"*:*"},
},
Process: sandbox.ProcessPolicy{
AllowExec: []string{"/bin/sh"},
DenyExec: []string{"/bin/bash"},
},
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
assert.NoError(t, err)
// Test common translation
assert.Contains(t, actual, "(version 1)")
assert.Contains(t, actual, fmt.Sprintf(";; PMG Sandbox Policy: %s", policy.Name))
assert.Contains(t, actual, fmt.Sprintf(";; %s", policy.Description))
assert.Contains(t, actual, ";; Generated by PMG sandbox system")
// Test deny default - it should contain a message tag
assert.Contains(t, actual, "(deny default (with message")
// Allow reading /dev and /etc
assert.Contains(t, actual, "(allow file-read* (subpath \"/dev\"))")
assert.Contains(t, actual, "(allow file-read* (subpath \"/etc\"))")
// Allow process fork
assert.Contains(t, actual, "(allow process-fork)")
}
func TestSeatbeltTranslatorDarwinFilesystemTranslation(t *testing.T) {
cases := []struct {
name string
policy *sandbox.SandboxPolicy
assert func(t *testing.T, actual string, err error)
}{
{
name: "simple path",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
DenyRead: []string{"/private/var"},
DenyWrite: []string{"/private/var"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
assert.Contains(t, actual, "(allow file-read* (subpath \"/tmp\"))")
assert.Contains(t, actual, "(allow file-write* (subpath \"/tmp\"))")
assert.Contains(t, actual, "(deny file-read* (subpath \"/private/var\") (with message")
assert.Contains(t, actual, "(deny file-write* (subpath \"/private/var\") (with message")
},
},
{
name: "glob pattern with /**",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/path/to/dir/**"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow file-read* (regex")
assert.Contains(t, actual, "^/path/to/dir/.*$")
},
},
{
name: "glob pattern with *.txt",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/path/to/*.txt"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow file-read* (regex")
assert.Contains(t, actual, `^/path/to/[^/]*\.txt$`)
},
},
{
name: "glob pattern with ? wildcard",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{"/path/to/file?.log"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow file-write* (regex")
assert.Contains(t, actual, `^/path/to/file[^/]\.log$`)
},
},
{
name: "glob pattern in middle of path",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{"/path/*/subdir"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(deny file-read* (regex")
assert.Contains(t, actual, `^/path/[^/]*/subdir$`)
},
},
{
name: "glob pattern with bracket wildcard",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp/test[123].txt"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow file-read* (regex")
assert.Contains(t, actual, `^/tmp/test[123]\.txt$`)
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(tt.policy)
tt.assert(t, actual, err)
})
}
}
func TestSeatbeltTranslatorDarwinProcessTranslation(t *testing.T) {
cases := []struct {
name string
policy *sandbox.SandboxPolicy
assert func(t *testing.T, actual string, err error)
}{
{
name: "literal exec path",
policy: &sandbox.SandboxPolicy{
Process: sandbox.ProcessPolicy{
AllowExec: []string{"/bin/sh"},
DenyExec: []string{"/bin/bash"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
assert.Contains(t, actual, "(allow process-exec* (literal \"/bin/sh\"))")
assert.Contains(t, actual, "(deny process-exec* (literal \"/bin/bash\") (with message")
},
},
{
name: "glob pattern with /** for exec",
policy: &sandbox.SandboxPolicy{
Process: sandbox.ProcessPolicy{
AllowExec: []string{"/usr/local/bin/**"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow process-exec* (regex")
assert.Contains(t, actual, "^/usr/local/bin/.*$")
},
},
{
name: "glob pattern with * wildcard for exec",
policy: &sandbox.SandboxPolicy{
Process: sandbox.ProcessPolicy{
AllowExec: []string{"/usr/bin/python*"},
},
},
assert: func(t *testing.T, actual string, err error) {
assert.NoError(t, err)
// Should use regex matching for glob patterns
assert.Contains(t, actual, "(allow process-exec* (regex")
assert.Contains(t, actual, `^/usr/bin/python[^/]*$`)
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(tt.policy)
tt.assert(t, actual, err)
})
}
}
func TestExtractBaseDir(t *testing.T) {
cases := []struct {
name string
pattern string
expected string
}{
{
name: "no glob pattern",
pattern: "/path/to/file",
expected: "/path/to/file",
},
{
name: "glob pattern with /**",
pattern: "/path/to/**",
expected: "/path/to",
},
{
name: "glob pattern with *.txt",
pattern: "/path/to/*.txt",
expected: "/path/to",
},
{
name: "glob pattern with ? wildcard",
pattern: "/path/to/file?.log",
expected: "/path/to",
},
{
name: "glob pattern in middle of path",
pattern: "/path/*/subdir",
expected: "/path",
},
{
name: "glob pattern with bracket wildcard",
pattern: "/tmp/test[123].txt",
expected: "/tmp",
},
{
name: "glob pattern at root",
pattern: "/*.txt",
expected: "/",
},
{
name: "multiple glob patterns",
pattern: "/path/*/sub/*.txt",
expected: "/path",
},
{
name: "complex glob with multiple wildcards",
pattern: "/usr/bin/python*",
expected: "/usr/bin",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := extractBaseDir(tt.pattern)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestGetAncestorDirectories(t *testing.T) {
cases := []struct {
name string
path string
expected []string
}{
{
name: "deep path",
path: "/private/tmp/test/file.txt",
expected: []string{"/private/tmp/test", "/private/tmp", "/private"},
},
{
name: "two level path",
path: "/tmp/file.txt",
expected: []string{"/tmp"},
},
{
name: "root level path",
path: "/file.txt",
expected: []string{},
},
{
name: "directory path",
path: "/usr/local/bin",
expected: []string{"/usr/local", "/usr"},
},
{
name: "path with trailing slash",
path: "/path/to/dir/",
expected: []string{"/path/to/dir", "/path/to", "/path"},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := getAncestorDirectories(tt.path)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestGenerateMoveBlockingRules(t *testing.T) {
cases := []struct {
name string
patterns []string
logTag string
assert func(t *testing.T, rules []string)
}{
{
name: "single literal path",
patterns: []string{"/sensitive/data"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should block moving the path itself
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/sensitive/data\") (with message \"test\"))")
// Should block moving the parent directory
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/sensitive\") (with message \"test\"))")
},
},
{
name: "glob pattern",
patterns: []string{"/path/to/*.txt"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should block moving the base directory
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/path/to\") (with message \"test\"))")
// Should block moving ancestor directories
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/path\") (with message \"test\"))")
},
},
{
name: "multiple paths",
patterns: []string{"/tmp/test", "/var/log/app"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/tmp/test\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/tmp\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/var/log/app\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/var/log\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/var\") (with message \"test\"))")
},
},
{
name: "deep nested path",
patterns: []string{"/a/b/c/d/e/file.txt"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should have rules for all ancestors
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c/d/e\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c/d\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a\") (with message \"test\"))")
},
},
{
name: "root level path",
patterns: []string{"/file"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should only have the file itself, no ancestors
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/file\") (with message \"test\"))")
// Should not contain root as ancestor
for _, rule := range rules {
assert.NotContains(t, rule, "(deny file-write-unlink (literal \"/\"))")
}
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
rules := generateMoveBlockingRules(tt.patterns, tt.logTag)
tt.assert(t, rules)
})
}
}
func TestFilesystemTranslationWithMoveProtection(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test with move protection",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{"/private/sensitive"},
DenyWrite: []string{"/usr/local/bin"},
},
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
assert.NoError(t, err)
// Should contain deny read rule
assert.Contains(t, actual, "(deny file-read* (subpath \"/private/sensitive\") (with message")
// Should contain move protection for deny read
assert.Contains(t, actual, ";; Prevent bypassing read restrictions via file movement")
assert.Contains(t, actual, "(deny file-write-unlink (subpath \"/private/sensitive\") (with message")
assert.Contains(t, actual, "(deny file-write-unlink (literal \"/private\") (with message")
// Should contain deny write rule
assert.Contains(t, actual, "(deny file-write* (subpath \"/usr/local/bin\") (with message")
// Should contain move protection for deny write
assert.Contains(t, actual, ";; Prevent bypassing write restrictions via file movement")
assert.Contains(t, actual, "(deny file-write-unlink (subpath \"/usr/local/bin\") (with message")
assert.Contains(t, actual, "(deny file-write-unlink (literal \"/usr/local\") (with message")
assert.Contains(t, actual, "(deny file-write-unlink (literal \"/usr\") (with message")
}
func TestPTYSupport(t *testing.T) {
t.Run("PTY disabled by default", func(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test without PTY",
PackageManagers: []string{"npm"},
AllowPTY: false,
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
assert.NoError(t, err)
// Should NOT contain PTY rules
assert.NotContains(t, actual, "(allow pseudo-tty)")
assert.NotContains(t, actual, "/dev/ptmx")
})
t.Run("PTY enabled when requested", func(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Description: "test with PTY",
PackageManagers: []string{"npm"},
AllowPTY: true,
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
assert.NoError(t, err)
// Should contain PTY rules
assert.Contains(t, actual, ";; Pseudo-terminal (PTY) support")
assert.Contains(t, actual, "(allow pseudo-tty)")
assert.Contains(t, actual, "(allow file-ioctl")
assert.Contains(t, actual, "(literal \"/dev/ptmx\")")
assert.Contains(t, actual, "(regex #\"^/dev/ttys\")")
assert.Contains(t, actual, "(allow file-read* file-write*")
})
}
+8
View File
@@ -15,6 +15,14 @@ type SandboxPolicy struct {
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
Network NetworkPolicy `yaml:"network" json:"network"`
Process ProcessPolicy `yaml:"process" json:"process"`
// AllowGitConfig allows write access to .git/config file.
// Default: false (blocks .git/config for security)
// Set to true if package managers need to modify git config (rare)
AllowGitConfig bool `yaml:"allow_git_config" json:"allow_git_config"`
// AllowPTY allows pseudo-terminal (PTY) operations.
// Default: false
// Set to true if package managers need interactive terminal support
AllowPTY bool `yaml:"allow_pty" json:"allow_pty"`
}
// FilesystemPolicy defines allowed and denied filesystem access patterns.
+37 -19
View File
@@ -6,8 +6,22 @@ package_managers:
- yarn
- bun
# Optional security settings (uncomment to enable)
# allow_git_config: false # Allow package managers to modify .git/config (default: false, blocks for security)
# Allow interactive terminal (PTY) operations (default: false)
allow_pty: false
filesystem:
allow_read:
# Essential system paths for process execution
- /
- /usr/**
- /var/**
- /Library/**
- /System/Library/**
- /private/var/**
# Project and user-specific paths
- ${CWD}/**
- ${HOME}/.npmrc
- ${HOME}/.yarnrc
@@ -19,43 +33,43 @@ filesystem:
- ${HOME}/.cache/yarn/**
- ${HOME}/.yarn/cache/**
- ${HOME}/.bun/install/cache/**
- /usr/local/**
- /Library/**
- /System/Library/**
- /private/var/**
allow_write:
# Note: ${TMPDIR} is automatically allowed when write restrictions are enabled (macOS)
# Temporary directories for shell scripts and package managers
- /tmp/**
- /var/tmp/**
# Project directories
- ${CWD}/node_modules/**
- ${CWD}/package-lock.json
- ${CWD}/yarn.lock
- ${CWD}/pnpm-lock.yaml
- ${CWD}/bun.lockb
# Package manager caches and stores
- ${HOME}/.npm/**
- ${HOME}/.pnpm-store/**
- ${HOME}/.cache/pnpm/**
- ${HOME}/.cache/yarn/**
- ${HOME}/.yarn/cache/**
- ${HOME}/.bun/install/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
# Additional deny rules (optional - credentials are automatically blocked)
# Automatically blocked for security:
# - .env, .env.*, .ssh/, .aws/, .gcloud/, .kube/, .gnupg/, .docker/config.json
# - .git/hooks/ (always blocked)
# - .git/config (blocked unless allow_git_config: true)
deny_read: []
deny_write:
- ${HOME}/.ssh/**
- ${HOME}/.aws/**
# Additional system directories to protect
- /etc/**
- /usr/**
- /bin/**
- /sbin/**
network:
# MacOS sandbox-exec does not support network restrictions, so we allow all outbound traffic
# when at least one allow outbound rule is present.
allow_outbound:
- registry.npmjs.org:443
- registry.yarnpkg.com:443
@@ -75,6 +89,10 @@ process:
- ${HOME}/.cache/yarn/**
- ${HOME}/.yarn/cache/**
- ${HOME}/.bun/install/cache/**
- ${HOME}/.asdf/shims/npm
- ${HOME}/.asdf/shims/pnpm
- ${HOME}/.asdf/shims/yarn
- ${HOME}/.asdf/shims/bun
- /usr/bin/git
- /usr/local/bin/git
- /bin/bash
+14 -7
View File
@@ -6,6 +6,12 @@ package_managers:
- poetry
- uv
# Optional security settings (uncomment to enable)
# allow_git_config: false # Allow package managers to modify .git/config (default: false, blocks for security)
# Allow interactive terminal (PTY) operations (default: false)
allow_pty: true
filesystem:
allow_read:
- ${CWD}/**
@@ -21,6 +27,7 @@ filesystem:
- /System/Library/**
allow_write:
# Note: ${TMPDIR} is automatically allowed when write restrictions are enabled (macOS)
- ${CWD}/.venv/**
- ${CWD}/venv/**
- ${HOME}/.cache/pip/**
@@ -28,16 +35,16 @@ filesystem:
- ${HOME}/.cache/poetry/**
- ${HOME}/.cache/uv/**
- ${HOME}/.local/lib/python*/**
- ${TMPDIR}/**
deny_read:
- ${HOME}/.ssh/**
- ${HOME}/.aws/**
- ${HOME}/.gcloud/**
- "**/.env"
# Additional deny rules (optional - credentials are automatically blocked)
# Automatically blocked for security:
# - .env, .env.*, .ssh/, .aws/, .gcloud/, .kube/, .gnupg/, .docker/config.json
# - .git/hooks/ (always blocked)
# - .git/config (blocked unless allow_git_config: true)
deny_read: []
deny_write:
- ${HOME}/.ssh/**
# Additional system directories to protect
- /etc/**
- /usr/**
+74
View File
@@ -0,0 +1,74 @@
package util
import (
"os"
"path/filepath"
)
// DANGEROUS_FILES are files that should always be blocked from write access
// to prevent credential theft and security compromise.
var DANGEROUS_FILES = []string{
".env",
".env.*",
".aws",
".gcloud",
".kube",
".ssh",
".gnupg",
".docker/config.json",
}
// GetMandatoryDenyPatterns returns filesystem paths that should always be blocked
// from write access for security reasons. These are automatically injected into
// all sandbox policies regardless of user configuration.
//
// Parameters:
// - allowGitConfig: if false, blocks write access to .git/config (recommended)
//
// Returns patterns in both absolute (from HOME) and glob forms for comprehensive coverage.
func GetMandatoryDenyPatterns(allowGitConfig bool) []string {
patterns := []string{}
// Get current working directory for CWD-relative patterns
cwd, err := os.Getwd()
if err != nil {
// Fallback to basic patterns if we can't get CWD
cwd = "."
}
// Get home directory for HOME-relative patterns
home, err := os.UserHomeDir()
if err != nil {
// If we can't get home, skip home-based patterns
home = ""
}
// Add dangerous files from CWD
for _, fileName := range DANGEROUS_FILES {
// Absolute path in CWD
patterns = append(patterns, filepath.Join(cwd, fileName))
// Glob pattern to catch in subdirectories
patterns = append(patterns, filepath.Join("**", fileName))
}
// Add dangerous files from HOME (if available)
if home != "" {
for _, fileName := range DANGEROUS_FILES {
patterns = append(patterns, filepath.Join(home, fileName))
}
}
// Git hooks are ALWAYS blocked for security (can execute arbitrary code)
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
patterns = append(patterns, filepath.Join(cwd, ".git/hooks/**"))
patterns = append(patterns, "**/.git/hooks")
patterns = append(patterns, "**/.git/hooks/**")
// Git config is conditionally blocked
if !allowGitConfig {
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
patterns = append(patterns, "**/.git/config")
}
return patterns
}
+79
View File
@@ -0,0 +1,79 @@
package util
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetMandatoryDenyPatterns(t *testing.T) {
t.Run("always blocks dangerous files", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should contain patterns for each dangerous file
assert.Contains(t, patterns, "**/.env")
assert.Contains(t, patterns, "**/.ssh")
assert.Contains(t, patterns, "**/.aws")
assert.Contains(t, patterns, "**/.gcloud")
assert.Contains(t, patterns, "**/.kube")
assert.Contains(t, patterns, "**/.gnupg")
assert.Contains(t, patterns, "**/.docker/config.json")
})
t.Run("always blocks git hooks", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should block git hooks
assert.Contains(t, patterns, "**/.git/hooks")
assert.Contains(t, patterns, "**/.git/hooks/**")
})
t.Run("blocks git config when allowGitConfig is false", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should block git config
assert.Contains(t, patterns, "**/.git/config")
})
t.Run("allows git config when allowGitConfig is true", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(true)
// Should NOT block git config
for _, pattern := range patterns {
assert.NotContains(t, pattern, ".git/config")
}
})
t.Run("includes CWD-relative patterns", func(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should include absolute paths in CWD
assert.Contains(t, patterns, filepath.Join(cwd, ".env"))
assert.Contains(t, patterns, filepath.Join(cwd, ".ssh"))
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
})
t.Run("includes HOME-relative patterns", func(t *testing.T) {
home, err := os.UserHomeDir()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should include absolute paths in HOME
assert.Contains(t, patterns, filepath.Join(home, ".env"))
assert.Contains(t, patterns, filepath.Join(home, ".ssh"))
assert.Contains(t, patterns, filepath.Join(home, ".aws"))
})
t.Run("includes glob patterns for env variants", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should include pattern for .env.* files
assert.Contains(t, patterns, "**/.env.*")
})
}
+86
View File
@@ -0,0 +1,86 @@
package util
import (
"regexp"
"strings"
)
// GlobToRegex converts a glob pattern to a Seatbelt-compatible regular expression.
//
// This implements gitignore-style pattern matching to match the behavior used
// in filesystem permission systems.
//
// Supported patterns:
// - * matches any characters except / (e.g., *.ts matches foo.ts but not foo/bar.ts)
// - ** matches any characters including / (e.g., src/**/*.ts matches all .ts files in src/)
// - ? matches any single character except / (e.g., file?.txt matches file1.txt)
// - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
//
// Note: This is designed for macOS sandbox (regex ...) syntax. The resulting regex
// will be used in sandbox profiles like: (deny file-write* (regex "pattern"))
//
// Examples:
// - "/path/to/*.txt" -> "^/path/to/[^/]*\\.txt$"
// - "/path/**/file" -> "^/path/(.*/)?file$"
// - "/tmp/file?.log" -> "^/tmp/file[^/]\\.log$"
func GlobToRegex(globPattern string) string {
result := globPattern
// Escape regex special characters (except glob chars * ? [ ])
// We need to escape: . ^ $ + { } ( ) | \
result = escapeRegexChars(result)
// Escape unclosed brackets (no matching ])
// This handles edge cases like "[abc" which should be treated literally
result = escapeUnclosedBrackets(result)
// Convert glob patterns to regex (order matters - ** before *)
// Use placeholders to avoid double-conversion
// 1. Handle **/ (globstar with slash)
result = strings.ReplaceAll(result, "**/", "__GLOBSTAR_SLASH__")
// 2. Handle ** (globstar standalone)
result = strings.ReplaceAll(result, "**", "__GLOBSTAR__")
// 3. Handle * (wildcard)
result = strings.ReplaceAll(result, "*", "[^/]*")
// 4. Handle ? (single char wildcard)
result = strings.ReplaceAll(result, "?", "[^/]")
// 5. Restore placeholders
result = strings.ReplaceAll(result, "__GLOBSTAR_SLASH__", "(.*/)?")
result = strings.ReplaceAll(result, "__GLOBSTAR__", ".*")
// Add anchors for exact matching
return "^" + result + "$"
}
// escapeRegexChars escapes regex special characters except glob wildcards.
// Escapes: . ^ $ + { } ( ) |
// Preserves: * ? [ ] \
// Note: We don't escape backslash because it shouldn't appear in file path glob patterns
func escapeRegexChars(s string) string {
// Characters that need escaping in regex (excluding glob chars)
// We don't include backslash here because:
// 1. File paths on Unix don't contain backslashes
// 2. We use backslash to escape regex chars, so escaping backslash would double them
specialChars := []string{".", "^", "$", "+", "{", "}", "(", ")", "|"}
result := s
for _, char := range specialChars {
result = strings.ReplaceAll(result, char, "\\"+char)
}
return result
}
var escapeUnclosedBracketsRegex = regexp.MustCompile(`\[([^\]]*?)$`)
// escapeUnclosedBrackets escapes bracket expressions that don't have a closing bracket.
// Example: "[abc" -> "\[abc"
func escapeUnclosedBrackets(s string) string {
// Find all opening brackets that don't have a closing bracket
return escapeUnclosedBracketsRegex.ReplaceAllString(s, `\[$1`)
}
+302
View File
@@ -0,0 +1,302 @@
package util
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGlobToRegex(t *testing.T) {
cases := []struct {
name string
pattern string
shouldMatch []string
shouldNotMatch []string
}{
{
name: "simple asterisk wildcard",
pattern: "/path/to/*.txt",
shouldMatch: []string{
"/path/to/file.txt",
"/path/to/test.txt",
"/path/to/a.txt",
"/path/to/.txt", // * matches zero or more chars (standard glob behavior)
},
shouldNotMatch: []string{
"/path/to/file.log",
"/path/to/sub/file.txt",
"/path/file.txt",
},
},
{
name: "globstar pattern",
pattern: "/path/**/*.txt",
shouldMatch: []string{
"/path/file.txt",
"/path/to/file.txt",
"/path/to/sub/file.txt",
"/path/a/b/c/file.txt",
},
shouldNotMatch: []string{
"/path/file.log",
"/other/path/file.txt",
},
},
{
name: "globstar with trailing slash",
pattern: "/src/**/",
shouldMatch: []string{
"/src/",
"/src/a/",
"/src/a/b/",
"/src/deep/nested/path/",
},
shouldNotMatch: []string{
"/src",
"/other/",
},
},
{
name: "question mark wildcard",
pattern: "/tmp/file?.log",
shouldMatch: []string{
"/tmp/file1.log",
"/tmp/file2.log",
"/tmp/filea.log",
},
shouldNotMatch: []string{
"/tmp/file.log",
"/tmp/file12.log",
"/tmp/file/.log",
},
},
{
name: "bracket wildcard",
pattern: "/tmp/test[123].txt",
shouldMatch: []string{
"/tmp/test1.txt",
"/tmp/test2.txt",
"/tmp/test3.txt",
},
shouldNotMatch: []string{
"/tmp/test4.txt",
"/tmp/testa.txt",
"/tmp/test.txt",
},
},
{
name: "bracket range",
pattern: "/tmp/file[0-9].log",
shouldMatch: []string{
"/tmp/file0.log",
"/tmp/file5.log",
"/tmp/file9.log",
},
shouldNotMatch: []string{
"/tmp/filea.log",
"/tmp/file10.log",
},
},
{
name: "regex special characters escaped",
pattern: "/path/to/file.txt",
shouldMatch: []string{
"/path/to/file.txt",
},
shouldNotMatch: []string{
"/path/to/fileXtxt",
"/path/to/file_txt",
},
},
{
name: "multiple wildcards",
pattern: "/path/*/sub/*.txt",
shouldMatch: []string{
"/path/a/sub/file.txt",
"/path/b/sub/test.txt",
},
shouldNotMatch: []string{
"/path/sub/file.txt",
"/path/a/sub/deep/file.txt",
"/path/a/b/sub/file.txt",
},
},
{
name: "globstar in middle",
pattern: "/usr/**/bin/node",
shouldMatch: []string{
"/usr/bin/node",
"/usr/local/bin/node",
"/usr/a/b/c/bin/node",
},
shouldNotMatch: []string{
"/usr/node",
"/usr/bin/npm",
},
},
{
name: "exact path (no wildcards)",
pattern: "/etc/passwd",
shouldMatch: []string{
"/etc/passwd",
},
shouldNotMatch: []string{
"/etc/passwd.bak",
"/etc/shadow",
},
},
{
name: "wildcard at beginning",
pattern: "*.txt",
shouldMatch: []string{
"file.txt",
"test.txt",
},
shouldNotMatch: []string{
"file.log",
"dir/file.txt",
},
},
{
name: "complex pattern with parens and dots",
pattern: "/path/to/file(1).txt",
shouldMatch: []string{
"/path/to/file(1).txt",
},
shouldNotMatch: []string{
"/path/to/file1.txt",
"/path/to/file(1)Xtxt",
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
regexPattern := GlobToRegex(tt.pattern)
re, err := regexp.Compile(regexPattern)
assert.NoError(t, err, "Generated regex should be valid")
for _, path := range tt.shouldMatch {
assert.True(t, re.MatchString(path), "Pattern %s should match %s (regex: %s)", tt.pattern, path, regexPattern)
}
for _, path := range tt.shouldNotMatch {
assert.False(t, re.MatchString(path), "Pattern %s should not match %s (regex: %s)", tt.pattern, path, regexPattern)
}
})
}
}
func TestGlobToRegexPatterns(t *testing.T) {
cases := []struct {
name string
pattern string
expectedRegex string
}{
{
name: "simple asterisk",
pattern: "*.txt",
expectedRegex: `^[^/]*\.txt$`,
},
{
name: "globstar",
pattern: "**/*.txt",
expectedRegex: `^(.*/)?[^/]*\.txt$`,
},
{
name: "question mark",
pattern: "file?.txt",
expectedRegex: `^file[^/]\.txt$`,
},
{
name: "absolute path with wildcard",
pattern: "/path/to/*.txt",
expectedRegex: `^/path/to/[^/]*\.txt$`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := GlobToRegex(tt.pattern)
assert.Equal(t, tt.expectedRegex, actual)
})
}
}
func TestEscapeRegexChars(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "dot escaping",
input: "file.txt",
expected: `file\.txt`,
},
{
name: "multiple special chars",
input: "file(1).txt",
expected: `file\(1\)\.txt`,
},
{
name: "glob chars not escaped but dots are",
input: "*.txt",
expected: `*\.txt`,
},
{
name: "brackets not escaped",
input: "[0-9]",
expected: "[0-9]",
},
{
name: "question mark not escaped but dots are",
input: "file?.txt",
expected: `file?\.txt`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := escapeRegexChars(tt.input)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestEscapeUnclosedBrackets(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "unclosed bracket at end",
input: "test[abc",
expected: `test\[abc`,
},
{
name: "closed bracket",
input: "test[abc]",
expected: "test[abc]",
},
{
name: "no brackets",
input: "test",
expected: "test",
},
{
name: "multiple closed brackets",
input: "[a-z][0-9]",
expected: "[a-z][0-9]",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := escapeUnclosedBrackets(tt.input)
assert.Equal(t, tt.expected, actual)
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package util
import (
"os"
"regexp"
"strings"
)
var tmpdirPatternRegex = regexp.MustCompile(`^/(private/)?var/folders/[^/]{2}/[^/]+/T/?$`)
// GetTmpdirParent returns the parent directory of TMPDIR if it matches the macOS pattern.
// On macOS, TMPDIR is typically /var/folders/XX/YYY/T/ where XX and YYY are random.
//
// Returns both /var/ and /private/var/ versions since /var is a symlink to /private/var.
// This is needed because package managers may reference either path.
//
// Returns empty slice if TMPDIR doesn't match the expected macOS pattern.
func GetTmpdirParent() []string {
tmpdir := os.Getenv("TMPDIR")
if tmpdir == "" {
return []string{}
}
// macOS TMPDIR pattern: /var/folders/XX/YYY/T/ or /private/var/folders/XX/YYY/T/
// where XX is 2 chars and YYY is random string
pattern := tmpdirPatternRegex
if !pattern.MatchString(tmpdir) {
return []string{}
}
// Remove trailing /T or /T/
parent := strings.TrimSuffix(tmpdir, "/")
parent = strings.TrimSuffix(parent, "/T")
// Return both /var/ and /private/var/ versions
if strings.HasPrefix(parent, "/private/var/") {
// Already has /private prefix
withoutPrivate := strings.Replace(parent, "/private", "", 1)
return []string{parent, withoutPrivate}
} else if strings.HasPrefix(parent, "/var/") {
// Missing /private prefix
withPrivate := "/private" + parent
return []string{parent, withPrivate}
}
return []string{parent}
}
+70
View File
@@ -0,0 +1,70 @@
package util
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetTmpdirParent(t *testing.T) {
// Save original TMPDIR
originalTmpdir := os.Getenv("TMPDIR")
defer os.Setenv("TMPDIR", originalTmpdir)
t.Run("macOS pattern with /var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/var/folders/ab/cd1234ef")
assert.Contains(t, parents, "/private/var/folders/ab/cd1234ef")
})
t.Run("macOS pattern with /private/var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/private/var/folders/xy/z9876543")
assert.Contains(t, parents, "/var/folders/xy/z9876543")
})
t.Run("macOS pattern without trailing slash", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/var/folders/12/abcdefgh")
assert.Contains(t, parents, "/private/var/folders/12/abcdefgh")
})
t.Run("non-macOS pattern returns empty", func(t *testing.T) {
testCases := []string{
"/tmp",
"/var/tmp",
"/custom/temp",
"/var/folders/",
"/var/folders/ab/",
"/var/folders/abc/def/T/", // XX should be 2 chars, not 3
}
for _, tmpdir := range testCases {
os.Setenv("TMPDIR", tmpdir)
parents := GetTmpdirParent()
assert.Empty(t, parents, "Expected empty result for TMPDIR=%s", tmpdir)
}
})
t.Run("empty TMPDIR returns empty", func(t *testing.T) {
os.Setenv("TMPDIR", "")
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
t.Run("unset TMPDIR returns empty", func(t *testing.T) {
os.Unsetenv("TMPDIR")
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
}