514 lines
12 KiB
Go
514 lines
12 KiB
Go
package policy
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ShellCommand es un comando simple extraído de una expresión de shell.
|
|
type ShellCommand struct {
|
|
Argv []string
|
|
Redirects []Redirect
|
|
}
|
|
|
|
// Redirect es una redirección de E/S (">", ">>", "<", ...).
|
|
type Redirect struct {
|
|
Op string
|
|
Target string
|
|
}
|
|
|
|
// Writes indica si la redirección escribe en su destino.
|
|
func (r Redirect) Writes() bool {
|
|
return strings.Contains(r.Op, ">")
|
|
}
|
|
|
|
// ShellScript es el resultado del análisis de un script.
|
|
//
|
|
// Opaque recoge construcciones que el analizador no puede evaluar con
|
|
// seguridad (case, eval dinámico, comillas sin cerrar...). Quien decide
|
|
// la política debe tratarlas como riesgo desconocido.
|
|
type ShellScript struct {
|
|
Commands []ShellCommand
|
|
Opaque []string
|
|
}
|
|
|
|
// ParseShell analiza una expresión de shell tipo POSIX/bash de forma
|
|
// aproximada y conservadora.
|
|
//
|
|
// Por qué existe: la herramienta shell ejecuta texto arbitrario y la política
|
|
// necesita saber qué programas se invocarán y qué archivos se redirigen.
|
|
// No es un intérprete completo: su objetivo es encontrar todos los comandos
|
|
// que se ejecutarían, incluidos los de sustituciones $(...), `...`, <(...) y
|
|
// bash -c, y marcar como opaco lo que no pueda entender.
|
|
//
|
|
// Limitación importante: es una barrera contra errores del modelo, no un
|
|
// sandbox frente a un adversario. Ver README (Seguridad).
|
|
func ParseShell(src string) ShellScript {
|
|
var out ShellScript
|
|
parseShellInto(src, 0, &out)
|
|
return out
|
|
}
|
|
|
|
const maxShellDepth = 8
|
|
|
|
func parseShellInto(src string, depth int, out *ShellScript) {
|
|
if depth > maxShellDepth {
|
|
out.Opaque = append(out.Opaque, "nesting too deep")
|
|
return
|
|
}
|
|
lx := &lexer{src: []rune(src)}
|
|
toks := lx.run()
|
|
out.Opaque = append(out.Opaque, lx.opaque...)
|
|
for _, sub := range lx.subs {
|
|
parseShellInto(sub, depth+1, out)
|
|
}
|
|
buildCommands(toks, out)
|
|
}
|
|
|
|
type tokKind int
|
|
|
|
const (
|
|
tokWord tokKind = iota
|
|
tokSep
|
|
tokRedir
|
|
)
|
|
|
|
type token struct {
|
|
kind tokKind
|
|
text string
|
|
target string // solo para tokRedir
|
|
}
|
|
|
|
type lexer struct {
|
|
src []rune
|
|
i int
|
|
subs []string // cuerpos de sustituciones de comandos a analizar
|
|
opaque []string
|
|
heredocs []heredoc
|
|
}
|
|
|
|
type heredoc struct {
|
|
delim string
|
|
quoted bool
|
|
strip bool // <<- elimina tabuladores iniciales
|
|
}
|
|
|
|
func (l *lexer) peek(off int) rune {
|
|
if l.i+off < len(l.src) {
|
|
return l.src[l.i+off]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (l *lexer) run() []token {
|
|
var toks []token
|
|
for l.i < len(l.src) {
|
|
c := l.src[l.i]
|
|
switch {
|
|
case c == ' ' || c == '\t' || c == '\r':
|
|
l.i++
|
|
case c == '\\' && l.peek(1) == '\n':
|
|
l.i += 2
|
|
case c == '#':
|
|
for l.i < len(l.src) && l.src[l.i] != '\n' {
|
|
l.i++
|
|
}
|
|
case c == '\n':
|
|
l.i++
|
|
toks = append(toks, token{kind: tokSep, text: "\n"})
|
|
l.readHeredocs()
|
|
case c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
|
|
// Redirección &> / &>>
|
|
if c == '&' && l.peek(1) == '>' {
|
|
toks = append(toks, l.readRedirect())
|
|
continue
|
|
}
|
|
op := string(c)
|
|
if n := l.peek(1); (c == '&' && n == '&') || (c == '|' && (n == '|' || n == '&')) || (c == ';' && n == ';') {
|
|
op += string(n)
|
|
}
|
|
l.i += len([]rune(op))
|
|
toks = append(toks, token{kind: tokSep, text: op})
|
|
case c == '<' || c == '>' || (isDigit(c) && l.redirAfterDigits()):
|
|
if (c == '<' || c == '>') && l.peek(1) == '(' {
|
|
// Sustitución de proceso <(...) / >(...)
|
|
l.i++
|
|
body, ok := l.balanced()
|
|
if !ok {
|
|
l.opaque = append(l.opaque, "unterminated process substitution")
|
|
}
|
|
l.subs = append(l.subs, body)
|
|
toks = append(toks, token{kind: tokWord, text: "<(...)"})
|
|
continue
|
|
}
|
|
toks = append(toks, l.readRedirect())
|
|
default:
|
|
toks = append(toks, token{kind: tokWord, text: l.readWord()})
|
|
}
|
|
}
|
|
if len(l.heredocs) > 0 {
|
|
l.opaque = append(l.opaque, "unterminated heredoc")
|
|
}
|
|
return toks
|
|
}
|
|
|
|
// redirAfterDigits detecta "2>" o "10<" (descriptor explícito). Solo se
|
|
// invoca al inicio de un token, así que "a2>b" no llega aquí.
|
|
func (l *lexer) redirAfterDigits() bool {
|
|
j := l.i
|
|
for j < len(l.src) && isDigit(l.src[j]) {
|
|
j++
|
|
}
|
|
return j < len(l.src) && (l.src[j] == '<' || l.src[j] == '>')
|
|
}
|
|
|
|
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
|
|
|
|
func isWordEnd(r rune) bool {
|
|
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';' || r == '&' || r == '|' || r == '(' || r == ')' || r == '<' || r == '>'
|
|
}
|
|
|
|
// readRedirect lee un operador de redirección y su destino.
|
|
func (l *lexer) readRedirect() token {
|
|
for l.i < len(l.src) && isDigit(l.src[l.i]) {
|
|
l.i++
|
|
}
|
|
var op strings.Builder
|
|
for l.i < len(l.src) && strings.ContainsRune("<>&|-", l.src[l.i]) && op.Len() < 3 {
|
|
// "-" solo forma parte del operador en "<<-"
|
|
if l.src[l.i] == '-' && op.String() != "<<" {
|
|
break
|
|
}
|
|
// "|" solo en ">|"
|
|
if l.src[l.i] == '|' && op.String() != ">" {
|
|
break
|
|
}
|
|
op.WriteRune(l.src[l.i])
|
|
l.i++
|
|
}
|
|
opText := op.String()
|
|
for l.i < len(l.src) && (l.src[l.i] == ' ' || l.src[l.i] == '\t') {
|
|
l.i++
|
|
}
|
|
quoted := l.i < len(l.src) && (l.src[l.i] == '\'' || l.src[l.i] == '"' || l.src[l.i] == '\\')
|
|
target := l.readWord()
|
|
|
|
switch opText {
|
|
case "<<", "<<-":
|
|
l.heredocs = append(l.heredocs, heredoc{delim: target, quoted: quoted, strip: opText == "<<-"})
|
|
}
|
|
return token{kind: tokRedir, text: opText, target: target}
|
|
}
|
|
|
|
// readHeredocs consume los cuerpos de heredoc pendientes tras un salto de línea.
|
|
// El cuerpo es dato, no comandos; pero si el delimitador no está entre
|
|
// comillas, el shell expande $(...) dentro del cuerpo, así que se analiza.
|
|
func (l *lexer) readHeredocs() {
|
|
for _, h := range l.heredocs {
|
|
var body strings.Builder
|
|
found := false
|
|
for l.i < len(l.src) {
|
|
end := l.i
|
|
for end < len(l.src) && l.src[end] != '\n' {
|
|
end++
|
|
}
|
|
line := string(l.src[l.i:end])
|
|
l.i = end
|
|
if l.i < len(l.src) {
|
|
l.i++
|
|
}
|
|
check := line
|
|
if h.strip {
|
|
check = strings.TrimLeft(line, "\t")
|
|
}
|
|
if check == h.delim {
|
|
found = true
|
|
break
|
|
}
|
|
body.WriteString(line)
|
|
body.WriteByte('\n')
|
|
}
|
|
if !found {
|
|
l.opaque = append(l.opaque, "unterminated heredoc")
|
|
}
|
|
if !h.quoted {
|
|
sub := &lexer{src: []rune(body.String())}
|
|
sub.scanSubstitutionsOnly()
|
|
l.subs = append(l.subs, sub.subs...)
|
|
l.opaque = append(l.opaque, sub.opaque...)
|
|
}
|
|
}
|
|
l.heredocs = nil
|
|
}
|
|
|
|
// scanSubstitutionsOnly busca $(...) y `...` en texto que no es código.
|
|
func (l *lexer) scanSubstitutionsOnly() {
|
|
for l.i < len(l.src) {
|
|
c := l.src[l.i]
|
|
switch {
|
|
case c == '\\':
|
|
l.i += 2
|
|
case c == '$' && l.peek(1) == '(':
|
|
l.i++
|
|
l.dollarParen(nil)
|
|
case c == '`':
|
|
l.backtick(nil)
|
|
default:
|
|
l.i++
|
|
}
|
|
}
|
|
}
|
|
|
|
// readWord lee una palabra eliminando comillas y registrando sustituciones.
|
|
func (l *lexer) readWord() string {
|
|
var w strings.Builder
|
|
for l.i < len(l.src) {
|
|
c := l.src[l.i]
|
|
if isWordEnd(c) {
|
|
break
|
|
}
|
|
switch c {
|
|
case '\\':
|
|
if l.i+1 < len(l.src) {
|
|
w.WriteRune(l.src[l.i+1])
|
|
}
|
|
l.i += 2
|
|
case '\'':
|
|
l.i++
|
|
end := l.i
|
|
for end < len(l.src) && l.src[end] != '\'' {
|
|
end++
|
|
}
|
|
if end >= len(l.src) {
|
|
l.opaque = append(l.opaque, "unterminated single quote")
|
|
}
|
|
w.WriteString(string(l.src[l.i:min(end, len(l.src))]))
|
|
l.i = end + 1
|
|
case '"':
|
|
l.i++
|
|
l.doubleQuoted(&w)
|
|
case '$':
|
|
switch l.peek(1) {
|
|
case '(':
|
|
l.i++
|
|
l.dollarParen(&w)
|
|
case '{':
|
|
// Expansión de parámetro ${...}: se copia tal cual.
|
|
end := l.i + 2
|
|
depth := 1
|
|
for end < len(l.src) && depth > 0 {
|
|
if l.src[end] == '{' {
|
|
depth++
|
|
} else if l.src[end] == '}' {
|
|
depth--
|
|
}
|
|
end++
|
|
}
|
|
w.WriteString(string(l.src[l.i:end]))
|
|
l.i = end
|
|
default:
|
|
w.WriteRune(c)
|
|
l.i++
|
|
}
|
|
case '`':
|
|
l.backtick(&w)
|
|
default:
|
|
w.WriteRune(c)
|
|
l.i++
|
|
}
|
|
}
|
|
return w.String()
|
|
}
|
|
|
|
func (l *lexer) doubleQuoted(w *strings.Builder) {
|
|
for l.i < len(l.src) {
|
|
c := l.src[l.i]
|
|
switch {
|
|
case c == '"':
|
|
l.i++
|
|
return
|
|
case c == '\\' && l.i+1 < len(l.src):
|
|
w.WriteRune(l.src[l.i+1])
|
|
l.i += 2
|
|
case c == '$' && l.peek(1) == '(':
|
|
l.i++
|
|
l.dollarParen(w)
|
|
case c == '`':
|
|
l.backtick(w)
|
|
default:
|
|
w.WriteRune(c)
|
|
l.i++
|
|
}
|
|
}
|
|
l.opaque = append(l.opaque, "unterminated double quote")
|
|
}
|
|
|
|
// dollarParen se llama con l.i apuntando a '(' tras '$'.
|
|
// Distingue $((aritmética)) de $(comando).
|
|
func (l *lexer) dollarParen(w *strings.Builder) {
|
|
if l.peek(1) == '(' {
|
|
// Aritmética: se omite, pero puede contener $(...) anidado.
|
|
body, ok := l.balanced()
|
|
if !ok {
|
|
l.opaque = append(l.opaque, "unterminated arithmetic expansion")
|
|
}
|
|
sub := &lexer{src: []rune(body)}
|
|
sub.scanSubstitutionsOnly()
|
|
l.subs = append(l.subs, sub.subs...)
|
|
if w != nil {
|
|
w.WriteString("$((...))")
|
|
}
|
|
return
|
|
}
|
|
body, ok := l.balanced()
|
|
if !ok {
|
|
l.opaque = append(l.opaque, "unterminated command substitution")
|
|
}
|
|
l.subs = append(l.subs, body)
|
|
if w != nil {
|
|
w.WriteString("$(...)")
|
|
}
|
|
}
|
|
|
|
// backtick extrae `...`.
|
|
func (l *lexer) backtick(w *strings.Builder) {
|
|
l.i++
|
|
var body strings.Builder
|
|
for l.i < len(l.src) && l.src[l.i] != '`' {
|
|
if l.src[l.i] == '\\' && l.i+1 < len(l.src) {
|
|
body.WriteRune(l.src[l.i+1])
|
|
l.i += 2
|
|
continue
|
|
}
|
|
body.WriteRune(l.src[l.i])
|
|
l.i++
|
|
}
|
|
if l.i >= len(l.src) {
|
|
l.opaque = append(l.opaque, "unterminated backtick")
|
|
}
|
|
l.i++
|
|
l.subs = append(l.subs, body.String())
|
|
if w != nil {
|
|
w.WriteString("$(...)")
|
|
}
|
|
}
|
|
|
|
// balanced lee desde '(' hasta su ')' correspondiente, respetando comillas.
|
|
// Devuelve el contenido interior.
|
|
func (l *lexer) balanced() (string, bool) {
|
|
l.i++ // salta '('
|
|
start := l.i
|
|
depth := 1
|
|
for l.i < len(l.src) {
|
|
c := l.src[l.i]
|
|
switch c {
|
|
case '\\':
|
|
l.i += 2
|
|
continue
|
|
case '\'':
|
|
l.i++
|
|
for l.i < len(l.src) && l.src[l.i] != '\'' {
|
|
l.i++
|
|
}
|
|
case '"':
|
|
l.i++
|
|
for l.i < len(l.src) && l.src[l.i] != '"' {
|
|
if l.src[l.i] == '\\' {
|
|
l.i++
|
|
}
|
|
l.i++
|
|
}
|
|
case '(':
|
|
depth++
|
|
case ')':
|
|
depth--
|
|
if depth == 0 {
|
|
body := string(l.src[start:l.i])
|
|
l.i++
|
|
return body, true
|
|
}
|
|
}
|
|
l.i++
|
|
}
|
|
return string(l.src[start:min(l.i, len(l.src))]), false
|
|
}
|
|
|
|
var assignmentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=`)
|
|
|
|
// buildCommands agrupa tokens en comandos simples, saltando palabras
|
|
// reservadas y asignaciones iniciales.
|
|
func buildCommands(toks []token, out *ShellScript) {
|
|
var cur ShellCommand
|
|
skipUntilSep := false // tras "for x in ...": la lista no son comandos
|
|
inCase := false
|
|
inDoubleBracket := false
|
|
|
|
flush := func() {
|
|
if len(cur.Argv) > 0 || len(cur.Redirects) > 0 {
|
|
out.Commands = append(out.Commands, cur)
|
|
}
|
|
cur = ShellCommand{}
|
|
}
|
|
|
|
for _, t := range toks {
|
|
switch t.kind {
|
|
case tokSep:
|
|
if inDoubleBracket {
|
|
continue // && y || dentro de [[ ]]
|
|
}
|
|
skipUntilSep = false
|
|
flush()
|
|
continue
|
|
case tokRedir:
|
|
cur.Redirects = append(cur.Redirects, Redirect{Op: t.text, Target: t.target})
|
|
continue
|
|
}
|
|
|
|
w := t.text
|
|
if inCase {
|
|
if w == "esac" {
|
|
inCase = false
|
|
}
|
|
continue
|
|
}
|
|
if inDoubleBracket {
|
|
if w == "]]" {
|
|
inDoubleBracket = false
|
|
}
|
|
continue
|
|
}
|
|
if skipUntilSep {
|
|
continue
|
|
}
|
|
if len(cur.Argv) == 0 {
|
|
switch w {
|
|
case "if", "then", "elif", "else", "do", "while", "until", "!", "time", "{", "}", "fi", "done", "coproc":
|
|
continue
|
|
case "for", "select":
|
|
skipUntilSep = true
|
|
continue
|
|
case "case":
|
|
out.Opaque = append(out.Opaque, "case statement")
|
|
inCase = true
|
|
continue
|
|
case "function":
|
|
skipUntilSep = false
|
|
continue
|
|
case "[[":
|
|
cur.Argv = append(cur.Argv, "[[")
|
|
inDoubleBracket = true
|
|
continue
|
|
}
|
|
if assignmentRe.MatchString(w) {
|
|
continue
|
|
}
|
|
// Definición "nombre()": el cuerpo se analiza como comandos normales.
|
|
if strings.HasSuffix(w, "()") {
|
|
continue
|
|
}
|
|
}
|
|
cur.Argv = append(cur.Argv, w)
|
|
}
|
|
flush()
|
|
}
|