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:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Executable
+74
@@ -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"
|
||||||
Reference in New Issue
Block a user