🛡️ sandbox-labs GitHub ↗

09 · Runner de CI con pull request externo#

En una frase, para cualquiera: cuando un desconocido propone un cambio a tu proyecto, tu sistema de integración continua ejecuta su código automáticamente, en una máquina que tiene las llaves para publicar. Este caso separa esas dos cosas.

Estado real: 🟡 building — hay código y 7 comprobaciones automáticas, sin levantarse bajo bwrap en CI · Carpeta: cases/09-ci-untrusted-pr/ · Puerto: 8809


Por qué se realiza este caso#

Es el sitio donde ejecutar código de desconocidos está institucionalizado. Alguien abre un pull request y, sin que nadie lo lea, arrancan las pruebas. Esas pruebas son código del pull request.

Y la máquina que las ejecuta suele tener, en su entorno:

Un fallo aquí no compromete un equipo: compromete el repositorio y todo lo que despliega, y a través de él a todo el que lo instale.

Lo que el pull request puede intentarConsecuencia
Imprimir las variables de entorno en el registroLos secretos quedan en un log público
Modificar el fichero de configuración de CISu propio código decide qué permisos tiene
Escribir en la caché compartidaLa siguiente ejecución, de otra rama, usa lo que dejó
Conectarse a un servidor propioExfiltrar lo que encuentre
Firmar un artefactoPublicar algo que otros aceptarán como auténtico

La idea que enseña, y que ningún otro caso enseña#

El código no puede alcanzar la credencial que lo ejecuta. No es aislar el sistema de ficheros ni la red: es que el secreto no esté en el mismo sitio que el código no confiable, ni en el entorno, ni en un fichero, ni en un agente accesible.

Casos de uso reales#

Cómo funcionará#

flowchart LR
  PR["🔀 Pull request<br/>de un desconocido"] --> C["📥 Checkout aislado"]
  C --> J
  subgraph J["🔒 Jaula sin secretos · red limitada"]
    T["🧪 Ejecutar las pruebas"]
  end
  J --> A["📦 Artefactos"]
  J --> L["📄 Logs filtrados"]
  S["🔑 Token de CI · claves"] -.->|"NO alcanzable"| J
  A --> P["✅ Publicación, en un paso aparte<br/>que sí tiene el token"]

La forma del diagrama es el diseño: dos etapas. La que ejecuta código ajeno no tiene llaves; la que tiene llaves no ejecuta código ajeno.

sequenceDiagram
  participant PR as Pull request
  participant R as Runner sin secretos
  participant G as Guardián
  participant D as Etapa con credenciales
  PR->>R: código
  R->>R: pruebas en jaula, red limitada
  R->>G: artefactos + logs
  G->>G: filtrar logs, verificar artefactos
  G->>D: solo si un humano aprueba
  D->>D: firmar y publicar

Esquemas#

Configuración de la ejecución#

{
  "pullRequest": 1234,
  "trusted": false,
  "secrets": [],
  "network": { "mode": "allowlist", "hosts": ["registry.ejemplo.com:443"] },
  "artifacts": { "path": "dist/", "maxBytes": 52428800 }
}

ejecución no ocurre.

Acta#

{
  "outcome": "passed",
  "secretsPresent": false,
  "networkAttempts": [
    { "host": "registry.ejemplo.com:443", "outcome": "permitido" },
    { "host": "203.0.113.7:443", "outcome": "bloqueado por lista de permitidos" }
  ],
  "artifacts": [{ "name": "dist/app.tar.gz", "sha256": "…" }]
}

Software necesario#

ComponentePara qué
Rust 1.75+El supervisor de la ejecución
bubblewrap 0.6+La jaula del runner
El proxy de salida con lista de permitidosYa construido: es lo que limita la red
gitCheckout aislado
Linux o WSL2Namespaces sin privilegios

Instalación#

sudo apt install bubblewrap git
cargo build --release

Procesos que se crearán#

sandboxctl ci run --pr 1234
  │
  ├─ checkout aislado          ← carpeta temporal, sin acceso al resto
  ├─ proxy de salida           ← registra cada conexión intentada
  │
  ├─ systemd --user scope      ← límites de memoria, CPU y procesos
  │   └─ bwrap                 ← entorno VACÍO: ni un secreto
  │       └─ las pruebas del pull request
  │
  └─ recolector de artefactos  ← fuera de la jaula

Tiempo de carga estimado#

OperaciónCoste esperado
Checkout aisladosegundos, según el repositorio
Arranque de la jaula5–15 ms
Ejecución de las pruebaslo que tarden, con techo por política
Filtrado de logsproporcional al tamaño del log

Qué hace falta para construirlo#

  1. Checkout aislado que no toque el árbol de trabajo real.
  2. Entorno vacío verificado: una sonda que compruebe que no hay secretos dentro.
  3. Red por lista de permitidos, con registro de cada intento.
  4. Recolección de artefactos con checksum, fuera de la jaula.
  5. Filtrado de logs para que un secreto no llegue al registro ni por accidente.
  6. Separación en dos etapas, con aprobación humana entre ellas.

Si algo falla#

El caso ya tiene código: el núcleo en core.py y el servicio en app.py. Lo que sigue son sus fallos, la causa y la salida:

SituaciónCausaCómo se resuelve
Las pruebas del pull request necesitan un secretoCon trusted: false no hay secretos, y no es configurableSe parte el flujo: la etapa que ejecuta código ajeno no tiene llaves; la que tiene llaves no ejecuta código ajeno, y entre las dos hay aprobación humana
Las pruebas fallan por falta de redLa lista de permitidos no incluye lo que necesitanAñadir el destino concreto a network.hosts. Nunca abrir la red entera: el registro de intentos es lo que da valor al caso
Un secreto aparece en el logEl filtrado falló, o el secreto llegó por otra vía1. Rotar el secreto de inmediato, un log público no se borra. 2. Comprobar que el entorno del runner estaba realmente vacío con la sonda de entorno
El checkout toca el árbol de trabajo realSe aisló malEl checkout va a una carpeta temporal montada solo dentro de la jaula. Si el árbol real cambió, es un fallo del supervisor y tumba el build
Un artefacto no llega a la etapa de publicaciónLa recolección se hace fuera de la jaulaComprobar tamaño frente a artifacts.maxBytes y el checksum. Un artefacto sin checksum no se publica

Los fallos que afectan a cualquier caso —no se puede crear el sandbox, no hay cgroups, un puerto ocupado, procesos huérfanos, la compilación en Windows— están resueltos uno a uno en Cuando algo falla.

Cómo se comprueba#

node scripts/verify-cases.mjs

Llama al núcleo del caso con situaciones concretas y comprueba qué hizo con ellas, no cómo está escrito. Son 7 comprobaciones, y corren en cada commit.

cargo run -p sandboxctl -- service up ci-untrusted-pr

Levanta el caso como producto en 127.0.0.1:8809. POST /api/run acepta el cuerpo que describen los esquemas de arriba.

Sigue en building, no en functional. El núcleo se comprueba, pero el servicio no se levanta bajo bwrap dentro de CI y el caso no emite evidencia firmada. La regla completa está en el ROADMAP.

Ver también: Catálogo completo · Caso 10 · construcción de paquetes · Referencia de políticas