diff --git a/README.md b/README.md index 7d5f489..179592e 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ make release # binary + docker image ## Security - **Default**: Secure mode, restricted to a narrow allowlist of read-only utilities. No interpreters. -- **Secure mode** (`use_shell_execution: false`): Executable allowlist, no shell parsing. Blocks injection. Never allowlist interpreters (bash/sh/python/git) — doing so defeats the allowlist. +- **Secure mode** (`use_shell_execution: false`): the command is parsed into a shell AST and only a single, fully-literal simple command is accepted (no pipes, lists, substitution, redirection or globs); its executable must be on the allowlist. Interpreters (bash/sh/python) are hard-denied even if allowlisted, and per-tool policies strip escape hatches (`git -c`, `find -exec`, `tar --checkpoint-action`). This is an early-reject layer, not a sandbox. - **Unrestricted**: Only via `MCP_SHELL_ALLOW_UNSAFE=true`. Full access; fine for local dev, dangerous otherwise. - **Docker**: Runs as non-root, Alpine-based. Use it in production. Best paired with an OS sandbox (read-only FS, dropped caps) as defense-in-depth. diff --git a/argpolicy.go b/argpolicy.go new file mode 100644 index 0000000..566faaf --- /dev/null +++ b/argpolicy.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" +) + +// argPolicy decides whether a resolved argv for one executable is permitted. +// argv[0] is the executable. Implementations are pure and stateless and exist +// to strip per-tool escape hatches (GTFOBins) that structural validation alone +// cannot see: an allowlisted binary may itself run arbitrary commands. +type argPolicy interface { + name() string + check(argv []string) error +} + +// policySet maps an executable basename to its governing argPolicy. Absence of +// an entry means no extra per-argument restriction beyond structural + allowlist. +type policySet struct { + byName map[string]argPolicy +} + +func newPolicySet(policies ...argPolicy) *policySet { + byName := make(map[string]argPolicy, len(policies)) + for _, p := range policies { + byName[p.name()] = p + } + return &policySet{byName: byName} +} + +func newDefaultPolicySet() *policySet { + return newPolicySet(newGitArgPolicy(), newFindArgPolicy(), newTarArgPolicy()) +} + +// governs reports whether a policy exists for the executable's basename. +func (s *policySet) governs(executable string) bool { + _, ok := s.byName[filepath.Base(executable)] + return ok +} + +func (s *policySet) check(argv []string) error { + if len(argv) == 0 { + return nil + } + if pol, ok := s.byName[filepath.Base(argv[0])]; ok { + return pol.check(argv) + } + return nil +} + +// gitArgPolicy blocks git's config-injection and alias-as-shell escape hatches. +type gitArgPolicy struct{} + +func newGitArgPolicy() *gitArgPolicy { return &gitArgPolicy{} } + +func (*gitArgPolicy) name() string { return "git" } + +func (*gitArgPolicy) check(argv []string) error { + for _, a := range argv[1:] { + if a == "-c" || a == "--config-env" { + return fmt.Errorf("git: -c/--config-env config injection is not allowed in secure mode") + } + if strings.HasPrefix(a, "--exec-path") || + strings.HasPrefix(a, "--upload-pack") || + strings.HasPrefix(a, "--receive-pack") { + return fmt.Errorf("git: %q can execute arbitrary programs and is not allowed in secure mode", a) + } + } + return nil +} + +// findArgPolicy blocks find's action primaries that spawn processes or mutate. +type findArgPolicy struct{} + +func newFindArgPolicy() *findArgPolicy { return &findArgPolicy{} } + +func (*findArgPolicy) name() string { return "find" } + +func (*findArgPolicy) check(argv []string) error { + blocked := map[string]struct{}{ + "-exec": {}, "-execdir": {}, "-ok": {}, "-okdir": {}, + "-fprintf": {}, "-fprint": {}, "-fprint0": {}, "-delete": {}, + } + for _, a := range argv[1:] { + if _, bad := blocked[a]; bad { + return fmt.Errorf("find: %q is not allowed in secure mode", a) + } + } + return nil +} + +// tarArgPolicy blocks tar options that run an external command. +type tarArgPolicy struct{} + +func newTarArgPolicy() *tarArgPolicy { return &tarArgPolicy{} } + +func (*tarArgPolicy) name() string { return "tar" } + +func (*tarArgPolicy) check(argv []string) error { + for _, a := range argv[1:] { + if a == "-I" || + strings.HasPrefix(a, "--to-command") || + strings.HasPrefix(a, "--checkpoint-action") || + strings.HasPrefix(a, "--use-compress-program") || + strings.HasPrefix(a, "--rsh-command") { + return fmt.Errorf("tar: %q can execute arbitrary programs and is not allowed in secure mode", a) + } + } + return nil +} diff --git a/argpolicy_test.go b/argpolicy_test.go new file mode 100644 index 0000000..cb11703 --- /dev/null +++ b/argpolicy_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPolicySet_check(t *testing.T) { + tests := []struct { + name string + argv []string + expectError bool + errorContains string + }{ + { + name: "git -c config injection blocked", + argv: []string{"git", "-c", "alias.pwn=!id"}, + expectError: true, + errorContains: "config injection", + }, + { + name: "git -c core.pager blocked", + argv: []string{"git", "-c", "core.pager=sh"}, + expectError: true, + errorContains: "config injection", + }, + { + name: "git --exec-path blocked", + argv: []string{"git", "--exec-path=/tmp"}, + expectError: true, + errorContains: "exec-path", + }, + { + name: "git status allowed", + argv: []string{"git", "status"}, + expectError: false, + }, + { + name: "git log allowed", + argv: []string{"git", "log", "--oneline"}, + expectError: false, + }, + { + name: "find -exec blocked", + argv: []string{"find", ".", "-exec", "rm", "{}", "+"}, + expectError: true, + errorContains: "-exec", + }, + { + name: "find -delete blocked", + argv: []string{"find", ".", "-delete"}, + expectError: true, + errorContains: "-delete", + }, + { + name: "find name search allowed", + argv: []string{"find", ".", "-name", "x", "-type", "f"}, + expectError: false, + }, + { + name: "tar checkpoint-action blocked", + argv: []string{"tar", "-cf", "/dev/null", "x", "--checkpoint-action=exec=sh"}, + expectError: true, + errorContains: "checkpoint-action", + }, + { + name: "tar to-command blocked", + argv: []string{"tar", "--to-command=sh", "-xf", "a.tar"}, + expectError: true, + errorContains: "to-command", + }, + { + name: "tar list allowed", + argv: []string{"tar", "-tf", "a.tar"}, + expectError: false, + }, + { + name: "ungoverned executable passes", + argv: []string{"ls", "-la"}, + expectError: false, + }, + } + + policies := newDefaultPolicySet() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := policies.check(tt.argv) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPolicySet_governs(t *testing.T) { + policies := newDefaultPolicySet() + + t.Run("governed by basename", func(t *testing.T) { + assert.True(t, policies.governs("git")) + assert.True(t, policies.governs("/usr/bin/git")) + assert.True(t, policies.governs("find")) + assert.True(t, policies.governs("tar")) + }) + + t.Run("ungoverned", func(t *testing.T) { + assert.False(t, policies.governs("ls")) + assert.False(t, policies.governs("/bin/bash")) + }) +} diff --git a/executor.go b/executor.go index 06c1f30..cf4b7f1 100644 --- a/executor.go +++ b/executor.go @@ -34,14 +34,16 @@ type SecurityInfo struct { } type CommandExecutor struct { - config SecurityConfig - logger zerolog.Logger + config SecurityConfig + logger zerolog.Logger + unfurler *commandUnfurler } func newCommandExecutor(cfg SecurityConfig, logger zerolog.Logger) *CommandExecutor { return &CommandExecutor{ - config: cfg, - logger: logger.With().Str("component", "executor").Logger(), + config: cfg, + logger: logger.With().Str("component", "executor").Logger(), + unfurler: newCommandUnfurler(), } } @@ -97,40 +99,6 @@ func (e *CommandExecutor) execute( return result, nil } -// parseCommand securely parses a command string into executable and arguments -// without using shell interpretation. This prevents command injection through -// shell metacharacters and substitution. -func (e *CommandExecutor) parseCommand(command string) (string, []string, error) { - command = strings.TrimSpace(command) - if command == "" { - return "", nil, fmt.Errorf("empty command") - } - - // Simple whitespace-based splitting - no shell interpretation - parts := strings.Fields(command) - if len(parts) == 0 { - return "", nil, fmt.Errorf("no command found") - } - - executable := parts[0] - args := parts[1:] - - // Validate that the executable doesn't contain shell metacharacters - if containsShellMetacharacters(executable) { - return "", nil, fmt.Errorf("executable contains shell metacharacters: %s", executable) - } - - // Validate arguments don't contain dangerous shell constructs - // In secure mode, this should be an error, not just a warning - for _, arg := range args { - if containsDangerousShellConstructs(arg) { - return "", nil, fmt.Errorf("argument contains dangerous shell constructs: %s", arg) - } - } - - return executable, args, nil -} - func (e *CommandExecutor) executeSecureCommand( ctx context.Context, command string, @@ -145,22 +113,23 @@ func (e *CommandExecutor) executeSecureCommand( Msg("Using legacy shell execution mode - vulnerable to injection attacks") cmd = exec.CommandContext(ctx, "bash", "-c", command) } else { - // Secure execution: parse command and execute directly - executable, args, err := e.parseCommand(command) - if err != nil { + // Secure execution: parse the command into a literal argv and execute it + // directly, using the same unfurler the validator used. + res := e.unfurler.unfurl(command) + if !res.Allowed { e.logger.Error(). - Err(err). Str("command", command). + Str("reason", res.Reason). Msg("Failed to parse command securely") - return nil, fmt.Errorf("command parsing failed: %w", err) + return nil, fmt.Errorf("command parsing failed: %s", res.Reason) } e.logger.Debug(). - Str("executable", executable). - Strs("args", args). + Str("executable", res.Argv[0]). + Strs("args", res.Argv[1:]). Msg("Executing command with direct execution") - cmd = exec.CommandContext(ctx, executable, args...) + cmd = exec.CommandContext(ctx, res.Argv[0], res.Argv[1:]...) } if e.config.WorkingDirectory != "" { diff --git a/executor_test.go b/executor_test.go index e846852..e1e1f2a 100644 --- a/executor_test.go +++ b/executor_test.go @@ -10,176 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCommandExecutor_parseCommand(t *testing.T) { - tests := []struct { - name string - command string - expectExec string - expectArgs []string - expectError bool - errorContains string - }{ - { - name: "simple command", - command: "ls -la", - expectExec: "ls", - expectArgs: []string{"-la"}, - expectError: false, - }, - { - name: "command with multiple args", - command: "grep -n test file.txt", - expectExec: "grep", - expectArgs: []string{"-n", "test", "file.txt"}, - expectError: false, - }, - { - name: "command with spaces around", - command: " echo hello world ", - expectExec: "echo", - expectArgs: []string{"hello", "world"}, - expectError: false, - }, - { - name: "single command no args", - command: "pwd", - expectExec: "pwd", - expectArgs: []string{}, - expectError: false, - }, - { - name: "empty command", - command: "", - expectError: true, - errorContains: "empty command", - }, - { - name: "whitespace only", - command: " ", - expectError: true, - errorContains: "empty command", - }, - { - name: "command with pipe (shell metacharacter)", - command: "ls | grep test", - expectError: true, - errorContains: "dangerous shell constructs", - }, - { - name: "command with semicolon", - command: "echo hello; rm file", - expectError: true, - errorContains: "dangerous shell constructs", - }, - { - name: "command with command substitution", - command: "echo $(whoami)", - expectError: true, - errorContains: "dangerous shell constructs", - }, - { - name: "command with backticks", - command: "echo `whoami`", - expectError: true, - errorContains: "dangerous shell constructs", - }, - { - name: "command with redirection", - command: "echo hello > file.txt", - expectError: true, - errorContains: "dangerous shell constructs", - }, - { - name: "command with background process", - command: "sleep 10 &", - expectError: true, - errorContains: "dangerous shell constructs", - }, - } - - logger := zerolog.New(zerolog.NewTestWriter(t)) - config := SecurityConfig{} - executor := newCommandExecutor(config, logger) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - exec, args, err := executor.parseCommand(tt.command) - - if tt.expectError { - require.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.expectExec, exec) - assert.Equal(t, tt.expectArgs, args) - } - }) - } -} - -func TestCommandExecutor_containsShellMetacharacters(t *testing.T) { - tests := []struct { - name string - input string - expected bool - }{ - {"normal command", "ls", false}, - {"path with slash", "/usr/bin/ls", false}, - {"command with dash", "ls-extended", false}, - {"command with underscore", "my_command", false}, - {"command with dot", "node.js", false}, - {"pipe character", "ls|grep", true}, - {"ampersand", "command&", true}, - {"semicolon", "cmd;", true}, - {"less than", "cmd<", true}, - {"greater than", "cmd>", true}, - {"parentheses", "cmd()", true}, - {"braces", "cmd{}", true}, - {"brackets", "cmd[]", true}, - {"dollar sign", "cmd$", true}, - {"backtick", "cmd`", true}, - {"backslash", "cmd\\", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := containsShellMetacharacters(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestCommandExecutor_containsDangerousShellConstructs(t *testing.T) { - tests := []struct { - name string - input string - expected bool - }{ - {"normal argument", "file.txt", false}, - {"normal flag", "-la", false}, - {"command substitution", "$(whoami)", true}, - {"backtick substitution", "`whoami`", true}, - {"variable expansion", "${HOME}", true}, - {"logical AND", "cmd && cmd2", true}, - {"logical OR", "cmd || cmd2", true}, - {"command separator", "cmd; cmd2", true}, - {"pipe", "cmd | cmd2", true}, - {"redirection out", "cmd > file", true}, - {"redirection in", "cmd < file", true}, - {"append redirection", "cmd >> file", true}, - {"here document", "cmd << EOF", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := containsDangerousShellConstructs(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestCommandExecutor_executeSecureCommand_secure_vs_legacy(t *testing.T) { logger := zerolog.New(zerolog.NewTestWriter(t)) ctx := context.Background() @@ -208,11 +38,11 @@ func TestCommandExecutor_executeSecureCommand_secure_vs_legacy(t *testing.T) { command: "echo hello | cat", useShellExecution: false, expectError: true, - errorContains: "dangerous shell constructs", + errorContains: "command parsing failed", }, { name: "command with pipe - legacy mode allows", - command: "echo hello | cat", + command: "echo hello | cat", useShellExecution: true, expectError: false, }, @@ -221,7 +51,7 @@ func TestCommandExecutor_executeSecureCommand_secure_vs_legacy(t *testing.T) { command: "echo $(whoami)", useShellExecution: false, expectError: true, - errorContains: "dangerous shell constructs", + errorContains: "command parsing failed", }, { name: "command substitution - legacy mode allows", @@ -260,9 +90,9 @@ func TestCommandExecutor_vulnerability_prevention(t *testing.T) { // These are actual injection payloads that should be blocked vulnerabilityTests := []struct { - name string - command string - description string + name string + command string + description string }{ { name: "VULN.md example - obfuscated chmod", @@ -327,7 +157,7 @@ func TestCommandExecutor_vulnerability_prevention(t *testing.T) { _, err := executor.executeSecureCommand(ctx, vt.command, false) // These may fail due to actual command execution, but should not fail due to parsing if err != nil { - assert.NotContains(t, err.Error(), "shell metacharacters", + assert.NotContains(t, err.Error(), "shell metacharacters", "Legacy mode should not block based on metacharacters") assert.NotContains(t, err.Error(), "command parsing failed", "Legacy mode should not fail at parsing stage") diff --git a/go.mod b/go.mod index ce7a51b..74e705c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.13.1 ) require ( diff --git a/go.sum b/go.sum index 518a83b..0d0d61e 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -45,3 +47,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/handler_test.go b/handler_test.go index 7ed62b7..31f6005 100644 --- a/handler_test.go +++ b/handler_test.go @@ -16,11 +16,11 @@ func TestShellHandler_handle_secure_mode(t *testing.T) { ctx := context.Background() tests := []struct { - name string - config SecurityConfig - requestArgs map[string]interface{} - expectError bool - expectErrorText string + name string + config SecurityConfig + requestArgs map[string]interface{} + expectError bool + expectErrorText string }{ { name: "secure mode allows safe command", @@ -126,7 +126,7 @@ func TestShellHandler_vulnerability_prevention_integration(t *testing.T) { result, err := handler.handle(ctx, vulnerabilityRequest) require.NoError(t, err) - + // Should be blocked at validation stage assert.True(t, result.IsError, "Secure mode should block the injection attempt") }) @@ -145,7 +145,7 @@ func TestShellHandler_vulnerability_prevention_integration(t *testing.T) { result, err := handler.handle(ctx, vulnerabilityRequest) require.NoError(t, err) - + // This demonstrates the vulnerability - legacy mode allows dangerous commands // In a real attack, this would execute the obfuscated chmod t.Logf("Legacy mode result - IsError: %v", result.IsError) @@ -164,7 +164,7 @@ func TestShellHandler_vulnerability_prevention_integration(t *testing.T) { result, err := handler.handle(ctx, vulnerabilityRequest) require.NoError(t, err) - + // This demonstrates the vulnerability - legacy mode cannot detect obfuscated commands // even with keyword blocking, since "chmod" doesn't appear literally assert.False(t, result.IsError, "Legacy mode with blocks still vulnerable to obfuscation") @@ -236,9 +236,9 @@ func TestShellHandler_direct_security_tests(t *testing.T) { err := validator.validateCommand("echo $(rm -rf /)") assert.Error(t, err, "Should block command with shell metacharacters") - // Test parsing - would also fail in executor - _, _, err = executor.parseCommand("echo $(rm -rf /)") - assert.Error(t, err, "Should fail to parse command with shell metacharacters") + // Test parsing - the executor's unfurler rejects it as well + res := executor.unfurler.unfurl("echo $(rm -rf /)") + assert.False(t, res.Allowed, "Executor should reject command with command substitution") }) t.Run("legacy_execution_allows_injection", func(t *testing.T) { diff --git a/security.go b/security.go index c060fc6..66ba8da 100644 --- a/security.go +++ b/security.go @@ -11,14 +11,18 @@ import ( ) type SecurityValidator struct { - config SecurityConfig - logger zerolog.Logger + config SecurityConfig + logger zerolog.Logger + unfurler *commandUnfurler + policies *policySet } func newSecurityValidator(cfg SecurityConfig, logger zerolog.Logger) *SecurityValidator { v := &SecurityValidator{ - config: cfg, - logger: logger.With().Str("component", "security").Logger(), + config: cfg, + logger: logger.With().Str("component", "security").Logger(), + unfurler: newCommandUnfurler(), + policies: newDefaultPolicySet(), } v.warnOnInterpreters() return v @@ -86,36 +90,31 @@ func (v *SecurityValidator) validateCommand(command string) error { return v.validateExecutableCommand(command) } -// validateExecutableCommand validates commands using the secure executable allowlist approach +// validateExecutableCommand validates commands using the secure executable +// allowlist approach. The command is parsed into an AST and accepted only if it +// is a single, fully-literal simple command (structural whitelist); the +// resolved argv is then checked against the executable allowlist, per-tool +// argument policies, and the blocked_patterns/blocked_commands filters. func (v *SecurityValidator) validateExecutableCommand(command string) error { - command = strings.TrimSpace(command) - if command == "" { - return fmt.Errorf("empty command") + res := v.unfurler.unfurl(command) + if !res.Allowed { + return fmt.Errorf("command rejected in secure mode: %s", res.Reason) } - // Check for shell metacharacters first - reject commands that try to use shell features - if containsShellMetacharacters(command) { - return fmt.Errorf("command contains shell metacharacters (not allowed in secure mode): %s", command) - } - - // Check for dangerous shell constructs in the entire command - if containsDangerousShellConstructs(command) { - return fmt.Errorf("command contains dangerous shell constructs (not allowed in secure mode): %s", command) - } - - // Simple whitespace-based splitting to get the executable - parts := strings.Fields(command) - if len(parts) == 0 { - return fmt.Errorf("no command found") - } - - executable := parts[0] + executable := res.Argv[0] - // Check if the executable is in the allowlist for _, allowed := range v.config.AllowedExecutables { if v.matchesExecutable(executable, allowed) { - // Executable allowed - now apply blocked_patterns and blocked_commands - // to restrict specific arguments (e.g. block "git remote -v" while allowing git) + // An interpreter defeats the allowlist by executing whatever it is + // handed. Hard-deny it unless a per-tool policy governs its arguments. + if isInterpreterExecutable(filepath.Base(executable)) && !v.policies.governs(executable) { + return fmt.Errorf("executable '%s' is an interpreter and cannot be allowed in secure mode", executable) + } + if err := v.policies.check(res.Argv); err != nil { + return err + } + // Apply blocked_patterns and blocked_commands to restrict specific + // arguments (e.g. block "git remote -v" while allowing git). if err := v.checkBlockedPatternsAndCommands(command); err != nil { return err } @@ -160,33 +159,6 @@ func (v *SecurityValidator) matchesExecutable(executable, pattern string) bool { return false } -// containsShellMetacharacters checks if a string contains shell metacharacters -// that could be used for command injection -func containsShellMetacharacters(s string) bool { - // '!' is included because git interprets `-c alias.x=!cmd` as a shell alias, - // turning an allowlisted git into arbitrary command execution. - metachars := "|&;<>(){}[]$`\\!" - for _, char := range s { - if strings.ContainsRune(metachars, char) { - return true - } - } - return false -} - -// containsDangerousShellConstructs checks for potentially dangerous shell constructs -func containsDangerousShellConstructs(s string) bool { - dangerous := []string{ - "$(", "`", "${", "&&", "||", ";", "|", ">", "<", ">>", "<<", "&", "=!", - } - for _, construct := range dangerous { - if strings.Contains(s, construct) { - return true - } - } - return false -} - // checkBlockedPatternsAndCommands checks if the command matches any blocked pattern or contains blocked keywords. // Used by both secure mode (validateExecutableCommand) and legacy mode (validateLegacyCommand). func (v *SecurityValidator) checkBlockedPatternsAndCommands(command string) error { diff --git a/security_test.go b/security_test.go index b1082bd..82b648e 100644 --- a/security_test.go +++ b/security_test.go @@ -138,6 +138,7 @@ func TestSecurityValidator_validateCommand(t *testing.T) { errorContains: "blocked keyword", }, // GHSA-74hp-mggr-hv58: git shell-alias bypass via `-c alias.x=!cmd`. + // Now caught by the per-tool git argument policy, not metacharacters. { name: "secure mode blocks git shell-alias injection", config: SecurityConfig{ @@ -147,7 +148,7 @@ func TestSecurityValidator_validateCommand(t *testing.T) { }, command: "git -c alias.pwn=!touch pwn /tmp/target", expectError: true, - errorContains: "shell metacharacters", + errorContains: "config injection", }, // GHSA-3x77-wg38-92r3: an interpreter still has to be in the allowlist // to be reachable; bash absent from the list is rejected outright. @@ -430,11 +431,10 @@ func TestSecurityValidator_vulnerability_scenarios(t *testing.T) { err := validator.validateCommand(payload.command) if err != nil { assert.Error(t, err, "Secure mode should block: %s", payload.description) - // Check for either error message since they both indicate blocking + // Any of these messages indicates the command was blocked. errorMsg := err.Error() shouldContainOne := strings.Contains(errorMsg, "not in allowed list") || - strings.Contains(errorMsg, "shell metacharacters") || - strings.Contains(errorMsg, "dangerous shell constructs") + strings.Contains(errorMsg, "rejected in secure mode") assert.True(t, shouldContainOne, "Error should indicate blocking: %s", errorMsg) } else { t.Errorf("Secure mode should block: %s", payload.description) diff --git a/unfurl.go b/unfurl.go new file mode 100644 index 0000000..513a756 --- /dev/null +++ b/unfurl.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "strings" + "sync" + + "mvdan.cc/sh/v3/syntax" +) + +// unfurlResult is the outcome of structurally analysing one command string. +// Argv is valid only when Allowed is true; otherwise Reason explains the reject. +type unfurlResult struct { + Argv []string + Allowed bool + Reason string +} + +// commandUnfurler parses a command string into a shell AST and decides whether +// it is a single, fully-literal simple command, extracting its resolved argv. +// syntax.Parser is reusable but not safe for concurrent use, so parsers are +// pooled: each call borrows one and returns it, keeping allocations low without +// sharing state across goroutines. +type commandUnfurler struct { + parsers sync.Pool +} + +func newCommandUnfurler() *commandUnfurler { + return &commandUnfurler{ + parsers: sync.Pool{ + New: func() any { + return syntax.NewParser(syntax.Variant(syntax.LangBash)) + }, + }, + } +} + +// unfurl reports unsafe input via unfurlResult.Allowed/Reason rather than an +// error. The structural whitelist is default-deny: only a single simple command +// whose every argument is a constant literal is allowed. The bash variant is +// used so the widest set of dynamic constructs is recognised and rejected. +// +// The resolved argv holds independent string copies (built via strings.Builder), +// so the borrowed parser is safe to return to the pool on return. +func (u *commandUnfurler) unfurl(command string) unfurlResult { + if strings.TrimSpace(command) == "" { + return unfurlResult{Reason: "empty command"} + } + + parser := u.parsers.Get().(*syntax.Parser) + defer u.parsers.Put(parser) + + file, err := parser.Parse(strings.NewReader(command), "") + if err != nil { + return unfurlResult{Reason: fmt.Sprintf("unparseable command: %v", err)} + } + if len(file.Stmts) != 1 { + return unfurlResult{Reason: "command must be exactly one statement"} + } + + stmt := file.Stmts[0] + if stmt.Background || stmt.Coprocess || stmt.Negated || len(stmt.Redirs) > 0 { + return unfurlResult{Reason: "background, coprocess, negation and redirection are not allowed"} + } + + call, ok := stmt.Cmd.(*syntax.CallExpr) + if !ok { + return unfurlResult{Reason: "only a single simple command is allowed (no pipelines, lists, subshells or control flow)"} + } + if len(call.Assigns) > 0 { + return unfurlResult{Reason: "inline environment assignments are not allowed"} + } + + argv := make([]string, 0, len(call.Args)) + for _, word := range call.Args { + // Surface brace expansion ({a,b}) as a dynamic BraceExp part so it is + // rejected like any other expansion. + syntax.SplitBraces(word) + lit, ok := literalWord(word) + if !ok { + return unfurlResult{Reason: "arguments must be constant literals (no expansion, command substitution or glob)"} + } + argv = append(argv, lit) + } + if len(argv) == 0 { + return unfurlResult{Reason: "empty command"} + } + + return unfurlResult{Argv: argv, Allowed: true} +} + +// literalWord returns the constant value of a word, or ok=false if any part is +// dynamic (parameter/command/process/arithmetic expansion, brace expansion, +// extended glob, ANSI-C quoting) or an unquoted glob. +func literalWord(word *syntax.Word) (string, bool) { + return literalParts(word.Parts, false) +} + +// literalParts concatenates constant word parts. When quoted is false, an +// unquoted literal containing a glob metacharacter (*, ?, [) is treated as +// dynamic; inside double quotes those characters are inert. +func literalParts(parts []syntax.WordPart, quoted bool) (string, bool) { + var sb strings.Builder + for _, part := range parts { + switch p := part.(type) { + case *syntax.Lit: + if !quoted && strings.ContainsAny(p.Value, "*?[") { + return "", false + } + sb.WriteString(p.Value) + case *syntax.SglQuoted: + // $'...' (ANSI-C) can encode arbitrary bytes via escapes; reject it + // rather than guess at decoding the parser leaves untouched. + if p.Dollar { + return "", false + } + sb.WriteString(p.Value) + case *syntax.DblQuoted: + inner, ok := literalParts(p.Parts, true) + if !ok { + return "", false + } + sb.WriteString(inner) + default: + return "", false + } + } + return sb.String(), true +} diff --git a/unfurl_test.go b/unfurl_test.go new file mode 100644 index 0000000..eba15c7 --- /dev/null +++ b/unfurl_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandUnfurler_unfurl(t *testing.T) { + tests := []struct { + name string + command string + wantAllowed bool + wantArgv []string + }{ + { + name: "simple command", + command: "ls -la", + wantAllowed: true, + wantArgv: []string{"ls", "-la"}, + }, + { + name: "multiple args", + command: "grep -n test file.txt", + wantAllowed: true, + wantArgv: []string{"grep", "-n", "test", "file.txt"}, + }, + { + name: "surrounding whitespace trimmed", + command: " echo hello world ", + wantAllowed: true, + wantArgv: []string{"echo", "hello", "world"}, + }, + { + name: "single command no args", + command: "pwd", + wantAllowed: true, + wantArgv: []string{"pwd"}, + }, + { + name: "quoted metacharacter is an inert literal", + command: "echo ';'", + wantAllowed: true, + wantArgv: []string{"echo", ";"}, + }, + { + name: "double-quoted argument keeps its space", + command: `echo "a b"`, + wantAllowed: true, + wantArgv: []string{"echo", "a b"}, + }, + { + name: "glob inside double quotes is literal", + command: `echo "a*b"`, + wantAllowed: true, + wantArgv: []string{"echo", "a*b"}, + }, + { + name: "empty command", + command: "", + wantAllowed: false, + }, + { + name: "whitespace only", + command: " ", + wantAllowed: false, + }, + { + name: "pipeline", + command: "ls | grep test", + wantAllowed: false, + }, + { + name: "command list with semicolon", + command: "echo hello; rm file", + wantAllowed: false, + }, + { + name: "logical and", + command: "echo a && rm b", + wantAllowed: false, + }, + { + name: "command substitution", + command: "echo $(whoami)", + wantAllowed: false, + }, + { + name: "backtick substitution", + command: "echo `whoami`", + wantAllowed: false, + }, + { + name: "parameter expansion braces", + command: "echo ${HOME}", + wantAllowed: false, + }, + { + name: "parameter expansion bare", + command: "echo $HOME", + wantAllowed: false, + }, + { + name: "redirection", + command: "echo hello > file.txt", + wantAllowed: false, + }, + { + name: "background", + command: "sleep 10 &", + wantAllowed: false, + }, + { + name: "unquoted glob", + command: "cat *", + wantAllowed: false, + }, + { + name: "brace expansion", + command: "echo a{b,c}", + wantAllowed: false, + }, + { + name: "subshell", + command: "(ls)", + wantAllowed: false, + }, + { + name: "inline assignment", + command: "FOO=bar ls", + wantAllowed: false, + }, + { + name: "negation", + command: "! ls", + wantAllowed: false, + }, + { + name: "control flow", + command: "if true; then ls; fi", + wantAllowed: false, + }, + { + name: "ansi-c quoting is rejected", + command: `echo $'\143hmod'`, + wantAllowed: false, + }, + } + + unfurler := newCommandUnfurler() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res := unfurler.unfurl(tt.command) + + if tt.wantAllowed { + require.True(t, res.Allowed, "expected allowed, got reason: %s", res.Reason) + assert.Equal(t, tt.wantArgv, res.Argv) + } else { + require.False(t, res.Allowed) + assert.NotEmpty(t, res.Reason) + } + }) + } +}