Files
SergioandClaude Opus 5 1d9ddcee37 qorpa D10: Steam en la mano, y los tres muros que sólo se ven así
Paso 6 del ADR 0015, PARCIAL y dicho como parcial. Steam 1.0.0.87 instalado de
verdad desde el multilib de Arch —las libs de 32 bits que la F2 del plan de
juegos daba por «una campaña entera»—, su cliente i386 bajado y desempacado por
el bootstrap de Valve junto con el Steam Runtime, y el BWRAP ANIDADO VERIFICADO
EXPLÍCITAMENTE (24 montajes propios dentro de la instancia), que es lo que este
paso pedía. Lo que NO se pudo: la máquina no tiene sesión gráfica, y el runtime
sniper sólo se baja al instalar un juego, que exige credenciales ⇒
pressure-vessel con un juego real sigue sin ejercitarse. Se dice, no se insinúa.

Tres muros, ninguno en el ADR:

1. LA JAULA MATABA TODOS LOS BINARIOS DE 32 BITS. El filtro seccomp comprueba
   arch == x86_64 y MATA lo que no lo sea; para un build es la defensa clásica y
   correcta, para el montón B es fatal porque el cliente de Steam es un ELF i386.
   El síntoma fue `ldd: exited with unknown exit code (159)` = 128+31 = SIGSYS,
   que no se parece en nada a la causa. La salida no es aflojar el check sino
   darle a i386 su propia tabla con la MISMA política. Los 25 números se
   verificaron uno por uno contra /usr/include/asm/unistd_32.h: 24 bien y UNO
   MAL — kexec_file_load no existe en i386 y su número de x86_64 (320) es ahí
   `utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que
   toque una marca de tiempo. Es la diferencia entre una tabla de memoria y una
   verificada.

2. STEAM SE NIEGA A CORRER COMO ROOT, y cambiar el mapa para evitarlo CORROMPE
   la instancia: un fichero creado bajo un mapa aparece con otro uid bajo el
   otro, así que el useradd de la preparación deja un /home que su propio dueño
   no puede escribir. ⇒ el mapa es parte de la IDENTIDAD de la instancia. La vía
   correcta es la de cualquier runtime de contenedores: un solo mapa y se BAJA de
   privilegio adentro — campo `run_as`, setpriv, con el CAP_SETUID que ya
   tenemos en el namespace.

3. EL XDG_RUNTIME_DIR ES DEL USUARIO QUE CORRE, no del uid del mapa: con
   `run_as`, apuntarlo al de root deja al Steam Runtime sin poder crear su
   temporal. Se lee como un aviso menor hasta que algo deja de andar sin decir
   por qué.

Lo que sí quedó probado, y es el corazón del ADR: un userland glibc ajeno con su
cadena de 32 bits completa corre enjaulado sobre nuestro kernel, con seccomp y
no_new_privs puestos, y un contenedor anidado funciona adentro — la forma exacta
en que Valve prueba Proton.

1 test nuevo (run_as resuelve uid/gid/home del passwd de la imagen, y un run_as
inexistente NO cae a root). 48/48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
2026-09-03 12:01:32 +00:00

398 lines
21 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// harkaq-exec — aplica la política y ejecuta el builder (SDD 16 §4). Corre DENTRO de bwrap,
// 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 <fichero> [--abi-min 3] [--allow-nesting] [--no-landlock] -- <cmd> [...]
//
// Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se
// DERIVA, no se escribe):
// ro /usr/include/zlib.h un fichero de la clausura: leer/ejecutar SÓLO él
// ro /opt/zig un directorio: todo el subárbol
// list /usr/include listar el directorio, SIN poder leer su contenido
// rw /out
//
// El grano POR FICHERO no es capricho: el sandbox de hammer funde las deps y el rootfs Alpine
// en el mismo `/usr` (`--overlay-src` × N + `--tmp-overlay /`), así que /usr/include/zlib.h
// (dep declarada) y /usr/include/stdio.h (Alpine, no declarado) son el MISMO directorio. Una
// regla sobre /usr concede los dos y la evidencia no vale nada. En el host sí sabemos qué
// ficheros aporta cada dep ⇒ la clausura se enumera fichero a fichero.
//
// `list` existe por la misma razón: pkgconf/configure NECESITAN listar /usr/lib/pkgconfig, pero
// listar no es leer. Landlock separa READ_DIR de READ_FILE, así que se concede el listado del
// directorio y se deniega la lectura de los .pc que la receta no declaró.
//
// Estático a propósito: corre dentro del rootfs Alpine (musl) del sandbox.
//
// D7 (fallo cerrado, honesto sobre el ABI):
// ABI < min → RECHAZA el build (sin REFER/TRUNCATE no compila software real)
// min <= ABI < 7 → corre, pero avisa SIN EVIDENCIA (no hay audit de denegaciones)
// ABI >= 7 → corre con LOG_NEW_EXEC_ON: la evidencia existe
// Nunca se afirma evidencia que el kernel no respaldó.
#define _GNU_SOURCE
#include "harkaq-uapi.h"
#include <errno.h>
#include <fcntl.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
static int ll_create(const struct landlock_ruleset_attr *a, size_t n, __u32 f) {
return syscall(SYS_landlock_create_ruleset, a, n, f);
}
static int ll_add(int fd, enum landlock_rule_type t, const void *a, __u32 f) {
return syscall(SYS_landlock_add_rule, fd, t, a, f);
}
static int ll_restrict(int fd, __u32 f) {
return syscall(SYS_landlock_restrict_self, fd, f);
}
// Conjuntos de derechos por ABI. Cada uno AÑADE a los anteriores; recortamos al ABI real del
// kernel (D7 / §3.1: se consulta el ABI por syscall, JAMÁS por versión de kernel — hay
// backports y el número miente).
#define FS_ABI1 \
(LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | \
LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \
LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | \
LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | \
LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_SOCK | \
LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK | \
LANDLOCK_ACCESS_FS_MAKE_SYM)
#define FS_ABI2 (FS_ABI1 | LANDLOCK_ACCESS_FS_REFER)
#define FS_ABI3 (FS_ABI2 | LANDLOCK_ACCESS_FS_TRUNCATE)
#define FS_ABI5 (FS_ABI3 | LANDLOCK_ACCESS_FS_IOCTL_DEV)
#define RO_RIGHTS \
(LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE)
// Derechos que Landlock SÓLO admite sobre directorios. Añadir una regla con cualquiera de
// éstos sobre un fichero regular hace que el kernel rechace la regla entera con EINVAL — y
// como la clausura es mayormente ficheros sueltos (§4), sin esta máscara no arranca nada.
#define DIR_ONLY_RIGHTS \
(LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | \
LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | \
LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \
LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | \
LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM | \
LANDLOCK_ACCESS_FS_REFER)
// ------------------------------------------------------------------ seccomp (D4)
//
// D4: "Landlock no basta". Landlock cubre fs, puertos TCP e IPC scoping — no cubre ptrace, bpf,
// userfaultfd, keyctl, io_uring, montajes, ni la mitad de la superficie interesante.
//
// DENYLIST, no allowlist (corrige a D4, que pedía allowlist). Un allowlist por syscall para
// builds ARBITRARIOS —compiladores, make, shells, linkers, perl— es un blanco móvil: cada
// herramienta nueva lo rompe. Es el riesgo del §7 ("los falsos positivos matan proyectos de
// sandboxing") aplicado a las syscalls, y con peor final: un build que muere por una syscall
// legítima no da un diagnóstico útil, da un misterio. Lo *peligroso*, en cambio, es enumerable
// y estable: no hay build honesto que cargue módulos, haga kexec o attachee un ptrace.
//
// EPERM y no KILL: matar el proceso deja un cadáver sin explicación; EPERM deja al build fallar
// donde corresponde y al log decir por qué. Y lo que importa —la evidencia— la sigue dando
// Landlock; esto sólo recorta superficie de escape.
static const int SYSCALLS_DENEGADAS[] = {
// io_uring: superficie de kernel enorme y bypassea buena parte del análisis por-syscall.
// Denegado explícitamente en v1 (D4). Si un builder lo necesita, que lo justifique.
#ifdef __NR_io_uring_setup
__NR_io_uring_setup, __NR_io_uring_enter, __NR_io_uring_register,
#endif
// Inspección/inyección entre procesos: un build no necesita mirar dentro de otro.
__NR_ptrace, __NR_process_vm_readv, __NR_process_vm_writev,
#ifdef __NR_bpf
__NR_bpf,
#endif
#ifdef __NR_userfaultfd
__NR_userfaultfd, // usado para ganar carreras TOCTOU en el kernel
#endif
// Llavero del kernel: ni se lee ni se siembra desde un build.
__NR_keyctl, __NR_add_key, __NR_request_key,
// Módulos y kexec: un build que toca esto no es un build.
__NR_init_module, __NR_finit_module, __NR_delete_module, __NR_kexec_load,
#ifdef __NR_kexec_file_load
__NR_kexec_file_load,
#endif
// perf: superficie histórica de escalada.
__NR_perf_event_open,
};
// 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,
#endif
// Salirse de los namespaces que bwrap puso.
__NR_setns,
};
// TECHO DE LA LISTA, y por qué no es un detalle: `jt`/`jf` de la BPF clásica son `__u8`. En la
// forma de abajo el salto más largo mide n+3, así que a partir de ~252 entradas el desplazamiento
// DA LA VUELTA y el filtro deja de decir lo que dice este fichero — sin error y sin aviso. Hoy son
// 25 y sobra sitio; el riesgo es que agregar syscalls a una denylist es exactamente lo que uno
// hace sin pensarlo. Esto no genera código: es un portón de compilación.
// (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_ANIDAMIENTO)) * 2 /
sizeof(SYSCALLS_DENEGADAS[0]) <= 240,
"la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro");
// ── i386: por qué hay una SEGUNDA tabla de números ──────────────────────────────────────────────
//
// El filtro de abajo comprueba la arquitectura y MATA lo que no sea x86_64. Para un build es
// correcto y es la defensa clásica: los números de syscall son POR ARCH, y sin el check un binario
// i386 pediría otra syscall con el mismo número. Para una instancia ajena es FATAL, y se midió:
// el cliente de Steam es un ELF i386, y moría con **SIGSYS** (`exit code 159`) antes de imprimir
// una línea. Un `ldd` que devuelve 159 no se parece en nada a «tu jaula mata los 32 bits».
//
// La salida NO es aflojar el check —eso reabre el agujero— sino darle a i386 su propia tabla y
// aplicarle la MISMA política. Los números van literales porque compilando para x86_64 no hay
// `__NR_*` de i386; son ABI estable, Linux no los renumera nunca.
#define I386_ARCH_TABLA_BASE \
26, /* ptrace */ \
347, 348, /* process_vm_readv, process_vm_writev */ \
357, /* bpf */ \
374, /* userfaultfd */ \
288, 286, 287, /* keyctl, add_key, request_key */ \
128, 350, 129, /* init_module, finit_module, delete_module */ \
283, /* kexec_load — `kexec_file_load` NO EXISTE en i386: poner su número de x86_64 (320)
habría denegado `utimensat`, que en i386 tiene ESE número y la usa cualquier cosa
que toque una marca de tiempo. Verificado contra
/usr/include/asm/unistd_32.h, los 25 números uno por uno; éste era el único mal. */ \
336, /* perf_event_open */ \
425, 426, 427 /* io_uring_{setup,enter,register} */
#define I386_ARCH_TABLA_ANIDAMIENTO \
21, 52, /* mount, umount2 */ \
428, 429, 430, 431, 432, /* open_tree, move_mount, fsopen, fsconfig, fsmount */ \
346 /* setns */
static const int SYSCALLS_DENEGADAS_I386[] = {I386_ARCH_TABLA_BASE};
static const int SYSCALLS_ANIDAMIENTO_I386[] = {I386_ARCH_TABLA_ANIDAMIENTO};
// Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó.
static int poner_seccomp(int permitir_anidamiento) {
const int n64b = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]));
const int n64a = permitir_anidamiento
? 0
: (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0]));
const int n32b = (int)(sizeof(SYSCALLS_DENEGADAS_I386) / sizeof(SYSCALLS_DENEGADAS_I386[0]));
const int n32a =
permitir_anidamiento
? 0
: (int)(sizeof(SYSCALLS_ANIDAMIENTO_I386) / sizeof(SYSCALLS_ANIDAMIENTO_I386[0]));
const int n64 = n64b + n64a, n32 = n32b + n32a;
int *a64 = calloc(n64 ? n64 : 1, sizeof(int));
int *a32 = calloc(n32 ? n32 : 1, sizeof(int));
if (!a64 || !a32) { free(a64); free(a32); return -1; }
for (int i = 0; i < n64b; i++) a64[i] = SYSCALLS_DENEGADAS[i];
for (int i = 0; i < n64a; i++) a64[n64b + i] = SYSCALLS_ANIDAMIENTO[i];
for (int i = 0; i < n32b; i++) a32[i] = SYSCALLS_DENEGADAS_I386[i];
for (int i = 0; i < n32a; i++) a32[n32b + i] = SYSCALLS_ANIDAMIENTO_I386[i];
// Disposición: [arch?x86_64] → tabla 64 → ALLOW ; si no, [arch?i386] → tabla 32 → ALLOW ;
// si no, KILL. El desplazamiento de un salto BPF clásico es __u8, así que todo esto tiene que
// caber en 255 instrucciones — de ahí el _Static_assert de arriba, que ahora suma las cuatro
// tablas.
const int len = 2 + 1 + n64 + 1 + 2 + 1 + n32 + 1 + 2;
struct sock_filter *f = calloc(len, sizeof(*f));
if (!f) { free(a64); free(a32); return -1; }
const int I_I386 = 2 + 1 + n64 + 1; // donde arranca la rama de 32 bits
const int I_ERRNO = len - 2;
const int I_KILL = len - 1;
int k = 0;
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, arch));
const int I_ARCH64 = k++;
f[I_ARCH64] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0,
I_I386 - I_ARCH64 - 1);
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr));
for (int i = 0; i < n64; i++, k++)
f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a64[i],
I_ERRNO - k - 1, 0);
f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
// Rama i386. Hay que RECARGAR `arch`: el acumulador quedó con el número de syscall.
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, arch));
const int I_ARCH32 = k++;
f[I_ARCH32] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_I386, 0,
I_KILL - I_ARCH32 - 1);
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr));
for (int i = 0; i < n32; i++, k++)
f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)a32[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));
f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS);
struct sock_fprog prog = {.len = (unsigned short)k, .filter = f};
// 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(a64);
free(a32);
return r;
}
int main(int argc, char **argv) {
const char *policy = NULL;
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) {
fprintf(stderr, "uso: harkaq-exec --policy <f> [--abi-min N] -- <cmd> [args...]\n");
return 2;
}
char **cmd = &argv[i];
int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < abi_min) {
// Fallo CERRADO: no correr un build que creemos enjaulado y no lo está.
fprintf(stderr,
"[harkaq] RECHAZO: ABI de Landlock = %d, se exige >= %d.\n"
" Sin REFER/TRUNCATE no compila software real y la jaula sería ficción.\n",
abi, abi_min);
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);
if (rs < 0) { perror("[harkaq] create_ruleset"); return 4; }
FILE *f = fopen(policy, "r");
if (!f) { perror("[harkaq] abrir política"); return 4; }
char line[4200];
while (fgets(line, sizeof(line), f)) {
char *nl = strchr(line, '\n');
if (nl) *nl = 0;
if (!line[0] || line[0] == '#') continue;
__u64 grant;
char *p;
if (!strncmp(line, "ro ", 3)) { grant = RO_RIGHTS; p = line + 3; }
else if (!strncmp(line, "rw ", 3)) { grant = handled; p = line + 3; }
else if (!strncmp(line, "list ", 5)) { grant = LANDLOCK_ACCESS_FS_READ_DIR; p = line + 5; }
else {
fprintf(stderr, "[harkaq] política inválida: %s\n", line);
return 4;
}
int dfd = open(p, O_PATH | O_CLOEXEC);
if (dfd < 0) {
// Una entrada de la clausura que no existe es un bug de harkaq-policy, no del
// build. Fallar ruidoso: si la ignoráramos, la política sería MÁS chica que la
// clausura y el build denegaría cosas legítimas — falsos positivos que matan
// el proyecto (§7).
fprintf(stderr, "[harkaq] la política nombra un path inexistente: %s (%m)\n", p);
return 4;
}
// Recortar los derechos de-sólo-directorio cuando el destino NO lo es: el kernel
// rechazaría la regla entera con EINVAL. Es el caso MAYORITARIO — la clausura es una
// lista de ficheros.
struct stat st;
int is_dir = (fstat(dfd, &st) == 0) && S_ISDIR(st.st_mode);
if (!is_dir) grant &= ~(__u64)DIR_ONLY_RIGHTS;
grant &= handled; // nunca conceder más de lo que el ruleset declara manejar
if (!grant) {
fprintf(stderr, "[harkaq] regla vacía tras recortar por tipo: %s\n", p);
close(dfd);
return 4;
}
struct landlock_path_beneath_attr pb = {.allowed_access = grant, .parent_fd = dfd};
if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) {
fprintf(stderr, "[harkaq] add_rule(%s): %m\n", p);
close(dfd);
return 4;
}
close(dfd);
is_dir ? n_dir++ : n_file++;
}
fclose(f);
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("[harkaq] no_new_privs"); return 4; }
// LOG_NEW_EXEC_ON: el flag del que cuelga TODO. harkaq restringe y DESPUÉS ejecuta el
// builder; sin este flag el kernel no loguea ninguna denegación posterior al execve y
// harkaq certificaría como herméticos todos los builds, para siempre, sin un solo error
// visible (medido: §3.4). No es configuración, es la diferencia entre el sistema y un
// sello de goma.
__u32 flags = 0;
if (abi >= 7) flags |= LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON;
if (abi >= 8) flags |= LANDLOCK_RESTRICT_SELF_TSYNC; // D5: cubre todos los hilos
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(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 (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",
abi);
else
fprintf(stderr,
"[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, "
"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]);
return 127;
}