fix: Fix sandbox policy generator for MacOS min permissions

This commit is contained in:
Abhisek Datta
2026-01-12 20:49:43 +05:30
parent d48ba68847
commit efca7ff711
13 changed files with 1597 additions and 68 deletions
+74
View File
@@ -0,0 +1,74 @@
package util
import (
"os"
"path/filepath"
)
// DANGEROUS_FILES are files that should always be blocked from write access
// to prevent credential theft and security compromise.
var DANGEROUS_FILES = []string{
".env",
".env.*",
".aws",
".gcloud",
".kube",
".ssh",
".gnupg",
".docker/config.json",
}
// GetMandatoryDenyPatterns returns filesystem paths that should always be blocked
// from write access for security reasons. These are automatically injected into
// all sandbox policies regardless of user configuration.
//
// Parameters:
// - allowGitConfig: if false, blocks write access to .git/config (recommended)
//
// Returns patterns in both absolute (from HOME) and glob forms for comprehensive coverage.
func GetMandatoryDenyPatterns(allowGitConfig bool) []string {
patterns := []string{}
// Get current working directory for CWD-relative patterns
cwd, err := os.Getwd()
if err != nil {
// Fallback to basic patterns if we can't get CWD
cwd = "."
}
// Get home directory for HOME-relative patterns
home, err := os.UserHomeDir()
if err != nil {
// If we can't get home, skip home-based patterns
home = ""
}
// Add dangerous files from CWD
for _, fileName := range DANGEROUS_FILES {
// Absolute path in CWD
patterns = append(patterns, filepath.Join(cwd, fileName))
// Glob pattern to catch in subdirectories
patterns = append(patterns, filepath.Join("**", fileName))
}
// Add dangerous files from HOME (if available)
if home != "" {
for _, fileName := range DANGEROUS_FILES {
patterns = append(patterns, filepath.Join(home, fileName))
}
}
// Git hooks are ALWAYS blocked for security (can execute arbitrary code)
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
patterns = append(patterns, filepath.Join(cwd, ".git/hooks/**"))
patterns = append(patterns, "**/.git/hooks")
patterns = append(patterns, "**/.git/hooks/**")
// Git config is conditionally blocked
if !allowGitConfig {
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
patterns = append(patterns, "**/.git/config")
}
return patterns
}
+79
View File
@@ -0,0 +1,79 @@
package util
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetMandatoryDenyPatterns(t *testing.T) {
t.Run("always blocks dangerous files", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should contain patterns for each dangerous file
assert.Contains(t, patterns, "**/.env")
assert.Contains(t, patterns, "**/.ssh")
assert.Contains(t, patterns, "**/.aws")
assert.Contains(t, patterns, "**/.gcloud")
assert.Contains(t, patterns, "**/.kube")
assert.Contains(t, patterns, "**/.gnupg")
assert.Contains(t, patterns, "**/.docker/config.json")
})
t.Run("always blocks git hooks", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should block git hooks
assert.Contains(t, patterns, "**/.git/hooks")
assert.Contains(t, patterns, "**/.git/hooks/**")
})
t.Run("blocks git config when allowGitConfig is false", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should block git config
assert.Contains(t, patterns, "**/.git/config")
})
t.Run("allows git config when allowGitConfig is true", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(true)
// Should NOT block git config
for _, pattern := range patterns {
assert.NotContains(t, pattern, ".git/config")
}
})
t.Run("includes CWD-relative patterns", func(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should include absolute paths in CWD
assert.Contains(t, patterns, filepath.Join(cwd, ".env"))
assert.Contains(t, patterns, filepath.Join(cwd, ".ssh"))
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
})
t.Run("includes HOME-relative patterns", func(t *testing.T) {
home, err := os.UserHomeDir()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should include absolute paths in HOME
assert.Contains(t, patterns, filepath.Join(home, ".env"))
assert.Contains(t, patterns, filepath.Join(home, ".ssh"))
assert.Contains(t, patterns, filepath.Join(home, ".aws"))
})
t.Run("includes glob patterns for env variants", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
// Should include pattern for .env.* files
assert.Contains(t, patterns, "**/.env.*")
})
}
+86
View File
@@ -0,0 +1,86 @@
package util
import (
"regexp"
"strings"
)
// GlobToRegex converts a glob pattern to a Seatbelt-compatible regular expression.
//
// This implements gitignore-style pattern matching to match the behavior used
// in filesystem permission systems.
//
// Supported patterns:
// - * matches any characters except / (e.g., *.ts matches foo.ts but not foo/bar.ts)
// - ** matches any characters including / (e.g., src/**/*.ts matches all .ts files in src/)
// - ? matches any single character except / (e.g., file?.txt matches file1.txt)
// - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
//
// Note: This is designed for macOS sandbox (regex ...) syntax. The resulting regex
// will be used in sandbox profiles like: (deny file-write* (regex "pattern"))
//
// Examples:
// - "/path/to/*.txt" -> "^/path/to/[^/]*\\.txt$"
// - "/path/**/file" -> "^/path/(.*/)?file$"
// - "/tmp/file?.log" -> "^/tmp/file[^/]\\.log$"
func GlobToRegex(globPattern string) string {
result := globPattern
// Escape regex special characters (except glob chars * ? [ ])
// We need to escape: . ^ $ + { } ( ) | \
result = escapeRegexChars(result)
// Escape unclosed brackets (no matching ])
// This handles edge cases like "[abc" which should be treated literally
result = escapeUnclosedBrackets(result)
// Convert glob patterns to regex (order matters - ** before *)
// Use placeholders to avoid double-conversion
// 1. Handle **/ (globstar with slash)
result = strings.ReplaceAll(result, "**/", "__GLOBSTAR_SLASH__")
// 2. Handle ** (globstar standalone)
result = strings.ReplaceAll(result, "**", "__GLOBSTAR__")
// 3. Handle * (wildcard)
result = strings.ReplaceAll(result, "*", "[^/]*")
// 4. Handle ? (single char wildcard)
result = strings.ReplaceAll(result, "?", "[^/]")
// 5. Restore placeholders
result = strings.ReplaceAll(result, "__GLOBSTAR_SLASH__", "(.*/)?")
result = strings.ReplaceAll(result, "__GLOBSTAR__", ".*")
// Add anchors for exact matching
return "^" + result + "$"
}
// escapeRegexChars escapes regex special characters except glob wildcards.
// Escapes: . ^ $ + { } ( ) |
// Preserves: * ? [ ] \
// Note: We don't escape backslash because it shouldn't appear in file path glob patterns
func escapeRegexChars(s string) string {
// Characters that need escaping in regex (excluding glob chars)
// We don't include backslash here because:
// 1. File paths on Unix don't contain backslashes
// 2. We use backslash to escape regex chars, so escaping backslash would double them
specialChars := []string{".", "^", "$", "+", "{", "}", "(", ")", "|"}
result := s
for _, char := range specialChars {
result = strings.ReplaceAll(result, char, "\\"+char)
}
return result
}
var escapeUnclosedBracketsRegex = regexp.MustCompile(`\[([^\]]*?)$`)
// escapeUnclosedBrackets escapes bracket expressions that don't have a closing bracket.
// Example: "[abc" -> "\[abc"
func escapeUnclosedBrackets(s string) string {
// Find all opening brackets that don't have a closing bracket
return escapeUnclosedBracketsRegex.ReplaceAllString(s, `\[$1`)
}
+302
View File
@@ -0,0 +1,302 @@
package util
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGlobToRegex(t *testing.T) {
cases := []struct {
name string
pattern string
shouldMatch []string
shouldNotMatch []string
}{
{
name: "simple asterisk wildcard",
pattern: "/path/to/*.txt",
shouldMatch: []string{
"/path/to/file.txt",
"/path/to/test.txt",
"/path/to/a.txt",
"/path/to/.txt", // * matches zero or more chars (standard glob behavior)
},
shouldNotMatch: []string{
"/path/to/file.log",
"/path/to/sub/file.txt",
"/path/file.txt",
},
},
{
name: "globstar pattern",
pattern: "/path/**/*.txt",
shouldMatch: []string{
"/path/file.txt",
"/path/to/file.txt",
"/path/to/sub/file.txt",
"/path/a/b/c/file.txt",
},
shouldNotMatch: []string{
"/path/file.log",
"/other/path/file.txt",
},
},
{
name: "globstar with trailing slash",
pattern: "/src/**/",
shouldMatch: []string{
"/src/",
"/src/a/",
"/src/a/b/",
"/src/deep/nested/path/",
},
shouldNotMatch: []string{
"/src",
"/other/",
},
},
{
name: "question mark wildcard",
pattern: "/tmp/file?.log",
shouldMatch: []string{
"/tmp/file1.log",
"/tmp/file2.log",
"/tmp/filea.log",
},
shouldNotMatch: []string{
"/tmp/file.log",
"/tmp/file12.log",
"/tmp/file/.log",
},
},
{
name: "bracket wildcard",
pattern: "/tmp/test[123].txt",
shouldMatch: []string{
"/tmp/test1.txt",
"/tmp/test2.txt",
"/tmp/test3.txt",
},
shouldNotMatch: []string{
"/tmp/test4.txt",
"/tmp/testa.txt",
"/tmp/test.txt",
},
},
{
name: "bracket range",
pattern: "/tmp/file[0-9].log",
shouldMatch: []string{
"/tmp/file0.log",
"/tmp/file5.log",
"/tmp/file9.log",
},
shouldNotMatch: []string{
"/tmp/filea.log",
"/tmp/file10.log",
},
},
{
name: "regex special characters escaped",
pattern: "/path/to/file.txt",
shouldMatch: []string{
"/path/to/file.txt",
},
shouldNotMatch: []string{
"/path/to/fileXtxt",
"/path/to/file_txt",
},
},
{
name: "multiple wildcards",
pattern: "/path/*/sub/*.txt",
shouldMatch: []string{
"/path/a/sub/file.txt",
"/path/b/sub/test.txt",
},
shouldNotMatch: []string{
"/path/sub/file.txt",
"/path/a/sub/deep/file.txt",
"/path/a/b/sub/file.txt",
},
},
{
name: "globstar in middle",
pattern: "/usr/**/bin/node",
shouldMatch: []string{
"/usr/bin/node",
"/usr/local/bin/node",
"/usr/a/b/c/bin/node",
},
shouldNotMatch: []string{
"/usr/node",
"/usr/bin/npm",
},
},
{
name: "exact path (no wildcards)",
pattern: "/etc/passwd",
shouldMatch: []string{
"/etc/passwd",
},
shouldNotMatch: []string{
"/etc/passwd.bak",
"/etc/shadow",
},
},
{
name: "wildcard at beginning",
pattern: "*.txt",
shouldMatch: []string{
"file.txt",
"test.txt",
},
shouldNotMatch: []string{
"file.log",
"dir/file.txt",
},
},
{
name: "complex pattern with parens and dots",
pattern: "/path/to/file(1).txt",
shouldMatch: []string{
"/path/to/file(1).txt",
},
shouldNotMatch: []string{
"/path/to/file1.txt",
"/path/to/file(1)Xtxt",
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
regexPattern := GlobToRegex(tt.pattern)
re, err := regexp.Compile(regexPattern)
assert.NoError(t, err, "Generated regex should be valid")
for _, path := range tt.shouldMatch {
assert.True(t, re.MatchString(path), "Pattern %s should match %s (regex: %s)", tt.pattern, path, regexPattern)
}
for _, path := range tt.shouldNotMatch {
assert.False(t, re.MatchString(path), "Pattern %s should not match %s (regex: %s)", tt.pattern, path, regexPattern)
}
})
}
}
func TestGlobToRegexPatterns(t *testing.T) {
cases := []struct {
name string
pattern string
expectedRegex string
}{
{
name: "simple asterisk",
pattern: "*.txt",
expectedRegex: `^[^/]*\.txt$`,
},
{
name: "globstar",
pattern: "**/*.txt",
expectedRegex: `^(.*/)?[^/]*\.txt$`,
},
{
name: "question mark",
pattern: "file?.txt",
expectedRegex: `^file[^/]\.txt$`,
},
{
name: "absolute path with wildcard",
pattern: "/path/to/*.txt",
expectedRegex: `^/path/to/[^/]*\.txt$`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := GlobToRegex(tt.pattern)
assert.Equal(t, tt.expectedRegex, actual)
})
}
}
func TestEscapeRegexChars(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "dot escaping",
input: "file.txt",
expected: `file\.txt`,
},
{
name: "multiple special chars",
input: "file(1).txt",
expected: `file\(1\)\.txt`,
},
{
name: "glob chars not escaped but dots are",
input: "*.txt",
expected: `*\.txt`,
},
{
name: "brackets not escaped",
input: "[0-9]",
expected: "[0-9]",
},
{
name: "question mark not escaped but dots are",
input: "file?.txt",
expected: `file?\.txt`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := escapeRegexChars(tt.input)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestEscapeUnclosedBrackets(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "unclosed bracket at end",
input: "test[abc",
expected: `test\[abc`,
},
{
name: "closed bracket",
input: "test[abc]",
expected: "test[abc]",
},
{
name: "no brackets",
input: "test",
expected: "test",
},
{
name: "multiple closed brackets",
input: "[a-z][0-9]",
expected: "[a-z][0-9]",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := escapeUnclosedBrackets(tt.input)
assert.Equal(t, tt.expected, actual)
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package util
import (
"os"
"regexp"
"strings"
)
var tmpdirPatternRegex = regexp.MustCompile(`^/(private/)?var/folders/[^/]{2}/[^/]+/T/?$`)
// GetTmpdirParent returns the parent directory of TMPDIR if it matches the macOS pattern.
// On macOS, TMPDIR is typically /var/folders/XX/YYY/T/ where XX and YYY are random.
//
// Returns both /var/ and /private/var/ versions since /var is a symlink to /private/var.
// This is needed because package managers may reference either path.
//
// Returns empty slice if TMPDIR doesn't match the expected macOS pattern.
func GetTmpdirParent() []string {
tmpdir := os.Getenv("TMPDIR")
if tmpdir == "" {
return []string{}
}
// macOS TMPDIR pattern: /var/folders/XX/YYY/T/ or /private/var/folders/XX/YYY/T/
// where XX is 2 chars and YYY is random string
pattern := tmpdirPatternRegex
if !pattern.MatchString(tmpdir) {
return []string{}
}
// Remove trailing /T or /T/
parent := strings.TrimSuffix(tmpdir, "/")
parent = strings.TrimSuffix(parent, "/T")
// Return both /var/ and /private/var/ versions
if strings.HasPrefix(parent, "/private/var/") {
// Already has /private prefix
withoutPrivate := strings.Replace(parent, "/private", "", 1)
return []string{parent, withoutPrivate}
} else if strings.HasPrefix(parent, "/var/") {
// Missing /private prefix
withPrivate := "/private" + parent
return []string{parent, withPrivate}
}
return []string{parent}
}
+70
View File
@@ -0,0 +1,70 @@
package util
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetTmpdirParent(t *testing.T) {
// Save original TMPDIR
originalTmpdir := os.Getenv("TMPDIR")
defer os.Setenv("TMPDIR", originalTmpdir)
t.Run("macOS pattern with /var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/var/folders/ab/cd1234ef")
assert.Contains(t, parents, "/private/var/folders/ab/cd1234ef")
})
t.Run("macOS pattern with /private/var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/private/var/folders/xy/z9876543")
assert.Contains(t, parents, "/var/folders/xy/z9876543")
})
t.Run("macOS pattern without trailing slash", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T")
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
assert.Contains(t, parents, "/var/folders/12/abcdefgh")
assert.Contains(t, parents, "/private/var/folders/12/abcdefgh")
})
t.Run("non-macOS pattern returns empty", func(t *testing.T) {
testCases := []string{
"/tmp",
"/var/tmp",
"/custom/temp",
"/var/folders/",
"/var/folders/ab/",
"/var/folders/abc/def/T/", // XX should be 2 chars, not 3
}
for _, tmpdir := range testCases {
os.Setenv("TMPDIR", tmpdir)
parents := GetTmpdirParent()
assert.Empty(t, parents, "Expected empty result for TMPDIR=%s", tmpdir)
}
})
t.Run("empty TMPDIR returns empty", func(t *testing.T) {
os.Setenv("TMPDIR", "")
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
t.Run("unset TMPDIR returns empty", func(t *testing.T) {
os.Unsetenv("TMPDIR")
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
}