Files
ARTEX-chixuweihu/intercept/shell_static.go
T

547 lines
15 KiB
Go

package intercept
import (
"os"
"path/filepath"
"strings"
"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/syntax"
)
const maxInlineVariants = 64
const maxInlineParses = 256
// Inline code is a shell string, not a filename. Accept escaped source text
// and simple quoted parameters proven by finite, unmodified enclosing loops.
// Expansion has no host environment, subprocess or filesystem callbacks.
func inlineShellWords(w *syntax.Word, bindings map[string][]string) ([]string, bool) {
if w == nil {
return nil, false
}
var names []string
seen := map[string]bool{}
var validParts func([]syntax.WordPart, bool) bool
validParts = func(parts []syntax.WordPart, quoted bool) bool {
for _, part := range parts {
switch p := part.(type) {
case *syntax.Lit:
if !quoted && strings.ContainsAny(p.Value, "*?[]~") {
return false
}
case *syntax.SglQuoted:
case *syntax.DblQuoted:
if !validParts(p.Parts, true) {
return false
}
case *syntax.ParamExp:
if !quoted || p.Excl || p.Length || p.Width || p.Index != nil || p.Slice != nil || p.Repl != nil || p.Names != 0 || p.Exp != nil || len(bindings[p.Param.Value]) == 0 {
return false
}
if !seen[p.Param.Value] {
names = append(names, p.Param.Value)
seen[p.Param.Value] = true
}
default:
return false
}
}
return true
}
if !validParts(w.Parts, false) {
return nil, false
}
envs := [][]string{nil}
for _, name := range names {
if len(envs) > maxInlineVariants/len(bindings[name]) {
return nil, false
}
var next [][]string
for _, env := range envs {
for _, value := range bindings[name] {
pairs := append([]string(nil), env...)
next = append(next, append(pairs, name+"="+value))
}
}
envs = next
}
var words []string
for _, env := range envs {
fields, err := expand.Fields(&expand.Config{Env: expand.ListEnviron(env...), NoUnset: true}, w)
if err != nil || len(fields) != 1 || len(fields[0]) > maxReviewScriptBytes {
return nil, false
}
words = append(words, fields[0])
}
return words, true
}
// Avoid guessing loop values in the presence of assignments or commands which
// can mutate shell variables. The analysis intentionally prefers incomplete
// capture to interpreting eval, source, arithmetic or arbitrary builtins.
func loopBindingWrites(tree *syntax.File, constants map[string]shellConstant) (map[string]bool, bool) {
writes := map[string]bool{}
unknown := false
loops := map[string]int{}
var parents []syntax.Node
syntax.Walk(tree, func(node syntax.Node) bool {
if node == nil {
parent := parents[len(parents)-1]
parents = parents[:len(parents)-1]
if loop, ok := parent.(*syntax.ForClause); ok {
if iter, ok := loop.Loop.(*syntax.WordIter); ok {
loops[iter.Name.Value]--
}
}
return true
}
parents = append(parents, node)
switch n := node.(type) {
case *syntax.Assign:
if n.Name != nil {
writes[n.Name.Value] = true
}
case *syntax.ForClause:
if iter, ok := n.Loop.(*syntax.WordIter); ok {
if loops[iter.Name.Value] > 0 {
writes[iter.Name.Value] = true
}
loops[iter.Name.Value]++
}
case *syntax.FuncDecl:
unknown = true
case *syntax.ArithmExp, *syntax.ArithmCmd, *syntax.CStyleLoop:
unknown = true
case *syntax.ParamExp:
if n.Exp != nil && (n.Exp.Op == syntax.AssignUnset || n.Exp.Op == syntax.AssignUnsetOrNull) {
writes[n.Param.Value] = true
}
case *syntax.CallExpr:
if len(n.Args) == 0 {
break
}
resolved, known := constantCallArgs(n.Args, constants)
if !known {
unknown = true
return true
}
args, name, ok := unwrapScriptCall(resolved)
if !ok {
unknown = true
return true
}
switch name {
case "eval", "source", ".", "read", "readarray", "mapfile", "unset", "declare", "typeset", "local", "export", "let", "getopts", "trap", "alias":
unknown = true
case "printf":
if len(args) > 1 {
values, known := inlineShellWords(args[1], nil)
if !known || strings.HasPrefix(values[0], "-v") {
unknown = true
}
}
}
}
return true
})
return writes, unknown
}
func forLoopBindings(loop *syntax.ForClause, bindings map[string][]string, writes map[string]bool, unknown bool) map[string][]string {
next := make(map[string][]string, len(bindings)+1)
for name, values := range bindings {
next[name] = values
}
iter, ok := loop.Loop.(*syntax.WordIter)
if !ok {
return next
}
name := iter.Name.Value
delete(next, name)
if unknown || writes[name] || loop.Select || !iter.InPos.IsValid() || len(iter.Items) == 0 || len(iter.Items) > maxInlineVariants {
return next
}
var values []string
for _, item := range iter.Items {
value, known := staticWord(item)
if !known {
return next
}
values = append(values, value)
}
next[name] = values
return next
}
// Unwrap only options whose argument layout is known. Treat unsupported layouts
// as opaque instead of mistaking an option's value for the executed command.
func unwrapScriptCall(args []*syntax.Word) ([]*syntax.Word, string, bool) {
for len(args) > 0 {
name, ok := staticCommandName(args[0])
if !ok {
return args, "", false
}
wrapper := filepath.Base(name)
switch wrapper {
case "env", "sudo", "exec", "timeout", "nohup", "command", "builtin", "time":
default:
return args, name, true
}
original := args
if len(args) == 1 && wrapper != "timeout" {
return args, name, true
}
args = args[1:]
for len(args) > 0 {
value, known := staticWord(args[0])
if !known {
return args, "", false
}
if value == "--" {
args = args[1:]
break
}
if wrapper == "env" && strings.Contains(value, "=") && !strings.HasPrefix(value, "-") {
args = args[1:]
continue
}
if !strings.HasPrefix(value, "-") || value == "-" {
break
}
consume := 1
switch wrapper {
case "timeout":
switch value {
case "-s", "--signal", "-k", "--kill-after":
consume = 2
case "--foreground", "--preserve-status", "--verbose", "-v":
default:
if !strings.HasPrefix(value, "--signal=") && !strings.HasPrefix(value, "--kill-after=") {
return args, "", false
}
}
case "time":
switch value {
case "-f", "--format", "-o", "--output":
consume = 2
case "-a", "--append", "-p", "--portability", "-q", "--quiet", "-v", "--verbose":
case "--help", "--version":
return original, name, true
default:
if !strings.HasPrefix(value, "--format=") && !strings.HasPrefix(value, "--output=") && !strings.HasPrefix(value, "-f") && !strings.HasPrefix(value, "-o") {
return args, "", false
}
}
case "sudo":
switch value {
case "-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-T", "--command-timeout":
consume = 2
case "-n", "--non-interactive", "-E", "--preserve-env", "-H", "--set-home", "-S", "--stdin", "-k", "--reset-timestamp":
default:
if !strings.HasPrefix(value, "--user=") && !strings.HasPrefix(value, "--group=") && !strings.HasPrefix(value, "--preserve-env=") {
return args, "", false
}
}
case "env":
switch value {
case "-u", "--unset":
consume = 2
case "-i", "--ignore-environment", "-0", "--null", "-v", "--debug":
default:
if !strings.HasPrefix(value, "--unset=") {
return args, "", false
}
}
case "exec":
if value == "-a" {
consume = 2
} else if value != "-c" && value != "-l" {
return args, "", false
}
case "command":
if value == "-v" || value == "-V" {
return original, name, true
}
if value != "-p" {
return args, "", false
}
default:
return args, "", false
}
if len(args) < consume {
return args, "", false
}
args = args[consume:]
}
if len(args) == 0 && wrapper != "timeout" {
return original, name, true
}
if wrapper == "timeout" {
if len(args) == 0 {
return args, "", false
}
if _, known := staticWord(args[0]); !known {
return args, "", false
}
args = args[1:]
}
}
return args, "", false
}
// Capture literal files created by a top-level `cat > file <<'EOF'` before a
// later interpreter call. These files do not exist yet during approval, and an
// existing on-disk version would be stale. Other writes remain explicitly opaque.
func generatedScriptWrites(stmt *syntax.Stmt, args []*syntax.Word, dir string, topLevel bool, generated map[string]scriptReference, allowSed bool) []scriptReference {
var writes []scriptReference
var input *syntax.Redirect
appendOutput := false
inputs, outputs := 0, 0
for _, r := range stmt.Redirs {
stdin := r.N == nil || r.N.Value == "0"
stdout := r.N == nil || r.N.Value == "1"
switch r.Op {
case syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc, syntax.RdrIn, syntax.DplIn:
if stdin {
inputs++
input = r
}
case syntax.RdrOut, syntax.ClbOut, syntax.AppOut, syntax.RdrAll:
if !stdout && r.Op != syntax.RdrAll {
continue
}
outputs++
appendOutput = appendOutput || r.Op == syntax.AppOut
path, known := staticWord(r.Word)
if !known || path == "" {
continue
}
if !filepath.IsAbs(path) {
path = filepath.Join(dir, path)
}
if filepath.IsAbs(path) || dir != "" {
absolute, err := filepath.Abs(path)
if err != nil {
continue
}
path = absolute
}
writes = append(writes, scriptReference{path: path, generated: true, problem: "命令内脚本写入内容或执行路径不确定"})
}
}
if allowSed && len(writes) == 1 && outputs == 1 && inputs == 0 && !appendOutput && topLevel && !stmt.Background {
content, problem, known := generatedSedSnapshot(stmt, args, dir, writes[0].path, generated)
if known {
writes[0].problem = ""
writes[0].inline = true
writes[0].content = content
return writes
}
if problem != "" {
writes[0].problem = problem
}
}
if len(writes) != 1 || outputs != 1 || inputs != 1 || input == nil || (input.Op != syntax.Hdoc && input.Op != syntax.DashHdoc) || !topLevel || stmt.Background || len(args) != 1 {
return writes
}
name, known := staticCommandName(args[0])
if !known || filepath.Base(name) != "cat" {
return writes
}
// Only a cat's stderr duplication is transparent. Replacing stdout (>&2),
// closing descriptors, or combining output from other producers is opaque.
for _, r := range stmt.Redirs {
if r.Op == syntax.RdrAll {
return writes
}
if r.Op == syntax.DplOut {
target, known := staticWord(r.Word)
if r.N == nil || r.N.Value != "2" || !known || target != "1" {
return writes
}
}
}
quoted := false
syntax.Walk(input.Word, func(node syntax.Node) bool {
switch n := node.(type) {
case *syntax.SglQuoted, *syntax.DblQuoted:
quoted = true
case *syntax.Lit:
quoted = quoted || strings.Contains(n.Value, "\\")
}
return true
})
body := input.Hdoc
if body == nil {
body = &syntax.Word{}
}
var content string
if quoted {
var b strings.Builder
for _, part := range body.Parts {
lit, ok := part.(*syntax.Lit)
if !ok {
return writes
}
b.WriteString(lit.Value)
}
content = b.String()
} else {
// An unquoted heredoc performs shell expansion. Accept literal bodies
// only, and use heredoc quote/escape semantics without any execution.
for _, part := range body.Parts {
if _, ok := part.(*syntax.Lit); !ok {
return writes
}
}
var err error
content, err = expand.Document(&expand.Config{NoUnset: true}, body)
if err != nil {
return writes
}
}
if input.Op == syntax.DashHdoc {
lines := strings.Split(content, "\n")
for i := range lines {
lines[i] = strings.TrimLeft(lines[i], "\t")
}
content = strings.Join(lines, "\n")
}
if appendOutput {
previous := generated[writes[0].path]
if previous.path != "" {
if !previous.inline || previous.problem != "" {
return writes
}
content = previous.content + content
} else {
if !filepath.IsAbs(writes[0].path) {
return writes
}
old, err := readScript(writes[0].path)
if err != nil && !os.IsNotExist(err) {
writes[0].problem = "脚本追加前内容无法读取:" + err.Error()
return writes
}
content = string(old) + content
}
}
writes[0].problem = ""
writes[0].inline = true
writes[0].content = content
return writes
}
// These preparations have a statically known success path and do not transform
// a following quoted heredoc. Arbitrary guards, pipelines and transforms are
// intentionally excluded.
func staticScriptPreparation(stmt *syntax.Stmt) bool {
if stmt.Background {
return false
}
if binary, ok := stmt.Cmd.(*syntax.BinaryCmd); ok {
return binary.Op == syntax.AndStmt && staticScriptPreparation(binary.X) && staticScriptPreparation(binary.Y)
}
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok {
return false
}
if len(call.Args) == 0 {
if len(call.Assigns) == 0 {
return false
}
for _, assign := range call.Assigns {
if assign.Name == nil || assign.Array != nil || assign.Index != nil {
return false
}
if _, known := staticWord(assign.Value); !known {
return false
}
}
return true
}
name, known := staticCommandName(call.Args[0])
if !known {
return false
}
if name == "cd" && len(call.Args) == 2 {
path, known := staticWord(call.Args[1])
return known && path != "-"
}
if name != "mkdir" || len(call.Args) < 3 {
return false
}
flag, known := staticWord(call.Args[1])
if !known || (flag != "-p" && flag != "--parents") {
return false
}
for _, arg := range call.Args[2:] {
if _, known := staticWord(arg); !known {
return false
}
}
return true
}
// Shell quote removal for literal words, including escaped spaces and ANSI-C
// quotes. Unquoted expansion syntax stays unresolved; no environment or code
// is consulted by the expander.
func literalShellParts(parts []syntax.WordPart) (string, bool) {
var known func([]syntax.WordPart, bool) bool
known = func(parts []syntax.WordPart, quoted bool) bool {
for _, part := range parts {
switch p := part.(type) {
case *syntax.Lit:
if !quoted && unquotedExpansion(p.Value) {
return false
}
case *syntax.SglQuoted:
case *syntax.DblQuoted:
if !known(p.Parts, true) {
return false
}
default:
return false
}
}
return true
}
if !known(parts, false) {
return "", false
}
fields, err := expand.Fields(&expand.Config{NoUnset: true}, &syntax.Word{Parts: parts})
if err != nil || len(fields) != 1 {
return "", false
}
return fields[0], true
}
func unquotedExpansion(value string) bool {
for i := 0; i < len(value); i++ {
if value[i] == '\\' {
i++
continue
}
if strings.ContainsRune("*?[]~", rune(value[i])) {
return true
}
}
return false
}
func interpreterInfoOnly(name, option string) bool {
if strings.HasPrefix(name, "python") {
switch option {
case "-V", "--version", "-h", "--help", "--help-all", "--help-env", "--help-xoptions":
return true
}
}
if name == "node" || name == "nodejs" {
switch option {
case "-v", "--version", "-h", "--help":
return true
}
}
return false
}