From 4eecba06f4847b68b307acf232a1fc04f87b86f6 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 15 Jul 2026 15:04:27 -0400 Subject: [PATCH] =?UTF-8?q?harkaq:=20Fase=201=20=E2=80=94=20la=20cadena=20?= =?UTF-8?q?corre=20de=20punta=20a=20punta=20(Hermetico=20/=20Impuro=20sobr?= =?UTF-8?q?e=20kernel=20real)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres piezas nuevas, las del §4 del SDD: - harkaq-audit.c lector de evidencia; HOST junto a hammerd, CAP_AUDIT_READ; espera el canario (que revela el domain=), junta las denegaciones de ESE dominio, cruza contra el contador del kernel al liberarse, y emite el Verdict JSON de 3 estados. - harkaq-exec.c aplica la política y execea el builder; DENTRO de bwrap, estático (rootfs musl). Negocia el ABI por syscall (D7: --- docs/16-harkaq-jaula.md | 22 +++ scripts/harkaq/README.md | 52 ++++++- scripts/harkaq/harkaq-audit.c | 270 ++++++++++++++++++++++++++++++++++ scripts/harkaq/harkaq-exec.c | 159 ++++++++++++++++++++ scripts/harkaq/harkaq-run.sh | 70 +++++++++ 5 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 scripts/harkaq/harkaq-audit.c create mode 100644 scripts/harkaq/harkaq-exec.c create mode 100755 scripts/harkaq/harkaq-run.sh diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index 19448bda..a7fe0b17 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -500,6 +500,28 @@ Precondiciones, todas ya medidas en §3 y ninguna especulativa: Después: tomar **una** receta real de hammer. Derivar su política de la clausura. Correrla bajo Landlock+seccomp dentro del bwrap actual. Emitir el `Verdict` con `denials = []` **y el canario visto**. + +**Estado (2026-07-15): la cadena corre de punta a punta.** `harkaq-audit` (lector, host, +`CAP_AUDIT_READ`) + `harkaq-exec` (política, estático, dentro de bwrap) + `harkaq-run.sh` +(orquesta y pone el canario). Los dos actos, con comandos sintéticos: + +```json +limpio {"estado":"Hermetico","canario_visto":true,"contador_kernel":1,"denials":[]} +impuro {"estado":"Impuro","canario_visto":true,"contador_kernel":2, + "denials":[{"blockers":"fs.read_file","path":"…/README.md","dev":"nvme1n1p3","ino":"2919408"}]} +``` + +Canario visto y contador coherente en ambos ⇒ los tres estados de D9 funcionan sobre el kernel +real. Precondiciones 2–4 ✅. Falta: la precondición 1 (sólo para que harkaq corra *dentro* de +hammer — el hito se demuestra en el laptop, que ya está en ABI 10), `harkaq-policy` derivando de +la clausura real, y el contraste receta-de-Alpinizada vs. receta-que-no. + +**Sutileza medida: lo que DAC ya bloquea es invisible.** Un acceso que los permisos Unix rechazan +no genera registro de Landlock (el hook del LSM no llega a correr). No rompe D2 — si DAC lo +bloqueó, el build tampoco lo usó, así que `Hermetico` sigue siendo cierto — pero acota el +*diagnóstico*: harkaq no reporta el intento. Para la de-Alpinización es inocuo (el rootfs Alpine +es world-readable); para escribir tests es una trampa (un escenario "impuro" sobre un path que DAC +ya prohíbe da `Hermetico` y el control no controla nada). Luego correr **una receta que sepamos no de-Alpinizada** y mostrar que la jaula nombra exactamente qué usó sin declarar, con path/dev/ino. **Ese par de corridas es el argumento entero del proyecto**, y se enseña en dos minutos. diff --git a/scripts/harkaq/README.md b/scripts/harkaq/README.md index 7efb7861..1f4558b6 100644 --- a/scripts/harkaq/README.md +++ b/scripts/harkaq/README.md @@ -1,4 +1,54 @@ -# harkaq — banco de pruebas +# harkaq — banco de pruebas y piezas + +## Las piezas (Fase 1) + +| Fichero | Qué es | Dónde corre | +|---|---|---| +| `harkaq-audit.c` | el lector de evidencia; emite el `Verdict` JSON | **host**, junto a hammerd; necesita `CAP_AUDIT_READ` | +| `harkaq-exec.c` | aplica la política y ejecuta el builder | **dentro** de bwrap, estático (rootfs musl) | +| `harkaq-run.sh` | encadena lector + bwrap + exec + canario | host | + +```sh +mkdir -p ~/.cache/harkaq +gcc -O1 -Wall -o ~/.cache/harkaq/harkaq-audit harkaq-audit.c +gcc -O1 -Wall -static -o ~/.cache/harkaq/harkaq-exec harkaq-exec.c +sudo setcap cap_audit_read,cap_audit_control+ep ~/.cache/harkaq/harkaq-audit # 1 vez + +printf 'ro /usr\nro /bin\nro /lib\nro /lib64\nro /etc\n' > /tmp/p.txt +./harkaq-run.sh /tmp/p.txt '/bin/echo hola' +``` + +**`setcap` se pierde al recompilar** el lector: hay que repetirlo. Es a propósito que el +privilegio viva en un helper chico y no en hammerd (§8 Q1c): `CAP_AUDIT_*` es superficie de más +para el daemon entero. + +### El hito de Fase 1 (2026-07-15) + +```json +limpio {"estado":"Hermetico","canario_visto":true,"contador_kernel":1,"denials":[]} +impuro {"estado":"Impuro","canario_visto":true,"contador_kernel":2, + "denials":[{"blockers":"fs.read_file","path":"/home/sergio/hammer/README.md",…}]} +``` + +Canario visto en ambos (la evidencia está *ganada*, no supuesta), contador del kernel coherente +en ambos, y el impuro nombra el path exacto. Falta el acto real: derivar la política de la +clausura de una receta de verdad (`harkaq-policy`) y contrastar una receta de-Alpinizada contra +una que no lo está. + +### Sutileza: lo que DAC ya bloquea es invisible + +Un acceso que los permisos Unix rechazan **no genera registro de Landlock** — el hook del LSM ni +llega a correr. Descubierto probando con `/root/.bashrc` (drwx------): el escenario "impuro" dio +`Hermetico` porque DAC lo bloqueó antes. + +No rompe la afirmación de D2 (si DAC lo bloqueó, el build tampoco lo usó ⇒ `Hermetico` sigue +siendo cierto), pero acota el **diagnóstico**: harkaq no te dice que el build lo *intentó*. Para +la de-Alpinización da igual — el rootfs Alpine es todo world-readable — pero al escribir tests +hay que elegir paths que DAC permita, o el control no controla nada. + +--- + +## Banco de pruebas Ver `docs/16-harkaq-jaula.md`. Acá viven los experimentos que sostienen las afirmaciones del SDD: si el doc dice "medido", el que lo midió está en este directorio. diff --git a/scripts/harkaq/harkaq-audit.c b/scripts/harkaq/harkaq-audit.c new file mode 100644 index 00000000..3f068de7 --- /dev/null +++ b/scripts/harkaq/harkaq-audit.c @@ -0,0 +1,270 @@ +// harkaq-audit — el lector de evidencia (SDD 16 §4). Vive en el HOST, junto a hammerd, fuera +// de bwrap: el audit no está namespaceado y los registros del build cruzan (medido, §3.5). +// +// Contrato: se lanza ANTES del build, con el canario de ese build. Espera el registro del +// canario (que revela el `domain=`), junta las demás denegaciones de ESE dominio, y al +// liberarse el dominio compara su conteo contra el del kernel. Emite el Verdict en JSON. +// +// harkaq-audit --canary /harkaq-canary- [--timeout 600] [--ready-fd N] +// +// Los tres estados (D9) — `denials=[]` NO se cree, se gana: +// Hermetico canario visto + cero denegaciones + conteo coherente +// Impuro canario visto + N denegaciones (el diagnóstico útil) +// SinEvidencia canario NO visto, o el conteo del kernel no cuadra ⇒ no se afirma NADA +// +// Requiere CAP_AUDIT_READ (multicast) y, si audit_enabled=0, CAP_AUDIT_CONTROL. Para el bucle +// de desarrollo: sudo setcap cap_audit_read,cap_audit_control+ep harkaq-audit + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// SIGTERM ⇒ emitir el veredicto y salir, en vez de morir mudo. El camino normal es salir solo +// al ver el `deallocated` del dominio (que siempre llega: el canario garantiza que el dominio +// loguea, y un dominio que loguea sí emite su dealloc — §3.4). Esto cubre el otro caso: que el +// build ni siquiera arranque, donde sin señal el lector esperaría el timeout entero. +static volatile sig_atomic_t parar = 0; +static void on_term(int s) { (void)s; parar = 1; } + +#define MAX_DENIALS 256 + +struct denial { + char blockers[128]; + char path[1024]; + char dev[64]; + char ino[32]; +}; + +// ---------------------------------------------------------------- parseo de registros + +// Extrae `key=valor` del texto del registro. Quita comillas si las hay. El kernel emite +// p.ej.: domain=146973ef2 blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369 +static int field(const char *rec, const char *key, char *out, size_t cap) { + char pat[64]; + snprintf(pat, sizeof(pat), " %s=", key); + const char *p = strstr(rec, pat); + if (!p) { // puede estar al principio, sin espacio delante + snprintf(pat, sizeof(pat), "%s=", key); + if (strncmp(rec, pat, strlen(pat)) != 0) return 0; + p = rec; + } else { + p++; + } + p += strlen(key) + 1; + int quoted = (*p == '"'); + if (quoted) p++; + size_t i = 0; + while (*p && i + 1 < cap) { + if (quoted ? (*p == '"') : (*p == ' ')) break; + out[i++] = *p++; + } + out[i] = 0; + return 1; +} + +// Escapa una cadena para meterla en JSON sin romperlo (los paths pueden traer comillas). +static void json_str(const char *s, char *out, size_t cap) { + size_t i = 0; + for (; *s && i + 2 < cap; s++) { + if (*s == '"' || *s == '\\') { + out[i++] = '\\'; + out[i++] = *s; + } else if ((unsigned char)*s < 0x20) { + i += snprintf(out + i, cap - i, "\\u%04x", *s); + } else { + out[i++] = *s; + } + } + out[i] = 0; +} + +// ---------------------------------------------------------------- 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; // sin ACK: la respuesta de AUDIT_GET es asíncrona (§3.4) + 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; +} + +// ---------------------------------------------------------------- veredicto + +static void emit(const char *estado, int canario, const char *domain, struct denial *d, int nd, + int kernel_count, const char *motivo) { + char esc[2048]; + printf("{\"estado\":\"%s\",\"canario_visto\":%s,\"domain\":\"%s\",\"contador_kernel\":%d,", + estado, canario ? "true" : "false", domain, kernel_count); + if (motivo) { + json_str(motivo, esc, sizeof(esc)); + printf("\"motivo\":\"%s\",", esc); + } + printf("\"denials\":["); + for (int i = 0; i < nd; i++) { + json_str(d[i].path, esc, sizeof(esc)); + printf("%s{\"blockers\":\"%s\",\"path\":\"%s\",\"dev\":\"%s\",\"ino\":\"%s\"}", + i ? "," : "", d[i].blockers, esc, d[i].dev, d[i].ino); + } + printf("]}\n"); + fflush(stdout); +} + +int main(int argc, char **argv) { + const char *canary = NULL; + int timeout = 600, ready_fd = -1; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--canary") && i + 1 < argc) canary = argv[++i]; + else if (!strcmp(argv[i], "--timeout") && i + 1 < argc) timeout = atoi(argv[++i]); + else if (!strcmp(argv[i], "--ready-fd") && i + 1 < argc) ready_fd = atoi(argv[++i]); + } + if (!canary) { + fprintf(stderr, "uso: harkaq-audit --canary [--timeout s] [--ready-fd n]\n"); + return 2; + } + + // GATE: sin audit_enabled=1 el kernel no emite NADA y un `denials=[]` sería el instrumento + // callado, no un build limpio (§3.4). Nunca reportar; abortar. + int enabled = -1; + audit_status(&enabled); + if (enabled != 1) { + audit_enable(); + usleep(100000); + audit_status(&enabled); + } + if (enabled != 1) { + emit("SinEvidencia", 0, "", NULL, 0, -1, + "audit_enabled != 1: el kernel no emite registros (¿CAP_AUDIT_CONTROL? ¿audit=1?)"); + 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) { + emit("SinEvidencia", 0, "", NULL, 0, -1, + "no pude abrir el multicast del audit (¿falta CAP_AUDIT_READ?)"); + return 4; + } + // Sólo ahora el build puede arrancar: si arrancara antes, sus primeras denegaciones —el + // canario entre ellas— se emitirían sin nadie escuchando y el veredicto sería SinEvidencia + // por una carrera, no por un problema real. + if (ready_fd >= 0) { (void)!write(ready_fd, "R", 1); close(ready_fd); } + + struct denial denials[MAX_DENIALS]; + int nd = 0, canary_seen = 0, kernel_count = -1; + char domain[64] = ""; + struct timeval tv = {.tv_sec = 1}; + setsockopt(nl, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + char buf[16384]; + time_t deadline = time(NULL) + timeout; + struct sigaction act = {.sa_handler = on_term}; + sigaction(SIGTERM, &act, NULL); + sigaction(SIGINT, &act, NULL); + + while (time(NULL) < deadline && !parar) { + 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; + char rec[8192]; + size_t len = NLMSG_PAYLOAD(h, 0); + if (len >= sizeof(rec)) len = sizeof(rec) - 1; + memcpy(rec, NLMSG_DATA(h), len); + rec[len] = 0; + + char dom[64] = ""; + field(rec, "domain", dom, sizeof(dom)); + + if (h->nlmsg_type == AUDIT_LANDLOCK_ACCESS) { + char path[1024] = ""; + field(rec, "path", path, sizeof(path)); + // El canario revela NUESTRO dominio. Es la clave primaria: `pid=` viaja en el + // `allocated`, que es perezoso, y dev+ino identifican el objeto y no el build + // (dos builds leyendo el mismo fichero dan el mismo ino) — §3.5. + if (!canary_seen && !strcmp(path, canary)) { + canary_seen = 1; + snprintf(domain, sizeof(domain), "%s", dom); + continue; // el canario no es una denegación del build: es el sello del lector + } + if (!canary_seen || strcmp(dom, domain) != 0) continue; // de otro build + if (nd < MAX_DENIALS) { + field(rec, "blockers", denials[nd].blockers, sizeof(denials[nd].blockers)); + snprintf(denials[nd].path, sizeof(denials[nd].path), "%s", path); + field(rec, "dev", denials[nd].dev, sizeof(denials[nd].dev)); + field(rec, "ino", denials[nd].ino, sizeof(denials[nd].ino)); + nd++; + } + } else if (canary_seen && !strcmp(dom, domain)) { + char status[32] = ""; + field(rec, "status", status, sizeof(status)); + if (!strcmp(status, "deallocated")) { + char cnt[32] = ""; + if (field(rec, "denials", cnt, sizeof(cnt))) kernel_count = atoi(cnt); + goto done; // el dominio murió: el build terminó + } + } + } + } + +done: + if (!canary_seen) { + // La afirmación central de harkaq (D2) sería indistinguible de un lector ciego. + emit("SinEvidencia", 0, domain, denials, nd, kernel_count, + "canario NO visto: el logging post-exec no llegó (¿falta LOG_NEW_EXEC_ON?) o el " + "lector no es confiable. Un build hermético y un lector ciego son bit-idénticos."); + return 3; + } + // El contador del kernel caza registros PERDIDOS por desborde del backlog de audit — un + // riesgo real con la granja construyendo en paralelo (§3.4). +1 = el propio canario. + if (kernel_count >= 0 && kernel_count != nd + 1) { + char m[256]; + snprintf(m, sizeof(m), + "el kernel contó %d denegaciones y recibí %d (+canario): se perdieron registros", + kernel_count, nd + 1); + emit("SinEvidencia", 1, domain, denials, nd, kernel_count, m); + return 3; + } + emit(nd == 0 ? "Hermetico" : "Impuro", 1, domain, denials, nd, kernel_count, NULL); + return nd == 0 ? 0 : 1; +} diff --git a/scripts/harkaq/harkaq-exec.c b/scripts/harkaq/harkaq-exec.c new file mode 100644 index 00000000..8eecf89e --- /dev/null +++ b/scripts/harkaq/harkaq-exec.c @@ -0,0 +1,159 @@ +// 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 [--abi-min 3] -- [args...] +// +// Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se +// DERIVA, no se escribe): +// ro /store/-zlib +// rw /out +// +// 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 +#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); +} + +// 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) + +struct rule { + char path[4096]; + int rw; +}; + +int main(int argc, char **argv) { + const char *policy = NULL; + int abi_min = 3, 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], "--")) { i++; break; } + } + if (!policy || i >= argc) { + fprintf(stderr, "uso: harkaq-exec --policy [--abi-min N] -- [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; + } + + __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]; + int n_ro = 0, n_rw = 0; + while (fgets(line, sizeof(line), f)) { + char *nl = strchr(line, '\n'); + if (nl) *nl = 0; + if (!line[0] || line[0] == '#') continue; + int rw; + char *p; + if (!strncmp(line, "ro ", 3)) { rw = 0; p = line + 3; } + else if (!strncmp(line, "rw ", 3)) { rw = 1; p = line + 3; } + 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; + } + struct landlock_path_beneath_attr pb = {.allowed_access = rw ? handled : RO_RIGHTS, + .parent_fd = dfd}; + // REFER sólo se concede sobre directorios; sobre un fichero suelto el kernel rechaza + // la regla entera con EINVAL. Reintentamos sin él antes de darlo por perdido. + if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) { + pb.allowed_access &= ~(__u64)LANDLOCK_ACCESS_FS_REFER; + 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); + rw ? n_rw++ : n_ro++; + } + 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); + + 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, %d ro + %d rw, logging post-exec ON\n", + abi, n_ro, n_rw); + + execvp(cmd[0], cmd); + fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]); + return 127; +} diff --git a/scripts/harkaq/harkaq-run.sh b/scripts/harkaq/harkaq-run.sh new file mode 100755 index 00000000..fc24db84 --- /dev/null +++ b/scripts/harkaq/harkaq-run.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# harkaq-run — corre un comando enjaulado y emite su Verdict (SDD 16, Fase 1). +# +# harkaq-run.sh +# +# Encadena las tres piezas: harkaq-audit escuchando en el HOST, bwrap poniendo los namespaces, +# harkaq-exec poniendo la política dentro. El canario (D9) lleva un nonce único por corrida: es +# lo que distingue "build limpio" de "lector ciego" —que producen los mismos bytes— y de paso +# es la clave primaria que atribuye los registros a ESTE build y no a otro de la granja (§3.5). +# +# Variables: HARKAQ_BIN (default ~/.cache/harkaq), NONCE (default: derivado del pid). +set -eu + +BIN="${HARKAQ_BIN:-$HOME/.cache/harkaq}" +POLICY="$1" +shift + +NONCE="${NONCE:-$$-$(date +%s 2>/dev/null || echo 0)}" +CANARY="/tmp/harkaq-canary-$NONCE" + +# El canario es un fichero que EXISTE en el sandbox y que la política deja deliberadamente +# fuera de la clausura. Su fuente da igual (sólo importa que el open rebote): usamos algo +# mínimo y siempre presente. +CANARY_SRC="$(mktemp)" +echo "harkaq-canary $NONCE" > "$CANARY_SRC" +trap 'rm -f "$CANARY_SRC" "$FIFO" 2>/dev/null || true' EXIT + +# Arrancar el lector ANTES del build y esperar a que confirme que escucha. Sin esta barrera, +# las primeras denegaciones —el canario entre ellas— se emitirían sin nadie escuchando y el +# veredicto sería SinEvidencia por una carrera, no por un problema real. +FIFO="$(mktemp -u)" +mkfifo "$FIFO" +VERDICT="$(mktemp)" +trap 'rm -f "$CANARY_SRC" "$FIFO" "$VERDICT" 2>/dev/null || true' EXIT + +"$BIN/harkaq-audit" --canary "$CANARY" --timeout "${HARKAQ_TIMEOUT:-120}" --ready-fd 3 \ + 3>"$FIFO" >"$VERDICT" & +AUDIT_PID=$! +read -r _ < "$FIFO" || true # bloquea hasta que el lector esté escuchando + +# El probe del canario va DENTRO del `sh -c`, o sea DESPUÉS del execve: es el único sitio donde +# valida lo que tiene que validar (que el logging post-exec funciona). Si lo hiciera harkaq-exec +# antes de ejecutar, sería una denegación same-exec —que el kernel loguea por defecto— y no +# probaría nada sobre el caso que importa (§3.4). +# Sin redirects: `>/dev/null` exige /dev en la clausura y, si falta, sh falla en la redirección +# y el probe no llega a correr — el error medía otra cosa (§3.5, nota de método). +CMD="cat $CANARY; $*" + +# harkaq-exec y la política van bajo /tmp (tmpfs) y NO bajo /: con `--ro-bind / /` la raíz es de +# sólo lectura y bwrap no puede crear ahí el punto de montaje. Que vivan en /tmp no los mete en +# la clausura: se leen y se ejecutan ANTES del restrict_self — la jaula empieza en el `sh -c`. +set +e +bwrap --unshare-all --ro-bind / / --tmpfs /tmp \ + --ro-bind "$CANARY_SRC" "$CANARY" \ + --ro-bind "$BIN/harkaq-exec" /tmp/harkaq-exec \ + --ro-bind "$POLICY" /tmp/harkaq.policy \ + --die-with-parent \ + /tmp/harkaq-exec --policy /tmp/harkaq.policy -- /bin/sh -c "$CMD" +BUILD_RC=$? +set -e + +# Gracia para que llegue el `deallocated` (el kernel lo emite con retraso: RCU/workqueue), y +# después despertar al lector si sigue esperando — pasa cuando el build ni arrancó y el canario +# nunca se emitió. Sin esto se comería el timeout entero por un fallo trivial. +sleep 2 +kill -TERM "$AUDIT_PID" 2>/dev/null || true +wait "$AUDIT_PID" 2>/dev/null || true +cat "$VERDICT" +echo "[harkaq-run] build rc=$BUILD_RC" >&2 +exit $BUILD_RC