mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
+2
-1
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user