diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index 68ae8b96..883b49b7 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -102,6 +102,10 @@ pub enum QorpaCmd { /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable. #[arg(long)] dry_run: bool, + /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar + /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos. + #[arg(long)] + no_jail: bool, /// Comando dentro (default: el shell de la imagen). Va después de `--`. #[arg(last = true)] cmd: Vec, @@ -122,7 +126,9 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { create(&resolve_root(root), &id, &base, distro.as_deref()) } QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id), - QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run), + QorpaCmd::Run { id, root, dry_run, no_jail, cmd } => { + run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail) + } QorpaCmd::Export { .. } => bail!( "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \ `Exec=` de un .desktop ajeno." @@ -503,7 +509,8 @@ struct Instance { grants: Grants, } -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize)] +#[serde(default)] struct Grants { /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1). @@ -517,6 +524,115 @@ struct Grants { /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan. #[serde(default)] network: bool, + /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su + /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la + /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto. + #[serde(default)] + nesting: bool, + /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`. + /// + /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado + /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de + /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes + /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con + /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga + /// hoy `apt` con su `setgroups`. + #[serde(default = "si")] + root: bool, + /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque + /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en + /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en + /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya. + #[serde(default)] + seal_image: bool, +} + +/// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`). +fn harkaq_exec() -> PathBuf { + std::env::var("HARKAQ_BIN") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) + .join(".cache/harkaq") + }) + .join("harkaq-exec") +} + +/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela. +const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"]; +/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen. +const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"]; + +/// Compila las concesiones a la política que consume `harkaq-exec`. +/// +/// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ───────────────────────────────────────────────────────────── +/// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una +/// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea +/// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla. +/// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben. +/// +/// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco: +/// 1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`, +/// `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie +/// importa MÁS con un prebuilt de terceros que con nuestros propios builds. +/// 2. **`no_new_privs`**: ningún setuid de la imagen escala adentro. +/// 3. **el canal de evidencia**: con ABI≥7 el kernel audita las denegaciones ⇒ se puede saber +/// qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B. +/// 4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la +/// imagen aunque adentro seas root. +fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> { + use std::fmt::Write as _; + let mut out = String::new(); + out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n"); + out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n"); + out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n"); + + if !g.seal_image { + // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché. + out.push_str("rw /\n"); + } else { + // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir. + out.push_str("list /\n# la imagen, congelada:\n"); + for d in IMAGEN_INMUTABLE { + if image_tree.join(d).exists() { + let _ = writeln!(out, "ro /{d}"); + } + } + out.push_str("# lo mutable de la instancia:\n"); + let mut mutables: Vec = std::fs::read_dir(image_tree) + .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))? + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str())) + .map(|n| format!("/{n}")) + .collect(); + for d in DEL_SANDBOX { + if !mutables.iter().any(|m| m == d) { + mutables.push((*d).to_string()); + } + } + mutables.sort(); + mutables.dedup(); + for m in &mutables { + let _ = writeln!(out, "rw {m}"); + } + } + std::fs::write(dst, out) + .with_context(|| format!("escribiendo la política {}", dst.display()))?; + Ok(()) +} + + +impl Default for Grants { + fn default() -> Self { + // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide + // quién sos adentro de tu propio rootfs. + Grants { + sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(), + network: false, nesting: false, root: true, seal_image: false, + } + } } #[derive(Serialize, Deserialize)] @@ -528,6 +644,7 @@ struct DirGrant { mode: String, } fn ro() -> String { "ro".into() } +fn si() -> bool { true } fn instances_dir(root: &Path) -> PathBuf { root.join("instances") } fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) } @@ -674,7 +791,7 @@ fn grants_to_args(g: &Grants) -> (Vec, Vec) { (a, warn) } -fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<()> { +fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; let (grant_args, warns) = grants_to_args(&inst.grants); @@ -684,11 +801,20 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result< // que no se declara, no entra (D2, "por defecto NADA"). let mut args: Vec = vec![ "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try", - "--uid", "0", "--gid", "0", "--hostname", "qorpa", "--clearenv", "--die-with-parent", ].into_iter().map(String::from).collect(); + if inst.grants.root { + args.extend(["--uid", "0", "--gid", "0"].map(String::from)); + if inst.grants.nesting { + eprintln!( + "⚠ `nesting` y `root` juntos NO funcionan hoy: con el uid remapeado a 0, un userns \ + anidado no puede escribir su uid_map ⇒ pressure-vessel no arranca. Poné \ + `root = false` para juegos, o esperá al mapeo por rango (subuid + --userns FD)." + ); + } + } args.push("--overlay-src".into()); args.push(tree.display().to_string()); args.push("--overlay".into()); @@ -707,9 +833,51 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result< ].map(String::from)); args.extend(grant_args); - let cmd: Vec = if cmd.is_empty() { + let mut cmd: Vec = if cmd.is_empty() { vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar. } else { cmd.to_vec() }; + + // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya + // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario + // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host + // es la ABI del kernel. + if !no_jail { + let exec = harkaq_exec(); + if !exec.exists() { + bail!( + "no encuentro harkaq-exec en {} — la instancia NO va a correr enjaulada y eso no se \ + hace en silencio.\n construilo: gcc -O1 -Wall -static -o {} scripts/harkaq/harkaq-exec.c\n o corré con --no-jail si sabés lo que estás haciendo.", + exec.display(), exec.display() + ); + } + let policy = dir.join("policy"); + compile_policy(&inst.grants, &tree, &policy)?; + + args.push("--ro-bind".into()); args.push(exec.display().to_string()); + args.push("/run/qorpa/harkaq-exec".into()); + args.push("--ro-bind".into()); args.push(policy.display().to_string()); + args.push("/run/qorpa/policy".into()); + + let mut envoltura: Vec = vec![ + "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(), + ]; + if inst.grants.nesting { + // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con + // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un + // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de + // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un + // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap, + // que ya es una lista blanca. + envoltura.push("--allow-nesting".into()); + envoltura.push("--no-landlock".into()); + } + envoltura.push("--".into()); + envoltura.extend(cmd); + cmd = envoltura; + } else { + eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \ + de bwrap. No es el modo normal."); + } args.extend(cmd.iter().cloned()); for w in &warns { @@ -725,9 +893,12 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result< println!("{linea}"); return Ok(()); } - eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s)", + eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \ + jaula={} · imagen={}", &sha[..12], inst.grants.network, inst.grants.sockets.len(), - inst.grants.devices.len(), inst.grants.dirs.len()); + inst.grants.devices.len(), inst.grants.dirs.len(), + if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" }, + if inst.grants.seal_image { "sellada" } else { "escribible" }); let st = Command::new("bwrap").args(&args).status() .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?; @@ -1011,6 +1182,49 @@ mod tests { assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); } + #[test] + fn sin_sellar_la_politica_no_finge_confinar_el_fs() { + let d = tempfile::tempdir().unwrap(); + imagen_falsa(d.path(), "d".repeat(64).as_str()); + let tree = d.path().join("images").join("d".repeat(64)).join(TREE); + let dst = d.path().join("policy"); + compile_policy(&Grants::default(), &tree, &dst).unwrap(); + let pol = std::fs::read_to_string(&dst).unwrap(); + // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian + // nada sería un sello de goma, así que la política se queda en una línea honesta. + assert!(pol.lines().any(|l| l == "rw /"), "{pol}"); + assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}"); + } + + #[test] + fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() { + let d = tempfile::tempdir().unwrap(); + let sha = "e".repeat(64); + imagen_falsa(d.path(), &sha); + let tree = d.path().join("images").join(&sha).join(TREE); + std::fs::create_dir_all(tree.join("var")).unwrap(); + std::fs::create_dir_all(tree.join("usr")).unwrap(); + let dst = d.path().join("policy"); + compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap(); + let pol = std::fs::read_to_string(&dst).unwrap(); + assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}"); + assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}"); + assert!(pol.lines().any(|l| l == "rw /var"), "{pol}"); + assert!(pol.lines().any(|l| l == "list /"), "{pol}"); + // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro. + assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}"); + // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente. + assert!(!pol.contains("/boot"), "{pol}"); + } + + #[test] + fn root_es_lo_unico_que_nace_encendido() { + let g = Grants::default(); + assert!(g.root, "sin esto un gestor de paquetes no arranca"); + assert!(!g.network && !g.nesting && !g.seal_image); + assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty()); + } + #[test] fn sha_mal_formado_falla_antes_de_tocar_la_red() { assert!(normalize_sha256("abc").is_err()); diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 37e4d5e3..cb4aba57 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -285,6 +285,42 @@ máquinas, que es lo que ya hace ADR 0013 con las fuentes) hay que mirar licenci redistribuir un rootfs de Ubuntu o Fedora sin modificar suele estar permitido, pero la política de marca es cosa aparte y no es una pregunta técnica. +### D9 — Qué aporta harkaq a una instancia, y los dos conflictos que sólo se ven midiendo + +**Honestidad primero: en el eje del sistema de ficheros, harkaq casi no agrega nada.** El namespace +de montaje de bwrap ya es una lista blanca —lo que no se bindea no existe adentro, y un `--ro-bind` +es de sólo lectura por el kernel— así que escribir reglas Landlock que repiten eso sería un sello de +goma. Por eso la política de una instancia **no sellada** es una sola línea (`rw /`) y no finge. + +Lo que harkaq sí aporta, y pesa mucho más con un binario ajeno y opaco que con nuestros propios +builds: + +| aporte | por qué importa acá | +|---|---| +| **seccomp** | bwrap **no instala filtro alguno**: hoy una instancia puede `io_uring`, `bpf`, `ptrace`, `process_vm_readv`, `userfaultfd`, `keyctl`, `perf_event_open`, `init_module`, `kexec`. Ésa es superficie de kernel contra un prebuilt de terceros | +| **`no_new_privs`** | ningún setuid de la imagen ajena escala adentro | +| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B (leer ese canal es trabajo aparte: pide el lector con `CAP_AUDIT_READ`) | +| **`seal_image`** | lo único del eje fs que el namespace no da: congelar `/usr`, `/bin`, `/lib`, `/opt` **aunque adentro seas root**, para que un exploit no persista en el `upper` | + +**Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy.** Medido: con un dominio +Landlock activo, `mount` falla con **EACCES aunque seccomp lo permita**; el kernel no admite montajes +nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no +arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de ficheros —`harkaq-exec +--allow-nesting --no-landlock`—, y **seccomp y `no_new_privs` siguen puestos**, que es lo que más +pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no +puso. + +**Conflicto 2 — `root` adentro y anidar se pelean.** Medido: con el uid remapeado a 0 (`--uid 0`), un +userns anidado **no puede escribir su `uid_map`**. Sin remapear, anida bien pero el gestor de +paquetes se queja. ⇒ la instancia de juegos y la de gestor de paquetes quieren mapeos **opuestos**, +y el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que +el impuesto de §NO-resuelve 2: **un rango real de subuid** mapeado con `newuidmap` y pasado a bwrap +con `--userns FD`. Ése es hoy el ticket que más desbloquea del ADR entero. + +**Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro +de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios, +que es exactamente como lo hace pressure-vessel. + --- ## Lo que este ADR admite que NO resuelve @@ -374,7 +410,10 @@ Se escriben acá para que no se descubran en producción. `recreate` / `run`. El overlay lo monta bwrap (`--overlay-src` + `--overlay`) dentro de su propio namespace: sin root y sin montar nada en el host. `run` entra con `--clearenv` y con `--unshare-net` salvo que se declare `network` — el entorno del host también es una concesión. -4. Concesiones → `PolicySpec` de harkaq. Empezar por **nada** y abrir sólo lo declarado. +4. ✅ **HECHO 2026-09-03.** Concesiones → política de harkaq. `harkaq-exec` entra como último + eslabón dentro de bwrap (cruza un binario **estático**, no una librería ⇒ D2 sigue en pie), y + aporta lo que bwrap no da: **seccomp** —hoy una instancia podría `io_uring`, `bpf`, `ptrace`, + `userfaultfd`, `keyctl`, `perf_event_open`—, `no_new_privs`, y el canal de evidencia. Ver D9. 5. Shims + `.desktop` generados (`export`), y clase `ajeno` en `build-state.py`. 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado. 7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias. diff --git a/scripts/harkaq/harkaq-exec.c b/scripts/harkaq/harkaq-exec.c index 0dc33cc7..01b2577f 100644 --- a/scripts/harkaq/harkaq-exec.c +++ b/scripts/harkaq/harkaq-exec.c @@ -2,7 +2,7 @@ // como último eslabón antes del `sh -c` del build: bwrap ya puso los namespaces, el overlay y // la ausencia de red; esto pone el grano fino y —lo que importa— abre el canal de evidencia. // -// harkaq-exec --policy [--abi-min 3] -- [args...] +// harkaq-exec --policy [--abi-min 3] [--allow-nesting] [--no-landlock] -- [...] // // Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se // DERIVA, no se escribe): @@ -121,8 +121,22 @@ static const int SYSCALLS_DENEGADAS[] = { #endif // perf: superficie histórica de escalada. __NR_perf_event_open, - // Montajes: bwrap ya armó el árbol; re-montar sólo sirve para escapar de él. `pivot_root` - // NO se deniega: bwrap lo usa ANTES de llegar acá (esto corre después del pivot). +}; + +// La FAMILIA DEL ANIDAMIENTO, separada del resto a propósito. +// +// Para un BUILD estas syscalls no tienen uso legítimo: bwrap ya armó el árbol y re-montar sólo +// sirve para escapar de él. Pero harkaq también enjaula instancias ajenas (qorpa, ADR 0015), y ahí +// hay un caso legítimo y central: **Steam ya es un contenedor**. Su pressure-vessel monta y crea +// namespaces DENTRO del nuestro, que es la configuración exacta que Valve prueba (ADR 0015 D6). +// Denegarle `mount` no lo hace más seguro: lo hace no arrancar. +// +// Por eso se deniegan POR DEFECTO —el build no cambia ni un byte— y sólo se levantan con +// `--allow-nesting`, que en qorpa cuelga de una concesión DECLARADA en el manifiesto. Abrir sólo +// lo declarado, otra vez. +// +// `pivot_root` no está en ninguna de las dos listas: bwrap lo usa ANTES de llegar acá. +static const int SYSCALLS_ANIDAMIENTO[] = { __NR_mount, __NR_umount2, #ifdef __NR_open_tree __NR_open_tree, __NR_move_mount, __NR_fsopen, __NR_fsconfig, __NR_fsmount, @@ -139,15 +153,24 @@ static const int SYSCALLS_DENEGADAS[] = { // (SDD 25 T17 midió además que la longitud de esta lista NO cuesta nada en ejecución — el bitmap // de acción constante del kernel la hace plana —, así que no hay ningún motivo de rendimiento // para recortarla ni para reordenarla.) -_Static_assert(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]) <= 250, +_Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) / + sizeof(SYSCALLS_DENEGADAS[0]) <= 250, "la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro"); // Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó. -static int poner_seccomp(void) { - const int n = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0])); +static int poner_seccomp(int permitir_anidamiento) { + const int n_base = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0])); + const int n_anid = permitir_anidamiento + ? 0 + : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0])); + const int n = n_base + n_anid; + int *nums = calloc(n ? n : 1, sizeof(*nums)); + if (!nums) return -1; + for (int i = 0; i < n_base; i++) nums[i] = SYSCALLS_DENEGADAS[i]; + for (int i = 0; i < n_anid; i++) nums[n_base + i] = SYSCALLS_ANIDAMIENTO[i]; // 2 (carga arch + salto) + 1 (carga nr) + n (comparaciones) + 3 (los tres RET). struct sock_filter *f = calloc(n + 6, sizeof(*f)); - if (!f) return -1; + if (!f) { free(nums); return -1; } int k = 0; const int I_ALLOW = 3 + n, I_ERRNO = 4 + n, I_KILL = 5 + n; @@ -168,7 +191,7 @@ static int poner_seccomp(void) { offsetof(struct seccomp_data, nr)); for (int i = 0; i < n; i++, k++) f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - (unsigned)SYSCALLS_DENEGADAS[i], I_ERRNO - k - 1, 0); + (unsigned)nums[i], I_ERRNO - k - 1, 0); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); @@ -179,15 +202,26 @@ static int poner_seccomp(void) { // Requiere no_new_privs, que ya está puesto antes de llamar acá. int r = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog); free(f); + free(nums); return r; } int main(int argc, char **argv) { const char *policy = NULL; - int abi_min = 3, i; + int abi_min = 3, permitir_anidamiento = 0, sin_landlock = 0, i; for (i = 1; i < argc; i++) { if (!strcmp(argv[i], "--policy") && i + 1 < argc) policy = argv[++i]; else if (!strcmp(argv[i], "--abi-min") && i + 1 < argc) abi_min = atoi(argv[++i]); + else if (!strcmp(argv[i], "--allow-nesting")) permitir_anidamiento = 1; + // MEDIDO 2026-09-03: un dominio Landlock activo hace fallar `mount` con EACCES aunque + // seccomp lo permita — el kernel no deja crear montajes nuevos bajo un dominio, porque un + // montaje nuevo escaparía de sus reglas por-ruta. Es decir: **Landlock y un runtime de + // contenedores anidado son incompatibles hoy**, y eso incluye a pressure-vessel de Valve + // (ADR 0015 D6). Para ese caso se cambia política-de-fs por poder anidar; seccomp y + // no_new_privs SIGUEN puestos, que es lo que más importa con un binario ajeno. Nunca por + // defecto, y siempre dicho en la línea de arranque: la regla de harkaq es no afirmar una + // jaula que no se puso. + else if (!strcmp(argv[i], "--no-landlock")) sin_landlock = 1; else if (!strcmp(argv[i], "--")) { i++; break; } } if (!policy || i >= argc) { @@ -206,6 +240,9 @@ int main(int argc, char **argv) { return 4; } + int n_file = 0, n_dir = 0; + if (sin_landlock) goto tras_landlock; + __u64 handled = abi >= 5 ? FS_ABI5 : abi >= 3 ? FS_ABI3 : abi >= 2 ? FS_ABI2 : FS_ABI1; struct landlock_ruleset_attr attr = {.handled_access_fs = handled}; int rs = ll_create(&attr, sizeof(attr), 0); @@ -214,7 +251,6 @@ int main(int argc, char **argv) { FILE *f = fopen(policy, "r"); if (!f) { perror("[harkaq] abrir política"); return 4; } char line[4200]; - int n_file = 0, n_dir = 0; while (fgets(line, sizeof(line), f)) { char *nl = strchr(line, '\n'); if (nl) *nl = 0; @@ -273,17 +309,24 @@ int main(int argc, char **argv) { if (ll_restrict(rs, flags) < 0) { perror("[harkaq] restrict_self"); return 4; } close(rs); +tras_landlock:; + // seccomp DESPUÉS de landlock y de no_new_privs, y justo antes del exec: el filtro se hereda // por fork/exec igual que el dominio de Landlock (D5), así que cubre todo el árbol del build. // Si el kernel lo rechaza NO se sigue: D7 es fallo cerrado — correr un build creyéndolo // enjaulado cuando le falta media jaula es peor que no correrlo. - if (poner_seccomp() < 0) { + if (poner_seccomp(permitir_anidamiento) < 0) { fprintf(stderr, "[harkaq] RECHAZO: no pude instalar el filtro seccomp (%m).\n" " ¿CONFIG_SECCOMP_FILTER=y? D4: seccomp no es opcional.\n"); return 4; } - if (abi < 7) + if (sin_landlock) + fprintf(stderr, + "[harkaq] SIN LANDLOCK por pedido explícito: seccomp y no_new_privs SÍ están, pero " + "NO hay política de ficheros ni evidencia. El confinamiento de fs queda en manos " + "del namespace de montaje de quien invoca.\n"); + else if (abi < 7) fprintf(stderr, "[harkaq] AVISO: ABI %d < 7 ⇒ el kernel no audita denegaciones. El build corre " "ENJAULADO pero SIN EVIDENCIA; el veredicto no podrá afirmar hermeticidad.\n", @@ -291,8 +334,9 @@ int main(int argc, char **argv) { else fprintf(stderr, "[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, " - "logging post-exec ON\n", - abi, n_file, n_dir); + "logging post-exec ON%s\n", + abi, n_file, n_dir, + permitir_anidamiento ? ", ANIDAMIENTO PERMITIDO (mount/setns)" : ""); execvp(cmd[0], cmd); fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]);