From 4a68dfe76149bfca9dfb0a3320407b3123070f53 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 21:57:26 +0000 Subject: [PATCH] =?UTF-8?q?qorpa=20D9:=20el=20canal=20de=20evidencia,=20ca?= =?UTF-8?q?bleado=20=E2=80=94=20la=20=C3=BAnica=20forma=20de=20auditar=20e?= =?UTF-8?q?l=20mont=C3=B3n=20B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --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) Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q --- Cargo.toml | 2 +- crates/hammer-cli/src/qorpa.rs | 186 ++++++++++++++++++++++++++++++- docs/adr/0015-imagenes-ajenas.md | 31 +++++- scripts/harkaq/harkaq-audit.c | 13 ++- scripts/qorpa/evidence-probe.sh | 74 ++++++++++++ 5 files changed, 299 insertions(+), 7 deletions(-) create mode 100755 scripts/qorpa/evidence-probe.sh diff --git a/Cargo.toml b/Cargo.toml index 40205725..9e43f201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ getrandom = "0.2" # `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS — # Wayland pasa descriptores (wl_shm, dmabuf) por el mismo socket, y sin relayarlos no dibuja # nada. Las features de cargo son ADITIVAS: activarlas no cambia lo que compilan los demás. -nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio"] } +nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio", "signal"] } tempfile = "3" clap = { version = "4", features = ["derive"] } tracing = "0.1" diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index b4fd6d71..cde91169 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -113,6 +113,15 @@ pub enum QorpaCmd { /// Para comparar: casi todo lo que "no anda adentro" es este mapeo. #[arg(long)] single_id: bool, + /// Abre el canal de evidencia: apunta qué intentó tocar la instancia y no pudo. + /// + /// **Es la única forma de auditar el montón B.** De un binario ajeno no hay fuente que + /// leer: lo único que queda es lo que el kernel deniega y anota. Exige una política que + /// pueda denegar algo (`seal_image`) y Landlock vivo (o sea, sin `nesting`); si falta + /// alguna, FALLA diciendo cuál — un `denials=[]` sin evidencia posible sería el + /// instrumento mintiendo. + #[arg(long)] + evidence: bool, /// Comando dentro (default: el shell de la imagen). Va después de `--`. #[arg(last = true)] cmd: Vec, @@ -180,9 +189,10 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { QorpaCmd::Recreate { id, no_provision, root } => { recreate(&resolve_root(root), &id, no_provision) } - QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => { - run_instance(&resolve_root(root), &id, &cmd, Ajustes { dry_run, no_jail, single_id, ..Default::default() }) - } + QorpaCmd::Run { id, root, dry_run, no_jail, single_id, evidence, cmd } => run_instance( + &resolve_root(root), &id, &cmd, + Ajustes { dry_run, no_jail, single_id, evidencia: evidence, ..Default::default() }, + ), QorpaCmd::Provision { id, force, dry_run, root } => { provision(&resolve_root(root), &id, force, dry_run) } @@ -1260,12 +1270,14 @@ struct Ajustes { /// Modo `provision`: la política declarada se ENSANCHA para poder instalar, y sólo mientras /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta. provisionando: bool, + /// Levanta el lector de audit y recoge el veredicto. Ver `preparar_evidencia`. + evidencia: bool, } fn run_instance( root: &Path, id: &str, cmd: &[String], aj: Ajustes, ) -> Result<()> { - let Ajustes { dry_run, no_jail, single_id, provisionando } = aj; + let Ajustes { dry_run, no_jail, single_id, provisionando, evidencia: _ } = aj; let (dir, mut inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; @@ -1431,6 +1443,34 @@ fn run_instance( } } + // ── EL CANAL DE EVIDENCIA ────────────────────────────────────────────────────────────────── + // De un binario ajeno no hay fuente que leer. Lo único que queda para saber qué intentó hacer + // es lo que el kernel le NEGÓ y anotó — por eso el ADR lo llama «la única forma de auditar el + // montón B». El lector (`harkaq-audit`) vive en el HOST, fuera de bwrap, porque el audit no + // está namespaceado; se levanta ANTES para no perderse las primeras denegaciones. + let mut ev: Option = None; + if aj.evidencia && !dry_run { + let e = preparar_evidencia(&dir, id, no_jail, &inst.grants)?; + args.push("--ro-bind".into()); + args.push(e.canario_src.display().to_string()); + args.push(e.canario.clone()); + // El canario se lee ADENTRO y su denegación es lo que revela el `domain=` del dominio + // Landlock, que es un número que nadie puede adivinar desde fuera. Sin esto el lector no + // sabe cuáles de todas las denegaciones del sistema son de ESTA instancia. + let mut w = vec![ + "/bin/sh".to_string(), "-c".into(), + // El `{ …; } 2>/dev/null` NO es lo mismo que `read _ < "$1" 2>/dev/null`: las + // redirecciones se aplican de izquierda a derecha, así que en la segunda forma el + // fallo de la entrada se reporta ANTES de silenciar stderr y el usuario ve un + // «Permission denied» que parece un error suyo y es el canario funcionando. + "{ read _ < \"$1\"; } 2>/dev/null || true; shift; exec \"$@\"".into(), + "qorpa".into(), e.canario.clone(), + ]; + w.extend(cmd); + cmd = w; + ev = Some(e); + } + // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host @@ -1531,6 +1571,9 @@ fn run_instance( std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento))); } let st = st.expect("el bucle sale con estado o con bail"); + if let Some(e) = ev { + recoger_evidencia(&dir, e)?; + } match st.code() { Some(0) => Ok(()), Some(c) => std::process::exit(c), @@ -1538,6 +1581,141 @@ fn run_instance( } } +// ── el canal de evidencia (D9 punto 3) ────────────────────────────────────────────────────────── +// +// LO QUE HACE ÚNICO A ESTE CANAL: con nuestros propios builds, si algo toca lo que no debe, está +// la receta y la fuente para mirar. Con un binario ajeno no hay nada que leer. Lo que el kernel +// deniega y anota es LA ÚNICA superficie observable — el ADR lo dice así en D9. +// +// Tres piezas, y ninguna sobra: +// · el LECTOR vive en el host, fuera de bwrap: el audit no está namespaceado y los registros +// cruzan igual. Se levanta ANTES de la instancia o se pierde el principio. +// · el CANARIO es un fichero en un sitio que la política NO alcanza. Al leerlo, el kernel emite +// una denegación con el `domain=` de este dominio Landlock — un número que desde fuera no se +// puede adivinar. Sin canario, el lector no sabe cuáles de todas las denegaciones del sistema +// son de esta instancia. +// · el VEREDICTO se gana: `denials=[]` sólo vale si el canario se vio Y el contador del kernel +// cuadra. Si no, el estado es `SinEvidencia`, que es distinto de «limpio». + +const EVIDENCE_FILE: &str = "evidence.json"; + +struct Evidencia { + canario: String, + canario_src: PathBuf, + veredicto: PathBuf, + lector: std::process::Child, +} + +fn preparar_evidencia(dir: &Path, id: &str, no_jail: bool, g: &Grants) -> Result { + // ── LAS TRES CONDICIONES, Y POR QUÉ SE FALLA EN VEZ DE SEGUIR ────────────────────────────── + // Si alguna falta, el lector devolvería `denials=[]` y eso se leería como «la instancia no + // intentó nada raro», cuando la verdad sería «no había forma de enterarse». Un instrumento que + // no puede medir tiene que decirlo, no marcar cero. + if no_jail { + bail!("`--evidence` con `--no-jail` no tiene sentido: sin jaula no hay dominio Landlock y \ + no hay nada que el kernel pueda anotar"); + } + if g.nesting { + bail!( + "`nesting` apaga Landlock (D9, conflicto 1: el kernel no admite montajes nuevos bajo \n \ + un dominio) ⇒ o la instancia anida o se audita, no las dos. Para auditar Steam hay \n \ + que mirar la instancia SIN pressure-vessel." + ); + } + if !g.seal_image { + bail!( + "sin `seal_image` la política de una instancia es UNA línea, `rw /` ⇒ no hay NADA que \n \ + el kernel pueda denegar, y el veredicto diría `denials=[]` por construcción y no por \n \ + mérito. Encendé `seal_image = true` en el manifiesto y volvé a correr." + ); + } + + let bin = harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit"); + if !bin.exists() { + bail!( + "no encuentro harkaq-audit en {}\n \ + construilo: gcc -O1 -Wall -o {} scripts/harkaq/harkaq-audit.c\n \ + y dale las capabilities: sudo setcap cap_audit_read,cap_audit_control+ep {}", + bin.display(), bin.display(), bin.display() + ); + } + + // El canario va en la RAÍZ. Con `seal_image` la política concede `list /` y nada más sobre la + // raíz misma, así que un fichero ahí se puede LISTAR pero no LEER: la denegación es exacta. + // En cualquier directorio con `rw` —/etc, /var, /tmp— se leería sin ruido y el canario sería + // mudo, que es la misma trampa que ya documenta el canario del build. + let canario = format!("/harkaq-canary-{id}-{}", std::process::id()); + let canario_src = dir.join("canary"); + std::fs::write(&canario_src, b"harkaq\n") + .with_context(|| format!("escribiendo el canario {}", canario_src.display()))?; + + let veredicto = dir.join(EVIDENCE_FILE); + let vf = std::fs::File::create(&veredicto) + .with_context(|| format!("creando {}", veredicto.display()))?; + let lector = Command::new(&bin) + .arg("--canary").arg(&canario) + .arg("--timeout").arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into())) + .stdout(std::process::Stdio::from(vf)) + .stderr(std::process::Stdio::inherit()) + .spawn() + .with_context(|| format!("no pude lanzar {}", bin.display()))?; + // El lector tiene que estar en el multicast antes de que arranque la instancia: si no, sus + // primeras denegaciones —el canario entre ellas— se emiten sin nadie del otro lado y el + // veredicto sale `SinEvidencia` por una carrera y no por un problema real. + std::thread::sleep(std::time::Duration::from_millis(300)); + eprintln!(" evidencia: lector puesto, canario {canario}"); + Ok(Evidencia { canario, canario_src, veredicto, lector }) +} + +fn recoger_evidencia(dir: &Path, mut e: Evidencia) -> Result<()> { + // Gracia para que llegue el `deallocated` del dominio: el kernel lo emite con retraso (RCU), + // y es lo que le permite al lector comparar su conteo contra el del kernel. + std::thread::sleep(std::time::Duration::from_millis(1500)); + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(e.lector.id() as i32), + nix::sys::signal::Signal::SIGTERM, + ); + let salida = e.lector.wait().ok().and_then(|s| s.code()); + let _ = std::fs::remove_file(&e.canario_src); + + let txt = std::fs::read_to_string(&e.veredicto).unwrap_or_default(); + // Un veredicto VACÍO no es un veredicto: es el lector que no llegó a emitir. Se dice con su + // código de salida, que es el único dato que queda — un `SinEvidencia` mudo manda a buscar el + // problema en el sitio equivocado. + if txt.trim().is_empty() { + println!( + "evidencia: SIN EVIDENCIA — el lector no emitió veredicto (salió con {}). NO quiere \ + decir «limpio».\n Comprobá: getcap {}", + salida.map(|c| c.to_string()).unwrap_or_else(|| "señal".into()), + harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit").display(), + ); + return Ok(()); + } + let v: serde_json::Value = serde_json::from_str(txt.trim()).unwrap_or(serde_json::Value::Null); + let estado = v.get("estado").and_then(|x| x.as_str()).unwrap_or("SinEvidencia"); + let denials = v.get("denials").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0); + match estado { + "Hermetico" => println!("evidencia: HERMÉTICO — 0 denegaciones, y el canario las respalda"), + "Impuro" => { + println!("evidencia: IMPURO — {denials} denegación(es); esto es el diagnóstico, no el fallo"); + for d in v["denials"].as_array().into_iter().flatten().take(12) { + println!(" {} · {}", + d.get("blockers").and_then(|x| x.as_str()).unwrap_or("?"), + d.get("path").and_then(|x| x.as_str()).unwrap_or("?")); + } + if denials > 12 { println!(" … y {} más en {}", denials - 12, e.veredicto.display()); } + } + _ => println!( + "evidencia: SIN EVIDENCIA — {}. NO quiere decir «limpio»: quiere decir que no se pudo \ + saber.", + v.get("motivo").and_then(|x| x.as_str()).unwrap_or("el lector no dejó motivo") + ), + } + println!(" veredicto completo: {}", e.veredicto.display()); + let _ = dir; + Ok(()) +} + /// Lanza bwrap y devuelve su estado junto con **si el fallo fue el overlay tomado**. /// /// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index f1afc387..1ea782b3 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -349,9 +349,38 @@ builds: |---|---| | **seccomp** | bwrap **no instala filtro alguno**: hoy una instancia puede `io_uring`, `bpf`, `ptrace`, `process_vm_readv`, `userfaultfd`, `keyctl`, `perf_event_open`, `init_module`, `kexec`. Ésa es superficie de kernel contra un prebuilt de terceros | | **`no_new_privs`** | ningún setuid de la imagen ajena escala adentro | -| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B (leer ese canal es trabajo aparte: pide el lector con `CAP_AUDIT_READ`) | +| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B. ✅ **cableado 2026-09-03**: `hammer qorpa run --evidence` | | **`seal_image`** | lo único del eje fs que el namespace no da: congelar `/usr`, `/bin`, `/lib`, `/opt` **aunque adentro seas root**, para que un exploit no persista en el `upper` | +**El canal de evidencia, cableado (2026-09-03).** `hammer qorpa run --evidence` levanta el +lector (`harkaq-audit`) en el HOST —el audit no está namespeceado y los registros cruzan— antes de +que arranque la instancia, y al terminar imprime uno de **tres** estados. Los tres se verificaron a +mano y quedan como guardián en `scripts/qorpa/evidence-probe.sh`: + +| estado | qué significa | comprobado con | +|---|---|---| +| **HERMÉTICO** | 0 denegaciones **y el canario las respalda** | una instancia que no toca nada prohibido | +| **IMPURO** | qué intentó y no pudo — *el diagnóstico, no el fallo* | `touch /usr/INTRUSO; mkdir /opt/INTRUSO` → `fs.make_reg · /usr`, `fs.make_dir · /opt` | +| **SIN EVIDENCIA** | no se pudo saber. **No es «limpio»** | quitándole las capabilities al lector | + +**El canario es lo que hace que «cero denegaciones» valga algo.** Es un fichero en un sitio que la +política no alcanza; al leerlo, el kernel emite una denegación que revela el `domain=` de ESE +dominio Landlock —un número que desde fuera no se puede adivinar—. Sin él, el lector no sabe cuáles +de todas las denegaciones del sistema son de esta instancia, y `denials=[]` sería el instrumento +callado en vez de una instancia limpia. + +**Dos condiciones estructurales, y `--evidence` FALLA en vez de dar un veredicto vacío si falta +alguna:** con `nesting` no hay dominio Landlock (conflicto 1, acá abajo) ⇒ **o anidás o auditás**; y +**sin `seal_image` la política es `rw /`, o sea que no hay nada denegable** ⇒ un veredicto limpio +sería cierto por construcción y no por mérito. Ese acoplamiento no es un defecto de la +implementación: *la evidencia sólo existe donde algo puede ser negado.* + +**Y una que salió midiendo el propio instrumento:** sin `CAP_AUDIT_READ` el kernel **responde que +no** (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa respuesta esperando una que no llegaría — 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**, y el veredicto quedaba vacío: un «no» tardío se parecía +demasiado a un cuelgue. Ahora el `NLMSG_ERROR` se atiende y el lector dice su motivo en 2 s. + **Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy.** Medido: con un dominio Landlock activo, `mount` falla con **EACCES aunque seccomp lo permita**; el kernel no admite montajes nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no diff --git a/scripts/harkaq/harkaq-audit.c b/scripts/harkaq/harkaq-audit.c index a45b1c17..afdcdfc8 100644 --- a/scripts/harkaq/harkaq-audit.c +++ b/scripts/harkaq/harkaq-audit.c @@ -109,12 +109,23 @@ static int audit_status(int *enabled) { 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)) + 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; diff --git a/scripts/qorpa/evidence-probe.sh b/scripts/qorpa/evidence-probe.sh new file mode 100755 index 00000000..14b41662 --- /dev/null +++ b/scripts/qorpa/evidence-probe.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Prueba el canal de evidencia de una instancia qorpa: que distinga HERMÉTICO de IMPURO, y que se +# NIEGUE a opinar cuando no hay nada que medir. ADR 0015 D9 punto 3. +# +# ── POR QUÉ ESTE GUARDIÁN ─────────────────────────────────────────────────────────────────────── +# De un binario ajeno no hay fuente que leer: lo único observable es lo que el kernel le NIEGA y +# anota. Si ese canal se rompe —caps que se pierden al recompilar, un kernel sin audit, una +# política que ya no deniega nada—, el síntoma es **un veredicto limpio**. O sea que el modo de +# fallo de este instrumento es *parecer que todo está bien*, y por eso hace falta medirlo con algo +# que TIENE que salir sucio. +# +# ── LAS TRES ASERCIONES ───────────────────────────────────────────────────────────────────────── +# 1. una instancia que no toca nada prohibido → HERMÉTICO +# 2. la MISMA instancia tocando /usr y /opt → IMPURO, y con esas rutas +# 3. una instancia sin `seal_image` → se NIEGA a correr (no hay nada denegable, así que +# un `denials=[]` sería el instrumento mintiendo) +# La 2 es la que sostiene a la 1: sin ella, «HERMÉTICO» lo cumple igual un canal muerto. +set -eu + +QORPA_ROOT="${HAMMER_QORPA_ROOT:-/var/lib/hammer/qorpa}" +HAMMER="${HAMMER_BIN:-./target/release/hammer}" +ID="${1:-arch-probe}" +export HAMMER_QORPA_ROOT="$QORPA_ROOT" + +INST="$QORPA_ROOT/instances/$ID" +[ -f "$INST/instance.toml" ] || { echo "no existe la instancia $ID en $QORPA_ROOT" >&2; exit 2; } + +AUDIT="${HARKAQ_BIN:-$HOME/.cache/harkaq}/harkaq-audit" +if [ ! -x "$AUDIT" ]; then + echo "falta $AUDIT — gcc -O1 -Wall -o $AUDIT scripts/harkaq/harkaq-audit.c" >&2; exit 2 +fi +# El `setcap` se pierde en CADA recompilación del lector, y ésa es la forma más probable de que el +# canal muera sin que nadie se entere: por eso se comprueba acá y no se descubre en el veredicto. +case "$(getcap "$AUDIT" 2>/dev/null)" in + *cap_audit_read*) : ;; + *) echo "el lector no tiene capabilities (se pierden al recompilar):" >&2 + echo " sudo setcap cap_audit_read,cap_audit_control+ep $AUDIT" >&2; exit 2 ;; +esac + +LOG=$(mktemp -d); trap 'rm -rf "$LOG"' EXIT +# A fichero y no a `$( )`: la sustitución espera al PIPE, no al proceso, y un descendiente vivo la +# cuelga para siempre (misma cicatriz que `pressure-vessel-probe.sh`). +corre() { timeout -k 5 180 "$HAMMER" qorpa run "$ID" --evidence -- /bin/sh -c "$1" > "$2" 2>&1 &2; exit 1; } +echo " HERMÉTICO ✓" + +echo "== 2. tocando la imagen sellada" +corre 'touch /usr/INTRUSO 2>/dev/null; mkdir /opt/INTRUSO 2>/dev/null; true' "$LOG/sucio" +grep -q "IMPURO" "$LOG/sucio" || { tail -8 "$LOG/sucio"; echo "FALLA: el canal no vio nada — un canal muerto se ve así" >&2; exit 1; } +for ruta in "/usr" "/opt"; do + grep -q " $ruta\$" "$LOG/sucio" || { tail -8 "$LOG/sucio"; echo "FALLA: falta la denegación de $ruta" >&2; exit 1; } +done +grep -E "fs\.(make_reg|make_dir)" "$LOG/sucio" | sed 's/^/ /' +echo " IMPURO con las dos rutas ✓" + +echo "== 3. sin seal_image: tiene que NEGARSE, no dar limpio" +sed -i 's/^seal_image = true/seal_image = false/' "$INST/instance.toml" +corre 'true' "$LOG/sinsello" +sed -i 's/^seal_image = false/seal_image = true/' "$INST/instance.toml" +# Se busca la LÍNEA de veredicto (`evidencia: …`), no la palabra «denials»: el propio mensaje de +# rechazo la contiene al explicar por qué se niega, y la primera versión de este guardián falló +# contra su propia explicación. +if grep -q "^evidencia:" "$LOG/sinsello"; then + tail -5 "$LOG/sinsello"; echo "FALLA: opinó sobre una política que no puede denegar nada" >&2; exit 1 +fi +grep -q "seal_image" "$LOG/sinsello" || { tail -5 "$LOG/sinsello"; echo "FALLA: no explicó por qué se niega" >&2; exit 1; } +echo " se niega y dice por qué ✓" + +echo "OK: el canal de evidencia distingue hermético de impuro, y no opina cuando no puede medir"