// 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 "harkaq-uapi.h" #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)) { // El kernel RESPONDE que no, y hasta ahora esa respuesta se ignoraba: sin // CAP_AUDIT_READ contesta un NLMSG_ERROR con -EPERM, y el bucle seguía esperando un // AUDIT_GET que no iba a llegar — 8 s por llamada, 16 s en el gate. Con el lector // arrancado por `qorpa run`, eso significaba que el SIGTERM del final lo mataba // DENTRO del gate, antes de instalar su manejador ⇒ moría por señal sin emitir nada y // el veredicto quedaba VACÍO. Un "no" tardío se parece demasiado a un cuelgue. if (h->nlmsg_type == NLMSG_ERROR) { close(fd); return -1; } 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; int drenando = 0; // el dominio ya murió: drenar lo que quede antes de emitir // ACCESS que llegan ANTES de ver el canario: no se pueden filtrar todavía (el `domain=` del // build se conoce al ver el canario), pero pueden ser SUYOS — el `sh` deniega cosas antes de // llegar al probe. Descartarlos perdía registros y el contador del kernel lo cazaba (4 vs 1). // Se bufferizan con su domain y se filtran retroactivamente. static struct { char dom[64]; struct denial d; } previos[MAX_DENIALS]; int n_prev = 0; 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); // Drenando: el dominio murió y sólo faltan los ACCESS rezagados. Si el socket se agota // (timeout de 1s) o ya cuadramos con el contador del kernel, cerramos. if (drenando && (n <= 0 || (kernel_count >= 0 && nd + 1 >= kernel_count))) goto done; 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); // Rescatar del buffer los ACCESS previos que resultaron ser de MI dominio. for (int k = 0; k < n_prev && nd < MAX_DENIALS; k++) if (!strcmp(previos[k].dom, domain)) denials[nd++] = previos[k].d; continue; // el canario no es una denegación del build: es el sello del lector } if (!canary_seen) { // Aún no sé mi domain: guardar por si resulta ser mío. if (n_prev < MAX_DENIALS) { snprintf(previos[n_prev].dom, sizeof(previos[n_prev].dom), "%s", dom); field(rec, "blockers", previos[n_prev].d.blockers, sizeof(previos[n_prev].d.blockers)); snprintf(previos[n_prev].d.path, sizeof(previos[n_prev].d.path), "%s", path); field(rec, "dev", previos[n_prev].d.dev, sizeof(previos[n_prev].d.dev)); field(rec, "ino", previos[n_prev].d.ino, sizeof(previos[n_prev].d.ino)); n_prev++; } continue; } if (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); // NO salir de una: el `deallocated` puede adelantarse a registros ACCESS que // siguen en el buffer del socket. Con un inquilino instantáneo (`htop // --version`) el kernel contaba 4 denegaciones y el lector recibía 1 ⇒ el // chequeo anti-pérdida disparaba SinEvidencia. Se drena hasta cuadrar con el // contador (o agotar el socket), y recién ahí se cierra. drenando = 1; continue; } } } } 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; }