CORRECCIÓN ESTRUCTURAL del §4 (otra herencia del marco nix). El Draft 2 decía
`fs_ro: [store paths de la clausura]`. Falso: esos paths NO existen dentro del
sandbox. `Sandbox::bwrap_args` apila las deps con --overlay-src y las FUNDE en
el mismo /usr que el rootfs Alpine, a propósito, para que el compilador las
encuentre sin plumbing de flags. Medido:
/usr/include/zlib.h (dep DECLARADA) dev=63 ino=13641973
/usr/include/stdio.h (Alpine, NO declarado) dev=63 ino=4196788
/usr/include (el directorio) dev=61 ino=15 ← overlayfs, uno
Una regla sobre /usr concede las dos ⇒ la evidencia no valdría nada. Ni `dev`
distingue. ⇒ La clausura se enumera FICHERO A FICHERO (computable: en el host
sabemos qué aporta cada dep). Dos consecuencias: `list` ≠ `ro` (pkgconf NECESITA
escanear /usr/lib/pkgconfig, pero listar no es leer; Landlock separa READ_DIR de
READ_FILE) y máscara por tipo (derechos de-sólo-dir sobre un fichero ⇒ EINVAL y
la regla entera se cae).
Piezas nuevas: harkaq-policy.sh (deriva la clausura, D1) + q2-runtime-base.sh
(responde Q2 midiendo, no adivinando) + harkaq-exec con `list`/máscara por tipo.
Q2, primeros datos con un build REAL en el sandbox REAL contra la dep zlib:
estado: Impuro | canario: True | contador kernel: 3
fs.read_file /usr/bin/env
fs.read_file /usr/lib/libz.so.1.3.2 ← la zlib de ALPINE!
El build se linkaba contra la zlib de Alpine en vez de la dep declarada (que
aporta libz.a). Sin harkaq eso pasa en verde y el artefacto queda dependiendo de
Alpine. ES EXACTAMENTE EL BUG DEL §0, cazado en un build real.
Piso duro del runtime base: /bin/sh → /bin/busybox → /lib/ld-musl (sin eso ni el
canario corre ⇒ el mínimo es "lo que hace falta para que el canario pueda correr").
Dos rastrillos: el canario no puede depender de un binario (`cat` moría en
/bin/cat ⇒ ahora `read _ < $CANARY`, sólo builtins) ni vivir en /tmp (la política
concede `rw /tmp` ⇒ el canario caía DENTRO de la clausura y no denegaba nada).
El segundo lo cazó D9 MISMO: el lector se negó a certificar en vez de decir
Hermetico. El canario funcionando como se diseñó, sobre un bug de quien lo diseñó.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
8.9 KiB
C
188 lines
8.9 KiB
C
// 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 /usr/include/zlib.h un fichero de la clausura: leer/ejecutar SÓLO él
|
||
// ro /opt/zig un directorio: todo el subárbol
|
||
// list /usr/include listar el directorio, SIN poder leer su contenido
|
||
// rw /out
|
||
//
|
||
// El grano POR FICHERO no es capricho: el sandbox de hammer funde las deps y el rootfs Alpine
|
||
// en el mismo `/usr` (`--overlay-src` × N + `--tmp-overlay /`), así que /usr/include/zlib.h
|
||
// (dep declarada) y /usr/include/stdio.h (Alpine, no declarado) son el MISMO directorio. Una
|
||
// regla sobre /usr concede los dos y la evidencia no vale nada. En el host sí sabemos qué
|
||
// ficheros aporta cada dep ⇒ la clausura se enumera fichero a fichero.
|
||
//
|
||
// `list` existe por la misma razón: pkgconf/configure NECESITAN listar /usr/lib/pkgconfig, pero
|
||
// listar no es leer. Landlock separa READ_DIR de READ_FILE, así que se concede el listado del
|
||
// directorio y se deniega la lectura de los .pc que la receta no declaró.
|
||
//
|
||
// 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/stat.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)
|
||
|
||
// Derechos que Landlock SÓLO admite sobre directorios. Añadir una regla con cualquiera de
|
||
// éstos sobre un fichero regular hace que el kernel rechace la regla entera con EINVAL — y
|
||
// como la clausura es mayormente ficheros sueltos (§4), sin esta máscara no arranca nada.
|
||
#define DIR_ONLY_RIGHTS \
|
||
(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 | \
|
||
LANDLOCK_ACCESS_FS_REFER)
|
||
|
||
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_file = 0, n_dir = 0;
|
||
while (fgets(line, sizeof(line), f)) {
|
||
char *nl = strchr(line, '\n');
|
||
if (nl) *nl = 0;
|
||
if (!line[0] || line[0] == '#') continue;
|
||
__u64 grant;
|
||
char *p;
|
||
if (!strncmp(line, "ro ", 3)) { grant = RO_RIGHTS; p = line + 3; }
|
||
else if (!strncmp(line, "rw ", 3)) { grant = handled; p = line + 3; }
|
||
else if (!strncmp(line, "list ", 5)) { grant = LANDLOCK_ACCESS_FS_READ_DIR; p = line + 5; }
|
||
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;
|
||
}
|
||
// Recortar los derechos de-sólo-directorio cuando el destino NO lo es: el kernel
|
||
// rechazaría la regla entera con EINVAL. Es el caso MAYORITARIO — la clausura es una
|
||
// lista de ficheros.
|
||
struct stat st;
|
||
int is_dir = (fstat(dfd, &st) == 0) && S_ISDIR(st.st_mode);
|
||
if (!is_dir) grant &= ~(__u64)DIR_ONLY_RIGHTS;
|
||
grant &= handled; // nunca conceder más de lo que el ruleset declara manejar
|
||
if (!grant) {
|
||
fprintf(stderr, "[harkaq] regla vacía tras recortar por tipo: %s\n", p);
|
||
close(dfd);
|
||
return 4;
|
||
}
|
||
struct landlock_path_beneath_attr pb = {.allowed_access = grant, .parent_fd = dfd};
|
||
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);
|
||
is_dir ? n_dir++ : n_file++;
|
||
}
|
||
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, clausura = %d ficheros + %d dirs, "
|
||
"logging post-exec ON\n",
|
||
abi, n_file, n_dir);
|
||
|
||
execvp(cmd[0], cmd);
|
||
fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]);
|
||
return 127;
|
||
}
|