gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1574Shell 204Rust 111Markdown 993763 Definiciones

⎇ 5b824740790aae52ada28860bd74b14769805472 scripts/harkaq/harkaq-exec.c C · 344 líneas · 18.0 KB Historia Quién tocó qué crudo

qorpa D9: harkaq enjaula la instancia, y midiendo salieron dos conflictos 5b8247407 · Sergio ·

1// harkaq-exec — aplica la política y ejecuta el builder (SDD 16 §4). Corre DENTRO de bwrap,
2// como último eslabón antes del `sh -c` del build: bwrap ya puso los namespaces, el overlay y
3// la ausencia de red; esto pone el grano fino y —lo que importa— abre el canal de evidencia.
4//
5//   harkaq-exec --policy <fichero> [--abi-min 3] [--allow-nesting] [--no-landlock] -- <cmd> [...]
6//
7// Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se
8// DERIVA, no se escribe):
9//   ro   /usr/include/zlib.h     un fichero de la clausura: leer/ejecutar SÓLO él
10//   ro   /opt/zig                un directorio: todo el subárbol
11//   list /usr/include            listar el directorio, SIN poder leer su contenido
12//   rw   /out
13//
14// El grano POR FICHERO no es capricho: el sandbox de hammer funde las deps y el rootfs Alpine
15// en el mismo `/usr` (`--overlay-src` × N + `--tmp-overlay /`), así que /usr/include/zlib.h
16// (dep declarada) y /usr/include/stdio.h (Alpine, no declarado) son el MISMO directorio. Una
17// regla sobre /usr concede los dos y la evidencia no vale nada. En el host sí sabemos qué
18// ficheros aporta cada dep ⇒ la clausura se enumera fichero a fichero.
19//
20// `list` existe por la misma razón: pkgconf/configure NECESITAN listar /usr/lib/pkgconfig, pero
21// listar no es leer. Landlock separa READ_DIR de READ_FILE, así que se concede el listado del
22// directorio y se deniega la lectura de los .pc que la receta no declaró.
23//
24// Estático a propósito: corre dentro del rootfs Alpine (musl) del sandbox.
25//
26// D7 (fallo cerrado, honesto sobre el ABI):
27//   ABI < min      → RECHAZA el build (sin REFER/TRUNCATE no compila software real)
28//   min <= ABI < 7 → corre, pero avisa SIN EVIDENCIA (no hay audit de denegaciones)
29//   ABI >= 7       → corre con LOG_NEW_EXEC_ON: la evidencia existe
30// Nunca se afirma evidencia que el kernel no respaldó.
31
32#define _GNU_SOURCE
33#include "harkaq-uapi.h"
34#include <errno.h>
35#include <fcntl.h>
36#include <linux/filter.h>
37#include <linux/seccomp.h>
38#include <stddef.h>
39#include <stdio.h>
40#include <stdlib.h>
41#include <string.h>
42#include <sys/prctl.h>
43#include <sys/stat.h>
44#include <sys/syscall.h>
45#include <unistd.h>
46
47static int ll_create(const struct landlock_ruleset_attr *a, size_t n, __u32 f) {
48    return syscall(SYS_landlock_create_ruleset, a, n, f);
49}
50static int ll_add(int fd, enum landlock_rule_type t, const void *a, __u32 f) {
51    return syscall(SYS_landlock_add_rule, fd, t, a, f);
52}
53static int ll_restrict(int fd, __u32 f) {
54    return syscall(SYS_landlock_restrict_self, fd, f);
55}
56
57// Conjuntos de derechos por ABI. Cada uno AÑADE a los anteriores; recortamos al ABI real del
58// kernel (D7 / §3.1: se consulta el ABI por syscall, JAMÁS por versión de kernel — hay
59// backports y el número miente).
60#define FS_ABI1                                                              \
61    (LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE |            \
62     LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR |            \
63     LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE |        \
64     LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR |            \
65     LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_SOCK |            \
66     LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK |          \
67     LANDLOCK_ACCESS_FS_MAKE_SYM)
68#define FS_ABI2 (FS_ABI1 | LANDLOCK_ACCESS_FS_REFER)
69#define FS_ABI3 (FS_ABI2 | LANDLOCK_ACCESS_FS_TRUNCATE)
70#define FS_ABI5 (FS_ABI3 | LANDLOCK_ACCESS_FS_IOCTL_DEV)
71
72#define RO_RIGHTS \
73    (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE)
74
75// Derechos que Landlock SÓLO admite sobre directorios. Añadir una regla con cualquiera de
76// éstos sobre un fichero regular hace que el kernel rechace la regla entera con EINVAL — y
77// como la clausura es mayormente ficheros sueltos (§4), sin esta máscara no arranca nada.
78#define DIR_ONLY_RIGHTS                                                           \
79    (LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR |                \
80     LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR |              \
81     LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG |                  \
82     LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO |                \
83     LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM |                \
84     LANDLOCK_ACCESS_FS_REFER)
85
86// ------------------------------------------------------------------ seccomp (D4)
87//
88// D4: "Landlock no basta". Landlock cubre fs, puertos TCP e IPC scoping — no cubre ptrace, bpf,
89// userfaultfd, keyctl, io_uring, montajes, ni la mitad de la superficie interesante.
90//
91// DENYLIST, no allowlist (corrige a D4, que pedía allowlist). Un allowlist por syscall para
92// builds ARBITRARIOS —compiladores, make, shells, linkers, perl— es un blanco móvil: cada
93// herramienta nueva lo rompe. Es el riesgo del §7 ("los falsos positivos matan proyectos de
94// sandboxing") aplicado a las syscalls, y con peor final: un build que muere por una syscall
95// legítima no da un diagnóstico útil, da un misterio. Lo *peligroso*, en cambio, es enumerable
96// y estable: no hay build honesto que cargue módulos, haga kexec o attachee un ptrace.
97//
98// EPERM y no KILL: matar el proceso deja un cadáver sin explicación; EPERM deja al build fallar
99// donde corresponde y al log decir por qué. Y lo que importa —la evidencia— la sigue dando
100// Landlock; esto sólo recorta superficie de escape.
101static const int SYSCALLS_DENEGADAS[] = {
102    // io_uring: superficie de kernel enorme y bypassea buena parte del análisis por-syscall.
103    // Denegado explícitamente en v1 (D4). Si un builder lo necesita, que lo justifique.
104#ifdef __NR_io_uring_setup
105    __NR_io_uring_setup, __NR_io_uring_enter, __NR_io_uring_register,
106#endif
107    // Inspección/inyección entre procesos: un build no necesita mirar dentro de otro.
108    __NR_ptrace, __NR_process_vm_readv, __NR_process_vm_writev,
109#ifdef __NR_bpf
110    __NR_bpf,
111#endif
112#ifdef __NR_userfaultfd
113    __NR_userfaultfd,  // usado para ganar carreras TOCTOU en el kernel
114#endif
115    // Llavero del kernel: ni se lee ni se siembra desde un build.
116    __NR_keyctl, __NR_add_key, __NR_request_key,
117    // Módulos y kexec: un build que toca esto no es un build.
118    __NR_init_module, __NR_finit_module, __NR_delete_module, __NR_kexec_load,
119#ifdef __NR_kexec_file_load
120    __NR_kexec_file_load,
121#endif
122    // perf: superficie histórica de escalada.
123    __NR_perf_event_open,
124};
125
126// La FAMILIA DEL ANIDAMIENTO, separada del resto a propósito.
127//
128// Para un BUILD estas syscalls no tienen uso legítimo: bwrap ya armó el árbol y re-montar sólo
129// sirve para escapar de él. Pero harkaq también enjaula instancias ajenas (qorpa, ADR 0015), y ahí
130// hay un caso legítimo y central: **Steam ya es un contenedor**. Su pressure-vessel monta y crea
131// namespaces DENTRO del nuestro, que es la configuración exacta que Valve prueba (ADR 0015 D6).
132// Denegarle `mount` no lo hace más seguro: lo hace no arrancar.
133//
134// Por eso se deniegan POR DEFECTO —el build no cambia ni un byte— y sólo se levantan con
135// `--allow-nesting`, que en qorpa cuelga de una concesión DECLARADA en el manifiesto. Abrir sólo
136// lo declarado, otra vez.
137//
138// `pivot_root` no está en ninguna de las dos listas: bwrap lo usa ANTES de llegar acá.
139static const int SYSCALLS_ANIDAMIENTO[] = {
140    __NR_mount, __NR_umount2,
141#ifdef __NR_open_tree
142    __NR_open_tree, __NR_move_mount, __NR_fsopen, __NR_fsconfig, __NR_fsmount,
143#endif
144    // Salirse de los namespaces que bwrap puso.
145    __NR_setns,
146};
147
148// TECHO DE LA LISTA, y por qué no es un detalle: `jt`/`jf` de la BPF clásica son `__u8`. En la
149// forma de abajo el salto más largo mide n+3, así que a partir de ~252 entradas el desplazamiento
150// DA LA VUELTA y el filtro deja de decir lo que dice este fichero — sin error y sin aviso. Hoy son
151// 25 y sobra sitio; el riesgo es que agregar syscalls a una denylist es exactamente lo que uno
152// hace sin pensarlo. Esto no genera código: es un portón de compilación.
153// (SDD 25 T17 midió además que la longitud de esta lista NO cuesta nada en ejecución — el bitmap
154//  de acción constante del kernel la hace plana —, así que no hay ningún motivo de rendimiento
155//  para recortarla ni para reordenarla.)
156_Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) /
157                       sizeof(SYSCALLS_DENEGADAS[0]) <= 250,
158               "la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro");
159
160// Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó.
161static int poner_seccomp(int permitir_anidamiento) {
162    const int n_base = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]));
163    const int n_anid = permitir_anidamiento
164                           ? 0
165                           : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0]));
166    const int n = n_base + n_anid;
167    int *nums = calloc(n ? n : 1, sizeof(*nums));
168    if (!nums) return -1;
169    for (int i = 0; i < n_base; i++) nums[i] = SYSCALLS_DENEGADAS[i];
170    for (int i = 0; i < n_anid; i++) nums[n_base + i] = SYSCALLS_ANIDAMIENTO[i];
171    // 2 (carga arch + salto) + 1 (carga nr) + n (comparaciones) + 3 (los tres RET).
172    struct sock_filter *f = calloc(n + 6, sizeof(*f));
173    if (!f) { free(nums); return -1; }
174    int k = 0;
175    const int I_ALLOW = 3 + n, I_ERRNO = 4 + n, I_KILL = 5 + n;
176
177    // Comprobar la arquitectura ANTES de mirar el número: los números de syscall son POR ARCH, y
178    // sin este check un binario i386 podría pedir otra syscall con el mismo número y colarse. Es
179    // el error clásico de los filtros seccomp escritos a mano.
180    f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
181                                          offsetof(struct seccomp_data, arch));
182    // El desplazamiento se cuenta desde ESTA instrucción, así que hace falta su índice. Fijarlo en
183    // una variable y no escribir `f[k++] = ... (I_KILL - k - 1 + 1)`: eso lee y modifica `k` en la
184    // misma expresión, sin punto de secuencia — UB en C11. Andaba (gcc y clang leen `k` después del
185    // incremento, y ese `+1` era justo la compensación), pero andaba apoyado en UB. Comprobado que
186    // el código generado no cambia, a `-O1` y `-O2`. Ver SDD 25 T17.
187    const int I_ARCH = k++;
188    f[I_ARCH] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0,
189                                             I_KILL - I_ARCH - 1);
190    f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
191                                          offsetof(struct seccomp_data, nr));
192    for (int i = 0; i < n; i++, k++)
193        f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
194                                            (unsigned)nums[i], I_ERRNO - k - 1, 0);
195    f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
196    f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K,
197                                          SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA));
198    f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS);
199    (void)I_ALLOW;
200
201    struct sock_fprog prog = {.len = (unsigned short)k, .filter = f};
202    // Requiere no_new_privs, que ya está puesto antes de llamar acá.
203    int r = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);
204    free(f);
205    free(nums);
206    return r;
207}
208
209int main(int argc, char **argv) {
210    const char *policy = NULL;
211    int abi_min = 3, permitir_anidamiento = 0, sin_landlock = 0, i;
212    for (i = 1; i < argc; i++) {
213        if (!strcmp(argv[i], "--policy") && i + 1 < argc) policy = argv[++i];
214        else if (!strcmp(argv[i], "--abi-min") && i + 1 < argc) abi_min = atoi(argv[++i]);
215        else if (!strcmp(argv[i], "--allow-nesting")) permitir_anidamiento = 1;
216        // MEDIDO 2026-09-03: un dominio Landlock activo hace fallar `mount` con EACCES aunque
217        // seccomp lo permita — el kernel no deja crear montajes nuevos bajo un dominio, porque un
218        // montaje nuevo escaparía de sus reglas por-ruta. Es decir: **Landlock y un runtime de
219        // contenedores anidado son incompatibles hoy**, y eso incluye a pressure-vessel de Valve
220        // (ADR 0015 D6). Para ese caso se cambia política-de-fs por poder anidar; seccomp y
221        // no_new_privs SIGUEN puestos, que es lo que más importa con un binario ajeno. Nunca por
222        // defecto, y siempre dicho en la línea de arranque: la regla de harkaq es no afirmar una
223        // jaula que no se puso.
224        else if (!strcmp(argv[i], "--no-landlock")) sin_landlock = 1;
225        else if (!strcmp(argv[i], "--")) { i++; break; }
226    }
227    if (!policy || i >= argc) {
228        fprintf(stderr, "uso: harkaq-exec --policy <f> [--abi-min N] -- <cmd> [args...]\n");
229        return 2;
230    }
231    char **cmd = &argv[i];
232
233    int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
234    if (abi < abi_min) {
235        // Fallo CERRADO: no correr un build que creemos enjaulado y no lo está.
236        fprintf(stderr,
237                "[harkaq] RECHAZO: ABI de Landlock = %d, se exige >= %d.\n"
238                "         Sin REFER/TRUNCATE no compila software real y la jaula sería ficción.\n",
239                abi, abi_min);
240        return 4;
241    }
242
243    int n_file = 0, n_dir = 0;
244    if (sin_landlock) goto tras_landlock;
245
246    __u64 handled = abi >= 5 ? FS_ABI5 : abi >= 3 ? FS_ABI3 : abi >= 2 ? FS_ABI2 : FS_ABI1;
247    struct landlock_ruleset_attr attr = {.handled_access_fs = handled};
248    int rs = ll_create(&attr, sizeof(attr), 0);
249    if (rs < 0) { perror("[harkaq] create_ruleset"); return 4; }
250
251    FILE *f = fopen(policy, "r");
252    if (!f) { perror("[harkaq] abrir política"); return 4; }
253    char line[4200];
254    while (fgets(line, sizeof(line), f)) {
255        char *nl = strchr(line, '\n');
256        if (nl) *nl = 0;
257        if (!line[0] || line[0] == '#') continue;
258        __u64 grant;
259        char *p;
260        if (!strncmp(line, "ro ", 3)) { grant = RO_RIGHTS; p = line + 3; }
261        else if (!strncmp(line, "rw ", 3)) { grant = handled; p = line + 3; }
262        else if (!strncmp(line, "list ", 5)) { grant = LANDLOCK_ACCESS_FS_READ_DIR; p = line + 5; }
263        else {
264            fprintf(stderr, "[harkaq] política inválida: %s\n", line);
265            return 4;
266        }
267        int dfd = open(p, O_PATH | O_CLOEXEC);
268        if (dfd < 0) {
269            // Una entrada de la clausura que no existe es un bug de harkaq-policy, no del
270            // build. Fallar ruidoso: si la ignoráramos, la política sería MÁS chica que la
271            // clausura y el build denegaría cosas legítimas — falsos positivos que matan
272            // el proyecto (§7).
273            fprintf(stderr, "[harkaq] la política nombra un path inexistente: %s (%m)\n", p);
274            return 4;
275        }
276        // Recortar los derechos de-sólo-directorio cuando el destino NO lo es: el kernel
277        // rechazaría la regla entera con EINVAL. Es el caso MAYORITARIO — la clausura es una
278        // lista de ficheros.
279        struct stat st;
280        int is_dir = (fstat(dfd, &st) == 0) && S_ISDIR(st.st_mode);
281        if (!is_dir) grant &= ~(__u64)DIR_ONLY_RIGHTS;
282        grant &= handled;  // nunca conceder más de lo que el ruleset declara manejar
283        if (!grant) {
284            fprintf(stderr, "[harkaq] regla vacía tras recortar por tipo: %s\n", p);
285            close(dfd);
286            return 4;
287        }
288        struct landlock_path_beneath_attr pb = {.allowed_access = grant, .parent_fd = dfd};
289        if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) {
290            fprintf(stderr, "[harkaq] add_rule(%s): %m\n", p);
291            close(dfd);
292            return 4;
293        }
294        close(dfd);
295        is_dir ? n_dir++ : n_file++;
296    }
297    fclose(f);
298
299    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("[harkaq] no_new_privs"); return 4; }
300
301    // LOG_NEW_EXEC_ON: el flag del que cuelga TODO. harkaq restringe y DESPUÉS ejecuta el
302    // builder; sin este flag el kernel no loguea ninguna denegación posterior al execve y
303    // harkaq certificaría como herméticos todos los builds, para siempre, sin un solo error
304    // visible (medido: §3.4). No es configuración, es la diferencia entre el sistema y un
305    // sello de goma.
306    __u32 flags = 0;
307    if (abi >= 7) flags |= LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON;
308    if (abi >= 8) flags |= LANDLOCK_RESTRICT_SELF_TSYNC;  // D5: cubre todos los hilos
309    if (ll_restrict(rs, flags) < 0) { perror("[harkaq] restrict_self"); return 4; }
310    close(rs);
311
312tras_landlock:;
313
314    // seccomp DESPUÉS de landlock y de no_new_privs, y justo antes del exec: el filtro se hereda
315    // por fork/exec igual que el dominio de Landlock (D5), así que cubre todo el árbol del build.
316    // Si el kernel lo rechaza NO se sigue: D7 es fallo cerrado — correr un build creyéndolo
317    // enjaulado cuando le falta media jaula es peor que no correrlo.
318    if (poner_seccomp(permitir_anidamiento) < 0) {
319        fprintf(stderr, "[harkaq] RECHAZO: no pude instalar el filtro seccomp (%m).\n"
320                        "         ¿CONFIG_SECCOMP_FILTER=y? D4: seccomp no es opcional.\n");
321        return 4;
322    }
323
324    if (sin_landlock)
325        fprintf(stderr,
326                "[harkaq] SIN LANDLOCK por pedido explícito: seccomp y no_new_privs SÍ están, pero "
327                "NO hay política de ficheros ni evidencia. El confinamiento de fs queda en manos "
328                "del namespace de montaje de quien invoca.\n");
329    else if (abi < 7)
330        fprintf(stderr,
331                "[harkaq] AVISO: ABI %d < 7 ⇒ el kernel no audita denegaciones. El build corre "
332                "ENJAULADO pero SIN EVIDENCIA; el veredicto no podrá afirmar hermeticidad.\n",
333                abi);
334    else
335        fprintf(stderr,
336                "[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, "
337                "logging post-exec ON%s\n",
338                abi, n_file, n_dir,
339                permitir_anidamiento ? ", ANIDAMIENTO PERMITIDO (mount/setns)" : "");
340
341    execvp(cmd[0], cmd);
342    fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]);
343    return 127;
344}