🛡️ Suite de contención#
La diferencia entre este repositorio y un documento sobre aislamiento.
hace en tu host, ahora, con tu kernel y tus binarios.
🤔 El problema que resuelve#
Un runtime puede declarar que aĂsla la red y no cortarla. Motivos reales:
- falta el binario (
bwrapno instalado y el plan degrada en silencio); - el kernel no lo permite (user namespaces deshabilitados por polĂtica);
- la polĂtica se compilĂł mal (un flag que se dejĂł de pasar en un refactor);
- el control no significa lo que parece (
RLIMIT_NPROCno limita los
procesos de la carga, sino los del UID en todo el host).
Los cuatro producen el mismo sĂntoma: un âś… en un documento y una fuga en producciĂłn. La distancia entre declarado y efectivo es donde viven los incidentes.
⚙️ Cómo funciona#
flowchart TB
S["đź§Ş Sondas registradas<br/>escape-suite/suite.json"] --> P["🛡️ PolĂtica de auditorĂa<br/>best-effort"]
P --> R{{"por cada runtime"}}
R --> N["native"] & B["bwrap"] & U["unshare"] & W["wasi"]
N & B & U & W --> O["📤 stdout con contrato<br/>probe= dimension= result= detail="]
O --> V["⚖️ Veredicto por sonda"]
V --> M["📊 Matriz de contención"]
style M fill:#e5f6ec,stroke:#1f7a4f
Cada sonda es una carga registrada como cualquier otra: tiene manifiesto, hash y se ejecuta por el mismo camino que el resto. No hay una vĂa especial para las sondas — si la hubiera, no estarĂan midiendo el sistema real.
Contrato de salida#
Las sondas imprimen una lĂnea por dimensiĂłn medida:
probe=<id> dimension=<dim> result=<contained|escaped|error> detail=<texto>
Se parsea la salida en lugar de mirar el cĂłdigo de salida porque una sonda puede reportar varias dimensiones, y porque el runtime puede matar el proceso (OOM killer, timeout) dejando un cĂłdigo que no dice nada Ăştil.
đź§Ş Las ocho dimensiones#
| Dimensión | Qué intenta la sonda | Por qué importa |
|---|---|---|
network | Conectar por TCP y resolver DNS | Una carga con red exfiltra lo que lea y descarga lo que ejecutará después |
filesystem | Leer secretos, escribir fuera, ver el árbol real | Leer claves o escribir en el sistema convierte una ejecución en persistencia |
process | Contar PIDs visibles, inspeccionar el PID 1 | Ver el árbol del host permite inspeccionar y señalizar procesos ajenos |
environment | Buscar credenciales heredadas | Un token en el entorno convierte cualquier ejecuciĂłn en una filtraciĂłn |
privilege | Leer CapEff y uid_map | Las capabilities que sobreviven permiten montar, trazar o tocar la red |
memory | Pedir el doble del presupuesto | Sin techo, una carga tumba el host y todo lo que corra en él |
processes | Crear procesos hasta pasarse | Sin techo de PIDs, una carga agota la tabla de procesos |
syscalls | Ejecutar llamadas que la polĂtica deniega | Sin filtro, la carga le pide al kernel cosas que ninguna aplicaciĂłn normal necesita: trazar procesos, cargar mĂłdulos, instrumentar la CPU |
La sonda de syscalls merece una nota, porque enseña cĂłmo se diseña una que mida algo. Ejecuta getcpu(NULL, NULL, NULL), que tiene Ă©xito siempre y para cualquiera: asĂ, Ă©xito significa «ningĂşn filtro la bloqueó» y EPERM significa «el filtro la denegó», sin ambigĂĽedad en ningĂşn host.
Los dos intentos anteriores fallaron por el mismo motivo:
| Intento | Por qué no sirve |
|---|---|
mount, ptrace | Ya fallan con EPERM sin privilegios: la sonda aprobarĂa con filtro y sin Ă©l |
perf_event_open | Su error sin filtro depende de perf_event_paranoid del host: EFAULT aquĂ, EACCES en el runner de CI, EPERM donde el sysctl valga 3 |
Medido con bubblewrap 0.9.0:
sin sandbox → escaped (getcpu tuvo éxito)
bubblewrap sin filtro → escaped (bubblewrap por sà solo no filtra)
bubblewrap con filtro → contained (getcpu → EPERM)
La fila del medio es la que da valor a la de abajo: sin ella, «contenido» podrĂa significar solo «bubblewrap estaba puesto».
controlled: solo observan y reportan, no dañan nada, y por eso pueden ejecutarse tambiĂ©n en native para obtener la lĂnea base. Las de memoria y procesos son resource-abuse y nunca corren sin aislamiento.📊 Los cuatro veredictos#
| Veredicto | Significado |
|---|---|
| âś… contenido | La sonda intentĂł salirse y no pudo |
| ❌ escapó | La sonda se salió: el control no se aplica en este host |
| ❌ DECLARADO | El runtime dice que aplica el control y la sonda demostró que no |
| ⚠️ no concluyente | No se pudo medir (error de la sonda, salida vacĂa) |
| — no aplica | El runtime no ejecuta cargas, o la polĂtica bloqueĂł el plan |
❌ DECLARADO es el hallazgo que más importa. Es peor que un ❌ normal, porque un control no declarado es honesto: te dice que no cuentes con él. Uno declarado y no aplicado invita a confiar.
▶️ Uso#
# Matriz completa de este host
cargo run -p sandboxctl -- escape
# Un runtime concreto, como puerta de CI (cĂłdigo 1 si algo escapa)
cargo run -p sandboxctl -- escape --runtime bwrap --strict
# Informe verificable
cargo run -p sandboxctl -- escape --json --report evidence/escape/matriz.json
# LĂnea base obligatoria: sin aislamiento TIENE que escapar
SANDBOX_LABS_ALLOW_NATIVE=1 cargo run -p sandboxctl -- escape --runtime native
Por quĂ© la polĂtica por defecto es best-effort#
modo best-effort. Con una polĂtica strict, el plan fallarĂa cerrado antes de ejecutar y la matriz saldrĂa entera en «no aplica»: correcto como comportamiento, inĂştil como mediciĂłn.
Auditar exige ejecutar. Por eso la polĂtica de auditorĂa es distinta de la polĂtica de producciĂłn, y por eso está separada y documentada.
🔍 Hallazgos reales de esta suite#
Los dos primeros los encontrĂł la suite en su primera ejecuciĂłn, sobre este mismo repositorio:
1. PID namespace sin /proc remontado#
El adaptador unshare pasaba --pid --fork y creaba el namespace… pero sin
PIDs. El namespace existĂa y no se notaba.
Corregido en crates/sandbox-runtimes/src/adapters/unshare.rs.
2. RLIMIT_NPROC no es un lĂmite de procesos de contenedor#
Los adaptadores declaraban el control processes porque envolvĂan la carga con
host**: fijarlo al presupuesto de la polĂtica mataba la ejecuciĂłn nada más arrancar (unshare: fork failed: Resource temporarily unavailable) y, peor, hacĂa pasar por control de contenciĂłn algo que no lo era.
Corregido: se retiró --nproc y el control processes dejó de declararse. Después se cerró de verdad: bubblewrap envuelve la ejecución en un scope de systemd con TasksMax, que el kernel traduce a pids.max. El control vuelve a declararse, pero solo donde el sondeo demuestra que el host lo admite — donde no hay gestor de usuario de systemd, sigue sin declararse. Ver B-01.
3. --strict aprobaba sin haber medido nada#
En el primer run de CI, bubblewrap no llegĂł a ejecutar ninguna sonda (AppArmor restringĂa los user namespaces en el runner) y las ocho quedaron «no concluyente». --strict pasĂł igual, porque solo miraba si algo habĂa escapado. Cero fugas de cero mediciones no es contenciĂłn: es no haber mirado.
Corregido: --strict falla si un runtime está disponible y ninguna sonda llegĂł a un veredicto. Y el detalle de una sonda sin salida arrastra ahora la Ăşltima lĂnea de stderr — fue lo que permitiĂł diagnosticar la causa exacta (bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted) en un solo intento.
4. La sonda de filesystem sobre-reportaba#
Contaba como fuga poder escribir en / y /etc dentro del sandbox. Pero la raĂz que monta bubblewrap es un tmpfs efĂmero: escribir ahĂ no toca al host.
Corregido: la sonda resuelve el tipo de filesystem en /proc/mounts y solo cuenta como fuga la escritura en un sistema persistente.
🤖 En integración continua#
El trabajo isolation de CI instala bubblewrap y ejecuta tres comprobaciones que se sostienen entre sĂ:
- bubblewrap debe contenerlo todo —
escape --runtime bwrap --strict.
Si deja de contener una dimensiĂłn, el build se cae.
- unshare debe cortar red y PIDs — no ofrece jaula de filesystem y se
documenta asĂ, pero esas dos dimensiones son obligatorias.
- native debe ESCAPAR — contraprueba deliberada. Si sin aislamiento
saliera todo contenido, las sondas no estarĂan midiendo nada y los âś… de bubblewrap no valdrĂan nada.
La tercera es la que impide que la suite se degrade en silencio.
Lo que mide hoy el runner#
âś… bwrap contiene network: sin salida TCP ni resoluciĂłn DNS
âś… bwrap contiene filesystem: ninguna ruta sensible del host es legible
âś… bwrap contiene process: solo 2 PIDs visibles, propio PID 2
âś… bwrap contiene environment: 4 variables, ninguna sensible
âś… bwrap contiene privilege: sin capabilities peligrosas (CapEff=0x0000000000000000)
âś… bwrap contiene memory: MemoryError tras 96 MB con presupuesto de 128 MB
âś… unshare contiene network: sin salida TCP ni resoluciĂłn DNS
âś… unshare contiene process: solo 1 PIDs visibles, propio PID 1
âś… native escapa por 3 dimensiones (esperado): network, filesystem, process
Qué hace fallar el build#
| Situación | ¿Falla? | Por qué |
|---|---|---|
| Falsa garantĂa (declara y no aplica) | âś… sĂ | Es la clase de fallo que el proyecto persigue |
| Ninguna sonda pudo medirse | âś… sĂ | Un informe sin mediciones no prueba contenciĂłn |
| Fuga en un control no declarado | ❌ no | Es un hueco honesto y documentado (processes) |
| Runtime ausente en el host | ❌ no | No hay nada que medir |
Hacer fallar el build por un hueco documentado dejarĂa dos salidas —silenciar la sonda o declarar un control inexistente— y las dos empeoran el sistema.
➕ Añadir una sonda#
- Crea la carga en
workloads/escape/<id>/con suprobe.pyy su
manifest.json.
- Imprime al menos una lĂnea con el contrato
probe= dimension= result= detail=.
- RegĂstrala en
escape-suite/suite.jsoncon su dimensiĂłn y el control del
modelo que mide.
node scripts/validate-config.mjscomprueba que la sonda apunta a una carga
registrada, a una dimensiĂłn declarada y a un control conocido.
Las pruebas de contrato en crates/sandbox-core/tests/repository.rs verifican además que ninguna dimensión se quede sin sonda.
🔗 Ver también#
- Modelo de amenazas — qué protege el sistema y qué no
- Formato de evidencia