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

784 lines
27 KiB
Go

package policy
import (
"path/filepath"
"strings"
)
// Risk es la categoría de riesgo de un comando.
type Risk string
const (
RiskReadOnly Risk = "read-only"
RiskNetwork Risk = "network"
RiskDownload Risk = "download"
RiskWrite Risk = "write"
RiskDelete Risk = "delete"
RiskPackage Risk = "package"
RiskService Risk = "service"
RiskAdmin Risk = "admin"
RiskDestructive Risk = "destructive"
RiskReboot Risk = "reboot"
RiskUnknown Risk = "unknown"
)
// Classification describe un comando concreto (argv) tras desenvolver
// envoltorios como sudo, env o timeout.
type Classification struct {
Program string // nombre base del programa efectivo
Argv []string
Risks []Risk
// AllArgsArePaths indica que todos los argumentos no-opción deben
// tratarse como rutas afectadas (rm, cp, chmod...).
AllArgsArePaths bool
// Nested contiene comandos anidados (bash -c, find -exec, xargs...).
Nested []Classification
// Script es el script de un "bash -c" anidado, si lo hay.
Script *ShellScript
}
// AllRisks devuelve los riesgos propios y de todos los comandos anidados.
func (c Classification) AllRisks() []Risk {
out := append([]Risk(nil), c.Risks...)
for _, n := range c.Nested {
out = append(out, n.AllRisks()...)
}
return out
}
// ReadOnly indica si el comando y todos sus anidados son de solo lectura
// (o solo red), es decir, seguros de ejecutar en --dry-run.
func (c Classification) ReadOnly() bool {
for _, r := range c.AllRisks() {
if r != RiskReadOnly && r != RiskNetwork {
return false
}
}
return true
}
// readOnlyPrograms nunca modifican el sistema, sean cuales sean sus argumentos.
var readOnlyPrograms = set(
"ls", "dir", "cat", "tac", "head", "tail", "grep", "egrep", "fgrep", "rg", "zgrep", "zcat", "bzcat", "xzcat", "zstdcat",
"stat", "file", "wc", "du", "df", "free", "uptime", "uname", "id", "whoami", "groups", "who", "w", "last", "lastlog",
"ps", "pgrep", "pidof", "ss", "netstat", "lsof", "lsblk", "blkid", "lscpu", "lsmem", "lspci", "lsusb", "lsmod",
"lsattr", "lsns", "lsipc", "lslocks", "lslogins", "findmnt", "cal", "printenv", "echo", "printf", "true", "false",
"test", "[", "[[", "basename", "dirname", "realpath", "readlink", "pwd", "which", "whereis", "type", "sort", "uniq",
"cut", "tr", "column", "nl", "fold", "fmt", "paste", "join", "comm", "diff", "cmp", "md5sum", "sha1sum", "sha224sum",
"sha256sum", "sha384sum", "sha512sum", "b2sum", "cksum", "base64", "base32", "xxd", "hexdump", "od", "strings", "jq",
"getent", "getconf", "nproc", "arch", "locale", "vmstat", "iostat", "mpstat", "pidstat", "sar", "systemd-analyze",
"modinfo", "tree", "locate", "dmidecode", "sensors", "aa-status", "getenforce", "sestatus", "apt-cache", "dpkg-query",
"named-checkconf", "named-checkzone", "cd", "export", "unset", "set", "read", "sleep", "wait", "exit", "return",
"local", "shift", "declare", "typeset", "readonly", "let", "seq", "yes", "expr", "tput", "ldd", "nm",
"objdump", "readelf", "lsinitramfs", "systemd-detect-virt", "virt-what", "numfmt", "rev", "shuf", "iconv", "less",
"more", "man", "whatis", "apropos", "hash", "ulimit", "logname", "tty", "users",
"free", "systemd-cgls", "systemd-cgtop", "diff3", "sdiff", "look", "zdiff", "zless", "zmore", "cmp")
// networkPrograms solo consultan la red.
var networkPrograms = set(
"ping", "ping6", "traceroute", "traceroute6", "tracepath", "mtr", "dig", "host", "nslookup", "whois", "nc", "ncat",
"netcat", "telnet", "nmap", "arping", "ssh", "sftp", "ftp", "drill", "delv", "kdig", "socat",
)
// destructivePrograms pueden destruir datos de forma irreversible.
var destructivePrograms = set(
"dd", "mkfs", "mke2fs", "mkswap", "wipefs", "fdisk", "sfdisk", "cfdisk", "gdisk", "sgdisk", "parted", "shred",
"blkdiscard", "pvremove", "vgremove", "lvremove", "userdel", "deluser", "groupdel", "delgroup", "killall5",
"mdadm", "zpool", "badblocks",
)
// adminPrograms modifican la configuración del sistema.
var adminPrograms = set(
"chmod", "chown", "chgrp", "setfacl", "chattr", "useradd", "usermod", "groupadd", "groupmod", "gpasswd", "passwd",
"chpasswd", "adduser", "addgroup", "chsh", "chfn", "kill", "pkill", "killall", "umount", "mount", "swapoff",
"modprobe", "insmod", "rmmod", "depmod", "chroot", "pvcreate", "vgcreate", "lvcreate", "lvextend", "lvresize",
"resize2fs", "xfs_growfs", "tune2fs", "e2fsck", "fsck", "update-grub", "grub-install", "update-initramfs",
"update-alternatives", "ldconfig", "setcap", "newaliases", "postmap", "a2enmod", "a2dismod", "a2ensite",
"a2dissite", "locale-gen", "update-ca-certificates", "certbot", "iptables-restore", "ip6tables-restore",
"renice", "systemd-tmpfiles", "setenforce", "exportfs", "quotaon", "quotaoff",
)
// writePrograms escriben archivos (sin llegar a administración del sistema).
var writePrograms = set(
"cp", "mv", "install", "ln", "touch", "mkdir", "tee", "truncate", "patch", "mkfifo", "mknod", "split", "csplit",
"make", "cmake", "ninja", "meson", "gcc", "cc", "g++", "c++", "clang", "clang++", "ld", "as", "rustc", "cargo", "go",
"javac", "ar", "strip", "zip", "7z", "convert", "sponge", "dos2unix", "unix2dos", "ssh-keygen",
)
// packagePrograms: gestores de paquetes (subcomandos de lectura se tratan aparte).
var packageReadVerbs = map[string]map[string]bool{
"apt": set("list", "show", "search", "policy", "depends", "rdepends", "showsrc", "changelog", "madison", "help", "moo"),
"apt-get": set("check", "changelog", "help"),
"dnf": set("list", "info", "search", "repolist", "repoquery", "provides", "whatprovides", "history", "check-update", "help", "deplist", "updateinfo", "module-list"),
"yum": set("list", "info", "search", "repolist", "provides", "whatprovides", "history", "check-update", "help", "deplist", "updateinfo"),
"zypper": set("search", "se", "info", "if", "list-updates", "lu", "repos", "lr", "packages", "pa", "patches", "lp", "help"),
"apk": set("info", "search", "list", "policy", "version", "dot", "stats", "help"),
"snap": set("list", "info", "find", "search", "version", "help", "changes", "services", "connections"),
"flatpak": set("list", "info", "search", "remote-ls", "remotes", "history", "help"),
"pip": set("list", "show", "freeze", "check", "help", "search", "inspect", "debug", "config"),
"pip3": set("list", "show", "freeze", "check", "help", "search", "inspect", "debug", "config"),
}
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, it := range items {
m[it] = true
}
return m
}
// Classify clasifica un argv. Nunca devuelve una lista de riesgos vacía.
func Classify(argv []string) Classification {
return classify(argv, 0)
}
func classify(argv []string, depth int) Classification {
if len(argv) == 0 {
return Classification{Risks: []Risk{RiskReadOnly}}
}
if depth > maxShellDepth {
return Classification{Program: argv[0], Argv: argv, Risks: []Risk{RiskUnknown}}
}
prog := filepath.Base(argv[0])
args := argv[1:]
c := Classification{Program: prog, Argv: argv}
// Un nombre de programa dinámico ($CMD, $(...)) no se puede clasificar.
if strings.ContainsAny(argv[0], "$`") {
c.Risks = []Risk{RiskUnknown}
return c
}
// Envoltorios: el riesgo es el del comando interior.
if inner, ok := unwrap(prog, args); ok {
if inner == nil {
c.Risks = []Risk{RiskUnknown}
return c
}
n := classify(inner, depth+1)
n.Nested = append([]Classification(nil), n.Nested...)
return n
}
// Intérpretes de shell con -c: se analiza el script.
if isShell(prog) {
if script, ok := shellDashC(args); ok {
s := ParseShell(script)
c.Script = &s
c.Risks = []Risk{RiskReadOnly}
if len(s.Opaque) > 0 {
c.Risks = []Risk{RiskUnknown}
}
for _, sc := range s.Commands {
c.Nested = append(c.Nested, classify(sc.Argv, depth+1))
}
return c
}
c.Risks = []Risk{RiskUnknown}
return c
}
// Consultas de versión/ayuda: seguras para cualquier programa.
if len(args) == 1 && (args[0] == "--version" || args[0] == "--help" || args[0] == "-version") {
c.Risks = []Risk{RiskReadOnly}
return c
}
c.Risks, c.AllArgsArePaths, c.Nested = programRisks(prog, args, depth)
return c
}
// programRisks contiene las reglas por programa y subcomando.
func programRisks(prog string, args []string, depth int) (risks []Risk, allPaths bool, nested []Classification) {
verb := firstNonFlag(args)
has := func(flags ...string) bool { return hasAny(args, flags...) }
one := func(r Risk) ([]Risk, bool, []Classification) { return []Risk{r}, false, nil }
switch prog {
case "rm", "rmdir", "unlink":
return []Risk{RiskDelete}, true, nil
case "cp", "mv", "install", "ln", "touch", "mkdir", "truncate", "tee", "mkfifo":
return []Risk{RiskWrite}, true, nil
case "chmod", "chown", "chgrp", "setfacl", "chattr":
return []Risk{RiskAdmin}, true, nil
case "shred":
return []Risk{RiskDestructive}, true, nil
case "find":
return classifyFind(args, depth)
case "xargs":
inner := skipFlags(args, "-n", "-I", "-P", "-d", "-L", "-s", "-E", "-a", "--max-args", "--max-procs", "--delimiter", "--arg-file")
if len(inner) == 0 {
return one(RiskReadOnly) // xargs sin comando ejecuta echo
}
n := classify(inner, depth+1)
return n.Risks, n.AllArgsArePaths, n.Nested
case "systemctl":
switch verb {
case "", "status", "is-active", "is-enabled", "is-failed", "is-system-running", "show", "cat", "list-units",
"list-unit-files", "list-sockets", "list-timers", "list-jobs", "list-dependencies", "list-machines",
"help", "get-default", "show-environment":
return one(RiskReadOnly)
case "start", "stop", "restart", "reload", "try-restart", "reload-or-restart", "try-reload-or-restart",
"enable", "disable", "reenable", "mask", "unmask", "daemon-reload", "daemon-reexec", "kill", "reset-failed",
"condrestart", "force-reload", "preset", "revert":
return one(RiskService)
case "reboot", "poweroff", "halt", "kexec", "suspend", "hibernate", "hybrid-sleep", "suspend-then-hibernate",
"rescue", "emergency", "default", "isolate", "soft-reboot", "switch-root":
return one(RiskReboot)
}
return one(RiskAdmin)
case "service":
if has("--status-all") || len(args) >= 2 && args[1] == "status" {
return one(RiskReadOnly)
}
return one(RiskService)
case "reboot", "shutdown", "poweroff", "halt":
return one(RiskReboot)
case "init", "telinit":
if verb == "0" || verb == "6" {
return one(RiskReboot)
}
return one(RiskAdmin)
case "apt", "apt-get", "aptitude", "dnf", "yum", "microdnf", "zypper", "apk", "snap", "flatpak", "pip", "pip3":
if verb == "" || packageReadVerbs[prog][verb] {
return one(RiskReadOnly)
}
return []Risk{RiskPackage, RiskNetwork}, false, nil
case "apt-mark", "dpkg-reconfigure", "debconf-set-selections", "add-apt-repository":
if prog == "apt-mark" && (verb == "showhold" || verb == "showauto" || verb == "showmanual") {
return one(RiskReadOnly)
}
return one(RiskPackage)
case "dpkg":
if has("-i", "--install", "-r", "--remove", "-P", "--purge", "--configure", "--unpack", "-a", "--set-selections",
"--clear-selections", "--add-architecture", "--remove-architecture", "-x", "--extract", "-b", "--build") {
return one(RiskPackage)
}
return one(RiskReadOnly)
case "rpm":
if has("-i", "--install", "-U", "--upgrade", "-e", "--erase", "-F", "--freshen", "--import", "--rebuilddb") {
return one(RiskPackage)
}
return one(RiskReadOnly)
case "pacman":
if verb == "" && len(args) > 0 {
f := args[0]
if strings.HasPrefix(f, "-Q") || strings.HasPrefix(f, "-F") || f == "-Ss" || f == "-Si" || f == "-Sl" || f == "-Sg" {
return one(RiskReadOnly)
}
} else if len(args) > 0 && (strings.HasPrefix(args[0], "-Q") || args[0] == "-Ss" || args[0] == "-Si") {
return one(RiskReadOnly)
}
return []Risk{RiskPackage, RiskNetwork}, false, nil
case "ip":
return classifyIP(args)
case "nft":
if verb == "list" || verb == "describe" || has("-c", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "iptables", "ip6tables", "iptables-legacy", "ip6tables-legacy", "iptables-nft", "ip6tables-nft", "ebtables", "arptables":
if onlyFlags(args, "-L", "--list", "-S", "--list-rules", "-n", "--numeric", "-v", "--verbose", "-t", "--table", "-x", "--line-numbers", "-C", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "iptables-save", "ip6tables-save":
return one(RiskReadOnly)
case "ufw":
if verb == "status" || verb == "show" || verb == "app" && len(args) > 1 && (args[1] == "list" || args[1] == "info") || verb == "version" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "firewall-cmd":
for _, a := range args {
if !(strings.HasPrefix(a, "--list") || strings.HasPrefix(a, "--get") || strings.HasPrefix(a, "--query") ||
a == "--state" || strings.HasPrefix(a, "--zone") || a == "--permanent" || strings.HasPrefix(a, "--info")) {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "tc", "bridge":
if verb == "" || containsWord(args, "show", "list", "ls") && !containsWord(args, "add", "del", "delete", "replace", "change", "set", "flush") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "nmcli":
if containsWord(args, "modify", "up", "down", "add", "delete", "del", "reload", "import", "connect", "disconnect", "on", "off", "set", "edit", "clone") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "networkctl", "resolvectl", "loginctl", "hostnamectl", "timedatectl", "localectl", "busctl", "coredumpctl", "machinectl":
if verb == "" || verb == "status" || verb == "show" || verb == "query" || verb == "list" || strings.HasPrefix(verb, "list") ||
verb == "statistics" || verb == "dns" || verb == "domain" || verb == "info" || verb == "cat-config" || verb == "timesync-status" ||
verb == "show-timesync" || verb == "tree" || verb == "introspect" || verb == "user-status" || verb == "session-status" || verb == "seat-status" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "ethtool":
if has("-s", "--change", "-K", "--features", "--offload", "-G", "--set-ring", "-A", "--pause", "-C", "--coalesce",
"-L", "--set-channels", "-E", "--change-eeprom", "-f", "--flash", "-r", "--negotiate", "-p", "--identify", "--reset", "-X", "-N", "-U") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "iw", "wg":
if containsWord(args, "set", "add", "del", "connect", "disconnect", "setconf", "addconf", "syncconf") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "mount":
if len(args) == 0 || onlyFlags(args, "-l", "--show-labels", "-t", "--types", "-v") && !hasNonFlag(args, "-t", "--types") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "swapon":
if len(args) == 0 || has("--show", "-s", "--summary") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "sysctl":
for _, a := range args {
if a == "-w" || a == "--write" || a == "-p" || a == "--load" || a == "--system" || strings.Contains(a, "=") && !strings.HasPrefix(a, "-") {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "hostname":
if verb == "" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "date":
if has("-s", "--set") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "dmesg":
if has("-c", "-C", "--clear", "--read-clear", "-D", "-E", "-n", "--console-off", "--console-on", "--console-level") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "journalctl":
for _, a := range args {
if strings.HasPrefix(a, "--vacuum") {
return []Risk{RiskDelete}, false, nil
}
if a == "--rotate" || a == "--flush" || a == "--relinquish-var" || a == "--sync" || a == "--setup-keys" {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "crontab":
if has("-l") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "top", "htop", "iotop", "atop":
if has("-b", "--batch") {
return one(RiskReadOnly)
}
return one(RiskUnknown) // interactivo: se colgaría hasta el timeout
case "kill":
if has("-l", "-L", "--list", "--table") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "smartctl":
if has("-t", "--test", "-s", "--smart", "-o", "--offlineauto", "-S", "--saveauto", "-X", "--abort") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "cryptsetup":
switch verb {
case "status", "luksDump", "isLuks", "luksUUID", "tcryptDump", "bitlkDump":
return one(RiskReadOnly)
case "luksFormat", "erase", "luksErase", "reencrypt", "luksKillSlot", "luksRemoveKey":
return one(RiskDestructive)
}
return one(RiskAdmin)
case "zfs":
if verb == "destroy" {
return one(RiskDestructive)
}
if verb == "list" || verb == "get" || verb == "status" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "lvm", "lvs", "vgs", "pvs", "lvdisplay", "vgdisplay", "pvdisplay", "pvscan", "vgscan", "lvscan":
return one(RiskReadOnly)
case "sed":
for _, a := range args {
if a == "-i" || strings.HasPrefix(a, "-i") && !strings.HasPrefix(a, "--") || strings.HasPrefix(a, "--in-place") ||
len(a) > 1 && a[0] == '-' && a[1] != '-' && strings.Contains(a, "i") {
return []Risk{RiskWrite}, true, nil
}
}
return one(RiskReadOnly)
case "awk", "gawk", "mawk", "nawk", "perl", "python", "python3", "ruby", "node", "php", "lua", "tclsh", "Rscript":
// Lenguajes con E/S y system(): no se pueden evaluar.
return one(RiskUnknown)
case "tar", "bsdtar":
// Modo de listado: "tf", "-tzf", "--list" (sin crear ni extraer).
mode := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
mode = strings.TrimPrefix(args[0], "-")
}
if has("--list") || strings.Contains(mode, "t") && !strings.ContainsAny(mode, "cxruA") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, false, nil
case "unzip":
if has("-l", "-v", "-t", "-Z", "-p") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, false, nil
case "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd", "unzstd", "lz4", "lzma":
if has("-l", "--list", "-t", "--test", "-c", "--stdout", "--to-stdout") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, true, nil
case "git":
switch verb {
case "status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "blame", "describe", "shortlog",
"grep", "cat-file", "ls-tree", "reflog", "config", "help", "version", "whatchanged", "tag", "remote":
if verb == "config" && !has("--get", "--list", "-l", "--get-all", "--get-regexp") ||
verb == "remote" && hasNonFlagAfterVerb(args, "remote") && !containsWord(args, "show", "-v", "get-url") ||
verb == "tag" && hasNonFlagAfterVerb(args, "tag") && !has("-l", "--list") ||
verb == "branch" && hasNonFlagAfterVerb(args, "branch") && !has("-l", "--list", "-a", "-r", "-v", "-vv") {
return []Risk{RiskWrite}, false, nil
}
return one(RiskReadOnly)
case "clone", "fetch", "pull", "push", "submodule":
return []Risk{RiskNetwork, RiskWrite}, false, nil
case "ls-remote":
return one(RiskNetwork)
}
return []Risk{RiskWrite}, false, nil
case "curl":
if has("-o", "--output", "-O", "--remote-name", "--remote-name-all", "-T", "--upload-file", "-c", "--cookie-jar", "-D", "--dump-header") {
return []Risk{RiskNetwork, RiskDownload, RiskWrite}, false, nil
}
return one(RiskNetwork)
case "wget", "aria2c":
if has("-O-", "--output-document=-", "--spider", "-qO-") || has("-O") && containsWord(args, "-") {
return one(RiskNetwork)
}
return []Risk{RiskNetwork, RiskDownload, RiskWrite}, false, nil
case "scp", "rsync":
r := []Risk{RiskWrite}
for _, a := range args {
if strings.Contains(a, ":") && !strings.HasPrefix(a, "-") {
r = append(r, RiskNetwork)
break
}
}
return r, prog == "scp", nil
case "openssl":
switch verb {
case "s_client", "s_time", "ocsp":
return one(RiskNetwork)
case "genrsa", "genpkey", "req", "ca", "rand", "enc", "pkcs12", "dhparam", "ecparam", "x509", "rsa", "ec", "pkey", "dgst", "smime", "cms":
if has("-out", "-keyout", "-new", "-newkey") {
return []Risk{RiskWrite}, false, nil
}
}
return one(RiskReadOnly)
case "nginx", "apache2ctl", "apachectl", "httpd":
if has("-t", "-T", "-v", "-V", "-S", "-M", "-l", "-L") || verb == "configtest" || verb == "-t" {
return one(RiskReadOnly)
}
return one(RiskService)
case "sshd":
if has("-t", "-T", "-G") {
return one(RiskReadOnly)
}
return one(RiskService)
case "visudo":
if has("-c", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "docker", "podman", "nerdctl":
switch verb {
case "ps", "images", "inspect", "logs", "info", "version", "stats", "top", "port", "diff", "history", "events":
return one(RiskReadOnly)
case "rm", "rmi", "prune", "system":
return []Risk{RiskAdmin, RiskDelete}, false, nil
}
return []Risk{RiskAdmin, RiskNetwork}, false, nil
case "command":
if has("-v", "-V") {
return one(RiskReadOnly)
}
}
switch {
case readOnlyPrograms[prog]:
return one(RiskReadOnly)
case networkPrograms[prog]:
return one(RiskNetwork)
case destructivePrograms[prog] || strings.HasPrefix(prog, "mkfs."):
return []Risk{RiskDestructive}, true, nil
case adminPrograms[prog]:
return one(RiskAdmin)
case writePrograms[prog]:
return []Risk{RiskWrite}, false, nil
case prog == "eval" || prog == "source" || prog == "." || prog == "exec" || prog == "trap" || prog == "alias":
return one(RiskUnknown)
}
return one(RiskUnknown)
}
// unwrap desenvuelve comandos que ejecutan otro comando.
// Devuelve (nil, true) si es un envoltorio pero no se pudo extraer el comando.
func unwrap(prog string, args []string) ([]string, bool) {
switch prog {
case "sudo", "doas":
if hasAny(args, "-s", "--shell", "-i", "--login", "-e", "--edit", "-l", "--list", "-v", "--validate", "-k", "-K") {
if hasAny(args, "-l", "--list", "-v", "-k", "-K") && firstNonFlag(args) == "" {
return []string{"true"}, true
}
return nil, true
}
return orNil(skipFlags(args, "-u", "--user", "-g", "--group", "-C", "--close-from", "-h", "--host", "-p", "--prompt", "-r", "--role", "-t", "--type", "-U", "--other-user", "-D", "--chdir", "-T", "--command-timeout")), true
case "env":
rest := skipFlags(args, "-u", "--unset", "-C", "--chdir", "-S", "--split-string")
for len(rest) > 0 && assignmentRe.MatchString(rest[0]) {
rest = rest[1:]
}
if len(rest) == 0 {
return []string{"printenv"}, true
}
return rest, true
case "nice":
return orNil(skipFlags(args, "-n", "--adjustment")), true
case "ionice":
return orNil(skipFlags(args, "-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid", "-u", "--uid")), true
case "nohup", "setsid", "unbuffer", "builtin", "time", "chronic":
return orNil(skipFlags(args)), true
case "stdbuf":
return orNil(skipFlags(args, "-i", "-o", "-e")), true
case "timeout":
rest := skipFlags(args, "-s", "--signal", "-k", "--kill-after")
if len(rest) > 0 {
rest = rest[1:] // duración
}
return orNil(rest), true
case "watch":
return orNil(skipFlags(args, "-n", "--interval", "-d")), true
case "taskset":
rest := skipFlags(args, "-c")
if len(rest) > 0 && !hasAny(args, "-p", "--pid") {
rest = rest[1:] // máscara
}
return orNil(rest), true
case "chrt":
rest := skipFlags(args)
if len(rest) > 0 {
rest = rest[1:] // prioridad
}
return orNil(rest), true
case "busybox":
return orNil(args), true
case "su":
for i, a := range args {
if (a == "-c" || a == "--command") && i+1 < len(args) {
return []string{"sh", "-c", args[i+1]}, true
}
}
return nil, true
case "command":
if hasAny(args, "-v", "-V") {
return nil, false // lo gestiona programRisks
}
return orNil(skipFlags(args, "-p")), true
}
return nil, false
}
func orNil(s []string) []string {
if len(s) == 0 {
return nil
}
return s
}
func isShell(prog string) bool {
switch prog {
case "sh", "bash", "dash", "zsh", "ksh", "ash", "mksh", "fish", "rbash":
return true
}
return false
}
// shellDashC busca el script de "sh -c SCRIPT" (también -lc, -ec, ...).
func shellDashC(args []string) (string, bool) {
for i, a := range args {
if len(a) > 1 && a[0] == '-' && a[1] != '-' && strings.Contains(a, "c") {
if i+1 < len(args) {
return args[i+1], true
}
return "", false
}
if !strings.HasPrefix(a, "-") {
return "", false // sh script.sh: contenido no analizado
}
}
return "", false
}
func classifyFind(args []string, depth int) ([]Risk, bool, []Classification) {
risks := []Risk{RiskReadOnly}
var nested []Classification
for i := 0; i < len(args); i++ {
switch args[i] {
case "-delete":
risks = append(risks, RiskDelete)
case "-fprint", "-fprint0", "-fprintf", "-fls":
risks = append(risks, RiskWrite)
case "-exec", "-execdir", "-ok", "-okdir":
j := i + 1
for j < len(args) && args[j] != ";" && args[j] != "+" {
j++
}
nested = append(nested, classify(args[i+1:j], depth+1))
i = j
}
}
return risks, false, nested
}
func classifyIP(args []string) ([]Risk, bool, []Classification) {
var words []string
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "-") {
if a == "-n" || a == "-netns" || a == "-b" || a == "-batch" || a == "-rc" || a == "-rcvbuf" || a == "-f" || a == "-family" {
if a == "-b" || a == "-batch" {
return []Risk{RiskAdmin}, false, nil // ejecuta comandos de un archivo
}
i++
}
continue
}
words = append(words, a)
}
if len(words) < 2 {
return []Risk{RiskReadOnly}, false, nil
}
switch words[1] {
case "show", "list", "ls", "lst", "get", "help", "monitor", "sh", "s", "l":
return []Risk{RiskReadOnly}, false, nil
}
if words[0] == "netns" && (words[1] == "identify" || words[1] == "pids") {
return []Risk{RiskReadOnly}, false, nil
}
return []Risk{RiskAdmin}, false, nil
}
// firstNonFlag devuelve el primer argumento que no es una opción.
func firstNonFlag(args []string) string {
for _, a := range args {
if !strings.HasPrefix(a, "-") {
return a
}
}
return ""
}
// skipFlags elimina opciones iniciales; las de valueFlags consumen un valor.
func skipFlags(args []string, valueFlags ...string) []string {
vf := set(valueFlags...)
i := 0
for i < len(args) {
a := args[i]
if a == "--" {
return args[i+1:]
}
if !strings.HasPrefix(a, "-") || a == "-" {
break
}
if vf[a] {
i++
}
i++
}
if i > len(args) {
return nil
}
return args[i:]
}
func hasAny(args []string, flags ...string) bool {
for _, a := range args {
for _, f := range flags {
if a == f || strings.HasPrefix(f, "--") && strings.HasPrefix(a, f+"=") {
return true
}
}
}
return false
}
func containsWord(args []string, words ...string) bool {
return hasAny(args, words...)
}
// onlyFlags informa si todos los argumentos que son opciones pertenecen a
// allowed (los valores no-opción se permiten, p.ej. nombres de cadena).
func onlyFlags(args []string, allowed ...string) bool {
al := set(allowed...)
for _, a := range args {
if strings.HasPrefix(a, "-") {
// Opciones combinadas tipo -nvL
if len(a) > 2 && a[1] != '-' {
for _, ch := range a[1:] {
if !al["-"+string(ch)] {
return false
}
}
continue
}
if !al[a] {
return false
}
}
}
return true
}
// hasNonFlag indica si hay argumentos posicionales, ignorando los valores
// de las opciones valueFlags.
func hasNonFlag(args []string, valueFlags ...string) bool {
vf := set(valueFlags...)
for i := 0; i < len(args); i++ {
if vf[args[i]] {
i++
continue
}
if !strings.HasPrefix(args[i], "-") {
return true
}
}
return false
}
func hasNonFlagAfterVerb(args []string, verb string) bool {
seen := false
for _, a := range args {
if !seen {
seen = a == verb
continue
}
if !strings.HasPrefix(a, "-") {
return true
}
}
return false
}