mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for sandbox allow override (#165)
* feat: Add support for sandbox allow override * fix: Main should fail on arg processing error * fix: Remove redundant policy conflict check
This commit is contained in:
+27
-1
@@ -1,6 +1,13 @@
|
|||||||
package config
|
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.
|
// ApplyCobraFlags applies the cobra flags to the command.
|
||||||
// These flags are local concern of the config package. This helper function is used
|
// 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)")
|
globalConfig.Config.Sandbox.EnforceAlways, "Apply sandbox to all commands, not just install commands (requires --sandbox)")
|
||||||
cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile",
|
cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile",
|
||||||
globalConfig.SandboxProfileOverride, "Override sandbox policy profile (built-in name or path to custom YAML)")
|
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
|
// Hide the experimental proxy mode flag but keep it for backward compatibility
|
||||||
_ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode")
|
_ = 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ type RuntimeConfig struct {
|
|||||||
// This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml.
|
// This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml.
|
||||||
SandboxProfileOverride string
|
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
|
// Internal config values computed at runtime and must be accessed via. API
|
||||||
configDir string
|
configDir string
|
||||||
configFilePath string
|
configFilePath string
|
||||||
@@ -161,6 +166,29 @@ func (r *RuntimeConfig) IsProxyModeEnabled() bool {
|
|||||||
return (r.Config.ExperimentalProxyMode || r.Config.ProxyMode)
|
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.
|
// DefaultConfig is a fail safe contract for the runtime configuration.
|
||||||
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
||||||
func DefaultConfig() RuntimeConfig {
|
func DefaultConfig() RuntimeConfig {
|
||||||
|
|||||||
@@ -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, "[")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -54,6 +54,34 @@ Run sandbox with custom policy file:
|
|||||||
pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express
|
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.).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Custom policy overrides using Policy Templates</summary>
|
<summary>Custom policy overrides using Policy Templates</summary>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
EventTypeDependencyResolved EventType = "dependency_resolved"
|
EventTypeDependencyResolved EventType = "dependency_resolved"
|
||||||
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
||||||
EventTypeProxyHostObserved EventType = "proxy_host_observed"
|
EventTypeProxyHostObserved EventType = "proxy_host_observed"
|
||||||
|
EventTypeSandboxOverride EventType = "sandbox_override"
|
||||||
EventTypeError EventType = "error"
|
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
|
// LogError logs an error event
|
||||||
func LogError(message string, err error) {
|
func LogError(message string, err error) {
|
||||||
event := Event{
|
event := Event{
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ func main() {
|
|||||||
if eventlogErr != nil {
|
if eventlogErr != nil {
|
||||||
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/dry/utils"
|
||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/eventlog"
|
||||||
"github.com/safedep/pmg/sandbox"
|
"github.com/safedep/pmg/sandbox"
|
||||||
"github.com/safedep/pmg/sandbox/platform"
|
"github.com/safedep/pmg/sandbox/platform"
|
||||||
"github.com/safedep/pmg/usefulerror"
|
"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)
|
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) {
|
if !policy.AppliesToPackageManager(pmName) {
|
||||||
return nil, fmt.Errorf("sandbox policy %s does not apply to %s", policy.Name, 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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user