qorpa D9: el canal de evidencia, cableado — la única forma de auditar el montón B

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 <id> --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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
This commit is contained in:
Sergio
2026-09-03 21:57:26 +00:00
co-authored by Claude Opus 5
parent e04fdf4599
commit 4a68dfe761
5 changed files with 299 additions and 7 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ getrandom = "0.2"
# `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS — # `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 # 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. # 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" tempfile = "3"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tracing = "0.1" tracing = "0.1"
+182 -4
View File
@@ -113,6 +113,15 @@ pub enum QorpaCmd {
/// Para comparar: casi todo lo que "no anda adentro" es este mapeo. /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
#[arg(long)] #[arg(long)]
single_id: bool, 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 `--`. /// Comando dentro (default: el shell de la imagen). Va después de `--`.
#[arg(last = true)] #[arg(last = true)]
cmd: Vec<String>, cmd: Vec<String>,
@@ -180,9 +189,10 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
QorpaCmd::Recreate { id, no_provision, root } => { QorpaCmd::Recreate { id, no_provision, root } => {
recreate(&resolve_root(root), &id, no_provision) recreate(&resolve_root(root), &id, no_provision)
} }
QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => { QorpaCmd::Run { id, root, dry_run, no_jail, single_id, evidence, cmd } => run_instance(
run_instance(&resolve_root(root), &id, &cmd, Ajustes { dry_run, no_jail, single_id, ..Default::default() }) &resolve_root(root), &id, &cmd,
} Ajustes { dry_run, no_jail, single_id, evidencia: evidence, ..Default::default() },
),
QorpaCmd::Provision { id, force, dry_run, root } => { QorpaCmd::Provision { id, force, dry_run, root } => {
provision(&resolve_root(root), &id, force, dry_run) 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 /// 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. /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta.
provisionando: bool, provisionando: bool,
/// Levanta el lector de audit y recoge el veredicto. Ver `preparar_evidencia`.
evidencia: bool,
} }
fn run_instance( fn run_instance(
root: &Path, id: &str, cmd: &[String], aj: Ajustes, root: &Path, id: &str, cmd: &[String], aj: Ajustes,
) -> Result<()> { ) -> 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 (dir, mut inst) = read_instance(root, id)?;
let (sha, tree) = resolve_image(root, &inst.base)?; 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<Evidencia> = 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 // 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 // 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 // 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))); std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento)));
} }
let st = st.expect("el bucle sale con estado o con bail"); let st = st.expect("el bucle sale con estado o con bail");
if let Some(e) = ev {
recoger_evidencia(&dir, e)?;
}
match st.code() { match st.code() {
Some(0) => Ok(()), Some(0) => Ok(()),
Some(c) => std::process::exit(c), 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<Evidencia> {
// ── 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**. /// 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 /// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo
+30 -1
View File
@@ -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 | | **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 | | **`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 <id> --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` | | **`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 <id> --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 **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 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 nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no
+12 -1
View File
@@ -109,12 +109,23 @@ static int audit_status(int *enabled) {
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
ssize_t n = recv(fd, buf, sizeof(buf), 0); ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) break; 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) { if (h->nlmsg_type == AUDIT_GET) {
*enabled = ((struct audit_status *)NLMSG_DATA(h))->enabled; *enabled = ((struct audit_status *)NLMSG_DATA(h))->enabled;
close(fd); close(fd);
return 0; return 0;
} }
}
} }
close(fd); close(fd);
return -1; return -1;
+74
View File
@@ -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 </dev/null || true; }
sed -i 's/^seal_image = false/seal_image = true/' "$INST/instance.toml"
echo "== 1. sin tocar nada prohibido"
corre 'true' "$LOG/limpio"
grep -q "HERMÉTICO" "$LOG/limpio" || { tail -5 "$LOG/limpio"; echo "FALLA: no dio hermético" >&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"