censar: tres nombres equivocados que llegaban al plan, y los 7 «binario desconocido» eran permisos

El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<n>` y el
que va de `label` en la tarjeta de arje.

· `comm` está capado a 15 caracteres: `willay-crosscheck` llegaba como `willay-crossche` y con ese
  nombre no casaba contra su declaración ⇒ figuraba como `no-declarado` TENIENDO su tarjeta en la
  semilla de arje. La línea de comando trae el nombre entero.
· `supervise-daemon` no es un servicio, es un ENVOLTORIO. Los cinco de gioser se fundían en una
  entrada `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon`
  salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar,
  entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token
  antes del `--` suelto (comprobado contra los cinco).
· `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a
  `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.

**Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** Muchos demonios reescriben su
`argv[0]` (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
comando no dice cuál es el binario — `/proc/<pid>/exe` sí, y necesita ser dueño o root. Medido:

    uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
    root     : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14

Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy
distintos, y confundirlos manda a alguien a investigar un permiso.

Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era
`/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado
era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que
ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de
arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a
la vista, un servicio que acá corre sin privilegios termina de root en el destino.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-11 22:56:47 +00:00
co-authored by Claude Opus 5
parent 2201fac08e
commit 2fa89ce3fa
3 changed files with 178 additions and 15 deletions
+134 -14
View File
@@ -94,13 +94,24 @@ def invocaciones(t, pids):
# Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
cmd, cwd = cmd.strip(), cwd.strip()
if cmd.startswith("(sonda falló"):
cmd = ""
if cwd.startswith("(sonda falló"):
cwd = ""
if cmd or cwd:
out[nombre] = {"cmdline": cmd, "cwd": cwd}
# ⚠ Tercera sonda, y también separada. Muchos demonios REESCRIBEN su `argv[0]` para decir en
# qué andan (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la
# línea de comando NO dice cuál es el binario. `/proc/<pid>/exe` sí — pero necesita permiso
# de ptrace, o sea ser el dueño o root. Medido en gioser con uid 1001: 7 de 38 servicios
# quedaban en «no se pudo leer su binario» por esto, y no por ser realmente desconocidos.
exe, _ = t.run(f"readlink /proc/{pid}/exe 2>/dev/null")
cmd, cwd, exe = cmd.strip(), cwd.strip(), exe.strip()
for v in ("cmd", "cwd", "exe"):
if locals()[v].startswith("(sonda falló"):
cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd),
("" if v == "exe" else exe))
sup = SUPERVISADOS.get(pid)
if sup:
cmd, exe = sup["cmdline"], sup["exec"]
if cmd or cwd or exe:
out[nombre] = {"cmdline": cmd, "cwd": cwd,
"exe": exe if exe.startswith("/") else "",
"usuario": (sup or {}).get("usuario", "")}
return out
@@ -136,14 +147,98 @@ def duenos(t, ejecutables):
return res
# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos,
# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco
# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del
# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca.
SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"}
_COMM_SUP = {s[:15] for s in SUPERVISORES}
# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer
# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al
# envoltorio, no al servicio.
SUPERVISADOS = {}
def _mandado_por_supervisor(args):
"""El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre.
El formato es `supervise-daemon <servicio> --start [opciones] <ejecutable> -- [argumentos]`, así
que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser
(dbus, dhcpcd, metalog, squid, shuma-daemon).
⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje
hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía
`/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`.
⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver
sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el
origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría."""
p = args.split()
if "--start" not in p:
return "", "", ""
resto = p[p.index("--start") + 1:]
usuario = ""
for i, x in enumerate(resto):
if x in ("--user", "-u") and i + 1 < len(resto):
usuario = resto[i + 1]
break
corte = resto.index("--") if "--" in resto else len(resto)
izq, der = resto[:corte], resto[corte + 1:]
if not izq or not izq[-1].startswith("/"):
return "", "", usuario
return izq[-1], " ".join([izq[-1]] + der), usuario
def _desenvolver(comm, args):
"""(nombre_real, args_reales) para un proceso que puede ser un envoltorio."""
p = args.split()
if not p:
return comm, args
base = os.path.basename(p[0])
if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}:
return comm, args
if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1:
# `supervise-daemon <servicio> --start …` · `runsv /etc/service/<servicio>`
return os.path.basename(p[1].rstrip("/")), args
for i, x in enumerate(p): # start-stop-daemon
if x in ("--name", "-n") and i + 1 < len(p):
return p[i + 1], args
if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p):
return os.path.basename(p[i + 1]), args
return comm, args
def _destruncar(comm, args):
"""`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como
`willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en
`--in /etc/init.d/<nombre>` (ruta que no existe) y el que va de `label` en la tarjeta de arje.
La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado."""
if len(comm) < 15:
return comm
p = args.split()
if p:
base = os.path.basename(p[0])
if base.startswith(comm):
return base
return comm
def procesos_vivos(t):
"""Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'")
out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'")
vivos = {}
for l in out.splitlines():
parts = l.split()
if len(parts) == 2 and parts[0].isdigit():
vivos.setdefault(parts[1], []).append(int(parts[0]))
parts = l.split(None, 3)
if len(parts) < 3 or not parts[0].isdigit():
continue
pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "")
nombre, args = _desenvolver(comm, args)
nombre = _destruncar(nombre, args)
exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "")
if exe:
SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario}
vivos.setdefault(nombre, []).append(pid)
# los kernel threads no son servicios
return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
"migration", "rcu_", "irq/", "idle_"))}
@@ -320,6 +415,11 @@ def fuera_de_git(t):
NO_SERVICIOS = {
"agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
"bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
# Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
# servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
# ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
"head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
"timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
}
@@ -432,6 +532,10 @@ def emitir_toml(c):
L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
L.append(f"exec = {toml_escape(s.get('exec',''))}")
if s.get('usuario'):
L.append(f"usuario = {toml_escape(s['usuario'])}")
if s.get('exec_motivo'):
L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
L.append("")
@@ -521,12 +625,28 @@ def main():
i = inv.get(e['name'], {})
e['cmdline'] = i.get('cmdline', '')
e['cwd'] = i.get('cwd', '')
ejes = [ (e['cmdline'] or '').split(' ')[0] for e in svc if (e.get('cmdline') or '').startswith('/') ]
e['exe_real'] = i.get('exe', '')
if i.get('usuario'):
e['usuario'] = i['usuario']
# `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
# proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
def _exe_de(e):
return e.get('exe_real') or (
(e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
ejes = [x for x in (_exe_de(e) for e in svc) if x]
dn = duenos(t, ejes)
raiz = os.geteuid() == 0 if a.local else None
for e in svc:
exe = (e.get('cmdline') or '').split(' ')[0]
e['exec'] = exe if exe.startswith('/') else ''
exe = _exe_de(e)
e['exec'] = exe
e['paquete'] = dn.get(exe, '')
if not exe and e.get('pids'):
# Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
# dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
"otro usuario no se puede leer. Repetilo con sudo"
if raiz is False else
"el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
pts = puertos(t)
nombres = dominios(t)
sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe