fix: resolve merge conflicts in seatbelt translator

- Keep detailed Chrome/Chromium-based system permissions for security
- Preserve move-blocking protection to prevent bypass attacks
- Keep mandatory security denies for credentials and git hooks
- Accept improved network handling with system-socket support
This commit is contained in:
Sahilb315
2026-01-12 22:00:37 +05:30
parent efca7ff711
commit b84f33aac3
4 changed files with 334 additions and 39 deletions
+2 -1
View File
@@ -256,7 +256,8 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package
}
func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context,
packages []*packagev1.PackageVersion) ([]*analyzer.PackageVersionAnalysisResult, error) {
packages []*packagev1.PackageVersion,
) ([]*analyzer.PackageVersionAnalysisResult, error) {
ctx, cancel := context.WithTimeout(ctx, g.config.AnalysisTimeout)
defer cancel()
+106 -32
View File
@@ -346,6 +346,11 @@ func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (str
}
// translateFilesystem translates filesystem access rules.
// Note: File reads are allowed globally by default (security enforced via deny rules).
// This function focuses on:
// 1. Allow write rules (writes are denied by default)
// 2. Deny read rules (to protect sensitive files like ~/.ssh, ~/.aws)
// 3. Deny write rules (additional write restrictions)
func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Filesystem access\n")
@@ -418,9 +423,10 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
expandedDenyRead = append(expandedDenyRead, expanded)
}
// Add file movement protection for deny read paths
// Add file movement protection for user-specified deny read paths only
// (not mandatory patterns, as those would block CWD/HOME operations)
if len(expandedDenyRead) > 0 {
sb.WriteString("\n;; Prevent bypassing read restrictions via file movement\n")
sb.WriteString(";; Prevent bypassing read restrictions via file movement\n")
for _, rule := range generateMoveBlockingRules(expandedDenyRead, t.logTag) {
sb.WriteString(rule + "\n")
}
@@ -428,6 +434,26 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
sb.WriteString("\n")
// Add mandatory deny read patterns for security (credentials, sensitive dirs)
// Note: Move-blocking is NOT applied to these to avoid blocking CWD/HOME operations
sb.WriteString(";; Mandatory security deny reads (credentials, sensitive directories)\n")
mandatoryDenyReads := util.GetMandatoryDenyPatterns(policy.AllowGitConfig)
for _, pattern := range mandatoryDenyReads {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand mandatory deny read pattern %s: %w", pattern, err)
}
if util.ContainsGlob(expanded) {
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\") (with message \"%s\"))\n", expanded, t.logTag))
}
}
sb.WriteString("\n")
expandedDenyWrite := []string{}
for _, pattern := range policy.Filesystem.DenyWrite {
expanded, err := util.ExpandVariables(pattern)
@@ -447,7 +473,19 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
sb.WriteString("\n")
// Add file movement protection for user-specified deny write paths only
// (not mandatory patterns, as those would block CWD/HOME operations)
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")
}
}
sb.WriteString("\n")
// Add mandatory deny patterns for security (credentials, git hooks, etc.)
// Note: Move-blocking is NOT applied to these to avoid blocking CWD/HOME operations
sb.WriteString(";; Mandatory security denies (credentials, git hooks, etc.)\n")
mandatoryDenies := util.GetMandatoryDenyPatterns(policy.AllowGitConfig)
for _, pattern := range mandatoryDenies {
@@ -464,17 +502,6 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
} 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")
}
}
sb.WriteString("\n")
@@ -482,11 +509,55 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
return nil
}
// formatFileRule formats a file access rule based on the path pattern.
// - Patterns ending with /** use subpath (recursive directory)
// - Patterns ending with /* use subpath (immediate children only, but seatbelt doesn't distinguish)
// - Patterns with globs like *.ext use regex
// - Plain paths use literal for files or subpath for directories (treated as subpath for simplicity)
func (t *seatbeltPolicyTranslator) formatFileRule(action, operation, path string) string {
// Handle recursive glob patterns like /path/**
if strings.HasSuffix(path, "/**") {
baseDir := strings.TrimSuffix(path, "/**")
return fmt.Sprintf("(%s %s (subpath \"%s\"))\n", action, operation, baseDir)
}
// Handle single-level glob patterns like /path/*
if strings.HasSuffix(path, "/*") {
baseDir := strings.TrimSuffix(path, "/*")
return fmt.Sprintf("(%s %s (subpath \"%s\"))\n", action, operation, baseDir)
}
// Handle patterns like **/.env or **/.env.* (glob patterns that should match anywhere)
if strings.HasPrefix(path, "**/") {
// Convert to regex pattern
pattern := strings.TrimPrefix(path, "**/")
// Escape special regex characters and convert glob to regex
pattern = strings.ReplaceAll(pattern, ".", "\\.")
pattern = strings.ReplaceAll(pattern, "*", ".*")
return fmt.Sprintf("(%s %s (regex #\".*/%s$\"))\n", action, operation, pattern)
}
// Handle file extension globs like /path/*.json
if util.ContainsGlob(path) {
// For other glob patterns, use the base directory as subpath
// This is a simplification - ideally we'd convert to proper regex
lastSlash := strings.LastIndex(path, "/")
if lastSlash > 0 {
baseDir := path[:lastSlash]
return fmt.Sprintf("(%s %s (subpath \"%s\"))\n", action, operation, baseDir)
}
}
// For plain paths, use subpath (works for both files and directories)
// Using subpath for files is more permissive but simpler
return fmt.Sprintf("(%s %s (subpath \"%s\"))\n", action, operation, path)
}
// translateNetwork translates network access rules.
func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Network access\n")
// Check if deny all is present
// Check if network is completely blocked
denyAll := false
for _, pattern := range policy.Network.DenyOutbound {
if pattern == "*:*" {
@@ -495,25 +566,28 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic
}
}
// If there are allow outbound rules, allow network-outbound generally
// (Seatbelt doesn't support fine-grained host:port filtering in all cases)
// Note: This is a limitation of Seatbelt - for more fine-grained control,
// consider using a network filtering solution or firewall rules
if len(policy.Network.AllowOutbound) > 0 {
sb.WriteString(";; Network outbound allowed to specific hosts\n")
sb.WriteString(";; Note: Seatbelt has limited host-based filtering, consider using firewall rules for strict control\n")
sb.WriteString("(allow network-outbound)\n")
} 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 denyAll && len(policy.Network.AllowOutbound) == 0 {
// Complete network block - deny all network operations
sb.WriteString(";; Network completely blocked\n")
sb.WriteString("(deny network*)\n")
} else if len(policy.Network.AllowOutbound) > 0 {
// Allow network outbound - Seatbelt doesn't support fine-grained host:port filtering
// The allowlist is informational; actual enforcement should use firewall rules if needed
sb.WriteString(";; Network outbound allowed\n")
sb.WriteString(";; Note: Seatbelt has limited host-based filtering\n")
sb.WriteString(";; Allowlist (informational): ")
for i, host := range policy.Network.AllowOutbound {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(host)
}
sb.WriteString("\n")
// 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.
// Allow network operations needed for HTTP/HTTPS
sb.WriteString("(allow network-outbound)\n")
sb.WriteString("(allow system-socket)\n")
}
sb.WriteString("\n")
+10 -6
View File
@@ -33,18 +33,19 @@ filesystem:
- ${HOME}/.cache/yarn/**
- ${HOME}/.yarn/cache/**
- ${HOME}/.bun/install/cache/**
- ${HOME}/.node-gyp/**
- ${HOME}/.nvm/**
- /usr/local/**
- ${TMPDIR}/**
- /opt/homebrew/**
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
# Project directories - allow writes within the current working directory
- ${CWD}/**
# Package manager caches and stores
- ${HOME}/.npm/**
- ${HOME}/.pnpm-store/**
@@ -52,6 +53,8 @@ filesystem:
- ${HOME}/.cache/yarn/**
- ${HOME}/.yarn/cache/**
- ${HOME}/.bun/install/cache/**
- ${TMPDIR}/**
- /private/var/folders/**
# Additional deny rules (optional - credentials are automatically blocked)
# Automatically blocked for security:
@@ -98,6 +101,7 @@ process:
- /bin/bash
- /bin/sh
- /usr/bin/env
- /opt/homebrew/bin/**
deny_exec:
- /usr/bin/curl
+216
View File
@@ -0,0 +1,216 @@
const fs = require('fs');
const { execSync, spawnSync } = require('child_process');
const path = require('path');
const os = require('os');
const home = os.homedir();
const results = { passed: 0, failed: 0, tests: [] };
function test(name, fn) {
try {
const result = fn();
results.tests.push({ name, status: result ? 'PASS' : 'FAIL', error: null });
result ? results.passed++ : results.failed++;
} catch (e) {
results.tests.push({ name, status: 'ERROR', error: e.message });
results.failed++;
}
}
console.log('=== PMG Sandbox Policy Violation Tests ===\n');
// ============================================
// TESTS THAT SHOULD BE BLOCKED (DENY RULES)
// ============================================
console.log('--- Tests that SHOULD be BLOCKED ---\n');
// Test 1: Read ~/.ssh (should be blocked)
test('BLOCK: Read ~/.ssh directory', () => {
try {
fs.readdirSync(path.join(home, '.ssh'));
console.log(' ❌ FAIL: Could read ~/.ssh');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.ssh blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.ssh - ${e.code} (may not exist)`);
return true; // ENOENT is okay if dir doesn't exist
}
});
// Test 2: Read ~/.aws (should be blocked)
test('BLOCK: Read ~/.aws directory', () => {
try {
fs.readdirSync(path.join(home, '.aws'));
console.log(' ❌ FAIL: Could read ~/.aws');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.aws blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.aws - ${e.code} (may not exist)`);
return true;
}
});
// Test 3: Read ~/.kube (should be blocked)
test('BLOCK: Read ~/.kube/config', () => {
try {
fs.readFileSync(path.join(home, '.kube', 'config'));
console.log(' ❌ FAIL: Could read ~/.kube/config');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.kube/config blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.kube/config - ${e.code} (may not exist)`);
return true;
}
});
// Test 4: Read ~/.gcloud (should be blocked)
test('BLOCK: Read ~/.gcloud directory', () => {
try {
fs.readdirSync(path.join(home, '.gcloud'));
console.log(' ❌ FAIL: Could read ~/.gcloud');
return false;
} catch (e) {
if (e.code === 'EPERM') {
console.log(' ✅ PASS: ~/.gcloud blocked (EPERM)');
return true;
}
console.log(` ⚠️ SKIP: ~/.gcloud - ${e.code} (may not exist)`);
return true;
}
});
// Test 5: Write to /etc (should be blocked)
test('BLOCK: Write to /etc', () => {
try {
fs.writeFileSync('/etc/test-pmg-sandbox', 'test');
fs.unlinkSync('/etc/test-pmg-sandbox');
console.log(' ❌ FAIL: Could write to /etc');
return false;
} catch (e) {
if (e.code === 'EPERM' || e.code === 'EACCES') {
console.log(' ✅ PASS: /etc write blocked');
return true;
}
console.log(` ✅ PASS: /etc write blocked (${e.code})`);
return true;
}
});
// Test 6: Write to /usr (should be blocked)
test('BLOCK: Write to /usr', () => {
try {
fs.writeFileSync('/usr/test-pmg-sandbox', 'test');
fs.unlinkSync('/usr/test-pmg-sandbox');
console.log(' ❌ FAIL: Could write to /usr');
return false;
} catch (e) {
console.log(' ✅ PASS: /usr write blocked');
return true;
}
});
// Test 7: Execute curl (should be blocked by policy)
test('BLOCK: Execute /usr/bin/curl', () => {
try {
const result = spawnSync('/usr/bin/curl', ['--version'], { timeout: 5000 });
if (result.status === null || result.signal === 'SIGKILL') {
console.log(' ✅ PASS: curl execution blocked');
return true;
}
console.log(' ❌ FAIL: curl executed successfully');
return false;
} catch (e) {
console.log(' ✅ PASS: curl blocked');
return true;
}
});
// ============================================
// TESTS THAT SHOULD BE ALLOWED
// ============================================
console.log('\n--- Tests that SHOULD be ALLOWED ---\n');
// Test 8: Read current directory
test('ALLOW: Read current directory', () => {
try {
fs.readdirSync('.');
console.log(' ✅ PASS: Can read current directory');
return true;
} catch (e) {
console.log(' ❌ FAIL: Cannot read current directory');
return false;
}
});
// Test 9: Write to current directory
test('ALLOW: Write to current directory', () => {
try {
fs.writeFileSync('test-sandbox-write.txt', 'test');
fs.unlinkSync('test-sandbox-write.txt');
console.log(' ✅ PASS: Can write to current directory');
return true;
} catch (e) {
console.log(' ❌ FAIL: Cannot write to current directory');
return false;
}
});
// Test 10: Read node_modules
test('ALLOW: Read node_modules', () => {
try {
fs.readdirSync('node_modules');
console.log(' ✅ PASS: Can read node_modules');
return true;
} catch (e) {
console.log(' ❌ FAIL: Cannot read node_modules');
return false;
}
});
// Test 11: Read system libraries
test('ALLOW: Read /usr/lib', () => {
try {
fs.readdirSync('/usr/lib');
console.log(' ✅ PASS: Can read /usr/lib');
return true;
} catch (e) {
console.log(' ❌ FAIL: Cannot read /usr/lib');
return false;
}
});
// Test 12: Network access (DNS + HTTP)
test('ALLOW: Network DNS resolution', () => {
try {
require('dns').lookup('registry.npmjs.org', (err) => {});
console.log(' ✅ PASS: DNS resolution works');
return true;
} catch (e) {
console.log(' ❌ FAIL: DNS resolution blocked');
return false;
}
});
// ============================================
// SUMMARY
// ============================================
console.log('\n=== SUMMARY ===');
console.log(`Passed: ${results.passed}/${results.tests.length}`);
console.log(`Failed: ${results.failed}/${results.tests.length}`);
if (results.failed > 0) {
console.log('\nFailed tests:');
results.tests.filter(t => t.status !== 'PASS').forEach(t => {
console.log(` - ${t.name}: ${t.status} ${t.error || ''}`);
});
process.exit(1);
}