diff --git a/docs/sandbox.md b/docs/sandbox.md index f77d8be..82839c6 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -5,6 +5,18 @@ PMG sandbox design goal is to protect against unknown supply chain attacks using We do not want to re-invent sandbox and likely rely on OS native sandbox primitives. This is at the cost of developer experience, where we have to work within the limitations of the sandbox implementations that we use. +## Security Model + +- **Default deny**: All operations are blocked unless explicitly allowed by the policy. An empty policy grants no access. +- **Deny rules override allow rules**: When a path appears in both allow and deny lists, the deny rule wins. Deny rules are placed after allow rules in the generated sandbox profile to ensure this. +- **Credential and sensitive file protection**: The sandbox automatically blocks read and write access to credential files regardless of user configuration. Protected files include `.env`, `.env.*`, `.aws`, `.gcloud`, `.kube`, `.ssh`, `.gnupg`, and `.docker/config.json`. These mandatory deny patterns are injected at translation time and cannot be removed by policy configuration or runtime overrides. +- **Git hooks are always blocked**: Write access to `.git/hooks/` in both `$CWD` and `$HOME` is always denied to prevent arbitrary code execution via repository hooks. +- **Git config is blocked by default**: Write access to `.git/config` is denied unless `allow_git_config: true` is set in the policy. This prevents credential helper manipulation. +- **Runtime overrides remove only exact-match deny entries**: When `--sandbox-allow` adds a path to an allow list, only a literal string match in the corresponding deny list is removed. Glob and wildcard deny patterns (e.g., `/etc/**`) are never removed. Mandatory deny patterns (credentials, git hooks) cannot be overridden because they are re-injected at translation time. +- **Profile inheritance is single-level**: A profile can inherit from one built-in profile. Allow and deny lists are merged using union semantics. Boolean fields (`allow_pty`, `allow_git_config`) in the child override the parent. +- **Variable expansion is runtime-only**: Policy paths use `${HOME}`, `${CWD}`, and `${TMPDIR}` which are expanded when the sandbox is set up, not when the policy is defined. +- **Process-level isolation only**: The sandbox restricts the package manager process and its children. It does not enforce CPU, memory, or disk quotas. Network filtering is coarse-grained — host-level filtering is not enforced on either platform. + ## Requirements - Bubblewrap on Linux @@ -33,7 +45,7 @@ See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installat ## Usage - Make sure sandbox is enabled in your `config.yml` file. -- Make sure sandbox profiles are configured for the package managers you want to sandbox. +- Make sure sandbox profiles are configured for the package managers you want to sandbox. See [configuration](./config.md) and [config/config.template.yml](../config/config.template.yml) for the configuration schema. Once sandbox is enabled, you can run package manager commands with sandbox protection. @@ -80,7 +92,7 @@ pmg \ Supported types: `read`, `write`, `exec`, `net-connect`, `net-bind`. -Overrides are additive (append to allow lists), non-persistent (apply to current invocation only), and logged in the event log for auditing. They cannot bypass explicit deny rules in the profile or mandatory security protections (`.env`, `.ssh`, `.aws`, `.git/hooks`, etc.). +Overrides are non-persistent (apply to current invocation only) and logged in the event log for auditing. An override adds the path to the allow list and removes an exact match entry from the corresponding deny list. Glob deny patterns are never removed. Mandatory security protections (`.env`, `.ssh`, `.aws`, `.git/hooks`, etc.) cannot be bypassed by overrides.
Custom policy overrides using Policy Templates @@ -174,7 +186,7 @@ coarse-grained fallback strategies when glob patterns match many files. - **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 +**Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific filtering is not enforced.
@@ -211,7 +223,7 @@ the policy model into their own native policy format. Rules for policy are: Profile is a named reference to a policy. It is used to associate a policy with a package manager. PMG ships with a set of built-in profiles that are used to enforce the policies for the package manager. See [sandbox/profiles](../sandbox/profiles) for the list of built-in profiles. -Custom profiles can be created by copying a built-in profile and modifying the rules to suit the needs. +Custom profiles can be created by copying a built-in profile and modifying the rules to suit the needs. See [sandbox/profiles/README.md](../sandbox/profiles/README.md) for more details. ### Policy Template @@ -251,10 +263,10 @@ Find the log tag in the debug log file and use it to investigate the sandbox pol grep "PMG_SBX_" /tmp/pmg-debug.log ``` -Use `log(1)` to filter the log file by the log tag or generic `PMG_SBX_` prefix. +Use `log(1)` to filter the log file by the log tag. ```bash -log show --last 5m --predicate 'message ENDSWITH "PMG_SBX_"' --style compact +log show --last 5m --predicate 'message ENDSWITH "PMG_SBX_${TAG}"' --style compact ``` ### Linux @@ -282,6 +294,6 @@ bwrap --verbose [arguments...] -- npm install express ## References -- https://github.com/anthropic-experimental/sandbox-runtime -- https://geminicli.com/docs/cli/sandbox/ -- https://github.com/containers/bubblewrap +- +- +- diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index cf3f76a..290b912 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -156,22 +156,26 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app } // applyRuntimeOverrides applies --sandbox-allow overrides to the policy. -// Overrides are additive — they only append to allow lists, never modify deny lists. -// Warnings are logged for conflicts with deny rules and mandatory deny patterns. +// Overrides append to allow lists and remove exact matches from corresponding deny lists +// so that deny rules don't shadow the explicit override. Only full-path exact matches are +// removed — glob and wildcard deny patterns are never modified to stay secure by default. func applyRuntimeOverrides(policy *sandbox.SandboxPolicy, overrides []config.SandboxAllowOverride) { for _, override := range overrides { switch override.Type { case config.SandboxAllowRead: log.Infof("Sandbox override: allowing read access to %s", override.Value) policy.Filesystem.AllowRead = append(policy.Filesystem.AllowRead, override.Value) + policy.Filesystem.DenyRead = removeExactMatch(policy.Filesystem.DenyRead, override.Value) case config.SandboxAllowWrite: log.Infof("Sandbox override: allowing write access to %s", override.Value) policy.Filesystem.AllowWrite = append(policy.Filesystem.AllowWrite, override.Value) + policy.Filesystem.DenyWrite = removeExactMatch(policy.Filesystem.DenyWrite, override.Value) case config.SandboxAllowExec: log.Infof("Sandbox override: allowing execution of %s", override.Value) policy.Process.AllowExec = append(policy.Process.AllowExec, override.Value) + policy.Process.DenyExec = removeExactMatch(policy.Process.DenyExec, override.Value) case config.SandboxAllowNetConnect: log.Infof("Sandbox override: allowing outbound connection to %s", override.Value) @@ -188,6 +192,23 @@ func applyRuntimeOverrides(policy *sandbox.SandboxPolicy, overrides []config.San } } +// removeExactMatch removes entries from the slice that exactly match the given value. +// Glob patterns and wildcards in the slice are never matched. Only literal string +// equality is used. This keeps broad deny rules intact while allowing targeted overrides. +func removeExactMatch(slice []string, value string) []string { + result := make([]string, 0, len(slice)) + for _, entry := range slice { + if entry == value { + log.Infof("Sandbox override: removing conflicting deny rule for %s", value) + continue + } + + result = append(result, entry) + } + + return result +} + // logSandboxOverridesToEventLog records sandbox allow overrides in the audit event log. func logSandboxOverridesToEventLog(profileName string, overrides []config.SandboxAllowOverride) { entries := make([]map[string]string, 0, len(overrides)) diff --git a/sandbox/executor/apply_test.go b/sandbox/executor/apply_test.go index dc350ad..ce318de 100644 --- a/sandbox/executor/apply_test.go +++ b/sandbox/executor/apply_test.go @@ -1,6 +1,8 @@ package executor import ( + "os" + "path/filepath" "testing" "github.com/safedep/dry/utils" @@ -136,7 +138,7 @@ func TestApplyRuntimeOverrides_EmptyOverrides(t *testing.T) { assert.Equal(t, []string{"/existing"}, policy.Filesystem.AllowWrite) } -func TestApplyRuntimeOverrides_DenyListsUnmodified(t *testing.T) { +func TestApplyRuntimeOverrides_DenyListsUnmodifiedWhenNoConflict(t *testing.T) { policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ DenyWrite: []string{"/protected"}, @@ -157,9 +159,93 @@ func TestApplyRuntimeOverrides_DenyListsUnmodified(t *testing.T) { applyRuntimeOverrides(policy, overrides) - // Deny lists should never be modified by overrides + // Deny lists should be unchanged when overrides don't conflict assert.Equal(t, []string{"/protected"}, policy.Filesystem.DenyWrite) assert.Equal(t, []string{"/usr/bin/curl"}, policy.Process.DenyExec) assert.Equal(t, []string{"*:*"}, policy.Network.DenyOutbound) } +func TestApplyRuntimeOverrides_RemovesExactDenyConflict(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{"/secret", "/other"}, + DenyWrite: []string{"/protected", "/tmp/data"}, + }, + Process: sandbox.ProcessPolicy{ + DenyExec: []string{"/usr/bin/curl", "/bin/bash"}, + }, + } + + overrides := []config.SandboxAllowOverride{ + {Type: config.SandboxAllowRead, Value: "/secret", Raw: "read=/secret"}, + {Type: config.SandboxAllowWrite, Value: "/protected", Raw: "write=/protected"}, + {Type: config.SandboxAllowExec, Value: "/bin/bash", Raw: "exec=/bin/bash"}, + } + + applyRuntimeOverrides(policy, overrides) + + // Exact matches should be removed from deny lists + assert.Equal(t, []string{"/other"}, policy.Filesystem.DenyRead) + assert.Equal(t, []string{"/tmp/data"}, policy.Filesystem.DenyWrite) + assert.Equal(t, []string{"/usr/bin/curl"}, policy.Process.DenyExec) + + // Allow lists should have the overrides + assert.Contains(t, policy.Filesystem.AllowRead, "/secret") + assert.Contains(t, policy.Filesystem.AllowWrite, "/protected") + assert.Contains(t, policy.Process.AllowExec, "/bin/bash") +} + +func TestApplyRuntimeOverrides_PreservesGlobDenyPatterns(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{"/etc/**"}, + DenyWrite: []string{"/usr/**"}, + }, + Process: sandbox.ProcessPolicy{ + DenyExec: []string{"/usr/bin/*"}, + }, + } + + overrides := []config.SandboxAllowOverride{ + {Type: config.SandboxAllowRead, Value: "/etc/hosts", Raw: "read=/etc/hosts"}, + {Type: config.SandboxAllowWrite, Value: "/usr/local/bin/tool", Raw: "write=/usr/local/bin/tool"}, + {Type: config.SandboxAllowExec, Value: "/usr/bin/git", Raw: "exec=/usr/bin/git"}, + } + + applyRuntimeOverrides(policy, overrides) + + // Glob/wildcard deny patterns must NOT be removed — only exact matches are removed + assert.Equal(t, []string{"/etc/**"}, policy.Filesystem.DenyRead) + assert.Equal(t, []string{"/usr/**"}, policy.Filesystem.DenyWrite) + assert.Equal(t, []string{"/usr/bin/*"}, policy.Process.DenyExec) +} + +func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testing.T) { + // Known limitation: deny entries using ${CWD} or ${HOME} variables are NOT + // removed by overrides that resolve to absolute paths. removeExactMatch uses + // literal string comparison, so "${CWD}/blocked.txt" != "/actual/cwd/blocked.txt". + // The override still adds the path to the allow list, but the unexpanded deny + // entry remains and will take precedence once the translator expands it. + cwd, err := os.Getwd() + assert.NoError(t, err) + + absolutePath := filepath.Join(cwd, "blocked.txt") + + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyWrite: []string{"${CWD}/blocked.txt"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowWrite, Value: absolutePath, Raw: "write=./blocked.txt"}, + }) + + // The override is added to the allow list + assert.Contains(t, policy.Filesystem.AllowWrite, absolutePath) + + // But the ${CWD} deny entry is NOT removed because the strings don't match literally. + // This means the deny rule will still shadow the allow after variable expansion. + assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite) +} +