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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user