diff --git a/config/cobra.go b/config/cobra.go index f174906..2e496cd 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -1,6 +1,13 @@ package config -import "github.com/spf13/cobra" +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing. +var sandboxAllowRaw []string // ApplyCobraFlags applies the cobra flags to the command. // These flags are local concern of the config package. This helper function is used @@ -29,7 +36,26 @@ func ApplyCobraFlags(cmd *cobra.Command) { globalConfig.Config.Sandbox.EnforceAlways, "Apply sandbox to all commands, not just install commands (requires --sandbox)") cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile", globalConfig.SandboxProfileOverride, "Override sandbox policy profile (built-in name or path to custom YAML)") + cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow", + nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind") // Hide the experimental proxy mode flag but keep it for backward compatibility _ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode") } + +// FinalizeSandboxAllowOverrides parses the raw --sandbox-allow flag values +// and stores the validated overrides in the global config. This must be called +// after cobra flag parsing is complete (e.g., in PersistentPreRun). +func FinalizeSandboxAllowOverrides() error { + if len(sandboxAllowRaw) == 0 { + return nil + } + + overrides, err := parseSandboxAllowOverrides(sandboxAllowRaw) + if err != nil { + return fmt.Errorf("failed to parse --sandbox-allow flags: %w", err) + } + + globalConfig.SandboxAllowOverrides = overrides + return nil +} diff --git a/config/config.go b/config/config.go index 126eced..a863ad9 100644 --- a/config/config.go +++ b/config/config.go @@ -134,6 +134,11 @@ type RuntimeConfig struct { // This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml. SandboxProfileOverride string + // SandboxAllowOverrides holds runtime sandbox allow rules from --sandbox-allow flags. + // These are additive rules applied on top of the resolved sandbox policy. + // Not persisted to config.yml. + SandboxAllowOverrides []SandboxAllowOverride + // Internal config values computed at runtime and must be accessed via. API configDir string configFilePath string @@ -161,6 +166,29 @@ func (r *RuntimeConfig) IsProxyModeEnabled() bool { return (r.Config.ExperimentalProxyMode || r.Config.ProxyMode) } +// SandboxAllowType represents the type of a sandbox allow override. +type SandboxAllowType string + +const ( + SandboxAllowRead SandboxAllowType = "read" + SandboxAllowWrite SandboxAllowType = "write" + SandboxAllowExec SandboxAllowType = "exec" + SandboxAllowNetConnect SandboxAllowType = "net-connect" + SandboxAllowNetBind SandboxAllowType = "net-bind" +) + +// SandboxAllowOverride represents a single --sandbox-allow flag value. +type SandboxAllowOverride struct { + // Type is the resource type (read, write, exec, net-connect, net-bind). + Type SandboxAllowType + + // Value is the resolved value (absolute path, host:port, etc.). + Value string + + // Raw is the original CLI value before resolution (for logging/warnings). + Raw string +} + // DefaultConfig is a fail safe contract for the runtime configuration. // The config package return an appropriate RuntimeConfig based on the environment and the configuration. func DefaultConfig() RuntimeConfig { diff --git a/config/sandbox_allow.go b/config/sandbox_allow.go new file mode 100644 index 0000000..f3443e9 --- /dev/null +++ b/config/sandbox_allow.go @@ -0,0 +1,217 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/safedep/dry/log" +) + +// validSandboxAllowTypes is the set of recognized --sandbox-allow type prefixes. +var validSandboxAllowTypes = map[SandboxAllowType]bool{ + SandboxAllowRead: true, + SandboxAllowWrite: true, + SandboxAllowExec: true, + SandboxAllowNetConnect: true, + SandboxAllowNetBind: true, +} + +// parseSandboxAllowOverrides parses raw --sandbox-allow flag values into validated overrides. +// Each raw value must be in the format "type=value" (e.g., "write=./.gitignore"). +func parseSandboxAllowOverrides(raw []string) ([]SandboxAllowOverride, error) { + overrides := make([]SandboxAllowOverride, 0, len(raw)) + + for _, r := range raw { + override, err := parseSingleOverride(r) + if err != nil { + return nil, fmt.Errorf("invalid --sandbox-allow %q: %w", r, err) + } + + overrides = append(overrides, override) + } + + return overrides, nil +} + +// parseSingleOverride parses and validates a single "type=value" string. +func parseSingleOverride(raw string) (SandboxAllowOverride, error) { + // Split on first '=' only to handle values containing '=' + idx := strings.IndexByte(raw, '=') + if idx < 0 { + return SandboxAllowOverride{}, fmt.Errorf("missing '=' separator, expected format: type=value (e.g., write=./file)") + } + + typStr := raw[:idx] + value := raw[idx+1:] + + if typStr == "" { + return SandboxAllowOverride{}, fmt.Errorf("missing type before '=', expected format: type=value") + } + + if value == "" { + return SandboxAllowOverride{}, fmt.Errorf("missing value after '=', expected format: type=value") + } + + allowType := SandboxAllowType(typStr) + + // Provide a helpful error for the common mistake of using "net" instead of "net-connect"/"net-bind" + if typStr == "net" { + return SandboxAllowOverride{}, fmt.Errorf("unknown type %q (use net-connect or net-bind)", typStr) + } + + if !validSandboxAllowTypes[allowType] { + return SandboxAllowOverride{}, fmt.Errorf("unknown type %q, valid types: read, write, exec, net-connect, net-bind", typStr) + } + + resolved, err := validateAndResolveValue(allowType, value) + if err != nil { + return SandboxAllowOverride{}, err + } + + return SandboxAllowOverride{ + Type: allowType, + Value: resolved, + Raw: raw, + }, nil +} + +// validateAndResolveValue validates the value for the given type and resolves paths. +func validateAndResolveValue(typ SandboxAllowType, value string) (string, error) { + switch typ { + case SandboxAllowRead, SandboxAllowWrite: + return resolveFilesystemPath(value) + case SandboxAllowExec: + return resolveExecPath(value) + case SandboxAllowNetConnect: + return validateNetConnect(value) + case SandboxAllowNetBind: + return validateNetBind(value) + default: + return "", fmt.Errorf("unhandled type: %s", typ) + } +} + +// resolveFilesystemPath resolves a filesystem path for read/write overrides. +// Supports glob patterns. Resolves relative paths to absolute via CWD. +func resolveFilesystemPath(value string) (string, error) { + if err := checkTildePath(value); err != nil { + return "", err + } + + return resolveToAbsolute(value) +} + +// resolveExecPath resolves an exec path. Rejects glob patterns. +func resolveExecPath(value string) (string, error) { + if err := checkTildePath(value); err != nil { + return "", err + } + + if containsGlob(value) { + return "", fmt.Errorf("glob patterns are not allowed for exec type (specify exact path)") + } + + return resolveToAbsolute(value) +} + +// validateNetConnect validates a net-connect value (host:port format, no wildcards). +func validateNetConnect(value string) (string, error) { + host, port, err := parseHostPort(value) + if err != nil { + return "", fmt.Errorf("invalid net-connect value: %w", err) + } + + if host == "*" || strings.Contains(host, "*") || strings.Contains(host, "?") { + return "", fmt.Errorf("wildcards are not allowed for net-connect (specify exact host:port)") + } + + if port == "*" { + return "", fmt.Errorf("port wildcard is not allowed for net-connect (specify exact host:port)") + } + + return value, nil +} + +// validateNetBind validates a net-bind value. +// Allows localhost:* and 127.0.0.1:* as special wildcard forms. +// Warns on non-localhost addresses. +func validateNetBind(value string) (string, error) { + host, _, err := parseHostPort(value) + if err != nil { + return "", fmt.Errorf("invalid net-bind value: %w", err) + } + + // Reject host-side wildcards + if host == "*" || strings.Contains(host, "*") || strings.Contains(host, "?") { + return "", fmt.Errorf("host wildcards are not allowed for net-bind (specify a host, e.g., localhost:3000)") + } + + // Warn on non-localhost addresses + if !isLocalhostAddress(host) { + log.Warnf("--sandbox-allow net-bind=%s uses non-localhost address, this exposes the port to the network", value) + } + + return value, nil +} + +// parseHostPort parses a host:port string. The port may be "*" for wildcard. +func parseHostPort(value string) (string, string, error) { + // Find the last ':' to split host and port (handles IPv6 in the future) + lastColon := strings.LastIndex(value, ":") + if lastColon < 0 { + return "", "", fmt.Errorf("expected host:port format (e.g., example.com:443), got %q", value) + } + + host := value[:lastColon] + port := value[lastColon+1:] + + if host == "" { + return "", "", fmt.Errorf("missing host in host:port value %q", value) + } + + if port == "" { + return "", "", fmt.Errorf("missing port in host:port value %q", value) + } + + return host, port, nil +} + +// isLocalhostAddress returns true if the host is a localhost address. +func isLocalhostAddress(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +// resolveToAbsolute resolves a path to an absolute path relative to CWD. +// Glob characters are preserved. The path is cleaned via filepath.Clean(). +func resolveToAbsolute(value string) (string, error) { + if filepath.IsAbs(value) { + return filepath.Clean(value), nil + } + + // For paths with glob characters, we need to preserve them through Clean. + // filepath.Clean handles ".." and "." but leaves glob chars intact. + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + + return filepath.Clean(filepath.Join(cwd, value)), nil +} + +// checkTildePath returns an error if the path starts with "~" (unexpanded tilde). +func checkTildePath(value string) error { + if strings.HasPrefix(value, "~") { + return fmt.Errorf("path %q starts with '~' which was not expanded by your shell; use an absolute path instead", value) + } + + return nil +} + +// containsGlob returns true if the pattern contains glob wildcards. +func containsGlob(pattern string) bool { + return strings.Contains(pattern, "*") || + strings.Contains(pattern, "?") || + strings.Contains(pattern, "[") +} diff --git a/config/sandbox_allow_test.go b/config/sandbox_allow_test.go new file mode 100644 index 0000000..c138ed8 --- /dev/null +++ b/config/sandbox_allow_test.go @@ -0,0 +1,256 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSandboxAllowOverrides_ValidFormats(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + raw string + expectedType SandboxAllowType + expectedValue string + }{ + { + name: "write with relative path", + raw: "write=./.gitignore", + expectedType: SandboxAllowWrite, + expectedValue: filepath.Join(cwd, ".gitignore"), + }, + { + name: "write with absolute path", + raw: "write=/tmp/output", + expectedType: SandboxAllowWrite, + expectedValue: "/tmp/output", + }, + { + name: "write with glob pattern", + raw: "write=./dist/**", + expectedType: SandboxAllowWrite, + expectedValue: filepath.Join(cwd, "dist/**"), + }, + { + name: "read with absolute path", + raw: "read=/opt/config/registry.json", + expectedType: SandboxAllowRead, + expectedValue: "/opt/config/registry.json", + }, + { + name: "read with glob pattern", + raw: "read=./src/**", + expectedType: SandboxAllowRead, + expectedValue: filepath.Join(cwd, "src/**"), + }, + { + name: "exec with absolute path", + raw: "exec=/usr/bin/curl", + expectedType: SandboxAllowExec, + expectedValue: "/usr/bin/curl", + }, + { + name: "net-connect with host:port", + raw: "net-connect=registry.npmjs.org:443", + expectedType: SandboxAllowNetConnect, + expectedValue: "registry.npmjs.org:443", + }, + { + name: "net-bind with localhost", + raw: "net-bind=127.0.0.1:3000", + expectedType: SandboxAllowNetBind, + expectedValue: "127.0.0.1:3000", + }, + { + name: "net-bind with localhost wildcard port", + raw: "net-bind=localhost:*", + expectedType: SandboxAllowNetBind, + expectedValue: "localhost:*", + }, + { + name: "write with relative path no dot prefix", + raw: "write=dist/output", + expectedType: SandboxAllowWrite, + expectedValue: filepath.Join(cwd, "dist/output"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + overrides, err := parseSandboxAllowOverrides([]string{tt.raw}) + require.NoError(t, err) + require.Len(t, overrides, 1) + + assert.Equal(t, tt.expectedType, overrides[0].Type) + assert.Equal(t, tt.expectedValue, overrides[0].Value) + assert.Equal(t, tt.raw, overrides[0].Raw) + }) + } +} + +func TestParseSandboxAllowOverrides_MultipleValues(t *testing.T) { + raw := []string{ + "write=./.gitignore", + "exec=/usr/bin/curl", + "net-connect=example.com:443", + } + + overrides, err := parseSandboxAllowOverrides(raw) + require.NoError(t, err) + require.Len(t, overrides, 3) + + assert.Equal(t, SandboxAllowWrite, overrides[0].Type) + assert.Equal(t, SandboxAllowExec, overrides[1].Type) + assert.Equal(t, SandboxAllowNetConnect, overrides[2].Type) +} + +func TestParseSandboxAllowOverrides_EmptySlice(t *testing.T) { + overrides, err := parseSandboxAllowOverrides([]string{}) + require.NoError(t, err) + assert.Empty(t, overrides) +} + +func TestParseSandboxAllowOverrides_InvalidFormats(t *testing.T) { + tests := []struct { + name string + raw string + errContains string + }{ + { + name: "missing separator", + raw: "./foo", + errContains: "missing '=' separator", + }, + { + name: "missing type", + raw: "=./foo", + errContains: "missing type before '='", + }, + { + name: "empty value", + raw: "write=", + errContains: "missing value after '='", + }, + { + name: "unknown type", + raw: "foo=bar", + errContains: "unknown type", + }, + { + name: "net shorthand rejected", + raw: "net=host:443", + errContains: "use net-connect or net-bind", + }, + { + name: "exec with glob pattern", + raw: "exec=/usr/bin/*", + errContains: "glob patterns are not allowed for exec", + }, + { + name: "net-connect with wildcard host", + raw: "net-connect=*:443", + errContains: "wildcards are not allowed for net-connect", + }, + { + name: "net-connect with glob host", + raw: "net-connect=*.example.com:443", + errContains: "wildcards are not allowed for net-connect", + }, + { + name: "net-connect with wildcard port", + raw: "net-connect=example.com:*", + errContains: "port wildcard is not allowed for net-connect", + }, + { + name: "net-bind with host wildcard", + raw: "net-bind=*:3000", + errContains: "host wildcards are not allowed for net-bind", + }, + { + name: "net-bind with full wildcard", + raw: "net-bind=*:*", + errContains: "host wildcards are not allowed for net-bind", + }, + { + name: "net-connect missing port", + raw: "net-connect=example.com", + errContains: "expected host:port format", + }, + { + name: "net-bind missing port", + raw: "net-bind=localhost", + errContains: "expected host:port format", + }, + { + name: "tilde path for write", + raw: "write=~/file", + errContains: "starts with '~'", + }, + { + name: "tilde path for read", + raw: "read=~/.config/foo", + errContains: "starts with '~'", + }, + { + name: "tilde path for exec", + raw: "exec=~/bin/tool", + errContains: "starts with '~'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseSandboxAllowOverrides([]string{tt.raw}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + }) + } +} + +func TestParseSandboxAllowOverrides_ValueWithEquals(t *testing.T) { + // Values can contain '=' characters — the parser splits on the first '=' only + overrides, err := parseSandboxAllowOverrides([]string{"write=./path=with=equals.txt"}) + require.NoError(t, err) + require.Len(t, overrides, 1) + + cwd, err := os.Getwd() + require.NoError(t, err) + + assert.Equal(t, SandboxAllowWrite, overrides[0].Type) + assert.Equal(t, filepath.Join(cwd, "path=with=equals.txt"), overrides[0].Value) +} + +func TestParseSandboxAllowOverrides_PathCleaning(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + // Paths with ".." are cleaned via filepath.Clean + overrides, err := parseSandboxAllowOverrides([]string{"write=./foo/../bar"}) + require.NoError(t, err) + require.Len(t, overrides, 1) + + assert.Equal(t, filepath.Join(cwd, "bar"), overrides[0].Value) +} + +func TestParseSandboxAllowOverrides_NetBindNonLocalhost(t *testing.T) { + // Non-localhost should succeed (with a warning logged, which we can't easily assert here) + overrides, err := parseSandboxAllowOverrides([]string{"net-bind=0.0.0.0:3000"}) + require.NoError(t, err) + require.Len(t, overrides, 1) + + assert.Equal(t, SandboxAllowNetBind, overrides[0].Type) + assert.Equal(t, "0.0.0.0:3000", overrides[0].Value) +} + +func TestParseSandboxAllowOverrides_FirstErrorStops(t *testing.T) { + // If the first value is invalid, the second is not parsed + _, err := parseSandboxAllowOverrides([]string{"write=./ok", "bad"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing '=' separator") +} diff --git a/docs/sandbox.md b/docs/sandbox.md index 0094384..f77d8be 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -54,6 +54,34 @@ Run sandbox with custom policy file: pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express ``` +### Runtime Allow Overrides + +Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful when a command needs access that the default profile blocks. + +```bash +# Allow writing to a specific file +pmg --sandbox-allow write=./.gitignore npx create-next-app@latest + +# Allow executing a binary blocked by the profile +pmg --sandbox-allow exec=$(which curl) npm install some-package + +# Allow outbound connection to a private registry +pmg --sandbox-allow net-connect=npm.internal.corp:443 npm install @corp/private-pkg + +# Allow a dev server to bind to a local port +pmg --sandbox-allow net-bind=127.0.0.1:3000 npx some-dev-tool + +# Multiple overrides +pmg \ + --sandbox-allow write=./.gitignore \ + --sandbox-allow exec=$(which curl) \ + npm install some-package +``` + +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.). +
Custom policy overrides using Policy Templates @@ -256,4 +284,4 @@ bwrap --verbose [arguments...] -- npm install express - https://github.com/anthropic-experimental/sandbox-runtime - https://geminicli.com/docs/cli/sandbox/ -- https://github.com/containers/bubblewrap \ No newline at end of file +- https://github.com/containers/bubblewrap diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go index b50c449..9c7166d 100644 --- a/internal/eventlog/eventlog.go +++ b/internal/eventlog/eventlog.go @@ -25,6 +25,7 @@ const ( EventTypeDependencyResolved EventType = "dependency_resolved" EventTypeInstallInsecureBypass EventType = "install_insecure_bypass" EventTypeProxyHostObserved EventType = "proxy_host_observed" + EventTypeSandboxOverride EventType = "sandbox_override" EventTypeError EventType = "error" ) @@ -413,6 +414,22 @@ func LogProxyHostObserved(hostname, method, reason string, details map[string]in } } +// LogSandboxOverrides logs when runtime sandbox allow overrides are applied. +func LogSandboxOverrides(sandboxProfile string, overrides []map[string]string) { + event := Event{ + EventType: EventTypeSandboxOverride, + Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)), + Details: map[string]interface{}{ + "sandbox_profile": sandboxProfile, + "sandbox_runtime_overrides": overrides, + }, + } + + if err := LogEvent(event); err != nil { + log.Warnf("failed to log sandbox override event: %s", err) + } +} + // LogError logs an error event func LogError(message string, err error) { event := Event{ diff --git a/main.go b/main.go index ac1ce92..e540b8a 100644 --- a/main.go +++ b/main.go @@ -72,6 +72,11 @@ func main() { if eventlogErr != nil { ui.Fatalf("failed to initialize event logging: %v", eventlogErr) } + + // Parse and validate --sandbox-allow flags after all flags are resolved + if err := config.FinalizeSandboxAllowOverrides(); err != nil { + ui.Fatalf("pmg: %v", err) + } }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index eb1d6a5..cf3f76a 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -7,7 +7,9 @@ import ( "path/filepath" "github.com/safedep/dry/log" + "github.com/safedep/dry/utils" "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/eventlog" "github.com/safedep/pmg/sandbox" "github.com/safedep/pmg/sandbox/platform" "github.com/safedep/pmg/usefulerror" @@ -114,6 +116,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app log.Debugf("Loaded sandbox policy %s", policy.Name) + // Apply runtime --sandbox-allow overrides to the policy before execution + if len(cfg.SandboxAllowOverrides) > 0 { + applyRuntimeOverrides(policy, cfg.SandboxAllowOverrides) + logSandboxOverridesToEventLog(policy.Name, cfg.SandboxAllowOverrides) + } + if !policy.AppliesToPackageManager(pmName) { return nil, fmt.Errorf("sandbox policy %s does not apply to %s", policy.Name, pmName) } @@ -146,3 +154,49 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app return result, nil } + +// 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. +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) + + case config.SandboxAllowWrite: + log.Infof("Sandbox override: allowing write access to %s", override.Value) + policy.Filesystem.AllowWrite = append(policy.Filesystem.AllowWrite, override.Value) + + case config.SandboxAllowExec: + log.Infof("Sandbox override: allowing execution of %s", override.Value) + policy.Process.AllowExec = append(policy.Process.AllowExec, override.Value) + + case config.SandboxAllowNetConnect: + log.Infof("Sandbox override: allowing outbound connection to %s", override.Value) + policy.Network.AllowOutbound = append(policy.Network.AllowOutbound, override.Value) + + case config.SandboxAllowNetBind: + log.Infof("Sandbox override: allowing network bind on %s", override.Value) + policy.Network.AllowBind = append(policy.Network.AllowBind, override.Value) + + // Enable AllowNetworkBind so the translator emits bind rules. + // Without this, AllowBind entries would be ignored on some platforms. + policy.AllowNetworkBind = utils.PtrTo(true) + } + } +} + +// 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)) + for _, o := range overrides { + entries = append(entries, map[string]string{ + "type": string(o.Type), + "value": o.Value, + }) + } + + eventlog.LogSandboxOverrides(profileName, entries) +} diff --git a/sandbox/executor/apply_test.go b/sandbox/executor/apply_test.go new file mode 100644 index 0000000..dc350ad --- /dev/null +++ b/sandbox/executor/apply_test.go @@ -0,0 +1,165 @@ +package executor + +import ( + "testing" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" +) + +func TestApplyRuntimeOverrides_Read(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/existing"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowRead, Value: "/new/path", Raw: "read=/new/path"}, + }) + + assert.Contains(t, policy.Filesystem.AllowRead, "/existing") + assert.Contains(t, policy.Filesystem.AllowRead, "/new/path") +} + +func TestApplyRuntimeOverrides_Write(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + AllowWrite: []string{"/existing"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowWrite, Value: "/new/file", Raw: "write=/new/file"}, + }) + + assert.Contains(t, policy.Filesystem.AllowWrite, "/existing") + assert.Contains(t, policy.Filesystem.AllowWrite, "/new/file") +} + +func TestApplyRuntimeOverrides_Exec(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Process: sandbox.ProcessPolicy{ + AllowExec: []string{"/usr/bin/node"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"}, + }) + + assert.Contains(t, policy.Process.AllowExec, "/usr/bin/node") + assert.Contains(t, policy.Process.AllowExec, "/usr/bin/curl") +} + +func TestApplyRuntimeOverrides_NetConnect(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Network: sandbox.NetworkPolicy{ + AllowOutbound: []string{"registry.npmjs.org:443"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"}, + }) + + assert.Contains(t, policy.Network.AllowOutbound, "registry.npmjs.org:443") + assert.Contains(t, policy.Network.AllowOutbound, "example.com:443") +} + +func TestApplyRuntimeOverrides_NetBind(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Network: sandbox.NetworkPolicy{ + AllowBind: []string{}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"}, + }) + + assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000") + assert.NotNil(t, policy.AllowNetworkBind) + assert.True(t, *policy.AllowNetworkBind) +} + +func TestApplyRuntimeOverrides_NetBindPreservesExistingTrue(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + AllowNetworkBind: utils.PtrTo(true), + Network: sandbox.NetworkPolicy{ + AllowBind: []string{"localhost:8080"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{ + {Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"}, + }) + + assert.Contains(t, policy.Network.AllowBind, "localhost:8080") + assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000") + assert.True(t, *policy.AllowNetworkBind) +} + +func TestApplyRuntimeOverrides_MultipleOverrides(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{}, + Process: sandbox.ProcessPolicy{}, + Network: sandbox.NetworkPolicy{}, + } + + overrides := []config.SandboxAllowOverride{ + {Type: config.SandboxAllowWrite, Value: "/path/a", Raw: "write=/path/a"}, + {Type: config.SandboxAllowWrite, Value: "/path/b", Raw: "write=/path/b"}, + {Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"}, + {Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"}, + } + + applyRuntimeOverrides(policy, overrides) + + assert.Len(t, policy.Filesystem.AllowWrite, 2) + assert.Len(t, policy.Process.AllowExec, 1) + assert.Len(t, policy.Network.AllowOutbound, 1) +} + +func TestApplyRuntimeOverrides_EmptyOverrides(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + AllowWrite: []string{"/existing"}, + }, + } + + applyRuntimeOverrides(policy, []config.SandboxAllowOverride{}) + + // Policy should be unchanged + assert.Equal(t, []string{"/existing"}, policy.Filesystem.AllowWrite) +} + +func TestApplyRuntimeOverrides_DenyListsUnmodified(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyWrite: []string{"/protected"}, + }, + Process: sandbox.ProcessPolicy{ + DenyExec: []string{"/usr/bin/curl"}, + }, + Network: sandbox.NetworkPolicy{ + DenyOutbound: []string{"*:*"}, + }, + } + + overrides := []config.SandboxAllowOverride{ + {Type: config.SandboxAllowWrite, Value: "/something", Raw: "write=/something"}, + {Type: config.SandboxAllowExec, Value: "/usr/bin/wget", Raw: "exec=/usr/bin/wget"}, + {Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"}, + } + + applyRuntimeOverrides(policy, overrides) + + // Deny lists should never be modified by overrides + 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) +} +