🛡️ sandbox-labs GitHub ↗

🛡️ 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:

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ónQué intenta la sondaPor qué importa
networkConectar por TCP y resolver DNSUna carga con red exfiltra lo que lea y descarga lo que ejecutará después
filesystemLeer secretos, escribir fuera, ver el árbol realLeer claves o escribir en el sistema convierte una ejecución en persistencia
processContar PIDs visibles, inspeccionar el PID 1Ver el árbol del host permite inspeccionar y señalizar procesos ajenos
environmentBuscar credenciales heredadasUn token en el entorno convierte cualquier ejecuciĂłn en una filtraciĂłn
privilegeLeer CapEff y uid_mapLas capabilities que sobreviven permiten montar, trazar o tocar la red
memoryPedir el doble del presupuestoSin techo, una carga tumba el host y todo lo que corra en él
processesCrear procesos hasta pasarseSin techo de PIDs, una carga agota la tabla de procesos
syscallsEjecutar llamadas que la polĂ­tica deniegaSin 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:

IntentoPor qué no sirve
mount, ptraceYa fallan con EPERM sin privilegios: la sonda aprobaría con filtro y sin él
perf_event_openSu 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».

Las sondas de red, filesystem, proceso, entorno y privilegios son de riesgo 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#

VeredictoSignificado
âś… contenidoLa sonda intentĂł salirse y no pudo
❌ escapóLa sonda se salió: el control no se aplica en este host
❌ DECLARADOEl runtime dice que aplica el control y la sonda demostró que no
⚠️ no concluyenteNo se pudo medir (error de la sonda, salida vacía)
— no aplicaEl 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.

Que la suite encontrara fallos en el propio repositorio es la mejor prueba de que mide algo. Una suite que siempre sale verde no está midiendo: está decorando.

🤖 En integración continua#

El trabajo isolation de CI instala bubblewrap y ejecuta tres comprobaciones que se sostienen entre sĂ­:

  1. bubblewrap debe contenerlo todo — escape --runtime bwrap --strict.

Si deja de contener una dimensiĂłn, el build se cae.

  1. unshare debe cortar red y PIDs — no ofrece jaula de filesystem y se

documenta asĂ­, pero esas dos dimensiones son obligatorias.

  1. 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❌ noEs un hueco honesto y documentado (processes)
Runtime ausente en el host❌ noNo 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#

  1. Crea la carga en workloads/escape/<id>/ con su probe.py y su

manifest.json.

  1. Imprime al menos una lĂ­nea con el contrato

probe= dimension= result= detail=.

  1. RegĂ­strala en escape-suite/suite.json con su dimensiĂłn y el control del

modelo que mide.

  1. node scripts/validate-config.mjs comprueba 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#