Files
Sergio f9ed89cd17 takana: espejo de GitHub renombrado, y las dos colas que quedaban
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.
2026-09-09 20:33:03 +00:00

313 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).