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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user