diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index 12ee7755..19448bda 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -231,6 +231,66 @@ chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen f > experimento ahora aborta con código 4 si no puede **confirmar** `audit_enabled=1`, en vez de > reportar cero. La misma disciplina es la que D9 le impone a harkaq. +### 3.5 Q1b resuelta: la evidencia cruza el userns, y el canario es la clave primaria + +Experimento `scripts/harkaq/q1b-attrib.c` (2026-07-15). Dos builds **concurrentes**, cada uno +dentro de bwrap con `--unshare-all` (los mismos ns que `Sandbox::bwrap_args`), sin privilegio +(uid 1000), con un canario de nonce distinto. El lector corre en el **host**, como root. + +``` +[ACCESS] domain=146973f1c blockers=fs.read_file path="/tmp/harkaq-canary-BBBB" dev="nvme1n1p6" ino=4457713 +[DOMAIN] domain=146973f1c status=allocated mode=enforcing pid=8498 uid=1000 exe="…/q1b" comm="q1b" +[ACCESS] domain=146973f1f blockers=fs.read_file path="/tmp/harkaq-canary-AAAA" dev="nvme1n1p6" ino=4457713 +[DOMAIN] domain=146973f1f status=allocated mode=enforcing pid=8499 uid=1000 exe="…/q1b" comm="q1b" +[DOMAIN] domain=146973f1f status=deallocated denials=1 +[DOMAIN] domain=146973f1c status=deallocated denials=1 +``` + +**(a) Los registros cruzan.** 6 registros landlock de adentro de bwrap llegaron al lector del +host. El audit no está namespaceado y se comporta como tal. ⇒ **`harkaq-audit` vive en el host, +junto a hammerd; la arquitectura de §4 se sostiene.** Y cruzan con builds **sin privilegio**, que +es como corren de verdad (si sólo cruzaran con builds root, no servirían). + +**(b) El canario con nonce atribuye.** `AAAA=1`, `BBBB=1`, cero registros ajenos, con dos builds +en paralelo. ⇒ **Q1b cerrada: la granja puede construir en paralelo sin contaminarse la +evidencia.** + +Tres hallazgos colaterales que valen más que el veredicto: + +1. **El contador quedó validado.** Los dos `deallocated denials=1` llegaron en ventana, uno por + dominio, y cuadran con 1 `ACCESS` cada uno. El chequeo anti-pérdida de §3.4 + (`nº ACCESS == denials`) está medido, no supuesto. +2. **La identidad sobrevive a los namespaces:** `pid=8498 uid=1000` son del **host** (adentro del + pidns la víctima se ve como pid 1). El audit reporta identidad del lado donde está el lector. +3. **`dev`+`ino` identifican el OBJETO, no el BUILD.** Los dos canarios reportan el mismo + `ino=4457713` (ambos bindean `/etc/hostname`). Atribuir por dev/ino habría fundido dos builds + que leen el mismo fichero no declarado — que es *el caso más común* que harkaq va a ver. **La + clave es `domain=`**; el canario es lo que la revela. + +⇒ El flujo de atribución queda cerrado: **canario ⇒ aprendo el `domain=` ⇒ atribuyo por `domain=` +todo lo demás del build.** D9 hace tres trabajos con un mecanismo: honestidad (§3.4), clave +primaria (acá) y — vía el contador — detección de pérdida. + +> **Nota de método (la tercera, y ya no es anécdota).** Esta corrida también falló primero, dos +> veces más, y las dos el banco reportó un resultado inventado con total confianza: +> 1. El comando de la víctima era `cat $canario >/dev/null`. `/dev` no estaba en la clausura ⇒ sh +> fallaba **en la redirección** y `cat` no llegaba a correr. El `rc=1` venía del redirect: el +> test medía una denegación que no era la que creía medir. +> 2. Corriendo como root, bwrap no pudo ni ejecutar el binario (`execvp: Permission denied`) y el +> veredicto imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en el host: replantear"* — una +> conclusión arquitectónica falsa, a partir de cero estímulo. (Causa: `--unshare-all` mapea +> sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` **en un userns +> sólo vale sobre uids mapeados** ⇒ root-en-userns no atraviesa un home `drwx------`.) +> +> El banco ahora tiene un **gate de estímulo**: si alguna víctima no sale con status 0, aborta +> (exit 4) en vez de contar registros. +> +> Van **tres** veces en una sesión —`NLM_F_ACK`, el redirect, el exec— que un cero resultó ser el +> instrumento y no el kernel, y las tres el programa afirmó con confianza algo falso. Es la +> evidencia empírica más fuerte a favor de D9, y viene del lado incómodo: *el banco lo escribió +> alguien que sabía exactamente cuál era el riesgo, mirándolo de frente, y cayó igual, tres veces.* +> harkaq sin canario no es un riesgo de configuración: es el comportamiento por defecto. + --- ## 4. Arquitectura @@ -261,10 +321,15 @@ chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen f ▼ ┌──────────────────────────────────────────────────────────┐ │ harkaq-audit (lector de AUDIT_LANDLOCK_*) │ + │ EN EL HOST, junto a hammerd — FUERA de bwrap (§3.5): │ + │ el audit no está namespaceado y los registros cruzan. │ + │ Requiere CAP_AUDIT_READ (§8 Q1c). │ + │ canario ⇒ domain= ⇒ atribuye el resto (D9) │ │ denegaciones → Denial { blockers, path, dev, ino, ... }│ └───────────────────────────┬──────────────────────────────┘ ▼ - Verdict { PolicyId, recipe_hash, artifact_hash, denials[] } + Verdict { PolicyId, recipe_hash, artifact_hash, + estado: Hermetico | Impuro{denials[]} | SinEvidencia } │ blake3 ──► CAS ──► willay ──► iniy ``` @@ -406,6 +471,14 @@ El canario cubre la mala configuración (flag ausente, audit apagado, lector ca `deallocated denials=N` de §3.4 cubre la otra falla, la pérdida de registros por backlog: se exige `nº de ACCESS == N`. Dos chequeos, dos fallas, ninguno redundante. +**Y el canario es además la clave primaria de la evidencia** (medido en §3.5). Con un nonce único +por build, su registro revela el `domain=` de ese build, y `domain=` ata todos los registros +restantes del mismo. Sin él no hay atribución fiable con la granja construyendo en paralelo: el +`pid=` viaja en el `allocated`, que es **perezoso**, y `dev`+`ino` identifican el **objeto**, no el +build — dos builds leyendo el mismo fichero no declarado dan el mismo `ino`, que es justo el caso +más común que harkaq va a ver. Un mecanismo, tres trabajos: honestidad, atribución y detección de +pérdida. + **Por qué es un no-negociable.** Es la misma disciplina de D7 (fallo cerrado) y de D3 (cero quieting), aplicada al único punto donde el sistema podría mentirse a sí mismo en silencio. Y no es hipotético: el banco de pruebas de Q1 ya cayó en esa trampa una vez (§3.4, nota de método). @@ -483,16 +556,18 @@ vibra. el multicast `AUDIT_NLGRP_READLOG`. Precondiciones medidas: `audit_enabled=1` (no viene de fábrica) y `LOG_NEW_EXEC_ON` (sin él, cero registros tras el `exec`). No hay canario gratis ⇒ D9. Detalle y mediciones en §3.4. **El gate del proyecto está pasado: harkaq es viable.** -- **Q1b (Fase 1, lo que Q1 destapó):** ¿el lector funciona igual con el build **dentro del userns - de bwrap**? El audit no está namespaceado, así que `harkaq-audit` lee desde el host (junto a - hammerd) y debería ver los denials de adentro — pero hay que medirlo, no suponerlo. Y de paso: - ¿cómo se atribuye un registro a *su* build cuando la granja corre varios en paralelo? El campo - `domain=` es la clave candidata; el `pid=` del `allocated` es el puente, pero §3.4 midió que ese - registro es perezoso. **Riesgo concreto:** sin atribución fiable, los builds paralelos se - contaminan la evidencia entre sí. -- **Q1c:** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ` (leer) y - `CAP_AUDIT_CONTROL` (encender el audit, si no se hornea `audit=1` en el cmdline). En el laptop - hizo falta root. ¿hammerd ya lo tiene, o hace falta un helper con capabilities acotadas? +- **Q1b — ✅ CERRADA (2026-07-15).** Los registros cruzan el userns/pidns/mountns de bwrap hasta un + lector en el host, con builds **sin privilegio**. La atribución con la granja en paralelo se + resuelve con el canario de nonce único (D9): revela el `domain=`, que ata el resto. Medido con 2 + builds concurrentes, cero cruces. Detalle en §3.5. +- **Q1c (Fase 1):** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ` + (leer el multicast) y, salvo que se hornee `audit=1` en el cmdline, `CAP_AUDIT_CONTROL` (para el + `AUDIT_SET`). En el laptop hizo falta root. Medido en §3.5: el lector privilegiado y los builds + sin privilegio conviven bien. Falta decidir si es hammerd o un helper con capabilities acotadas + — preferible lo segundo: `CAP_AUDIT_*` es superficie de más para el daemon entero. +- **Q1d (Fase 1, menor):** el `exe=` del registro trae la ruta **del host**. Con el sandbox real + (`--bind /src`) hay que confirmar qué ruta reporta para un builder que vive en `/src`, y si + sirve para algo o basta con `domain=`. - **Q2 (Fase 2):** ¿qué es exactamente el "runtime base" que toda receta necesita y ninguna declara (`/bin/sh`, la libc del rootfs, `/opt/zig`)? ¿Va en la clausura implícitamente, o se declara y se vuelve parte del hash de la receta? *Esta es la decisión de diseño de verdad*, y diff --git a/scripts/harkaq/README.md b/scripts/harkaq/README.md index 23ad6c67..7efb7861 100644 --- a/scripts/harkaq/README.md +++ b/scripts/harkaq/README.md @@ -40,3 +40,41 @@ Es el modo de falla de harkaq sobre su propio banco de pruebas: un sistema cuyo *ausencia* de algo no falla ruidosamente — dice que todo está bien. De ahí sale D9 (el canario deliberado), y de ahí sale la regla de este directorio: **todo experimento lleva un control que debe dar positivo.** Un experimento sin control no mide, tranquiliza. + +## `q1b-attrib.c` — userns y atribución (Q1b, ✅ cerrado 2026-07-15) + +Responde: *¿el lector del host ve las denegaciones de adentro de bwrap, y se puede atribuir cada +registro a SU build con la granja en paralelo?* + +```sh +gcc -O1 -Wall -o ~/.cache/harkaq/q1b q1b-attrib.c # NO compilar bajo /tmp: --tmpfs /tmp lo tapa +sudo ~/.cache/harkaq/q1b +``` + +Lanza 2 víctimas concurrentes en bwrap `--unshare-all` (los ns de `Sandbox::bwrap_args`), cada una +con un canario de nonce distinto, y escucha desde el host. Las víctimas bajan a `$SUDO_UID`: el +despliegue real es lector privilegiado + builds sin privilegio. + +Resultado: los registros **cruzan** el userns/pidns/mountns, y el canario con nonce **atribuye** +sin ambigüedad (`AAAA=1 BBBB=1`, cero cruces). Análisis en `docs/16-harkaq-jaula.md` §3.5. + +### Los tres rastrillos de este banco, para el próximo que lo toque + +1. **No compilar bajo `/tmp`**: el sandbox hace `--tmpfs /tmp` y tapa el binario. +2. **El canario necesita un punto de montaje escribible.** Con `--ro-bind / /`, bwrap no puede + crear `/harkaq-canary-X` en la raíz de sólo lectura (el sandbox real usa `--tmp-overlay /` y no + tendría el problema). Va bajo `/tmp`, y por eso `/tmp` **no** entra en la clausura de la víctima. +3. **Root + `--unshare-all` no atraviesa un home `drwx------`.** bwrap mapea sólo uid 0→0; los + ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` en un userns sólo vale sobre uids + **mapeados**. De ahí `drop_privs()`. + +### Por qué también aborta con código 4 + +Mismo gate que `q1-audit`, más uno: **si alguna víctima no sale con status 0, aborta** en vez de +contar registros. La corrida que lo motivó imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en +el host: replantear"* cuando bwrap ni siquiera había podido ejecutar el binario. Una conclusión +arquitectónica falsa, a partir de cero estímulo. + +Van tres veces en una sola sesión (`NLM_F_ACK`, el redirect a `/dev/null`, el exec) que un cero +resultó ser el instrumento y no el kernel. Un veredicto que no verifica su propio estímulo es el +falso `Hermetico` de D9, pero del lado del banco. diff --git a/scripts/harkaq/q1b-attrib.c b/scripts/harkaq/q1b-attrib.c new file mode 100644 index 00000000..5fe017a7 --- /dev/null +++ b/scripts/harkaq/q1b-attrib.c @@ -0,0 +1,261 @@ +// Q1b de harkaq (SDD 16 §8): ¿el lector del host ve las denegaciones de ADENTRO de bwrap, +// y se puede atribuir cada registro a SU build con la granja corriendo en paralelo? +// +// Q1 probó que la evidencia llega, pero con la víctima corriendo en el host. El caso real es +// otro: `Sandbox::run` lanza bwrap con --unshare-all (user/pid/mount/net/ipc/uts ns) y el +// lector (harkaq-audit) vive AFUERA, junto a hammerd. Dos preguntas: +// +// (a) ¿Cruzan los registros el userns/pidns? El audit no está namespaceado, así que +// DEBERÍA — pero medirlo es el punto. +// (b) ATRIBUCIÓN: con N builds en paralelo hay N dominios. ¿Cuál es de quién? +// Hipótesis: el canario de D9 lo resuelve gratis. Si cada build usa un canario con +// nonce ÚNICO, el primer registro logueado trae path="/harkaq-canary-" ⇒ +// identifica el build sin depender del pid (que el pidns reescribe) ⇒ y de paso +// revela su domain=, que ata todos los registros siguientes de ese build. +// +// Uso (como root): ./q1b-attrib → lanza 2 víctimas concurrentes y atribuye +// ./q1b-attrib victim (uso interno, dentro de bwrap) + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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); +} + +#define ACCESS_READ \ + (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \ + LANDLOCK_ACCESS_FS_EXECUTE) + +// ------------------------------------------------------------------ víctima (dentro de bwrap) + +// Política: todo lo que sh/cat necesitan, y NADA más. El canario queda deliberadamente fuera +// ⇒ exactamente UNA denegación esperada, con un path que identifica a este build. +static int victim(const char *canary) { + struct landlock_ruleset_attr attr = {.handled_access_fs = ACCESS_READ}; + int rs = ll_create(&attr, sizeof(attr), 0); + if (rs < 0) { perror("[victima] create_ruleset"); return 2; } + // /tmp NO va en la clausura: es donde vive el canario (único punto de montaje escribible + // bajo `--ro-bind / /`, ver README). Todo lo demás es lo que sh/cat necesitan para correr. + const char *allowed[] = {"/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", NULL}; + for (int i = 0; allowed[i]; i++) { + int dfd = open(allowed[i], O_PATH | O_CLOEXEC); + if (dfd < 0) continue; + struct landlock_path_beneath_attr pb = {.allowed_access = ACCESS_READ, .parent_fd = dfd}; + ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0); + close(dfd); + } + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("[victima] nnp"); return 2; } + // LOG_NEW_EXEC_ON: sin esto no se loguea nada tras el execve (Q1/§3.4). Obligatorio. + if (ll_restrict(rs, LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON) < 0) { + perror("[victima] restrict_self"); + return 2; + } + close(rs); + // Post-exec, como el builder real (bwrap → sh -c → make). + // Sin redirects: `>/dev/null` necesita /dev, que NO está en la clausura ⇒ sh fallaba en la + // redirección y `cat` no llegaba a correr nunca. El rc=1 venía del redirect, no del canario: + // el test habría medido la denegación equivocada y "pasado" igual. Mantener el comando + // mínimo, y que el único acceso fuera de la clausura sea EL CANARIO. + char cmd[512]; + snprintf(cmd, sizeof(cmd), "cat %s; echo \"[victima] canario=%s cat rc=$?\" >&2", canary, + canary); + execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); + perror("[victima] execl"); + return 2; +} + +// ------------------------------------------------------------------ netlink + +static int nl_send(int fd, int type, const void *payload, size_t len) { + struct { struct nlmsghdr h; char data[256]; } req = {0}; + req.h.nlmsg_len = NLMSG_LENGTH(len); + req.h.nlmsg_type = type; + req.h.nlmsg_flags = NLM_F_REQUEST; + req.h.nlmsg_seq = 1; + req.h.nlmsg_pid = getpid(); + if (payload) memcpy(req.data, payload, len); + struct sockaddr_nl sa = {.nl_family = AF_NETLINK}; + return sendto(fd, &req, req.h.nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa)); +} + +static int audit_status(int *enabled) { + int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT); + if (fd < 0) return -1; + if (nl_send(fd, AUDIT_GET, NULL, 0) < 0) { close(fd); return -1; } + struct timeval tv = {.tv_sec = 1}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + char buf[8192]; + for (int i = 0; i < 8; i++) { + ssize_t n = recv(fd, buf, sizeof(buf), 0); + if (n <= 0) break; + for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n); h = NLMSG_NEXT(h, n)) + if (h->nlmsg_type == AUDIT_GET) { + *enabled = ((struct audit_status *)NLMSG_DATA(h))->enabled; + close(fd); + return 0; + } + } + close(fd); + return -1; +} + +static int audit_enable(void) { + int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT); + if (fd < 0) return -1; + struct audit_status st = {.mask = AUDIT_STATUS_ENABLED, .enabled = 1}; + int r = nl_send(fd, AUDIT_SET, &st, sizeof(st)); + close(fd); + return r < 0 ? -1 : 0; +} + +// ------------------------------------------------------------------ driver + +// Baja a un uid sin privilegio antes de lanzar bwrap. Dos razones: +// 1. Corrección: bwrap con --unshare-all mapea SÓLO uid 0→0. Los ficheros de otro uid quedan +// sin mapear (aparecen como `nobody`) y CAP_DAC_OVERRIDE dentro de un userns sólo vale +// sobre uids MAPEADOS ⇒ root-en-userns no puede atravesar un home drwx------ y el exec +// del binario falla con EACCES. Costó una corrida entera diagnosticarlo. +// 2. Fidelidad: modela el despliegue real — harkaq-audit privilegiado (CAP_AUDIT_READ), +// los builds sin privilegio. Si la evidencia cruzara sólo con builds root, no serviría. +static void drop_privs(void) { + const char *su = getenv("SUDO_UID"), *sg = getenv("SUDO_GID"); + if (!su) return; // no venimos de sudo: nada que bajar + uid_t uid = (uid_t)atoi(su); + gid_t gid = sg ? (gid_t)atoi(sg) : uid; + if (setgroups(0, NULL) < 0) perror("[q1b] setgroups"); + if (setgid(gid) < 0) { perror("[q1b] setgid"); _exit(2); } + if (setuid(uid) < 0) { perror("[q1b] setuid"); _exit(2); } +} + +static void spawn_victim(const char *self, const char *canary_src, const char *canary_path) { + // bwrap con los mismos ns que Sandbox::bwrap_args (--unshare-all). El canario se bindea + // en una ruta fija que la política de landlock NO concede. + execlp("bwrap", "bwrap", "--unshare-all", "--ro-bind", "/", "/", "--tmpfs", "/tmp", + "--ro-bind", canary_src, canary_path, "--die-with-parent", self, "victim", + canary_path, (char *)NULL); + perror("execlp bwrap"); + _exit(2); +} + +int main(int argc, char **argv) { + if (argc >= 3 && !strcmp(argv[1], "victim")) return victim(argv[2]); + + char self[4096]; + ssize_t sl = readlink("/proc/self/exe", self, sizeof(self) - 1); + if (sl < 0) { perror("readlink"); return 1; } + self[sl] = 0; + + int enabled = -1; + audit_status(&enabled); + if (enabled != 1) { + fprintf(stderr, "[q1b] audit enabled=%d → AUDIT_SET\n", enabled); + audit_enable(); + usleep(100000); + audit_status(&enabled); + } + // Mismo gate que q1-audit: sin audit_enabled=1 un 0 no significa "sin denegaciones", + // significa "el instrumento no mide". Abortar, nunca reportar. + if (enabled != 1) { + fprintf(stderr, "[q1b] ABORTO: no pude confirmar audit_enabled=1 (¿root?)\n"); + return 4; + } + + int nl = socket(AF_NETLINK, SOCK_RAW, NETLINK_AUDIT); + struct sockaddr_nl sa = {.nl_family = AF_NETLINK, + .nl_groups = 1 << (AUDIT_NLGRP_READLOG - 1)}; + if (nl < 0 || bind(nl, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + perror("[q1b] bind multicast (¿CAP_AUDIT_READ?)"); + return 1; + } + fprintf(stderr, "[q1b] lector del HOST escuchando; audit enabled=%d\n", enabled); + + // DOS builds concurrentes, canarios con nonce distinto. Si la atribución por canario + // funciona, cada registro cae inequívocamente en su build sin mirar el pid. + const char *canaries[] = {"/tmp/harkaq-canary-AAAA", "/tmp/harkaq-canary-BBBB"}; + pid_t kids[2]; + for (int i = 0; i < 2; i++) { + if ((kids[i] = fork()) == 0) { + usleep(300000); + drop_privs(); + spawn_victim(self, "/etc/hostname", canaries[i]); + } + fprintf(stderr, "[q1b] build %d: bwrap pid=%d canario=%s\n", i, kids[i], canaries[i]); + } + + struct timeval tv = {.tv_sec = 1}; + setsockopt(nl, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + char buf[16384]; + int seen[2] = {0, 0}, other = 0, total = 0; + time_t deadline = time(NULL) + 8; + while (time(NULL) < deadline) { + ssize_t n = recv(nl, buf, sizeof(buf), 0); + if (n <= 0) continue; + for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type != AUDIT_LANDLOCK_ACCESS && h->nlmsg_type != AUDIT_LANDLOCK_DOMAIN) + continue; + total++; + const char *rec = (const char *)NLMSG_DATA(h); + printf("[%s] %.*s\n", + h->nlmsg_type == AUDIT_LANDLOCK_ACCESS ? "ACCESS" : "DOMAIN", + (int)NLMSG_PAYLOAD(h, 0), rec); + int matched = 0; + for (int i = 0; i < 2; i++) + if (strstr(rec, canaries[i])) { seen[i]++; matched = 1; } + if (!matched && h->nlmsg_type == AUDIT_LANDLOCK_ACCESS) other++; + } + } + // GATE DEL INSTRUMENTO: si la víctima no corrió, "0 registros" no significa "nada cruzó", + // significa "no medí nada". La corrida anterior imprimió "NADA cruzó ⇒ replantear la + // arquitectura" cuando bwrap ni siquiera había podido ejecutar el binario (EACCES). Un + // veredicto que no verifica su propio estímulo es exactamente el falso 'hermético' de D9, + // pero del lado del banco de pruebas. Tercera vez en esta sesión: por eso es un gate. + int bad = 0; + for (int i = 0; i < 2; i++) { + int st = 0; + waitpid(kids[i], &st, 0); + if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) { + fprintf(stderr, "[q1b] build %d NO corrió limpio (status=%d)\n", i, st); + bad = 1; + } + } + if (bad) { + fprintf(stderr, + "\n[q1b] ABORTO: alguna víctima no llegó a ejecutarse ⇒ el estímulo no existió.\n" + " Cualquier conteo de registros sería del instrumento, no del kernel.\n"); + return 4; + } + + fprintf(stderr, "\n[q1b] ── VEREDICTO ──\n"); + fprintf(stderr, "[q1b] (a) ¿cruzan el userns de bwrap? %s (%d registros landlock)\n", + total > 0 ? "SÍ" : "NO", total); + fprintf(stderr, "[q1b] (b) atribución por canario: AAAA=%d BBBB=%d (otros ACCESS=%d)\n", + seen[0], seen[1], other); + if (seen[0] >= 1 && seen[1] >= 1) + fprintf(stderr, "[q1b] ⇒ el canario con nonce ATRIBUYE: 2 builds paralelos, sin ambigüedad.\n"); + else if (total == 0) + fprintf(stderr, "[q1b] ⇒ NADA cruzó. harkaq-audit no puede vivir en el host: replantear.\n"); + else + fprintf(stderr, "[q1b] ⇒ registros SÍ, atribución NO. Hace falta otra clave (¿domain=?).\n"); + return 0; +}