gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 02513b861f6fbb462045ba411c6bec0a2cae7e93 recipes/atuq/extensions/sct/fondo.js JavaScript · 255 líneas · 11.7 KB Historia Quién tocó qué crudo

atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca 02513b861 · Sergio ·

1"use strict";
2// sct — transparencia de scripts (SDD 26 §6.1), v1.
3//
4// Lo que hace: por cada carga de página, junta los `<script src>` que se ejecutaron, y le pasa al
5// host nativo (`puriy-costura`) el origen y el TEXTO de cada uno. El host hashea con BLAKE3, aprende
6// el conjunto estable de cada sitio (TOFU) y contesta si ese sitio ya era estable y si ejecutó algo
7// que nadie vio nunca. Acá NO se hashea nada y NO se guarda ningún registro: quien sabe es
8// `puriy-sct`, del otro lado del cable. Duplicarlo en JS sería un segundo registro que se desalinea
9// del primero, y el primero es el que está certificado.
10//
11// ── V1 OBSERVA, NO BLOQUEA — Y ESO NO ES UNA CONCESIÓN ────────────────────────────────────────
12// El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2, con el
13// gancho en el script loader de Gecko. Acá se observa y se AVISA, por dos razones y las dos son del
14// diseño, no del apuro:
15//   1. `puriy-sct` es explícito: su v1 «ALERTA (no bloquea)». Un bloqueo necesita que exista antes
16//      la acción del usuario (aceptar/pinnear un cambio), que es su M1.
17//   2. Bloquear cada script hasta que vuelva un mensaje del proceso nativo pondría un viaje
18//      ida-y-vuelta entre procesos en el camino crítico de CADA script de CADA página. Eso no es
19//      «más seguro»: es un navegador que nadie usa, y una función que nadie usa no protege a nadie.
20//
21// ── LO QUE ESTA VÍA NO PUEDE VER, DICHO ANTES DE QUE ALGUIEN LO SUPONGA ───────────────────────
22// `filterResponseData` entrega el cuerpo de una PETICIÓN. Los scripts **inline** no son una
23// petición: viajan dentro del HTML del documento, así que por esta vía son invisibles. Para v1 está
24// bien y no es un agujero disimulado: el ataque que `sct` nombra —sustitución en un CDN, supply
25// chain comprometido, script dirigido a un usuario— es exactamente el caso `<script src>`. Los
26// inline son código propio de la página, y los ve la v2.
27
28const HOST = "puriy_costura";
29
30// Tope de lo que se guarda por script. Un cuerpo más grande NO se manda a medias: hashear un texto
31// truncado daría un hash que no corresponde a nada, y eso no produce «menos detección» sino ALERTAS
32// FALSAS la próxima vez que el mismo script llegue entero. Se descarta y se dice.
33const TOPE_CUERPO = 4 * 1024 * 1024;
34
35// Cuánto se espera, tras el último script de una página, antes de mandarle la tanda al host. Los
36// scripts no llegan con el documento —y los inyectados dinámicamente llegan bastante después—, así
37// que no hay un «fin de carga» que sirva de disparador. Un rebote es honesto: junta lo que llegó.
38const REBOTE_MS = 1500;
39
40let puerto = null;
41const tandas = new Map(); // clave `${tabId}|${documento}` → { origen, scripts[], reloj }
42
43function di(s) {
44  // El veredicto sale por el stdout del proceso cuando `browser.dom.window.dump.enabled` está
45  // prendida — que es cómo lo miden los guardianes de `scripts/test-atuq-*`. En producción está
46  // apagada y esto no hace nada.
47  try { dump("SCT " + s + "\n"); } catch (e) {}
48}
49
50function conectar() {
51  try {
52    puerto = browser.runtime.connectNative(HOST);
53    puerto.onMessage.addListener(alRecibir);
54    puerto.onDisconnect.addListener(p => {
55      // El host es un proceso largo; si se cae, la función tiene que poder volver. Un `puerto` que
56      // queda muerto para siempre apaga `sct` en silencio hasta que alguien reinicie el navegador.
57      const causa = p.error ? p.error.message : "sin error";
58      di("DESCONECTADO " + causa);
59      puerto = null;
60    });
61    di("CONECTADO " + HOST);
62  } catch (e) {
63    // `connectNative` tira si no hay permiso; que no haya manifiesto se ve en `onDisconnect`.
64    di("SIN HOST " + e.message);
65    puerto = null;
66  }
67}
68
69function enviar(msg) {
70  if (!puerto) { conectar(); }
71  if (!puerto) { return false; }
72  try {
73    puerto.postMessage(msg);
74    return true;
75  } catch (e) {
76    di("ENVÍO FALLÓ " + e.message);
77    puerto = null;
78    return false;
79  }
80}
81
82function alRecibir(r) {
83  if (!r || r.ok !== true) {
84    di("RECHAZO " + (r && r.error ? r.error : "sin error"));
85    return;
86  }
87  if (r.verb !== "sct.observe") { return; }
88  const eventos = r.events || [];
89  di("VEREDICTO " + r.origin + " fase=" + r.phase + " visitas=" + r.visits +
90     " recursos=" + r.resources + " eventos=" + eventos.length);
91  for (const e of eventos) {
92    di("EVENTO " + r.origin + " " + e.resource + " " + e.old.slice(0, 12) + "→" +
93       e.new.slice(0, 12) + " " + (e.size_delta >= 0 ? "+" : "") + e.size_delta + "B");
94  }
95  // La pestaña sale del `id` que se mandó y el host DEVUELVE tal cual — para eso existe ese campo
96  // del protocolo. Inventar un `tabId` en la respuesta habría sido agregarle al host un concepto del
97  // navegador que no le toca conocer.
98  pintar(typeof r.id === "number" ? r.id : -1, r, eventos);
99}
100
101// El aviso es PASIVO: una insignia en la barra, no un modal. Un modal por cada despliegue de un
102// sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se cierra.
103function pintar(tabId, r, eventos) {
104  if (typeof tabId !== "number" || tabId < 0) { return; }
105  try {
106    if (eventos.length) {
107      browser.browserAction.setBadgeText({ tabId, text: String(eventos.length) });
108      browser.browserAction.setBadgeBackgroundColor({ tabId, color: "#c15728" });
109      browser.browserAction.setTitle({
110        tabId,
111        title: "atuq: " + eventos.length + " script(s) que este sitio nunca había ejecutado:\n" +
112               eventos.map(e => "· " + e.resource).join("\n"),
113      });
114    } else {
115      browser.browserAction.setBadgeText({ tabId, text: "" });
116      browser.browserAction.setTitle({
117        tabId,
118        title: "atuq · transparencia de scripts\n" + r.origin + " — " + r.phase +
119               " (" + r.visits + " visita(s), " + r.resources + " recurso(s))",
120      });
121    }
122    // Y se RELEE lo que quedó puesto. Poner una insignia y suponer que se puso es la forma de que el
123    // aviso —lo único que el usuario llega a ver de todo esto— sea la pieza sin medir de la cadena.
124    browser.browserAction.getBadgeText({ tabId }).then(
125      t => di("INSIGNIA tab=" + tabId + " texto=" + JSON.stringify(t)),
126      e => di("INSIGNIA NO SE PUDO LEER " + e.message));
127  } catch (e) { di("INSIGNIA FALLÓ " + e.message); }
128}
129
130// ⚠ LA CLAVE ES EL DOCUMENTO, **SIN** EL `tabId` — Y ESO ES MEDIDO, NO ESTILO (2026-09-10).
131// Con el `tabId` adentro, `scripts/test-atuq-sct.py` mostró DOS tandas para una sola carga de página:
132//
133//     TANDA  1|http://…/pagina.html scripts=1
134//     TANDA -1|http://…/pagina.html scripts=1     ← misma página, petición SIN pestaña
135//
136// Hay peticiones del mismo documento que llegan con `tabId = -1` (no pertenecen a ninguna pestaña), y
137// cada tanda es una VISITA para el TOFU. El efecto no es cosmético: **inflar las visitas estabiliza
138// el origen antes de tiempo**, y un origen estabilizado antes de conocer su código real alerta por
139// churn legítimo — o sea, el falso positivo que la spec de `puriy-sct` pide evitar por encima de todo.
140//
141// Agrupando por documento, dos pestañas con la MISMA url caen en una tanda: eso CUENTA DE MENOS, que
142// es el error seguro (tarda más en proteger, no alerta de más). El `tabId` sigue haciendo falta para
143// la insignia, así que se guarda el primero que no sea -1.
144function clave(d) {
145  return d.documentUrl || d.originUrl || "";
146}
147
148function anotar(d, texto) {
149  const documento = d.documentUrl || d.originUrl;
150  if (!documento) { return; }        // sin documento no hay origen que registrar
151  const k = clave(d);
152  let t = tandas.get(k);
153  if (!t) {
154    t = { documento, tabId: -1, scripts: [], reloj: null };
155    tandas.set(k, t);
156  }
157  if (t.tabId < 0 && typeof d.tabId === "number" && d.tabId >= 0) { t.tabId = d.tabId; }
158  t.scripts.push({ url: d.url, body: texto, module: false });
159  if (t.reloj) { clearTimeout(t.reloj); }
160  t.reloj = setTimeout(() => vaciar(k), REBOTE_MS);
161}
162
163function vaciar(k) {
164  const t = tandas.get(k);
165  tandas.delete(k);
166  if (!t || !t.scripts.length) { return; }
167  // La TANDA se anuncia con su clave y su tamaño. No es ruido de depuración: una MISMA carga de
168  // página puede producir más de una tanda —el rebote las corta si un script llega tarde—, y cada
169  // tanda es una VISITA para el TOFU. Contar visitas de más hace que un origen se estabilice antes
170  // de tiempo, o sea que alerte por churn legítimo. Sin esta línea, eso se ve como «el contador va
171  // raro» y no como qué lo causó.
172  di("TANDA " + k + " scripts=" + t.scripts.length);
173  const msg = { verb: "sct.observe", url: t.documento, scripts: t.scripts };
174  // `id` es u64 del otro lado: una pestaña -1 (peticiones que no son de ninguna) no se manda, o el
175  // mensaje entero se rechazaría por un campo que sólo servía para pintar una insignia.
176  if (typeof t.tabId === "number" && t.tabId >= 0) { msg.id = t.tabId; }
177  enviar(msg);
178}
179
180// ── EL FILTRO ─────────────────────────────────────────────────────────────────────────────────
181// ⚠ REGLA DE ORO DE ESTE BLOQUE: los bytes SIEMPRE pasan. Un filtro que se rompe y no escribe deja
182// la respuesta colgada para siempre, y el síntoma no es «sct falló» sino «esta página no carga» —
183// una extensión de seguridad que rompe la web se desinstala, y con ella la seguridad. Por eso cada
184// camino tiene su `catch` y el `catch` desconecta el filtro, que es lo que deja fluir el resto.
185browser.webRequest.onBeforeRequest.addListener(
186  d => {
187    let filtro;
188    try {
189      filtro = browser.webRequest.filterResponseData(d.requestId);
190    } catch (e) {
191      di("FILTRO NO DISPONIBLE " + e.message);
192      return {};
193    }
194    const trozos = [];
195    let total = 0;
196    let pasado = false;
197
198    filtro.ondata = e => {
199      try {
200        if (!pasado) {
201          total += e.data.byteLength;
202          if (total > TOPE_CUERPO) {
203            // Se deja de acumular, pero NO se deja de escribir: la página sigue igual.
204            pasado = true;
205            trozos.length = 0;
206            di("DEMASIADO GRANDE " + d.url + " (>" + TOPE_CUERPO + "B): no se observa");
207          } else {
208            trozos.push(new Uint8Array(e.data));
209          }
210        }
211      } catch (err) {
212        pasado = true;
213      }
214      filtro.write(e.data);
215    };
216
217    filtro.onstop = () => {
218      try {
219        if (!pasado && trozos.length) {
220          const todo = new Uint8Array(total);
221          let i = 0;
222          for (const t of trozos) { todo.set(t, i); i += t.length; }
223          // El texto YA DECODIFICADO es lo que se manda, y el host hashea su UTF-8. Está dicho allá
224          // qué implica: el hash vale para comparar dos cargas nuestras, no para compararse con el
225          // hash de los bytes que sirvió un tercero.
226          anotar(d, new TextDecoder("utf-8", { fatal: false }).decode(todo));
227        }
228      } catch (err) {
229        di("OBSERVAR FALLÓ " + err.message);
230      }
231      try { filtro.close(); } catch (err) { try { filtro.disconnect(); } catch (e2) {} }
232    };
233
234    filtro.onerror = () => {
235      // El navegador ya decidió (aborto, error de red): no hay nada que observar y nada que escribir.
236      trozos.length = 0;
237    };
238    return {};
239  },
240  { urls: ["<all_urls>"], types: ["script"] },
241  ["blocking"]
242);
243
244// Una pestaña que se cierra no deja una tanda pendiente esperando un rebote que ya no sirve.
245browser.tabs.onRemoved.addListener(tabId => {
246  for (const [k, t] of tandas) {
247    if (t.tabId === tabId) {
248      if (t.reloj) { clearTimeout(t.reloj); }
249      tandas.delete(k);
250    }
251  }
252});
253
254conectar();
255di("LISTA v1 observa y avisa; no bloquea");