GitHub: sergiovelasquezzeballos/hammer -> takana (sigue privado). El segundo pushurl del origin y el default de espejo-setup.sh actualizados; push real verificado contra los DOS destinos. Las dos colas las encontro hammer-4a y las verifique antes de aplicarlas: 1) CINCO scripts hardcodeaban /home/sergio/hammer y hoy funcionaban SOLO por el symlink que puse al mover. El peor era respaldo-storagebox.sh, cuya RAIZ por defecto salia de ahi: si alguien limpia el symlink dando el renombre por cerrado, el RESPALDO apunta a una ruta inexistente. Un respaldo que no encuentra su raiz no falla ruidosamente — se descubre el dia que lo necesitas. Ahora apuntan a /mnt/vvv/takana, la ruta real, sin depender de ningun enlace. 2) Cuatro referencias a gitea.gioser.net/sergio/hammer. El diagnostico del peer era el correcto y lo comprobe: ese HOST no resuelve, y no por el renombre — ya estaba mal antes, el remoto real es git.gioser.net. Cambiar solo hammer->takana las habria dejado igual de rotas. Van a https://git.gioser.net/sergio/takana, que responde 200.
313 lines
15 KiB
Markdown
313 lines
15 KiB
Markdown
# 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":"/mnt/vvv/takana/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.
|
||
|
||
## `q1-audit.c` — el gate del proyecto (Q1, ✅ cerrado 2026-07-15)
|
||
|
||
Responde: *¿llegan los registros `AUDIT_LANDLOCK_*` a un lector?* Todo harkaq cuelga de eso — su
|
||
producto es la evidencia, no la jaula.
|
||
|
||
```sh
|
||
gcc -O1 -Wall -o q1-audit q1-audit.c
|
||
for s in same-exec new-exec new-exec-logon; do sudo ./q1-audit $s; done
|
||
```
|
||
|
||
Necesita root (`CAP_AUDIT_READ` para leer el multicast, `CAP_AUDIT_CONTROL` para encender el
|
||
audit). **Enciende `audit_enabled=1` globalmente y no lo restaura** — reversible, no persiste al
|
||
reboot (no toca el cmdline), pero deja `dmesg` más ruidoso mientras tanto.
|
||
|
||
### Resultados en el laptop (ABI 10, kernel 7.2.0-rc3)
|
||
|
||
| Escenario | Registros `ACCESS` | Qué prueba |
|
||
|---|---|---|
|
||
| `same-exec` | 2 | el kernel loguea por defecto sin `execve` |
|
||
| `new-exec` | **0** | **flags por defecto ⇒ ciego tras `execve`** — el caso real de harkaq |
|
||
| `new-exec-logon` | 4 | `LOG_NEW_EXEC_ON` lo arregla |
|
||
|
||
Los registros traen `blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369` y el
|
||
`exe=` que lo intentó. Análisis completo en `docs/16-harkaq-jaula.md` §3.4.
|
||
|
||
### Por qué aborta con código 4
|
||
|
||
Si no puede **confirmar** `audit_enabled=1`, aborta en vez de reportar 0 registros. No es
|
||
paranoia: la primera corrida dio 0 en los tres escenarios y parecía confirmar la hipótesis. Era un
|
||
bug del instrumento (`AUDIT_GET` con `NLM_F_ACK` → el ACK llegaba antes que la respuesta → nunca
|
||
se encendía el audit). Se detectó sólo porque el **control** (`same-exec`) también dio 0, y eso era
|
||
imposible.
|
||
|
||
Es el modo de falla de harkaq sobre su propio banco de pruebas: un sistema cuyo producto es la
|
||
*ausencia* de algo no falla ruidosamente — dice que todo está bien. De ahí sale D9 (el canario
|
||
deliberado), y de ahí sale la regla de este directorio: **todo experimento lleva un control que
|
||
debe dar positivo.** Un experimento sin control no mide, tranquiliza.
|
||
|
||
## `q1b-attrib.c` — userns y atribución (Q1b, ✅ cerrado 2026-07-15)
|
||
|
||
Responde: *¿el lector del host ve las denegaciones de adentro de bwrap, y se puede atribuir cada
|
||
registro a SU build con la granja en paralelo?*
|
||
|
||
```sh
|
||
gcc -O1 -Wall -o ~/.cache/harkaq/q1b q1b-attrib.c # NO compilar bajo /tmp: --tmpfs /tmp lo tapa
|
||
sudo ~/.cache/harkaq/q1b
|
||
```
|
||
|
||
Lanza 2 víctimas concurrentes en bwrap `--unshare-all` (los ns de `Sandbox::bwrap_args`), cada una
|
||
con un canario de nonce distinto, y escucha desde el host. Las víctimas bajan a `$SUDO_UID`: el
|
||
despliegue real es lector privilegiado + builds sin privilegio.
|
||
|
||
Resultado: los registros **cruzan** el userns/pidns/mountns, y el canario con nonce **atribuye**
|
||
sin ambigüedad (`AAAA=1 BBBB=1`, cero cruces). Análisis en `docs/16-harkaq-jaula.md` §3.5.
|
||
|
||
### Los tres rastrillos de este banco, para el próximo que lo toque
|
||
|
||
1. **No compilar bajo `/tmp`**: el sandbox hace `--tmpfs /tmp` y tapa el binario.
|
||
2. **El canario necesita un punto de montaje escribible.** Con `--ro-bind / /`, bwrap no puede
|
||
crear `/harkaq-canary-X` en la raíz de sólo lectura (el sandbox real usa `--tmp-overlay /` y no
|
||
tendría el problema). Va bajo `/tmp`, y por eso `/tmp` **no** entra en la clausura de la víctima.
|
||
3. **Root + `--unshare-all` no atraviesa un home `drwx------`.** bwrap mapea sólo uid 0→0; los
|
||
ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` en un userns sólo vale sobre uids
|
||
**mapeados**. De ahí `drop_privs()`.
|
||
|
||
### Por qué también aborta con código 4
|
||
|
||
Mismo gate que `q1-audit`, más uno: **si alguna víctima no sale con status 0, aborta** en vez de
|
||
contar registros. La corrida que lo motivó imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en
|
||
el host: replantear"* cuando bwrap ni siquiera había podido ejecutar el binario. Una conclusión
|
||
arquitectónica falsa, a partir de cero estímulo.
|
||
|
||
Van tres veces en una sola sesión (`NLM_F_ACK`, el redirect a `/dev/null`, el exec) que un cero
|
||
resultó ser el instrumento y no el kernel. Un veredicto que no verifica su propio estímulo es el
|
||
falso `Hermetico` de D9, pero del lado del banco.
|
||
|
||
## `harkaq-policy.sh` + `q2-runtime-base.sh` — la clausura y el runtime base
|
||
|
||
`harkaq-policy.sh <dir-dep>...` deriva la política de la clausura (D1: no se escribe, se deriva).
|
||
Enumera **fichero a fichero** —no por directorio— porque el sandbox funde las deps y Alpine en el
|
||
mismo `/usr` (§4.1). Emite `list` de los directorios para que pkgconf/configure puedan escanear
|
||
sin poder leer lo no declarado.
|
||
|
||
`q2-runtime-base.sh` responde Q2 midiendo, no adivinando: corre un build real en el sandbox real
|
||
con `política = clausura` y deja que las denegaciones nombren el runtime base. Se itera con
|
||
`BASE_EXTRA` (una línea por regla), y **cada línea que se añade ahí debe venir de una denegación
|
||
que el kernel denunció, nunca de una corazonada** — si se define de más, la política concede
|
||
Alpine entero y la evidencia deja de valer.
|
||
|
||
```sh
|
||
export BASE_EXTRA='ro /bin/sh
|
||
ro /bin/busybox
|
||
ro /lib/ld-musl-x86_64.so.1'
|
||
HARKAQ_TIMEOUT=25 scripts/harkaq/q2-runtime-base.sh
|
||
```
|
||
|
||
Resultado (2026-07-15, contra la dep `zlib` del store):
|
||
|
||
```
|
||
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, no la dep declarada!
|
||
```
|
||
|
||
### Dos rastrillos más, aprendidos acá
|
||
|
||
1. **El canario no puede depender de un binario.** Con `cat $CANARY` moría en `/bin/cat` (applet
|
||
de busybox fuera de la clausura) y el probe no probaba nada. Ahora usa sólo builtins:
|
||
`read _ < $CANARY || true`.
|
||
2. **El canario no puede vivir en `/tmp`.** La política concede `rw /tmp` (es el HOME del build),
|
||
así que un canario ahí cae DENTRO de la clausura, se lee sin problema y no deniega nada ⇒
|
||
`SinEvidencia` eterno. Va en la raíz, donde la política no alcanza por construcción.
|
||
|
||
El segundo lo cazó **D9 mismo**: el lector se negó a certificar (`SinEvidencia`) en vez de decir
|
||
`Hermetico`. El canario funcionando como se diseñó, sobre un bug de quien lo diseñó.
|
||
|
||
### Q2 resuelta: el runtime base son 5 paths (2026-07-15)
|
||
|
||
`MODE=base` corre **sin ninguna dep declarada**: sin clausura que pueda explicarlas, toda
|
||
denegación es runtime base por definición. Eso aísla la respuesta sin juicio de valor.
|
||
|
||
```sh
|
||
export BASE_EXTRA='ro /bin/sh
|
||
ro /bin/busybox
|
||
ro /lib/ld-musl-x86_64.so.1
|
||
ro /usr/bin/env
|
||
ro /usr/lib/os-release
|
||
rw /dev/null
|
||
ro /dev/urandom'
|
||
MODE=base scripts/harkaq/q2-runtime-base.sh # rc=0, sólo queda /usr/bin/gcc (esperada)
|
||
MODE=zlib scripts/harkaq/q2-runtime-base.sh # Impuro: [/usr/lib/libz.so.1.3.2] — deuda pura
|
||
```
|
||
|
||
Tres categorías, todas medidas (detalle en `docs/16-harkaq-jaula.md` §4.2):
|
||
|
||
- **Contrato**: `/src /out /tmp /dev/null(rw) /dev/urandom /proc /opt/zig` — los monta bwrap, no
|
||
son Alpine.
|
||
- **Runtime base Alpine**: `/bin/sh /bin/busybox /lib/ld-musl /usr/bin/env /usr/lib/os-release`.
|
||
Cinco. El resto de Alpine es deuda genuina.
|
||
- **Denegación esperada**: `/usr/bin/gcc` — `zig cc` lo sondea y el build sale `rc=0` igual.
|
||
Concederla dejaría a zig usar el gcc de Alpine por detrás. **Se deniega y se clasifica** (D3).
|
||
|
||
Ojo con dos: `/dev/null` es destino de **escritura** (`rw`, no `ro` — con `ro` quedan
|
||
`fs.write_file` colgando), y el piso duro es `/bin/sh`→`busybox`→`ld-musl`: sin eso ni el canario
|
||
corre y no hay diagnóstico, sólo `SinEvidencia`.
|
||
|
||
### ¿Aguantan los 5 paths en otra forma de build? No — pero converge (§4.3)
|
||
|
||
`MODE=configure` corre un `configure` de autotools **real** (`work/sources/libgpg-error-*`,
|
||
override con `Q2_SRC`). Iterando con el mismo método:
|
||
|
||
| Ronda | Deuda restante (paths únicos) |
|
||
|---|---|
|
||
| 1 | `/bin/bash`, `/bin/coreutils` |
|
||
| 2 | `libacl`, `libattr`, `libcrypto`, `libreadline`, `libutmps` |
|
||
| 3 | `libncursesw`, `libskarnet` |
|
||
| 4 | `/usr/bin/c89`, `/usr/bin/c99`, `/usr/bin/ldd`, `/usr/bin/make` |
|
||
|
||
El runtime base es **por forma de build** (~16 entradas para autotools, no 5), pero **converge en
|
||
3 rondas y se queda chico** ⇒ la métrica de <5% de la Fase 2 sigue siendo plausible.
|
||
|
||
Lo importante: **el runtime base no es una lista de binarios, es su cierre de `.so`** (bash
|
||
arrastra libreadline+libncursesw; coreutils arrastra libacl+libattr). Es computable con `ldd`, no
|
||
adivinable — `harkaq-policy` debería derivarlo igual que deriva la clausura de las deps.
|
||
|
||
Y el residuo es todo señal: `c89`/`c99`/`ldd` son sondas de compilador de Alpine (→ esperadas,
|
||
como `gcc`), y `/usr/bin/make` es una decisión de diseño real — hoy los builds usan el `make` de
|
||
Alpine sin declararlo. **Es la misma lista que reemplazan los swaps del `selfhost-verify`**,
|
||
descubierta por otro camino.
|
||
|
||
### El clasificador va FUERA del lector
|
||
|
||
`harkaq-verdict.py <verdict-crudo> <politica> [--human]`. El lector tiene `CAP_AUDIT_READ`;
|
||
clasificar es política, no privilegio. Mantenerlo tonto es el argumento de Q1c, y de yapa se
|
||
itera la clasificación sin recompilar el binario capabilitado (sin perder el `setcap`).
|
||
|
||
Las expectativas viajan en la política como `# expect <path>` — una sola fuente de verdad, y
|
||
`harkaq-exec` las ignora como comentario. `SinEvidencia` manda sobre todo: si el lector no es
|
||
confiable no se clasifica nada, porque reinterpretarlo sería el falso `Hermetico` que el canario
|
||
existe para impedir.
|
||
|
||
### `harkaq-base-closure.py` — el runtime base, derivado
|
||
|
||
```sh
|
||
scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine /bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env
|
||
```
|
||
|
||
Emite `ro <path>` para cada binario **y todo su cierre transitivo de `.so`**, en rutas del
|
||
sandbox. No usa `ldd` (resolvería contra el host): lee los `DT_NEEDED` del ELF y resuelve dentro
|
||
del rootfs por las rutas de musl. Emite symlink **y** destino — el kernel denuncia el fichero real
|
||
(`libz.so.1.3.2`) pero el build abre por el nombre corto (`libz.so.1`).
|
||
|
||
**Validación:** reproduce exactamente las 7 librerías que las rondas 2 y 3 de §4.3 habían
|
||
descubierto a mano, en una pasada, y además caza los symlinks y `libc.musl-x86_64.so.1` que se
|
||
habían escapado.
|
||
|
||
### El diagnóstico sobre un `configure` real (§4.4)
|
||
|
||
Con el entorno del sandbox replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`,
|
||
`LC_ALL=C`), 205 denegaciones del kernel se clasifican en:
|
||
|
||
```
|
||
esperadas (170): /usr/bin/gcc, /usr/bin/ldd
|
||
DEUDA (34) en 8 paths:
|
||
/usr/bin/{ld,nm,objdump,strip} ← binutils de ALPINE
|
||
/usr/bin/{make,getconf}
|
||
/usr/lib/gcc/x86_64-alpine-linux-musl
|
||
/opt ← bug de política: falta `list` en los ancestros
|
||
```
|
||
|
||
Sin clasificar son 205 líneas planas; clasificado, 8 paths accionables. **Regla que salió de acá:
|
||
todo directorio concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso
|
||
positivo.
|
||
|
||
## Fase 2 — el barrido
|
||
|
||
`fase2-barrido.sh <receta.toml>...` + `harkaq-suggest.py`. La métrica del SDD (<5% de recetas
|
||
necesitan excepción) medía otra cosa: una deuda que el store YA PROVEE no es una excepción, es
|
||
una línea en `[deps]`. Sólo la deuda **irreducible** cuenta.
|
||
|
||
```sh
|
||
scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine /bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env > ~/.cache/harkaq/base.policy
|
||
printf "ro /usr/lib/os-release\n# expect /usr/bin/gcc\n# expect /usr/bin/ldd\n" >> ~/.cache/harkaq/base.policy
|
||
scripts/harkaq/fase2-barrido.sh recipes/zlib.toml recipes/brotli.toml ...
|
||
```
|
||
|
||
Primer barrido (6 recetas C, fuentes cacheadas): **5 con sólo deuda declarable —y las 5 por el
|
||
MISMO path, `/usr/bin/make`—** y 1 irreducible (brotli: `libstdc++`/`libgcc_s`, el runtime C++ de
|
||
Alpine = la frontera que *matar gcc* ya tenía identificada). No hay cola larga de excepciones,
|
||
que era el riesgo de muerte del §7.
|
||
|
||
**Gotcha del barrido:** copiar la receta a otro directorio rompe la resolución de `deps.build`
|
||
(son relativas al dir de la receta) ⇒ se descartaban en silencio justo las recetas CON deps. La
|
||
copia va al lado del original.
|
||
|
||
## El bucle cerrado (§4.7)
|
||
|
||
Diagnosticar no vale nada si no se puede accionar. Sobre `recipes/zlib.toml`, cada paso guiado
|
||
SÓLO por lo que dijo harkaq:
|
||
|
||
| Paso | Veredicto | harkaq dijo | Acción |
|
||
|---|---|---|---|
|
||
| zlib tal cual | `Impuro` | `/usr/bin/make` → declarar dep: make | `[deps] build=["make"]` |
|
||
| + make | `Impuro` | `/usr/bin/ranlib` → declarar dep: binutils | `build=["make","binutils"]` |
|
||
| + binutils | `Impuro` | `liblto_plugin.so` → irreducible | clasificar como esperada |
|
||
| final | **`Hermetico` ×3** | — | `b3:adc5c251…` sellado |
|
||
|
||
Cada capa destapa la siguiente y **todas convergen en el gcc de Alpine**. El último hallazgo no se
|
||
encuentra a mano: **los binutils DE HAMMER cargan el plugin LTO del gcc DE ALPINE** — un agujero
|
||
de soberanía dentro de un artefacto que hammer construye, no un fallo de la receta.
|
||
|
||
**El hash del artefacto es idéntico antes y después de clasificar** (`b3:adc5c251…` las dos veces):
|
||
clasificar cambia el veredicto, no el build. La evidencia es comportamiento, no identidad.
|
||
|
||
**NO modificar `recipes/zlib.toml`** para probar esto: cambiaría su hash e invalidaría el
|
||
artefacto sellado y todo lo aguas abajo. Se prueba en una copia `recipes/.harkaq-*.toml` (al lado
|
||
del original, o se rompe la resolución de deps).
|