De un binario ajeno no hay fuente que leer. Lo único observable es lo que el
kernel le NIEGA y anota, y hasta acá ese canal existía en el build pero ninguna
instancia lo abría. `hammer qorpa run <id> --evidence` levanta el lector
(`harkaq-audit`) en el HOST —el audit no está namespaceado— antes de que arranque
la instancia, y al terminar imprime uno de tres estados. Los tres, medidos:
HERMÉTICO 0 denegaciones Y el canario las respalda
IMPURO `touch /usr/INTRUSO; mkdir /opt/INTRUSO` →
fs.make_reg · /usr fs.make_dir · /opt
SIN EVIDENCIA quitándole las capabilities al lector. NO es «limpio»
**El canario es lo que hace que «cero denegaciones» valga algo:** un fichero
donde la política no alcanza; al leerlo, el kernel emite una denegación que
revela el `domain=` de ESTE dominio Landlock, un número que desde fuera no se
adivina. Sin él, `denials=[]` sería el instrumento callado.
**Dos condiciones estructurales, y se FALLA en vez de dar un veredicto vacío:**
con `nesting` no hay Landlock (D9 conflicto 1) ⇒ o anidás o auditás; y sin
`seal_image` la política es `rw /` ⇒ no hay NADA denegable y el veredicto sería
limpio por construcción, no por mérito. No es un defecto de la implementación:
**la evidencia sólo existe donde algo puede ser negado.**
**Un bug del propio instrumento, que sólo salió usándolo:** sin CAP_AUDIT_READ
el kernel RESPONDE que no (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa
respuesta esperando una que no iba a llegar — 8 s por consulta, 16 s en su
compuerta. Como `qorpa run` lo despierta al terminar, moría por señal dentro de
la compuerta **sin emitir nada**: un «no» tardío se parecía demasiado a un
cuelgue. Ahora atiende el NLMSG_ERROR y dice su motivo en 2 s. Y si aun así el
veredicto sale vacío, se reporta con el código de salida del lector, que es el
único dato que queda.
Guardián: `scripts/qorpa/evidence-probe.sh`, con las tres aserciones. La 2 es la
que sostiene a la 1 — sin algo que TIENE que salir sucio, «HERMÉTICO» lo cumple
igual un canal muerto. Comprueba también las capabilities del lector, que **se
pierden en cada recompilación** y son la forma más probable de que el canal
muera en silencio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
313 lines
14 KiB
C
313 lines
14 KiB
C
// 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-<nonce> [--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 <linux/netlink.h>
|
|
#include <signal.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/socket.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
// 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 <path> [--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;
|
|
}
|