harkaq: Fase 1 — la cadena corre de punta a punta (Hermetico / Impuro sobre kernel real)
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:
<min rechaza, <7 avisa SIN EVIDENCIA) y pone
LOG_NEW_EXEC_ON + TSYNC.
- harkaq-run.sh encadena lector+bwrap+exec, pone el canario con nonce y
espera a que el lector confirme que escucha (--ready-fd)
antes de arrancar el build: sin esa barrera el canario se
emitiría sin nadie escuchando y daría SinEvidencia por una
carrera, no por un problema real.
El hito, con comandos sintéticos sobre el kernel real (ABI 10):
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",…}]}
Canario visto y contador coherente en ambos ⇒ los 3 estados de D9 funcionan.
Q1c contestada en la práctica: el privilegio vive en un helper chico con
`setcap cap_audit_read,cap_audit_control`, NO en hammerd entero.
Sutileza medida: lo que DAC ya bloquea es INVISIBLE para harkaq (el hook del
LSM no llega a correr). Descubierto porque el escenario "impuro" con
/root/.bashrc (drwx------) dio Hermetico: DAC lo bloqueó antes que Landlock.
No rompe D2 (si DAC lo bloqueó, el build tampoco lo usó ⇒ Hermetico es cierto)
pero acota el diagnóstico, y al escribir tests hay que elegir paths que DAC
permita o el control no controla nada.
Falta para cerrar Fase 1: harkaq-policy derivando de la clausura real + el
contraste receta-de-Alpinizada vs. receta-que-no. El rebuild del kernel
(SECURITY_LANDLOCK) NO bloquea el hito: sólo hace falta para que harkaq corra
DENTRO de hammer; el laptop ya está en ABI 10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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-<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 <linux/audit.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))
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -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 <fichero> [--abi-min 3] -- <cmd> [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/<hash>-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 <fcntl.h>
|
||||
#include <linux/landlock.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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 <f> [--abi-min N] -- <cmd> [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;
|
||||
}
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
# harkaq-run — corre un comando enjaulado y emite su Verdict (SDD 16, Fase 1).
|
||||
#
|
||||
# harkaq-run.sh <fichero-política> <cmd...>
|
||||
#
|
||||
# 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
|
||||
Reference in New Issue
Block a user