Files
ARTEX/intercept/argv.go
T

245 lines
7.7 KiB
Go

package intercept
import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"unicode"
"mvdan.cc/sh/v3/syntax"
)
// argvArity is the code-owned arity table (a port of cyberstrike's BashArity):
// how many leading tokens of a command form its human prefix, the program plus
// its subcommands. The longest entry that matches the start of the argv wins;
// a program that is not listed takes 1. It derives a prefix and nothing else:
// the security checks are in ParseSimpleCommand and ArgvPrefix.
var argvArity = map[string]int{
"cat": 1, "cd": 1, "ls": 1, "rm": 1, "grep": 1, "head": 1, "tail": 1,
"echo": 1, "pwd": 1, "find": 1, "curl": 1, "wget": 1, "nmap": 1,
"git": 2, "npm": 2, "docker": 2, "kubectl": 2, "terraform": 2,
"npm run": 3, "docker compose": 3, "kubectl rollout": 3, "git config": 3,
"gcloud": 3, "gh": 3, "aws": 3,
}
// argvPrefixLen returns the number of leading tokens that form the prefix.
func argvPrefixLen(tokens []string) int {
for n := len(tokens); n > 0; n-- {
if a, ok := argvArity[strings.Join(tokens[:n], " ")]; ok {
return min(a, len(tokens))
}
}
return min(1, len(tokens))
}
// ArgvPrefixOf returns the prefix of an argv: the program and its subcommands.
func ArgvPrefixOf(tokens []string) []string { return tokens[:argvPrefixLen(tokens)] }
// argvWord is one word of a parsed command. Literal reports that the word is
// fixed text: no expansion of any kind (no glob, tilde, brace, escape or
// parameter), so Value is exactly what the program receives.
type argvWord struct {
Value string
Literal bool
}
var (
errNotSimple = errors.New("not a single simple command")
errBackground = errors.New("the command runs in the background")
errRedirect = errors.New("the command has a redirection")
errAssign = errors.New("the command has a prefix assignment")
errWrapper = errors.New("the program is a wrapper that runs its arguments as a command")
)
// argvWrappers are programs that run their arguments as another command: the
// prefix of "command rm -rf /" would be ["command"], so no argv rule matches them.
var argvWrappers = map[string]bool{"command": true, "builtin": true, "env": true, "exec": true, "eval": true}
// ParseSimpleCommand parses a shell command line and accepts exactly one
// simple command: no assignment, redirection, pipeline, list (&&, ||, ;),
// subshell, background job or compound command, no wrapper program (command,
// builtin, env, exec, eval), and no command substitution, process
// substitution, parameter or arithmetic expansion or extended glob anywhere in
// the line. Arguments may hold plain globs and a tilde (they run nothing);
// such a word is returned with Literal false. The grammar is Bash, the shell
// the Bash tool runs the command with (norma tool/task.go shellCmd: bash -c).
func ParseSimpleCommand(command string) ([]argvWord, error) {
f, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "")
if err != nil {
return nil, fmt.Errorf("the command does not parse: %w", err)
}
if len(f.Stmts) != 1 {
return nil, errNotSimple
}
st := f.Stmts[0]
switch {
case st.Background || st.Coprocess || st.Disown:
return nil, errBackground
case len(st.Redirs) > 0:
return nil, errRedirect
}
call, ok := st.Cmd.(*syntax.CallExpr)
if !ok || st.Negated {
return nil, errNotSimple
}
if len(call.Assigns) > 0 {
return nil, errAssign
}
if len(call.Args) == 0 {
return nil, errNotSimple
}
var bad error
syntax.Walk(f, func(n syntax.Node) bool {
switch n.(type) {
case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob, *syntax.BraceExp:
bad = errors.New("the command contains an expansion or a substitution")
}
return bad == nil
})
if bad != nil {
return nil, bad
}
words := make([]argvWord, 0, len(call.Args))
for _, w := range call.Args {
word, ok := argvWordOf(w)
if !ok {
return nil, errors.New("the command contains an expansion or a substitution")
}
words = append(words, word)
}
if argvWrappers[path.Base(words[0].Value)] {
return nil, errWrapper
}
return words, nil
}
// argvWordOf reads one word. ok is false for a part that is not text.
func argvWordOf(w *syntax.Word) (argvWord, bool) {
var sb strings.Builder
literal := true
for _, part := range w.Parts {
switch p := part.(type) {
case *syntax.Lit:
// Unquoted text: a glob, a tilde, a brace or an escape makes the word
// differ from its text, so it is not literal.
if strings.ContainsAny(p.Value, "*?[]{}~\\") {
literal = false
}
sb.WriteString(p.Value)
case *syntax.SglQuoted:
if p.Dollar {
literal = false
}
sb.WriteString(p.Value)
case *syntax.DblQuoted:
if p.Dollar {
literal = false
}
for _, q := range p.Parts {
lit, ok := q.(*syntax.Lit)
if !ok {
return argvWord{}, false
}
if strings.Contains(lit.Value, "\\") {
literal = false
}
sb.WriteString(lit.Value)
}
default:
return argvWord{}, false
}
}
return argvWord{Value: sb.String(), Literal: literal}, true
}
// ArgvPrefix parses a command and derives its prefix with the arity table. It
// refuses (returns an error) when the command is not one simple command, when
// a prefix token is not literal, and when a prefix token is a flag: a flag
// before a subcommand ("git -C /repo push") yields no prefix at all, rather
// than an invented one.
func ArgvPrefix(command string) ([]string, error) {
words, err := ParseSimpleCommand(command)
if err != nil {
return nil, err
}
tokens := make([]string, len(words))
for i, w := range words {
tokens[i] = w.Value
if !w.Literal {
tokens[i] = "\x00" // never a key of the arity table
}
}
n := argvPrefixLen(tokens)
for _, w := range words[:n] {
if !w.Literal {
return nil, errors.New("a token of the prefix is not literal text")
}
if err := checkArgvToken(w.Value); err != nil {
return nil, err
}
}
return tokens[:n], nil
}
// argvMaxTokens bounds the length of an argv pattern.
const argvMaxTokens = 8
// checkArgvToken applies the per-token pattern rules.
func checkArgvToken(tok string) error {
if tok == "" {
return errors.New("a token is empty")
}
if strings.HasPrefix(tok, "-") {
return fmt.Errorf("token %q is a flag; a prefix holds the program and its subcommands only", tok)
}
if strings.ContainsAny(tok, ";&|><`$()") || strings.IndexFunc(tok, unicode.IsSpace) >= 0 {
return fmt.Errorf("token %q contains a shell metacharacter or a space", tok)
}
return nil
}
// ParseArgvPattern validates the pattern of an argv rule: a JSON array of 1 to
// 8 tokens, none empty, none starting with '-', none holding a shell
// metacharacter (; & | > < ` $ ( )) or whitespace.
func ParseArgvPattern(pattern string) ([]string, error) {
var tokens []string
if err := json.Unmarshal([]byte(pattern), &tokens); err != nil {
return nil, fmt.Errorf(`an argv pattern is a JSON array of strings, for example ["git","push"]: %w`, err)
}
if len(tokens) < 1 || len(tokens) > argvMaxTokens {
return nil, fmt.Errorf("an argv pattern has 1 to %d tokens, got %d", argvMaxTokens, len(tokens))
}
for _, tok := range tokens {
if err := checkArgvToken(tok); err != nil {
return nil, err
}
}
return tokens, nil
}
// argvRuleMatches reports whether a Bash call's command derives exactly the
// prefix tokens. Flags after the prefix are not constrained.
func argvRuleMatches(tokens []string, toolName string, input []byte) bool {
if toolName != "Bash" || len(tokens) == 0 {
return false
}
var in struct {
Command string `json:"command"`
}
if json.Unmarshal(input, &in) != nil {
return false
}
prefix, err := ArgvPrefix(in.Command)
if err != nil || len(prefix) != len(tokens) {
return false
}
for i := range prefix {
if prefix[i] != tokens[i] {
return false
}
}
return true
}