#!/usr/bin/env python3 """¿`sct` avisa cuando un sitio ya estable ejecuta código que nadie vio nunca? (SDD 26 §6.1) python3 scripts/test-atuq-sct.py # la cadena entera, seis cargas de página python3 scripts/test-atuq-sct.py --negative-control # sin el manifiesto: exige que NO haya veredicto ── QUÉ CADENA SE EJERCITA, Y POR QUÉ NINGUNA PIEZA ALCANZA SOLA ────────────────────────────── Un servidor HTTP de verdad sirve una página con un `sct """ def fatal(msg): print(f"✗ {msg}", file=sys.stderr) raise SystemExit(1) def artefacto(receta, nombre, escotilla=None): """El artefacto VIGENTE, por `takana hash` y nunca por glob. ⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear `firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado obtenido con la escotilla NO se cita como «el atuq de hoy pasa». """ forzado = os.environ.get(escotilla) if escotilla else None if forzado: if not os.path.isdir(forzado): fatal(f"{escotilla}={forzado} no existe") print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") return forzado r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"], cwd=ROOT, capture_output=True, text=True) if r.returncode != 0: fatal(f"no pude hashear {receta}: {r.stderr.strip()}") h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") d = os.path.join(STORE, f"{h}-{nombre}") if not os.path.isdir(d): fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes") return d class Silencioso(http.server.SimpleHTTPRequestHandler): """Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre cargas, que es justo lo que `sct` tiene que notar.""" cuerpo_script = SCRIPT_ORIGINAL def log_message(self, *a): pass def do_GET(self): if self.path.startswith("/app.js"): datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript" else: datos, tipo = PAGINA.encode(), "text/html" self.send_response(200) self.send_header("Content-Type", tipo) self.send_header("Content-Length", str(len(datos))) # Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la # prueba diría «no alerta» por una razón que no tiene nada que ver con sct. self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") self.end_headers() self.wfile.write(datos) def puerto_libre(): s = socket.socket() s.bind(("127.0.0.1", 0)) p = s.getsockname()[1] s.close() return p def correr(atuq, costura, salida, capa, url, negativo): """Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`.""" borrar = f"rm -f {MANIFIESTO}" if negativo else "true" guion = f""" mkdir -p /root /salida/hogar [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id {borrar} export HOME=/salida/hogar timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ > /salida/consola.log 2>&1 exit 0 """ subprocess.run([ "bwrap", "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa, "--tmp-overlay", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid", "--bind", salida, "/salida", "--uid", "0", "--gid", "0", "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin", "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1", "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1", "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1", "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", "sh", "-c", guion, ], check=False) log = open(os.path.join(salida, "consola.log"), errors="replace").read() return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log def preguntarle_al_host(costura, estado): """Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso.""" binario = os.path.join(costura, "usr/bin/puriy-costura") peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode() entrada = struct.pack(" 1: fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU" f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}") vistas = [(i + 1, veredicto(c), eventos(c), insignia(c)) for i, c in enumerate(cargas) if veredicto(c)] perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)] if perdidas: print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la" " inicialización de la extensión)") if perdidas and perdidas != [1]: fatal(f"se perdieron cargas que no son la del arranque: {perdidas}") if len(vistas) < 5: fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede" " afirmar la secuencia aprender→estabilizar→alertar") if vistas[-1][0] != 6: fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no" " concluye nada") # Una carga observada = una visita: el número de visita tiene que seguir a la posición en la # secuencia, sin saltos. Un salto es la señal de la tanda duplicada. for pos, (nro, v, _, _) in enumerate(vistas, start=1): if v.get("visitas") != str(pos): fatal(f"carga {nro}: es la observación nº {pos} y el host contó" f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita") for pos in (1, 2, 3): nro, v, ev, ins = vistas[pos - 1] if v.get("fase") != "learning": fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}") if ev: fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo") nro4, v4, ev4, _ = vistas[3] if v4.get("fase") != "stable": fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}") if ev4: fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}") # La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace # que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual. nroA, vA, evA, insA = vistas[-2] if evA: fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y" " un detector que grita todos los días no se mira más") if vA.get("fase") != "stable": fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}") if insA != '""': fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad") # Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice. nroZ, vZ, evZ, insZ = vistas[-1] if len(evZ) != 1: fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo" f" {len(evZ)}: {evZ}") if "app.js" not in evZ[0]: fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}") # ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ──────────────── # La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a # la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar. if insZ != '"1"': fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no" " llegó a la única parte que el usuario ve") estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura") ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else [] if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros: fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del" " navegador volvería a «aprendiendo» y nada alertaría nunca") print(f"\nestado en disco: {ficheros}") print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →" " native messaging → puriy-costura → puriy-sct") print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)") print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta") print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el" " mismo código, y alerta en la sexta con el código cambiado") print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco") return 0 finally: servidor.shutdown() shutil.rmtree(tmp, ignore_errors=True) sys.exit(main())