Files
2026-09-27 16:04:02 -06:00

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()
}