mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: Fix sandbox policy generator for MacOS min permissions
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*")
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.*")
|
||||
})
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user