fix: Glob pattern expansion limit for linux

This commit is contained in:
Abhisek Datta
2026-01-14 18:05:51 +05:30
parent 8ebaa37f14
commit 189446ebac
4 changed files with 368 additions and 38 deletions
+24 -7
View File
@@ -107,14 +107,31 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
### Platform-Specific Limitations ### Platform-Specific Limitations
**Linux (Bubblewrap)**: <details>
- **Filesystem permissions are coarse-grained**: Linux sandbox uses bind mounts for filesystem isolation. When you specify a glob pattern like `${CWD}/*.txt`, the pattern is expanded to matching files at policy translation time, but Bubblewrap mounts entire directories rather than individual files. This means filesystem access control is at the directory level, not file-pattern level. <summary>Linux (Bubblewrap)</summary>
- **Example**: A policy allowing `${CWD}/node_modules/**` will mount the entire `node_modules` directory tree, not selectively filter files by pattern.
- **Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific filtering is not enforced in the initial implementation.
**macOS (Seatbelt)**: **Filesystem permissions are coarse-grained**: [Bubblewrap](https://github.com/containers/bubblewrap) uses bind mounts for filesystem isolation.
- **Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained host:port filtering is not consistently enforced across all connection types.
- **Filesystem permissions are precise**: Uses regex-based pattern matching, allowing file-level access control. To prevent `Argument list too long` errors with large directory trees, PMG automatically uses
coarse-grained fallback strategies when glob patterns match many files.
**Fallback Behavior:**
- **Small patterns** (< 100 matches): Individual files are mounted (fine-grained, most precise)
- **Large patterns** (> 100 matches): Parent directory is mounted (coarse-grained, scalable)
- **Threshold**: 100 paths per pattern triggers coarse-grained fallback
**Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific
filtering is not enforced.
</details>
<details>
<summary>macOS (Seatbelt)</summary>
**Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained `host:port` filtering is not enforced.
</details>
## Concepts ## Concepts
+13 -2
View File
@@ -34,6 +34,15 @@ type bubblewrapConfig struct {
// Prevents memory exhaustion from patterns matching huge directory trees. // Prevents memory exhaustion from patterns matching huge directory trees.
maxGlobPaths int maxGlobPaths int
// Glob fallback threshold for coarse-grained binding.
// When glob expansion yields more than this many paths, fallback to binding
// the parent directory instead of individual files for scalability.
globFallbackThreshold int
// Total argument limit for bwrap command.
// Warns when total arguments exceed this limit (approaching ARG_MAX).
totalArgsLimit int
// Whether to unshare the network namespace by default if policy has no network rules. // Whether to unshare the network namespace by default if policy has no network rules.
// When true and no network rules specified, completely isolates network access. // When true and no network rules specified, completely isolates network access.
unshareNetworkByDefault bool unshareNetworkByDefault bool
@@ -118,8 +127,10 @@ func newDefaultBubblewrapConfig() *bubblewrapConfig {
// Glob expansion limits // Glob expansion limits
// Conservative defaults to prevent DoS via huge glob patterns // Conservative defaults to prevent DoS via huge glob patterns
maxGlobDepth: 5, // Scan up to 5 directory levels maxGlobDepth: 5, // Scan up to 5 directory levels
maxGlobPaths: 10000, // Maximum 10k paths per glob pattern maxGlobPaths: 10000, // Maximum 10k paths per glob pattern
globFallbackThreshold: 100, // Fallback to parent dir above 100 paths
totalArgsLimit: 8000, // Total bwrap argument safety limit
// Network isolation (default: isolate network if no rules) // Network isolation (default: isolate network if no rules)
unshareNetworkByDefault: true, unshareNetworkByDefault: true,
+120 -27
View File
@@ -45,6 +45,7 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to add essential system permissions: %w", err) return nil, fmt.Errorf("failed to add essential system permissions: %w", err)
} }
args = append(args, systemArgs...) args = append(args, systemArgs...)
// 2. Add isolation namespaces // 2. Add isolation namespaces
@@ -56,6 +57,7 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to translate filesystem rules: %w", err) return nil, fmt.Errorf("failed to translate filesystem rules: %w", err)
} }
args = append(args, filesystemArgs...) args = append(args, filesystemArgs...)
// 4. Add PTY support if needed // 4. Add PTY support if needed
@@ -68,7 +70,14 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
tmpdirArgs := t.addTmpdirSupport() tmpdirArgs := t.addTmpdirSupport()
args = append(args, tmpdirArgs...) args = append(args, tmpdirArgs...)
log.Debugf("Translated policy '%s' to %d bwrap arguments", policy.Name, len(args)) // 6. Check total argument limit and log warning if exceeded
// Do not fail, let bwrap fail naturally if it does.
if len(args) > t.config.totalArgsLimit {
log.Warnf("Total bwrap arguments (%d) exceeds safety limit (%d), sandbox may fail with 'Argument list too long' error",
len(args), t.config.totalArgsLimit)
}
log.Debugf("Translated policy '%s' to %d bwrap arguments (limit: %d)", policy.Name, len(args), t.config.totalArgsLimit)
return args, nil return args, nil
} }
@@ -80,7 +89,6 @@ func (t *bubblewrapPolicyTranslator) addEssentialSystemPermissions() ([]string,
// Add essential system paths (read-only) // Add essential system paths (read-only)
for _, path := range t.config.getEssentialSystemPaths() { for _, path := range t.config.getEssentialSystemPaths() {
// Use --ro-bind-try which doesn't fail if path doesn't exist
args = append(args, "--ro-bind-try", path, path) args = append(args, "--ro-bind-try", path, path)
} }
@@ -231,17 +239,28 @@ func (t *bubblewrapPolicyTranslator) processReadRule(path string, boundPaths map
// Check if path contains glob pattern // Check if path contains glob pattern
if util.ContainsGlob(path) { if util.ContainsGlob(path) {
// Expand glob pattern to concrete paths // Expand glob pattern to concrete paths with fallback detection
paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths) paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err) return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
} }
// Create read-only bind for each expanded path if useFallback {
for _, p := range paths { // Coarse-grained: bind parent directory
if !boundPaths[p] { for _, parentDir := range paths {
args = append(args, "--ro-bind-try", p, p) if !boundPaths[parentDir] {
boundPaths[p] = true args = append(args, "--ro-bind-try", parentDir, parentDir)
boundPaths[parentDir] = true
log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-only)", parentDir)
}
}
} else {
// Fine-grained: bind individual paths
for _, p := range paths {
if !boundPaths[p] {
args = append(args, "--ro-bind-try", p, p)
boundPaths[p] = true
}
} }
} }
} else { } else {
@@ -261,22 +280,36 @@ func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths ma
// Check if path contains glob pattern // Check if path contains glob pattern
if util.ContainsGlob(path) { if util.ContainsGlob(path) {
// Expand glob pattern to concrete paths // Expand glob pattern to concrete paths with fallback detection
paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths) paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err) return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
} }
// Create read-write bind for each expanded path if useFallback {
for _, p := range paths { // Coarse-grained: bind parent directory
if !boundPaths[p] { for _, parentDir := range paths {
args = append(args, "--bind-try", p, p) if !boundPaths[parentDir] {
boundPaths[p] = true args = append(args, "--bind-try", parentDir, parentDir)
} else { boundPaths[parentDir] = true
// Path already bound as read-only, upgrade to read-write log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-write)", parentDir)
// This is a limitation of the simple approach - we'd need to track } else {
// and replace the previous bind. For now, log warning. // Path already bound, skip (likely already bound as read-only from essential paths)
log.Warnf("Path '%s' already bound, cannot upgrade to read-write", p) log.Debugf("Parent directory '%s' already bound, skipping duplicate bind", parentDir)
}
}
} else {
// Fine-grained: bind individual paths
for _, p := range paths {
if !boundPaths[p] {
args = append(args, "--bind-try", p, p)
boundPaths[p] = true
} else {
// Path already bound as read-only, upgrade to read-write
// This is a limitation of the simple approach - we'd need to track
// and replace the previous bind. For now, log warning.
log.Warnf("Path '%s' already bound, cannot upgrade to read-write", p)
}
} }
} }
} else { } else {
@@ -298,7 +331,9 @@ func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, err
// For glob patterns, expand and deny each path // For glob patterns, expand and deny each path
if util.ContainsGlob(path) { if util.ContainsGlob(path) {
// For deny rules, we scan for existing files matching the pattern // For deny rules, we scan for existing files matching the pattern
paths, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths) // Note: For deny rules, we ignore the fallback indicator since we want to
// deny all matched paths individually for maximum security
paths, _, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths)
if err != nil { if err != nil {
// If glob expansion fails, it's not critical for deny rules // If glob expansion fails, it's not critical for deny rules
return args, nil return args, nil
@@ -350,25 +385,49 @@ func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) strin
// expandGlobPattern expands a glob pattern to a list of concrete paths. // expandGlobPattern expands a glob pattern to a list of concrete paths.
// Implements depth limiting and path count limiting to prevent DoS. // Implements depth limiting and path count limiting to prevent DoS.
func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) { // Returns (paths, useFallback, error) where useFallback indicates if
// coarse-grained parent directory fallback should be used.
func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, bool, error) {
// Handle ** globstar patterns specially // Handle ** globstar patterns specially
if strings.Contains(pattern, "**") { if strings.Contains(pattern, "**") {
return t.expandGlobstarPattern(pattern, maxDepth, maxPaths) paths, err := t.expandGlobstarPattern(pattern, maxDepth, maxPaths)
if err != nil {
return nil, false, err
}
// Check if we should use fallback
if len(paths) > t.config.globFallbackThreshold {
log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
pattern, len(paths), t.config.globFallbackThreshold)
parentDir := t.extractParentDir(pattern)
return []string{parentDir}, true, nil
}
return paths, false, nil
} }
// Use filepath.Glob for simple patterns (*, ?, []) // Use filepath.Glob for simple patterns (*, ?, [])
matches, err := filepath.Glob(pattern) matches, err := filepath.Glob(pattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("glob expansion failed: %w", err) return nil, false, fmt.Errorf("glob expansion failed: %w", err)
} }
// Limit number of matches // Check fallback threshold before applying maxPaths limit
if len(matches) > t.config.globFallbackThreshold {
log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
pattern, len(matches), t.config.globFallbackThreshold)
parentDir := t.extractParentDir(pattern)
return []string{parentDir}, true, nil
}
// Limit number of matches (shouldn't happen if fallback threshold < maxPaths)
if len(matches) > maxPaths { if len(matches) > maxPaths {
log.Warnf("Glob pattern '%s' matched %d paths, limiting to %d", pattern, len(matches), maxPaths) log.Warnf("Glob pattern '%s' matched %d paths, limiting to %d", pattern, len(matches), maxPaths)
matches = matches[:maxPaths] matches = matches[:maxPaths]
} }
return matches, nil return matches, false, nil
} }
// expandGlobstarPattern expands patterns containing ** (recursive glob). // expandGlobstarPattern expands patterns containing ** (recursive glob).
@@ -451,6 +510,40 @@ func (t *bubblewrapPolicyTranslator) walkWithDepthLimit(root string, suffix stri
return err return err
} }
// extractParentDir extracts the parent directory from a glob pattern.
// This is used for coarse-grained fallback when glob expansion yields too many paths.
//
// Examples:
// - ${CWD}/node_modules/** → ${CWD}/node_modules
// - ${HOME}/.cache/pnpm/** → ${HOME}/.cache/pnpm
// - /tmp/*.txt → /tmp
// - /usr/lib/**/*.so → /usr/lib
// - ${CWD}/package.json.* → ${CWD}
func (t *bubblewrapPolicyTranslator) extractParentDir(pattern string) string {
// Remove trailing /** or /*
pattern = strings.TrimSuffix(pattern, "/**")
pattern = strings.TrimSuffix(pattern, "/*")
// Remove any remaining glob characters and find the parent directory
idx := strings.IndexAny(pattern, "*?[")
if idx >= 0 {
// Glob found - truncate at glob character and get the directory
pattern = pattern[:idx]
// Get the directory containing the file/pattern
pattern = filepath.Dir(pattern)
}
// Clean up trailing separator
pattern = strings.TrimSuffix(pattern, string(filepath.Separator))
// If pattern is now empty or just a separator, default to current directory
if pattern == "" || pattern == string(filepath.Separator) {
return "."
}
return pattern
}
// addPTYSupport adds arguments for pseudo-terminal support. // addPTYSupport adds arguments for pseudo-terminal support.
// Required for interactive package manager commands. // Required for interactive package manager commands.
func (t *bubblewrapPolicyTranslator) addPTYSupport() []string { func (t *bubblewrapPolicyTranslator) addPTYSupport() []string {
@@ -4,6 +4,7 @@
package platform package platform
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -496,8 +497,8 @@ func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
policy := &sandbox.SandboxPolicy{ policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{ Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{ DenyWrite: []string{
testFile, // Existing file testFile, // Existing file
nonExistentPath, // Non-existent file nonExistentPath, // Non-existent file
}, },
}, },
} }
@@ -659,11 +660,219 @@ func TestFindFirstNonExistentPath(t *testing.T) {
} }
} }
// TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths
func TestGlobFallbackThreshold(t *testing.T) {
tmpDir := t.TempDir()
// Create 150 files (exceeds threshold of 100)
for i := 0; i < 150; i++ {
filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
}
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
policy := &sandbox.SandboxPolicy{
Name: "test-fallback",
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{tmpDir + "/*.txt"},
},
}
args, err := translator.translate(policy)
require.NoError(t, err)
argsStr := argSliceToString(args)
// Should bind parent directory (tmpDir), not individual files
assert.Contains(t, argsStr, tmpDir)
// Should NOT contain individual file paths (fallback to parent dir)
assert.NotContains(t, argsStr, "file1.txt")
assert.NotContains(t, argsStr, "file50.txt")
assert.NotContains(t, argsStr, "file100.txt")
// Verify total argument count is reasonable (coarse-grained fallback should prevent explosion)
assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
}
// TestGlobFallbackThresholdGlobstar tests fallback with ** globstar patterns
func TestGlobFallbackThresholdGlobstar(t *testing.T) {
tmpDir := t.TempDir()
// Create deep directory structure with 200 files (exceeds threshold)
for i := 0; i < 10; i++ {
subDir := filepath.Join(tmpDir, fmt.Sprintf("dir%d", i))
require.NoError(t, os.MkdirAll(subDir, 0755))
for j := 0; j < 20; j++ {
filePath := filepath.Join(subDir, fmt.Sprintf("file%d.txt", j))
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
}
}
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
policy := &sandbox.SandboxPolicy{
Name: "test-fallback-globstar",
Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{tmpDir + "/**"},
},
}
args, err := translator.translate(policy)
require.NoError(t, err)
argsStr := argSliceToString(args)
// Should bind parent directory (tmpDir)
assert.Contains(t, argsStr, tmpDir)
// Should NOT contain individual subdirectory paths (fallback to parent)
assert.NotContains(t, argsStr, "dir1")
assert.NotContains(t, argsStr, "dir5")
// Verify total argument count is reasonable
assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
}
// TestGlobNoFallbackSmallPattern tests that small patterns don't trigger fallback
func TestGlobNoFallbackSmallPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create only 10 files (below threshold)
for i := 0; i < 10; i++ {
filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
}
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
policy := &sandbox.SandboxPolicy{
Name: "test-no-fallback",
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{tmpDir + "/*.txt"},
},
}
args, err := translator.translate(policy)
require.NoError(t, err)
argsStr := argSliceToString(args)
// Should bind individual files (no fallback)
assert.Contains(t, argsStr, "file0.txt")
assert.Contains(t, argsStr, "file5.txt")
}
// TestTotalArgsLimit verifies global argument limit warning
func TestTotalArgsLimit(t *testing.T) {
// Create policy with many patterns that would exceed limit
policy := &sandbox.SandboxPolicy{
Name: "test-args-limit",
Filesystem: sandbox.FilesystemPolicy{
AllowRead: make([]string, 1000), // 1000 patterns
},
}
// Fill with literal paths to avoid glob expansion
for i := 0; i < 1000; i++ {
policy.Filesystem.AllowRead[i] = fmt.Sprintf("/tmp/path%d", i)
}
config := newDefaultBubblewrapConfig()
config.totalArgsLimit = 500 // Set low for testing
translator := newBubblewrapPolicyTranslator(config)
args, err := translator.translate(policy)
require.NoError(t, err) // Should not error, just warn
// Verify args were generated despite exceeding limit
assert.Greater(t, len(args), config.totalArgsLimit)
}
// TestExtractParentDir tests the extractParentDir helper function
func TestExtractParentDir(t *testing.T) {
cases := []struct {
name string
pattern string
expected string
}{
{
name: "double star pattern",
pattern: "/home/user/node_modules/**",
expected: "/home/user/node_modules",
},
{
name: "single star pattern",
pattern: "/tmp/*.txt",
expected: "/tmp",
},
{
name: "middle glob",
pattern: "/usr/lib/*.so",
expected: "/usr/lib",
},
{
name: "complex glob",
pattern: "/home/user/.cache/**/*.log",
expected: "/home/user/.cache",
},
{
name: "file-level glob with dot",
pattern: "/home/user/project/package.json.*",
expected: "/home/user/project",
},
{
name: "file-level glob in CWD",
pattern: "/home/user/project/*.lock",
expected: "/home/user/project",
},
{
name: "question mark glob",
pattern: "/tmp/file?.txt",
expected: "/tmp",
},
{
name: "bracket glob",
pattern: "/usr/lib/lib[abc].so",
expected: "/usr/lib",
},
{
name: "no glob",
pattern: "/home/user/file.txt",
expected: "/home/user/file.txt",
},
{
name: "trailing double star",
pattern: "/home/user/cache/**",
expected: "/home/user/cache",
},
{
name: "trailing single star",
pattern: "/var/log/*",
expected: "/var/log",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
result := translator.extractParentDir(tt.pattern)
assert.Equal(t, tt.expected, result)
})
}
}
// Helper function to convert arg slice to string for easier assertion // Helper function to convert arg slice to string for easier assertion
func argSliceToString(args []string) string { func argSliceToString(args []string) string {
result := "" result := ""
for _, arg := range args { for _, arg := range args {
result += arg + " " result += arg + " "
} }
return result return result
} }