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
+182 -4
View File
@@ -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<String>,
@@ -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<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
// 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<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**.
///
/// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo