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