This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/command.go
2025-12-25 20:47:11 -08:00

301 lines
7.0 KiB
Go

package sandbox
import (
"fmt"
"path/filepath"
"strings"
"github.com/Use-Tusk/fence/internal/config"
)
// CommandBlockedError is returned when a command is blocked by policy.
type CommandBlockedError struct {
Command string
BlockedPrefix string
IsDefault bool
}
func (e *CommandBlockedError) Error() string {
if e.IsDefault {
return fmt.Sprintf("command blocked by default sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix)
}
return fmt.Sprintf("command blocked by sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix)
}
// CheckCommand checks if a command is allowed by the configuration.
// It parses shell command strings and checks each sub-command in pipelines/chains.
// Returns nil if allowed, or CommandBlockedError if blocked.
func CheckCommand(command string, cfg *config.Config) error {
if cfg == nil {
cfg = config.Default()
}
subCommands := parseShellCommand(command)
for _, subCmd := range subCommands {
if err := checkSingleCommand(subCmd, cfg); err != nil {
return err
}
}
return nil
}
// checkSingleCommand checks a single command (not a chain) against the policy.
func checkSingleCommand(command string, cfg *config.Config) error {
command = strings.TrimSpace(command)
if command == "" {
return nil
}
// Normalize the command for matching
normalized := normalizeCommand(command)
// Check if explicitly allowed (takes precedence over deny)
for _, allow := range cfg.Command.Allow {
if matchesPrefix(normalized, allow) {
return nil
}
}
// Check user-defined deny list
for _, deny := range cfg.Command.Deny {
if matchesPrefix(normalized, deny) {
return &CommandBlockedError{
Command: command,
BlockedPrefix: deny,
IsDefault: false,
}
}
}
// Check default deny list (if enabled)
if cfg.Command.UseDefaultDeniedCommands() {
for _, deny := range config.DefaultDeniedCommands {
if matchesPrefix(normalized, deny) {
return &CommandBlockedError{
Command: command,
BlockedPrefix: deny,
IsDefault: true,
}
}
}
}
return nil
}
// parseShellCommand splits a shell command string into individual commands.
// Handles: pipes (|), logical operators (&&, ||), semicolons (;), and subshells.
func parseShellCommand(command string) []string {
var commands []string
var current strings.Builder
var inSingleQuote, inDoubleQuote bool
var parenDepth int
runes := []rune(command)
for i := 0; i < len(runes); i++ {
c := runes[i]
// Handle quotes
if c == '\'' && !inDoubleQuote {
inSingleQuote = !inSingleQuote
current.WriteRune(c)
continue
}
if c == '"' && !inSingleQuote {
inDoubleQuote = !inDoubleQuote
current.WriteRune(c)
continue
}
// Skip splitting inside quotes
if inSingleQuote || inDoubleQuote {
current.WriteRune(c)
continue
}
// Handle parentheses (subshells)
if c == '(' {
parenDepth++
current.WriteRune(c)
continue
}
if c == ')' {
parenDepth--
current.WriteRune(c)
continue
}
// Skip splitting inside subshells
if parenDepth > 0 {
current.WriteRune(c)
continue
}
// Handle shell operators
switch c {
case '|':
// Check for || (or just |)
if i+1 < len(runes) && runes[i+1] == '|' {
// ||
if s := strings.TrimSpace(current.String()); s != "" {
commands = append(commands, s)
}
current.Reset()
i++ // Skip second |
} else {
// Just a pipe
if s := strings.TrimSpace(current.String()); s != "" {
commands = append(commands, s)
}
current.Reset()
}
case '&':
// Check for &&
if i+1 < len(runes) && runes[i+1] == '&' {
if s := strings.TrimSpace(current.String()); s != "" {
commands = append(commands, s)
}
current.Reset()
i++ // Skip second &
} else {
// Background operator - keep in current command
current.WriteRune(c)
}
case ';':
if s := strings.TrimSpace(current.String()); s != "" {
commands = append(commands, s)
}
current.Reset()
default:
current.WriteRune(c)
}
}
// Add remaining command
if s := strings.TrimSpace(current.String()); s != "" {
commands = append(commands, s)
}
// Handle nested shell invocations like "bash -c 'git push'"
var expanded []string
for _, cmd := range commands {
expanded = append(expanded, expandShellInvocation(cmd)...)
}
return expanded
}
// expandShellInvocation detects patterns like "bash -c 'cmd'" or "sh -c 'cmd'"
// and extracts the inner command for checking.
func expandShellInvocation(command string) []string {
command = strings.TrimSpace(command)
if command == "" {
return nil
}
tokens := tokenizeCommand(command)
if len(tokens) < 3 {
return []string{command}
}
// Check for shell -c pattern
shell := filepath.Base(tokens[0])
isShell := shell == "sh" || shell == "bash" || shell == "zsh" ||
shell == "ksh" || shell == "dash" || shell == "fish"
if !isShell {
return []string{command}
}
// Look for -c flag (could be combined with other flags like -lc, -ic, etc.)
for i := 1; i < len(tokens)-1; i++ {
flag := tokens[i]
// Check for -c, -lc, -ic, -ilc, etc. (any flag containing 'c')
if strings.HasPrefix(flag, "-") && strings.Contains(flag, "c") {
// Next token is the command string
innerCmd := tokens[i+1]
// Recursively parse the inner command
innerCommands := parseShellCommand(innerCmd)
// Return both the outer command and inner commands
// (we check both for safety)
result := []string{command}
result = append(result, innerCommands...)
return result
}
}
return []string{command}
}
// tokenizeCommand splits a command string into tokens, respecting quotes.
func tokenizeCommand(command string) []string {
var tokens []string
var current strings.Builder
var inSingleQuote, inDoubleQuote bool
for _, c := range command {
switch {
case c == '\'' && !inDoubleQuote:
inSingleQuote = !inSingleQuote
case c == '"' && !inSingleQuote:
inDoubleQuote = !inDoubleQuote
case (c == ' ' || c == '\t') && !inSingleQuote && !inDoubleQuote:
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
default:
current.WriteRune(c)
}
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
// normalizeCommand normalizes a command for matching.
// - Strips leading path from the command (e.g., /usr/bin/git -> git)
// - Collapses multiple spaces
func normalizeCommand(command string) string {
command = strings.TrimSpace(command)
if command == "" {
return ""
}
tokens := tokenizeCommand(command)
if len(tokens) == 0 {
return command
}
tokens[0] = filepath.Base(tokens[0])
return strings.Join(tokens, " ")
}
// matchesPrefix checks if a command matches a blocked prefix.
// The prefix matches if the command starts with the prefix followed by
// end of string, a space, or other argument.
func matchesPrefix(command, prefix string) bool {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return false
}
prefix = normalizeCommand(prefix)
if command == prefix {
return true
}
if strings.HasPrefix(command, prefix+" ") {
return true
}
return false
}