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:
@@ -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*")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user