post inicial
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user