Guía interna · Ingeniería Mesa247 · agosto 2026

Criterio: cómo escribimos código en Mesa247

Para quienes mantienen l-home-restaurantes, l-parallevar, l-widgetexperiencias y l-librodereservas. No es un manual de Laravel ni de React: es lo que decidimos que se hace y no se hace aquí, con ejemplos sacados de nuestro propio código.

La sintaxis la sabemos. Lo que nos ha costado incidentes, noches y clientes es el criterio: qué se mergea y qué no, dónde va un secreto, cómo se responde un error, qué pasa a las 7 de la noche con las fechas, y qué significa "listo". Esta guía fija eso.

Repos 4 · Laravel 5.4/5.5 + React 15 Documento interno · no publicar fuera del equipo Fuente auditoría de código del 16-ago-2026 Guías hermanas Python para m-b-core · React y SSR
La guía

Por qué esta guía, por qué ahora

En julio y agosto de 2026 pasamos por un incidente serio: tokens impresos en HTML público, endpoints que emitían sesiones sin contraseña, URLs crudas de Cloud Run llamadas desde el navegador saltándose el balanceador, secretos commiteados en repos, y un merge que despliega a producción sin que nadie lo revise. Ninguna de esas cosas fue un error de sintaxis. Fueron decisiones pequeñas, repetidas durante años, que nadie escribió como regla.

Esta guía hace tres cosas: (1) fija las reglas del equipo, (2) muestra los vicios concretos que encontramos en nuestro código —con ruta y línea, para que nadie piense que es teoría— y cómo se hace bien, y (3) da herramientas para que el criterio no dependa de la memoria: un checklist de PR, una definición de listo, y reglas para trabajar con IA sin bajar la vara.

Cómo usarla

Léela una vez completa (40 minutos). Después, vive en dos lugares: el checklist de PR se pega en la plantilla de Pull Request de cada repo, y las reglas van en el CLAUDE.md / CONTRIBUTING.md. Cuando revises código ajeno y encuentres un vicio de la lista, enlaza a su sección en lugar de discutirlo de nuevo.

Las reglas del equipo

Ocho reglas. Mandan sobre cualquier atajo. Si una choca con "necesito que salga hoy", gana la regla, y lo que sale hoy se hace de otra forma.

  1. Nada llega a main/master_mesa sin PR revisado por otra persona.

    Ni "es una línea", ni "es urgente". Aquí merge = deploy a producción en varios repos, así que el PR es el único control que existe. Un PR sin revisión no es un PR, es un push con otro nombre.

  2. Un secreto nunca toca el repositorio.

    Ni en código, ni en .env.example, ni en un JSON de service account, ni en un commit viejo, ni "un ratito para probar". Si un secreto tocó git, está comprometido: se rota, no se borra el commit.

  3. Un token de usuario nunca sale en HTML ni se guarda en el navegador.

    Ni en Blade, ni en JS inline, ni en localStorage. Lo que el navegador ve, cualquiera lo ve. El servidor habla con la API; el navegador habla con el servidor.

  4. Los errores se responden con el código HTTP correcto y se registran.

    Nunca 200 {"error": ...}. Nunca catch (\Exception $e) {}. Un error silenciado no desapareció: se mudó a producción y va a explotar cuando nadie esté mirando.

  5. Toda fecha tiene zona horaria explícita.

    El servidor está en UTC y los clientes en Lima, Santiago, Quito y Bogotá. Carbon::now() a secas es un bug esperando las 19:00.

  6. La lógica de negocio vive en una capa de servicio, no en el controlador ni en la vista.

    El controlador recibe, valida y responde. La vista pinta. Si una regla ("no puedes reservar en el pasado") está en dos controladores, ya tiene un bug de sincronía.

  7. Lo que no tiene test no está terminado.

    No pedimos cobertura total: pedimos que cada regla de negocio y cada bug arreglado tengan un test que lo demuestre. Y que la CI corra esos tests antes del deploy, no después.

  8. Nada que no puedas explicar se mergea.

    Aplica al código que escribiste, al que copiaste, y sobre todo al que te escribió una IA. Si en la revisión no puedes decir qué hace una línea y qué pasa si se borra, no entra.

Cómo trabajamos: el flujo de un cambio

El mismo flujo para un fix de una línea y para una funcionalidad de dos semanas. Lo que cambia es el tamaño, no los pasos.

1 · RamaUna rama por cambio, desde main actualizado. Nombre que diga qué es: fix/reserva-tz-lima, feat/widget-multipais. Nunca se trabaja directo en la rama de deploy.
2 · CommitsPequeños, con mensaje en imperativo que explica el porqué. "fix" o "cambios" no es un mensaje. Si necesitas "y", son dos commits.
3 · LocalTests en verde, linter limpio, probado en local o staging. Nunca "lo pruebo en prod".
4 · PRDescripción con qué/por qué/cómo probarlo, checklist marcado, capturas si hay UI. Tamaño revisable (< 400 líneas de diff; si es más, se parte).
5 · RevisiónOtra persona lee todo el diff con el checklist. Comenta, pide cambios o aprueba. El autor responde cada comentario: cambia o argumenta.
6 · CI verdeTests + análisis estático + build de la imagen. Rojo no se mergea, punto. Nadie desactiva un test para que pase.
7 · Merge y deploySquash o merge commit; el deploy es un paso explícito y observado (¿logs limpios? ¿/health responde? ¿la versión que corre es la que mergeé?). Se sabe cómo hacer rollback antes de mergear.
8 · CierreTicket cerrado con enlace al PR. Si hubo una decisión no obvia, va al DECISIONES.md del repo.
Merge = deploy: la trampa de esta casa

En l-widgetexperiencias, l-parallevar y otros, mergear a la rama de deploy dispara Cloud Build y sale a producción sin aprobación humana. Mientras eso siga así, la revisión del PR es la aprobación de deploy. Objetivo del plan: separar los dos pasos (deploy con aprobación o desde tag) y correr tests en la CI antes de construir la imagen.

Reglas de oro de seguridad

Las que ya están en la guía de revisión local, resumidas para tenerlas a mano. Van antes que cualquier funcionalidad.

  • Nunca apuntes un local a un backend de producción que mute datos o emita tokens. Nuestros widgets usan la misma URL para leer y para acuñar tokens Passport y crear reservas. Un GET a la ruta equivocada acuña un token real. Orden: stub local → staging read-only → prod solo GET y solo si no hay otra cosa.
  • No se despliega desde una laptop. Nada de gcloud run deploy, merges a ramas de deploy ni terraform apply como efecto secundario de "estaba revisando".
  • Los secretos que ya están en git se tratan como comprometidos. Se rotan. No se reutilizan para levantar local; se genera un APP_KEY desechable.
  • El navegador nunca habla con *.run.app directo. Todo pasa por el balanceador y sus protecciones. Una URL cruda en un axios.create({ baseURL }) es un agujero, aunque "funcione".
  • Los tokens de staff y de comensal no se mezclan. Un endpoint que a partir de un token de comensal devuelve uno de staff es una escalada de privilegios, aunque esté "detrás de una URL rara".
  • Las contraseñas se guardan con hash fuerte y nunca en claro. Sin excepciones históricas: lo que existe en claro se migra y se resetea.
Los vicios

Qué encontramos en nuestro código

Auditamos los repos el 16 de agosto de 2026 en dos rondas (primero los cuatro repos PHP/React; luego siete analistas con dos modelos sobre m-incidente, m-b-core, los repos de operación, web202101/gateway/apisocial y una segunda pasada con lentes nuevas), solo lectura, con evidencia por archivo y línea. Lo que sigue no es una lista de bugs: son hábitos, cosas que aparecen decenas o cientos de veces y en más de un repo. Están ordenadas por el daño que causan. Cada una trae cómo se ve, por qué duele (con lo que ya nos pasó cuando aplica), cómo se hace bien y la regla de una línea que sale de ahí.

13repos auditados: 8 de aplicación (Laravel 5.4/5.5/8, CodeIgniter 2.1, React 15, Python 3.12) + 5 de operación e incidente
1repo con CI real (m-b-core); los otros 12 despliegan sin tests, o no despliegan desde el repo
25tests reales en total (240.000 líneas solo en el backend)
570Carbon::now() sin zona horaria en el backend
92whereRaw con variables interpoladas en el SQL
≥40lugares donde un token de sesión llega al navegador (HTML, JSON o localStorage)

Convención: [home] = l-home-restaurantes (API/backoffice), [pll] = l-parallevar, [exp] = l-widgetexperiencias, [libro] = l-librodereservas. Los valores de secretos nunca se copian; solo dónde están.

A · Secretos y configuración

1 · Secretos dentro del repositorio

alta[home] [exp] [pll] [libro]
Así está

[home] un JSON de service account de Google con private_key en la raíz, cinco .pem de APNs con clave privada en app/Notifications/, y un dump.rdb de Redis de 5 MB, todos versionados. Además vendor/ commiteado (24.964 archivos) y el .dockerignore se llama .docker-ignore, así que todo eso entra también a la imagen de producción.l-home-restaurantes: GoogleBookingAPI-*.json · app/Notifications/apns-*.pem · dump.rdb · .docker/Dockerfile:74

[exp] los master tokens "robot" de PE y CL escritos en el controlador base y elegidos por host; los merchant IDs de Kushki (incluido el privado) y una URL de UAT en config/app.php; .env.example con una pk_live de Culqi y APP_DEBUG=true.l-widgetexperiencias: app/Http/Controllers/Controller.php:50 · config/app.php:168-170 · .env.example:4,15

[pll] el secreto X-Auth-Sguard hardcodeado en dos controladores mientras .env.example declara la variable vacía. [libro] claves de Pusher y DSN de Sentry como constantes del bundle.l-parallevar (rama de deploy): ReservationController.php:862, GiftcardController.php:510 · l-librodereservas: src/Config/Origins.js:90-92

Por qué duele

Un secreto en git está en cada clon, cada fork, cada laptop y cada imagen Docker. Rotarlo obliga a reescribir historia y redesplegar todo. Con la cantidad de accesos que ha tenido esta base de código, hay que asumir que están comprometidos.

Así se hace
// config/services.php
'mesa' => [
    'robot_token' => env('MESA_ROBOT_TOKEN'),
],
// en código, nunca env():
$token = config('services.mesa.robot_token');

Los valores viven en Secret Manager y llegan por --set-secrets (como ya hace MASTER_API_TOKEN en parallevar). .env.example solo con nombres. Llaves .pem montadas como secreto en un archivo, fuera del repo. gitleaks en la CI para que no vuelva a pasar.

Regla. Un secreto entra por variable de entorno, se lee una sola vez en config/ y jamás toca git; si tocó git, se rota.

2 · Tokens de terceros y de la propia API escritos a mano y duplicados

alta[home]
Así está

Diez o más tokens literales en código: Nubefact, Cabify, apis.net.pe (el mismo token copiado en cinco archivos), Mapbox (en cuatro sitios, aunque también existe config('app.map_box_token')), un bearer de la propia plataforma en una librería, y un token estático de 20 caracteres en un middleware comparado con !=.l-home-restaurantes: HomeController.php:270-273 · Console/Commands/Billing/RucPeru.php:56,89 · Libraries/Api/V3/DniLibrary.php:22 · Libraries/Api/V3/RSMLibrary.php:65,399 · Http/Middleware/LlaveClienteToken.php:11

// Middleware/LlaveClienteToken.php:11
if ($request->bearerToken() != 'xxxxxxxxxxxxxxxxxxxx') { ... }
Por qué duele

Cinco copias del mismo token son cinco lugares que olvidar al rotarlo. Comparar secretos con != permite ataques de tiempo. Y el token de "la propia API" en el código es un backdoor con nombre de librería.

Así se hace
// una sola clave por credencial
$token = config('services.apisnet.token');
// comparar secretos siempre en tiempo constante
if (! hash_equals(config('services.cliente.key'), (string) $request->bearerToken())) {
    abort(401);
}

Regla. Una credencial = una clave de config; nunca un literal, nunca duplicada, nunca comparada con ==.

3 · El secreto viaja por la URL

alta[exp] [pll]
Así está
// Controller.php:61 [exp] · ReservationController.php:1078 [pll]
$client->get("auth/1234?type=U&token={$defaultToken}");

El master token se manda como parámetro de query en un GET, en cada page view, para acuñar un token de comensal.

Por qué duele

Todo lo que va en la URL queda en los logs de acceso del balanceador, del backend, de los proxies, y en cualquier herramienta de observabilidad. Es el secreto más importante del sistema escrito en texto plano en cien lugares.

Así se hace
$client->post('auth/token', [
    'headers' => ['Authorization' => 'Bearer '.config('services.mesa.robot_token')],
    'json'    => ['type' => 'U'],
]);

Regla. Credenciales en headers o cuerpo, nunca en la URL; y con POST, no GET.

4 · La zona horaria como constante del código (y "restar cinco horas")

alta[home] [exp] [pll]
Así está
// config/app.php:254 [home] · :68 [exp]
'timezone' => 'UTC',
// AnalyticsController.php:187 [home]
gmdate('Y-m-d', time() - 3660*5)
// 10 sitios más: ->subHours(5) / ->addHours(5)

[home] además 102 literales 'America/Lima' en 50 archivos y 570 Carbon::now() sin argumento. [pll] el síntoma se parchó en el cliente con una fecha en duro: start_date: "2026-07-01" "porque el server en UTC ya está en agosto".l-home-restaurantes: config/app.php:254 · Http/Helpers/date.php:7-9 · l-parallevar: detailv2.blade.php:895,2693 · l-widgetexperiencias: Libraries/Utilities.php:39-41

Por qué duele

Cloud Run corre en UTC. Desde las 19:00 de Lima, "hoy" es mañana: reservas que caen en otro día, reportes corridos, disponibilidad equivocada. Ya nos pasó y lo documentamos. Restar 5 horas ignora que Chile tiene horario de verano y que ya somos cuatro países.

Así se hace
// config/app.php
'timezone' => env('APP_TIMEZONE', 'America/Lima'),
// la zona es un dato del local, no del servidor
$ahora = Carbon::now($local->timezone);      // 'America/Santiago', 'America/Guayaquil'…
$hoy   = Carbon::today($local->timezone);
// nunca: date(), strtotime(), time()-3600*5, new Date() en JS para "hoy"

Regla. Toda fecha nace con la zona del local (o del tenant) explícita; nunca se "convierte" sumando horas ni se parcha con una fecha literal.

5 · La configuración se resuelve dentro del request

media[exp]
Así está

public/index.php comprueba en cada request si existe .env; si no, hace curl al metadata server, descarga el archivo desde GCS y ejecuta exec('php ../artisan config:clear') (y cache/route clear). Con concurrencia 30, varios requests escriben el mismo archivo a la vez.l-widgetexperiencias: public/index.php:12-57 · Dockerfile:37-38 (sin config:cache)

Por qué duele

El primer request de cada instancia fría paga una descarga y tres procesos; si GCS falla, la app arranca sin config y reintenta en cada request; y hay un exec() en el front controller. La misma familia de problemas apareció en web202101 (APP_DEBUG=true y LOG_LEVEL=critical viviendo en un .env en un bucket).

Así se hace

Config por variables de entorno y --set-secrets de Cloud Run; si hay que descargar algo, en docker-entrypoint.sh antes de arrancar PHP y fallando rápido; php artisan config:cache y route:cache en el build.

Regla. La configuración se resuelve al arrancar el proceso, nunca en el request; y en producción está cacheada.

B · Lo que le damos al navegador

6 · El token de sesión impreso en HTML, devuelto en JSON y guardado en localStorage

alta[pll] [exp] [libro]
Así está
{{-- detailv2.blade.php:2724-2725 [pll] --}}
access_token: '{{ $access_token }}',
access_token2: "{{ $access_token2 }}",   // el de STAFF
{{-- index.blade.php:712-713 [exp] --}}
localStorage.setItem('expToken', "{{ $accessToken }}");

[pll] 25 vistas con access_token, 17 "Bearer " + "{{ $access_token }}" solo en el carrito, y /login, /registro, /login-social devuelven el token en JSON. [exp] 16 localStorage.setItem('expToken') y 37 llamadas axios directas a la API con ese bearer. [libro] el token en localStorage.token y embebido en hrefs hacia /configuration/#/<TOKEN>/….l-parallevar: resources/views/reservation/detailv2.blade.php:2724 · togo/cart.blade.php ×17 · TogoController.php:367-375 · l-widgetexperiencias: LoginController.php:172-179 · l-librodereservas: src/Utils/Initialize.js:62 · Components/Navigation/Navigation.jsx:519

Por qué duele

Es el vicio que causó el incidente. Cualquier curl a una página pública devolvía un bearer de staff en texto plano; y con un token de comensal se podía obtener uno de staff (escalada confirmada en producción). Los tokens duraban 365 días y matar el endpoint no los revocaba. Lo que el navegador ve, cualquiera lo ve: en el HTML, en un console.log, en el localStorage de una PC compartida del restaurante, o robado por un script inyectado (ver vicio 11).

Así se hace
// El navegador habla con NUESTRO servidor (misma sesión, CSRF).
Route::post('/reservas/{slug}/disponibilidad', [DisponibilidadController::class, 'show']);

// El servidor habla con la API con el token que solo él conoce.
public function show(string $slug, DisponibilidadRequest $req, MesaApi $api) {
    return $api->disponibilidad($slug, $req->validated()); // bearer desde session()/config
}

Si de verdad el navegador debe llamar a la API: cookie HttpOnly; Secure; SameSite emitida por la API, o un token de corta vida y alcance mínimo. Nunca el token de sesión completo, nunca en localStorage, nunca en una URL.

Regla. Ningún token de sesión ni credencial de servidor se serializa a HTML, JSON de respuesta, localStorage ni URL. El navegador habla con nuestro servidor; nuestro servidor habla con la API.

7 · Credenciales de la pasarela en el HTML y firmas sobre el monto que manda el cliente

alta[pll] [exp]
Así está
{{-- cart.blade.php:1545-1546 [pll] · cart/detail.blade.php:35-36 [exp] --}}
apiKey: "{{ $payu_api_key }}",     // la misma clave con la que el servidor FIRMA
apiLogin: "{{ $payu_api_login }}"
// TogoController.php:700-712 [pll] · ExperienceController.php:364-367 [exp]
$signature = md5($ApiKey."~".$merchant."~".$ref."~".$request->amount."~".$request->currency);

Y /checkout reenvía $request->transaction (con PAN y CVV) tal cual a la pasarela, devolviendo la excepción como JSON si falla. Es código de un flujo que el front ya no usa, pero sigue ruteado en producción.

Por qué duele

Quien puede pedir una firma para el monto que quiera puede pagar 1 sol una orden de 500 si la pasarela solo valida la firma. La API key de comercio en el HTML de todo carrito es un secreto regalado. Y pasar tarjetas por nuestro servidor nos mete en alcance PCI.

Así se hace
public function signature(SignatureRequest $req, CartRepository $carts) {
    $cart = $carts->forOrder($req->order_id);      // el monto lo decide el servidor
    return ['signature' => PayU::sign($cart->total, $cart->currency, $cart->reference)];
}
// las credenciales de pasarela no se pasan a la vista; el flujo muerto se borra (rutas + controlador + props)

Regla. El servidor es la única fuente de verdad del monto a cobrar; el cliente solo dice "quiero pagar el carrito X". Y "es demo/UAT" no es excusa: si está ruteado en prod, es prod.

8 · URLs crudas de Cloud Run llamadas desde el navegador (y desde el entorno de desarrollo)

alta[pll] [libro]
Así está
// detailv2.blade.php:4801-4803 [pll]
if (self.useNewReservationCalendarService == 1) {
    baseRoute = 'https://m-b-core-api-staging-…run.app/v2/search';   // ¡staging! desde el widget en prod
// src/Config/Origins.js:25-37 [libro]
const DEV_ORIGIN = { apiUrl: 'https://mesa-backend-prod-….run.app/v1', … }  // localhost → PRODUCCIÓN
Por qué duele

Una URL absoluta se salta el balanceador, Cloud Armor y el ip-enforcer que pusimos justamente para el incidente (la segunda ola de agosto entró por la URL cruda de run.app). Locales en producción consultando disponibilidad contra staging. Y cada npm start en la laptop de un dev pega contra la base de datos de producción con un token real.

Así se hace
// Laravel: config → vista con @json, o mejor, ruta same-origin
'calendar_url' => env('CALENDAR_SERVICE_URL'),
// React: por variable de build, dev apunta a staging
const API_URL = process.env.REACT_APP_API_URL;   // .env.development → staging detrás del LB

Regla. Ninguna URL de servicio se escribe en una vista ni en el bundle; entra por config/entorno, siempre por el dominio detrás del balanceador, y el default de desarrollo nunca es producción.

C · Entrada, salida y errores

9 · Nadie valida la entrada; el request va directo al modelo

alta[home] [exp] [pll]
Así está
// AgentController.php:44 [home]
new RepresentanteLegal( $request->except('_token') );
// PreviewController.php:43 [home]
$local->fill( $request->all() );
// app/UsuarioSistema.php:39 [home]
protected $fillable = [ …, 'Password', 'PasswordPlain', 'Salt' ];

[home] 163 de 208 controladores que reciben Request no validan nada; 17 modelos con $guarded = []. [exp] una sola llamada a validate() en todo app/, y es scaffolding. [pll] cero validaciones en rutas vivas frente a 56 $request->campo.

Por qué duele

Campos que nadie esperaba llegan a la base de datos (incluida la contraseña de un usuario de sistema por asignación masiva). El error, cuando ocurre, lo produce MySQL como 500 en vez de un 422 claro. Y un input malformado que se reenvía a una pasarela es un 500 opaco lejos de la causa.

Así se hace
class StoreAgenteRequest extends FormRequest {
    public function rules(): array {
        return ['nombre' => 'required|string|max:120', 'documento' => 'required|digits:8'];
    }
}
public function store(StoreAgenteRequest $req) {
    $agente = RepresentanteLegal::create($req->validated());   // solo lo validado
    return response()->json($agente, 201);
}
// $fillable mínimo y explícito; jamás Password/Salt dentro

Ya hay 58 FormRequests bien hechas en [home] (RequestCapacity es un buen ejemplo). La técnica existe; falta hacerla obligatoria.

Regla. Toda acción pública empieza validando: sin FormRequest no hay controlador; nada del request toca un modelo sin pasar por validated().

10 · SQL con variables interpoladas

alta[home]
Así está
// app/ProgramacionReserva.php:31
->whereRaw("( ...HoraInicio <= '$hour' AND ...HoraFin >= '$hour' )")
// Libraries/SearchV2.php:1891
->whereRaw("(Fecha = '{$date_start}' OR Dia = '{$day_of_week}')")
// LocalController.php:218
DB::select("select * from ProgramacionReservasNuevo PR where PR.LocalId = {$local->Id} ...")

377 whereRaw, 430 DB::select, 297 DB::raw; 92 whereRaw con variables dentro del string en 45 archivos. El patrón con input del usuario ({$request->term}) existió y quedó comentado.

Por qué duele

Hoy muchas de esas variables son internas. El hábito no distingue origen: el día que una venga del request es inyección SQL sobre la base que tiene las contraseñas. Y el revisor no puede saber, en un diff, si $hour vino del usuario.

Así se hace
->whereRaw('HoraInicio <= ? AND HoraFin >= ?', [$hour, $hour])
DB::select('select * from ProgramacionReservasNuevo where LocalId = ?', [$local->Id])
// y para casi todo: el query builder, que parametriza solo
Programacion::where('LocalId', $local->Id)->whereTime('HoraInicio', '<=', $hour)

En el mismo repo ya está bien hecho en ChargebackController.php:114: bindings. Esa es la vara.

Regla. Ningún valor entra a un string SQL por interpolación o concatenación; siempre placeholder o query builder.

11 · HTML sin escapar: {!! !!}, v-html, innerHTML

alta[exp] [pll] [libro]
Así está
{{-- layouts/app.blade.php:70-173 [exp] --}}
{!! $data['local']['extras']['head_scripts'] !!}
{!! $data['local']['extras']['body_scripts'] !!}
{{-- payment.blade.php:1058 [pll] --}}
{!! $widget['header_scripts'] !!}
// Api.js:89 [libro]
containerError.innerHTML = '<div class="network-error">' + message + …

[pll] 135 v-html en el widget de reservas. [exp] el HTML/JS/CSS del tenant inyectado en el layout de la página donde vive el token y el formulario de tarjeta.

Por qué duele

El skimmer de agosto entró por dynamic_inputs: contenido del panel de restaurantes (cuyas cuentas comparten contraseña) renderizado sin escapar en 90 locales. Cada v-html nuevo es la misma puerta.

Así se hace

{{ }} y v-text por defecto. Si el negocio exige HTML del tenant: sanitizar en servidor con lista blanca (HTMLPurifier), nada de <script> inline (GTM por ID + lista de dominios), y CSP con nonce. En React: JSX y textContent, nunca strings concatenados.

Regla. El default es escapado. {!! !!} / v-html / innerHTML requieren justificación en el PR y sanitizado explícito con lista blanca.

12 · El error se traga, se imprime, o se devuelve como 200

alta[home] [pll] [exp]
Así está
// AgentController.php:56-58 [home]  (en un store() web)
catch( Exception $e ){ echo $e->getMessage(); }
// ExperienceController.php:353-356 [exp]
catch(RequestException $e){ $data = $e; }   return response()->json($data);   // 200 con la excepción serializada
// TogoController.php:361-366 [pll]  — la API cayó, pero al usuario:
return ['success' => false, 'message' => 'Tu email y/o contraseña son incorrectas'];   // HTTP 200
// Exceptions/Handler.php:92 [home] — en PRODUCCIÓN:
$message = $exception->getFile()." - ".$exception->getLine()." - ".$exception->getMessage();

[home] 876 catch: 73 hacen echo, 71 devuelven success:false con 200, 28 están vacíos; el Handler además guarda request()->all() (con contraseñas y tarjetas si vienen) en la tabla de logs. [pll] 40 catch(RequestException) y un solo Log:: en toda la app; helpers que devuelven $e->getMessage() como si fuera un array y luego un isset($token['acces_token']) sobre un string. [exp] getAccessToken(): string que en el catch hace return redirect(): TypeError.

Por qué duele

La app móvil y el widget no pueden distinguir éxito de fallo por status. Los errores tragados no llegan a Sentry ni a Cloud Logging: cuando el core falló el 24 de julio, la operación no vio nada. El usuario recibe culpas falsas ("contraseña incorrecta" cuando la API está caída). Y las rutas de archivo y trazas en la respuesta son un mapa para el atacante.

Así se hace
// Excepciones del dominio, mapeadas una sola vez en Handler::render
class UpstreamNoDisponible extends \RuntimeException {}
try {
    $r = $api->login($req->validated());
} catch (RequestException $e) {
    report($e);                                  // Sentry/log con contexto
    throw new UpstreamNoDisponible('core', 0, $e);
}
// Handler::render → UpstreamNoDisponible ⇒ 503 {"message":"Servicio no disponible","id":"req-…"}
// De negocio: response()->json(['message'=>'Sin stock'], 409). Nunca 200 con error.
// En producción el cliente recibe mensaje + id de correlación; el detalle va al log.

Regla. Un catch reporta y relanza (o traduce a un tipo de error), o no existe. Nunca echo, nunca devuelve $e, nunca 200 para un error, nunca inventa el mensaje.

13 · Llamadas HTTP salientes sin timeout, un cliente nuevo por método

alta[pll] [exp] [libro] [home]
Así está

[pll] 41 new \GuzzleHttp\Client, cero con timeout; una página de reserva hace cuatro llamadas en serie al core con max_execution_time=60 y 30 workers. [exp] 16 clientes, igual. [libro] axios sin timeout. [home] 21 curl_init a mano conviviendo con Guzzle, y 8 sitios con verify => false (sin verificar TLS) para pagos y consulta de DNI.l-parallevar: TogoController.php:189, ReservationController.php:625,672,850,1076 · l-home-restaurantes: Console/Commands/Billing/RucPeru.php:57,90 · Traits/PushNotification.php:162

Por qué duele

Un upstream lento (el 24 de julio, el 401 de TokenLogeo) retiene cada worker hasta 60 segundos; con 30 workers y concurrencia 30 la instancia se satura y Cloud Run escala pagando. Sin connect_timeout, un host caído cuelga el connect. Y verify=false es MITM sobre pagos.

Así se hace
// app/Services/CoreApiClient.php — singleton registrado en el container
$this->http = new Client([
    'base_uri'        => config('services.core.url'),
    'timeout'         => 5,
    'connect_timeout' => 2,
    'handler'         => $stackConRetryParaGET,   // reintentos solo en idempotentes
]);
// JS: axios.create({ baseURL, timeout: 15000 }) una vez, exportado

Regla. Toda llamada de red tiene timeout corto, se hace por un cliente único inyectado, verifica TLS, y su base URL sale de config (no de un str_replace('v1','v3')).

14 · Un GET que crea cosas

media[pll]
Así está

TogoController::index() (el GET de la home del restaurante) crea una cuenta real xxx@mesa247.info en el core si no viene ?guest_user, hay un solo local y el user-agent "parece navegador". Sin try/catch (el único método sin él).l-parallevar: TogoController.php:250-260, 1298-1330

Por qué duele

Cada crawler o prefetch que pase el filtro crea una identidad en la tabla Usuario (el "lote fantasma" del 24 de julio). Y un fallo del core en ese POST tumba la home con 500. También [home]: 52 rutas de primer nivel sin auth:api, incluidas GET /v1/test con dump() y POST /v1/payment/refund.

Así se hace

La cuenta invitada se crea cuando el usuario actúa (POST del carrito o de la reserva), con manejo de error y degradación a modo anónimo. Las rutas públicas son un grupo pequeño y explícito con throttle; todo lo demás dentro de auth; los scripts de mantenimiento son comandos Artisan, no rutas.

Regla. Un GET no crea ni muta estado; y la ruta pública es la excepción declarada, no el default.

D · Estructura

15 · Controladores, métodos y componentes gigantes

alta[home] [pll] [libro] [exp]
Así está

[home] AnalyticsController 4.391 líneas con un show() de ~2.900; ReservationController::store() ~970 líneas con 40 if a 20 espacios de indentación; 245 métodos de más de 150 líneas; un trait de 3.533 líneas. [pll] TogoController 1.359 líneas, sin app/Services. [libro] ReservationInfo.jsx 6.427 líneas, un render() de 3.485, 74 claves de state, 194 setState. [exp] vistas de 2.209 líneas con 1.492 de JS.l-home-restaurantes: Admin/Clients/Analytics/AnalyticsController.php · Api/Book/Reservation/ReservationController.php:806-1775 · l-librodereservas: Components/…/ReservationInfo.jsx

Por qué duele

No se puede revisar en un PR, no se puede testear, y cada bugfix toca el mismo archivo que todos los demás: conflictos y regresiones. La lógica de "reservar" está en el mismo método que la autenticación, el SEO y los redirects.

Así se hace
// El controlador orquesta; una clase por caso de uso; métodos que caben en una pantalla
public function store(StoreReservaRequest $req, CrearReserva $crear) {
    $reserva = $crear->ejecutar(ReservaData::fromRequest($req));   // app/Services o app/Actions
    return new ReservaResource($reserva);                            // 201
}
// React: contenedor (datos) + presentación; subcomponentes por pestaña < 300 líneas; reglas en módulos puros con test

Regla. Si un método no cabe en una pantalla es más de un método; si un controlador pasa de 300 líneas (o un componente de 500) es más de uno. La lógica de negocio vive en una capa de servicio testeable.

16 · Copiar, pegar, cambiar diez líneas

alta[home] [pll] [exp] [libro]
Así está

[pll] getLocalInfo() pegada en cinco controladores (diff entre dos de ellas: una línea), checkRestaurantSubdomain() ×5, el bloque SEO de 11 líneas ×11. [home] 3.987 bloques duplicados; PedidosYaLibraryPedidosYaLibraryV3, ContractControllerContractOldController, tres ReservationErrorController; archivos versionados por sufijo (Anunciante2, SearchV2, editOld.blade). [exp] el fallback de local ×5, el switch($host) de SEO ×3, componentes gemelos CartDetailComponent / …IziPay / …Old. [libro] WaitListItem vs WalkinListItem: 354 de 419 líneas idénticas.

Por qué duele

Cada fix (el rawurlencode, el X-Auth-Sguard, el sufijo .ec/.co) hay que aplicarlo N veces y alguna se olvida: experiencias.mesa247.chile (host inexistente) sobrevivió en cuatro sitios. Y nadie sabe cuál de las copias está viva: en [pll] detail.blade.php (7.679 líneas) no se renderiza desde hace tiempo y se sigue parcheando en paralelo a detailv2.

Así se hace

A la segunda copia se extrae a un servicio o componente parametrizado; a la tercera ya es deuda. Las versiones "V2/Old/Nuevo" se hacen con ramas o feature flags y la vieja se borra al terminar. Un archivo muerto se borra en el mismo PR que lo deja obsoleto: git guarda la historia.

Regla. Copiar-pegar-modificar es deuda instantánea: extraer antes de duplicar; y nunca sufijos 2/Old/Nuevo en producción.

17 · La lógica (y aplicaciones enteras) dentro de la vista

alta[pll] [exp] [home]
Así está

[pll] 21.008 líneas de JavaScript inline en 47 Blade; 27 new Vue( dentro de vistas; el bundle compilado por mix tiene 31 líneas. Estado de negocio como literales en la plantilla: start_date: "2026-07-01", [4612, 4477].includes(consumption_type) //HACK. [exp] 54 config() en vistas, bucles anidados en @php, funciones JS generadas por interpolación de Blade. [home] queries dentro de Blade: \DB::table('FacturaReporte')… en invoicing/reports/index.blade.php:120-145.

Por qué duele

Ese JS no pasa por linter, minificación, tests ni cache; cada cambio de UI es un diff en un archivo de 8.000 líneas; los hotfix de negocio quedan como literales que caducan. Mezclar Blade con JS crea bugs de escape ("{{ $token }}" dentro de un string JS es exactamente el vicio 6).

Así se hace
{{-- Blade solo maqueta e inyecta datos por un único puente --}}
<script type="application/json" id="app-props">@json($props)</script>
<script src="{{ mix('js/reservas.js') }}"></script>
// resources/assets/js/reservas/*.vue → compilado, linteado, testeable
// Un ViewModel/ViewComposer entrega el array; la vista no llama a config(), session() ni DB

Regla. La vista no consulta y no calcula: pinta datos ya preparados; el JS vive en resources/assets/js y pasa por el build.

18 · Reglas de negocio como números mágicos: IDs de locales, correos, umbrales

media[home] [pll] [exp] [libro]
Así está
// ReservationController.php:823 [home]
$freeReservationEnabled = (($request->email == 'persona@…' && in_array($local_id, [2346, 2432, 2387, 2433])) || …);
// CreditCardController.php:120 [home]   if(in_array($user->Id, [40700, 48496]))
// detailv2.blade.php:2315 [pll]        ![93,2192,686,54,27,175,55,28,2363].includes(local_id)  // "cusco restaurants asked"
// ReservationListItem.jsx:1262 [libro] onClick={localStorage.userType > 8 ? … }   // ×65

[home] 90 in_array($local, [ids]), 135 LocalId == N, autorización por sufijo @mesa247.* en 37 archivos frente a 3 Policies. [pll] 59 arrays de IDs. [libro] autorización decidida en el cliente con userType > 8 en 65 sitios.

Por qué duele

Son reglas de negocio invisibles para el negocio: nadie sabe por qué existen ni cuándo caducan; sobreviven al cliente que las motivó; activar algo para un restaurante exige un deploy; y con cuatro países los IDs colisionan. Un umbral de rol en el cliente lo cambia cualquiera en DevTools.

Así se hace

Flags por local en la tabla de configuración del local (que la API ya devuelve) o en config/business.php con nombre y comentario. Roles y permisos por Gate/Policy en el servidor; la UI solo oculta con can('editar_reserva') derivado de lo que el backend dice.

Regla. Un ID o un correo en el código es una regla de negocio escondida: sácala a datos con nombre. La UI oculta; el servidor autoriza.

19 · Estado global a mano y consultas dentro del bucle

media[libro] [home]
Así está

[libro] un singleton Config mutable instanciado 16 veces, 48 claves distintas de localStorage (localId leído 164 veces), un bus fbemitter con 233 emit de strings libres y 37 listeners de los que solo 15 se dan de baja; 30 mutaciones directas de this.state; 175 new XxxApi() que registran un handler jQuery cada uno. [home] 94 foreach con ::find/->where dentro (N+1) aunque ->with() se usa 273 veces.

Por qué duele

Dos fuentes de verdad que se resincronizan a mano, fugas de memoria al desmontar, flujo de datos que nadie puede seguir. En el backend: cientos de queries por request en listados de reservas, sobre instancias con poca CPU.

Así se hace

Un store con acciones nombradas (Context/Redux/Zustand), localStorage solo como caché de arranque leído en un único módulo, listeners siempre con su remove(). En Laravel: with(), whereIn + keyBy, o chunk; contar queries en desarrollo.

Regla. Una sola fuente de verdad por dato; una query por colección, no por elemento.

E · Proceso, herramientas e higiene

20 · Merge = deploy, sin tests, sin lint, sin nadie que pueda decir que no

alta[home] [pll] [exp] [libro]
Así está

Los tres cloudbuild.yaml tienen tres pasos: build, push, deploy. Ningún phpunit, php -l, composer validate, análisis estático ni aprobación. Tests: 21 en [home] (2 son ExampleTest), 2 de scaffolding en [pll] y [exp], 1 en [libro] más dos runners caseros "porque el jest de react-scripts está roto". [libro] compila con CI=false DISABLE_ESLINT_PLUGIN=true, y cómo llega build/ a producción no está en el repo. Ningún phpstan, php-cs-fixer, pint ni .eslintrc efectivo. Frameworks y runtimes fuera de soporte: Laravel 5.4/5.5, PHP 7.3/7.4, Node 12/14, React 15, Vue 2.

Por qué duele

Cualquier dd(), die() o error de sintaxis llega a producción. No hay red de seguridad para refactorizar los gigantes del vicio 15. Ningún CVE de Laravel o PHP desde 2020 se puede parchear con un composer update. Y el PR es la única barrera, así que cuando el PR no se revisa, no hay barrera.

Así se hace
# cloudbuild.yaml — antes de construir la imagen
- name: composer:2      args: ['install', '--no-interaction']
- name: php:7.4-cli     entrypoint: vendor/bin/phpunit
- name: php:7.4-cli     entrypoint: vendor/bin/phpstan   args: ['analyse', '--level=1']
# recién después: build → push → deploy (a staging; prod desde tag o con aprobación manual)

Rama protegida con una revisión obligatoria y CI verde. Plan de upgrade por saltos con la suite de tests como red (ver plan).

Regla. Nada llega a producción sin que una máquina lo haya ejecutado antes; el trigger de deploy no dispara en merge sin gate.

21 · Lo que no debería estar en el repo: builds, basura, dependencias, y lo que sí debería: el lock

media[home] [pll] [exp] [libro]
Así está

[home] vendor/ commiteado y composer.lock en .gitignore (builds no reproducibles, .git de 294 MB). [pll] [exp] public/js/app.js y public/css/app.css compilados y versionados (822 KB; nada garantiza que coincidan con la fuente), web.config de IIS en un contenedor Apache, tres copias de v-calendar. [libro] logs del profiler V8, tree.txt, updates.txt, un README genérico de 104 KB, y un build que modifica public/index.html en cada ejecución. [exp] txt.txt vacío, appOld.css, componentes *Old.vue.

Por qué duele

Un git blame sobre un CSS compilado no dice nada; los PR de estilos son conflictos binarios; sin lock, dos builds del mismo commit instalan versiones distintas; y una imagen con vendor/ de desarrollo, dumps y llaves es más grande y más peligrosa.

Así se hace

Se versiona la fuente y el lock; los artefactos se generan en el build (etapa Node + composer install --no-dev en un Dockerfile multi-stage); .gitignore y .dockerignore reales; la versión se inyecta como variable de build, no editando archivos versionados.

Regla. Se versiona el lock, no las dependencias; se versiona la fuente, no el resultado del build; el build produce artefactos y nunca modifica el árbol.

22 · Depuración, código muerto y "feat: up"

media[home] [pll] [exp] [libro]
Así está

[home] dd( en 61 archivos (13 vivas, una en un controlador web), 900 líneas con dump(, 37 exit/die vivos, 240 echo, $_REQUEST directo, ~7.600 líneas de código comentado (con credenciales de eFact dentro de un comentario en config/services.php:38-70). [libro] 77 console.log activos en 27 archivos, uno con los datos de la reserva; 838 líneas comentadas; un archivo TableMap/html sin extensión que nadie importa; métodos getReservationDates y getReservationDates2. [exp] 42 console.log. Mensajes de commit históricos: feat: up ×9, fix var, Update app.php, hardcode code, set hard key; mediana de 18 caracteres.

Por qué duele

die() rompe el ciclo de respuesta (sin logs, sin middleware) y en un comando convierte un error en exit 0. El código comentado no dice si volverá, y los secretos comentados siguen siendo secretos. git blame con "feat: up" no responde "por qué está esto así", que es justo lo que se necesita con tantos IDs mágicos.

Así se hace

Hook de pre-commit y regla de lint que fallan con dd(/dump(/console.log. Borrar el código comentado en el mismo PR que lo desactiva. Commits tipo(ámbito): qué — por qué, como ya hacen los de agosto (fix(logs): chequear existencia columna por columna, no via centinela): esa es la vara.

Regla. dd/dump/echo/die/console.log no se mergean (el linter lo impide); el código comentado se borra; el commit explica la intención, no repite el diff.

23 · Contraseñas: MD5+salt todavía se crean, y PasswordPlain sigue en el modelo

alta[home]
Así está
// Auth/RegisterController.php:157,165
$salt = substr(md5(time()), 0, 10);  …  'Password' => md5($password . $salt)
// Console/Commands/User/CreateFromContact.php:122-123
$user->PasswordPlain = $password;  $user->Password = md5($password . $salt);
Por qué duele

Hay 1,6 millones de contraseñas en claro en Usuario.PasswordPlain y 7.484 cuentas del panel que comparten una. Mientras exista un camino que escriba MD5 o plain, la migración nunca termina. Lo positivo: app/Support/PasswordHasher.php ya verifica el legado con hash_equals y rehashea a bcrypt; y el login pone PasswordPlain = null.

Así se hace

Hash::make() (bcrypt/argon) en toda alta, por una única puerta (PasswordHasher); PasswordPlain y Salt fuera del $fillable y, cuando el reset esté hecho, fuera del esquema.

Regla. Hay una única función que escribe contraseñas, y usa bcrypt/argon; nada las guarda en claro, ni "temporalmente".

F · Infraestructura, operación y datos (segunda ronda)

Segunda ronda del 16 de agosto: siete analistas (cuatro con Fable, tres con Opus) sobre m-incidente, m-b-core, los repos de operación (ip-enforcer, detector-anomalias, l-network, m-infrastructure) y una segunda pasada con lentes nuevas sobre los cuatro repos de la primera ronda. Los hallazgos donde los dos modelos coincidieron de forma independiente están marcados Fable+Opus. Detalle y evidencia completa en el anexo INFORME-analisis-ampliado-2026-08-16.md.

24 · El proxy que reabre TLS no verifica el certificado del backend

alta[m-incidente/webs-vhosts] [l-network] · Fable+Opus
Así está
# webs-vhosts/templates/vanity.conf.tmpl y 74/74 build/*.conf · l-network: 78/92 vhosts
SSLProxyVerify none
SSLProxyCheckPeerName off

Y los scripts que comprueban el alta de dominios usan curl -sk (7 sitios). En la capa de aplicación es el mismo vicio: 8 sitios con verify => false en [home], incluidas llamadas a pasarelas.m-incidente: webs-vhosts/templates/vanity.conf.tmpl · aws-newmesa/onboard_dominios.sh · l-network: vm-web2026/etc/apache2/sites-enabled/*.conf

Por qué duele

El tramo AWS→GCP viaja por Internet. Sin verificar el peer, quien controle el camino (o el DNS de un tenant) puede suplantar al backend y servir el skimmer desde el proxy, para 74 dominios de restaurantes. El SNI ya es el tenant correcto: no hay motivo técnico para apagar la verificación.

Así se hace
SSLProxyVerify require
SSLProxyCheckPeerName on
SSLProxyCACertificateFile /etc/ssl/certs/ca-certificates.crt

Regla. verify=false no se mergea: ni en un cliente HTTP, ni en un .conf. Un proxy que reabre TLS verifica el upstream igual que un navegador.

25 · Vhosts sin cabeceras de seguridad, sin log propio y con el banner del sistema

alta[webs-vhosts] [l-network] · Fable+Opus
Así está

0/74 vhosts generados (y 0/92 en la VM) con Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options o X-Frame-Options; 0/74 con ErrorLog/CustomLog propio (todos caen en el log global, sin %v garantizado); ServerTokens OS y ServerSignature On en conf-enabled/security.conf; SSLProtocol/SSLCipherSuite dependen del apache2.conf del box, que no está versionado; php8.5 cargado en un gateway que solo proxea; 74 vhosts con RequestHeader unset Accept-Encoding para poder reescribir el HTML con Substitute (sin compresión para nadie).m-incidente: webs-vhosts/build/*.conf · l-network: vm-web2026/etc/apache2/conf-enabled/security.conf:12,23 · mods-available/ssl.conf:57

Por qué duele

El daño material del incidente fue un <script src> a un workers.dev inyectado en el checkout: una CSP con script-src en lista blanca neutraliza esa clase entera y es una línea en la plantilla que ya existe. Sin log por vhost, responder "¿este dominio fue atacado el día X?" es grepear un log mezclado de 74 sitios. El banner con versión de SO se lo damos a quien ya conoce esta flota.

Así se hace

Un bloque Header always set … (CSP primero en Report-Only, luego enforcing; HSTS; nosniff) en la plantilla, ErrorLog/CustomLog por vhost o LogFormat con %v a un colector, ServerTokens Prod, ServerSignature Off, cipher suite "intermediate" de Mozilla versionada, y validar.sh que falle si un vhost no las trae.

Regla. Lo que decide la seguridad del borde (TLS, cabeceras, logs) vive versionado junto al vhost y se valida en el build; tras un skimmer, CSP es control primario, no "hardening".

26 · Producción se edita a mano, y el deploy "declarativo" no lo es

alta[m-incidente/scripts] [l-network] · Fable+Opus
Así está

Cinco scripts hacen hotfix con sed -i directamente sobre /etc/apache2/sites-enabled como root (escritura no atómica; el rollback restaura los .bak de otras corridas); dos copias divergentes del script de alta de dominios (283 líneas de diff), y la vieja deduce el tenant del dominio, exactamente lo que el README de webs-vhosts prohíbe en negrita ("40 de 82 tienen slug distinto"). En l-network, el rsync a sites-enabled va sin --delete (un vhost borrado en git vive en la VM para siempre), el rollback no borra los archivos nuevos que rompieron el configtest, y /etc/hosts entero se reemplaza desde el repo (18 vhosts dependen de una línea ahí). El pipeline entra a la VM con ssh-keyscan … || true + StrictHostKeyChecking=no. Y el README promete desplegar.sh, auditar.sh y un runbook que no existen.m-incidente: 03-remediacion-y-migracion/scripts/fix_www_roto.sh:52-58 · aws-newmesa/onboard_dominios.sh:88 · l-network: deploy/l-network-setup-vm.sh:39-49 · .github/workflows/deploy.yml:29-41

Por qué duele

El estado del servidor deja de derivarse del repo: el siguiente deploy pisa el hotfix o el hotfix pisa el repo, y nadie sabe qué corre. Un rollback que no revierte es peor que no tener rollback, porque da confianza. Y el canal de deploy sin verificar la identidad del destino es un vector hacia el borde de producción, que además es la misma flota EC2 comprometida en junio.

Así se hace

El fix se hace en plantilla/datos → generador → validación → deploy del build/; si hubo que operar a mano, vuelve al repo el mismo día. rsync -a --delete en ambos sentidos (o apache2ctl -S + diff antes/después), rollback con rsync --delete desde el backup, /etc/hosts gestionado por línea (o DNS interno), known_hosts fijo en el repo con StrictHostKeyChecking=yes, llave de deploy con command= forzado. Una plantilla, un generador, un runbook; el README se verifica contra ls en el mismo PR.

Regla. En producción no se edita, se despliega; un deploy declarativo borra lo que ya no está en el repo y su rollback deja el sistema exactamente como antes; la automatización nunca desactiva la verificación de identidad del destino.

27 · Código de seguridad copiado en tres repos: el bypass que uno corrigió, el otro lo reintrodujo

alta[ip-enforcer] [l-network] [detector-anomalias]
Así está
// l-network/scripts/ip-enforcer/main.go:122-146 — recorre XFF de derecha a izquierda saltando proxies confiables
// "verified live on 2026-08-13: curl -H 'X-Forwarded-For: 8.8.8.8' from a banned IP bypassed the ban under the old first-entry logic"
// ip-enforcer/main.go:214-226 (repo nuevo, commit posterior) — vuelve al PRIMER elemento:
first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])

Tres artefactos del enforcer (la imagen desplegada cuya fuente "se perdió", la copia en l-network, el repo nuevo) y ningún repo dice cuál corre en prod. Detector, poller y enforcer canonicalizan el doc-id de IP de tres formas distintas (net.ParseIP().String() vs string crudo): una IPv6 escrita de otra forma en XFF produce un ban que "silenciosamente nunca matchea". config.go y analyzer.go del detector difieren entre su repo y la copia de l-network.l-network/scripts/ban-poller/banwriter.go:14-25 · detector-anomalias/store.go:60-66 · ip-enforcer/main.go:230-238

Por qué duele

Si el repo nuevo llega a prod, cualquiera que hable directo con run.app evade el ban con un header, y puede hacer que se banee la IP que él elija (incluidos los GFE del balanceador: 403 para todo el servicio, porque esa versión no tiene never-ban de proxies). Es el vicio 16 (copiar-pegar) aplicado al control que sostiene la producción durante el incidente.

Así se hace

Una sola fuente por componente (las copias se borran o se referencian por submódulo/SHA de imagen); una función compartida banid usada por escritores y lector; test que reproduzca el bypass del 13 de agosto; y el README diciendo qué SHA corre en prod y cómo verificarlo (gcloud run services describe … --format='value(spec.template.spec.containers[].image)').

Regla. El control de ingress tiene una única fuente versionada, con test del bypass conocido; escritor y lector de una lista de bloqueo comparten el mismo código de clave, no la misma intención.

28 · Controles que fallan abiertos sin avisar, y configuración "preservada" en la consola

alta[ip-enforcer] [detector-anomalias]
Así está

El enforcer, si la lectura de Firestore devuelve 0 documentos, instala el snapshot vacío sin log ni métrica; si Firestore falla al arrancar, deja pasar todo ("start with empty lists (allow)"). Nadie mide count(banned_ips); /healthz devuelve 200 aunque Firestore esté caído (igual en detector y poller). El vaciado del 15 de agosto (1.449 → 0) se descubrió por sus efectos y ningún escritor versionado lo explica (solo hacen Create). El detector arranca en MODE=enforce, con TRUSTED_IP_PREFIXES y NEVER_BAN_IPS como env "preservadas del servicio existente" por el cloudbuild: el estado real vive en la consola. El script que desbanea (sin versionar) borra por batchWrite sin paginar, sin dry-run y con un backup posiblemente parcial.ip-enforcer/main.go:110-114,187-201 · detector-anomalias/cloudbuild.yaml:22-31 · config.go:225-232 · m-incidente/05-monitoreo/scripts/unban-browser-user-agents.py

Por qué duele

Fail-open es la decisión correcta para no tumbar el servicio; fail-open sin alarma es "sin protección y sin saberlo". Y si alguien recrea el servicio o redeploya con --set-env-vars incompletos, el detector vuelve a solo-avisar en silencio.

Así se hace

Métrica snapshot_size{coll} en cada refresh y alerta cuando cae >50 % o a 0; /healthz que reporte edad y tamaño del snapshot; job diario que compara banned_ips con el último backup; los env del servicio en el cloudbuild.yaml/Terraform con --set-env-vars explícito; quitar un control exige el mismo rigor que ponerlo: dry-run, backup verificado y registro.

Regla. Todo control que falla abierto emite una señal cuando lo hace, y alguien la mira; la configuración que decide si protege o solo avisa está versionada, no "preservada".

29 · La infraestructura nueva nace con los defaults que el incidente ya pagó

alta[m-infrastructure] [m-b-core/cloudbuild] · Fable+Opus
Así está
# m-infrastructure/modules/cloud_run/main.tf:7 · variables.tf:60-62
ingress = "INGRESS_TRAFFIC_ALL"
variable "allow_unauthenticated" { default = true }     # → member = "allUsers"
# modules/cloud_sql/main.tf:9,36   deletion_protection = false · ipv4_enabled = true
# backend.tf:6-13  backend GCS comentado: "State: local by default (quick)"
# Makefile:39-56   apply-prod / destroy-prod con -auto-approve

Y en m-b-core: los tres cloudbuild.yaml solo añaden --add-cloudsql-instances "si el trigger define _LEGACY_SQL_INSTANCE; en prod no está definida" (comentario textual): como gcloud run deploy es declarativo, cada build arranca el conector de la nueva revisión (causa raíz del incidente del 16-ago); el .env completo baja de Secret Manager a un archivo del workspace, se hace head -5 a los logs y termina como variables de entorno planas de Cloud Run vía --env-vars-file (no --set-secrets); el comando de deploy se construye por concatenación y se evalúa; alembic/env.py "enmascara" la URL con DB_URL.split('@')[0], que conserva justo usuario:contraseña, y lo imprime en Cloud Build en cada migración (verificado ejecutando la expresión); el mismo patrón se repite en la línea 99 para la URL del engine, así que la credencial se escribe dos veces por deploy.m-b-core: services/api/cloudbuild.yaml:125-128,152-153,286-298 · alembic/env.py:48,99 · m-infrastructure/scripts/load-secrets.sh:6,29 (set -a)

Por qué duele

Es reproducir en el proyecto nuevo exactamente lo que el incidente enseñó a no hacer: run.app público fuera del LB, base de datos con IP pública, destroy-prod a un make de distancia, state local que dos personas pueden pisar, y una contraseña de Postgres escrita en Cloud Logging en cada deploy.

Así se hace

ingress = INTERNAL_AND_GCLB y allow_unauthenticated = false como default con excepción explícita; deletion_protection = true en prod; state en GCS con locking desde el día 1; apply con plan revisado; _LEGACY_SQL_INSTANCE obligatoria en la validación de variables que ya existe (o gcloud run services replace service.yaml); --set-secrets por clave; make_url(DB_URL).render_as_string(hide_password=True); nunca head sobre un archivo de secretos; argumentos como array, no eval.

Regla. Con gcloud run deploy todo flag omitido se pierde: la configuración de la revisión está completa en el pipeline o en un YAML versionado; la infra nueva nace privada, detrás del LB, protegida contra borrado y con state compartido; una máscara de credenciales sin test es una fuga.

30 · El repo del incidente reproduce la fuga que investiga

alta[m-incidente] · Fable+Opus
Así está

Credenciales de producción completas pegadas como "evidencia" en informes de main (la contraseña de la BD y una secret key de AWS en REVISION-l-gateway.md:29; el MASTER_API_TOKEN entero en un informe del 12-ago; ≥15 valores en 6 informes según Opus), mientras el mismo dato aparece enmascarado en scans/l-gateway.md:76: dos políticas de redacción en la misma auditoría. Correos personales de comensales y empleados en 11 informes. El export de notificación de tarjetas selecciona TarjetaNumero y TarjetaHash (cuyo salt está commiteado en l-gateway) junto a nombre/email a un .xlsx sin cifrar, teniendo ya RIGHT(...,4). La cifra de tarjetas afectadas que va a la ANPD y a Izipay tiene seis valores conviviendo (989 / 569 / 636 / 811 / 494 / ~1.940) y el README publica la más vieja. 36 commits de forense en una rama local sin pushear y 16 archivos sin trackear; el código operativo mejor hecho (run-sql.py, el desbaneo, waf-fase-1.sh) está sin versionar; el reset de 222.792 contraseñas se ejecuta pegando 9 UPDATE en Cloud SQL Studio; 50/58 commits directos a main; una decisión de seguridad ("los tokens expiran solos en ~40 días", eran 365) tomada sobre un supuesto sin etiquetar.m-incidente: 02-revision-repos/REVISION-l-gateway.md:29 · 00-incidente/2026-08-12/INFORME-mapeo-completo-tokenlogeo.md:32 · 01-tarjetas-y-notificacion/export_tarjetas_afectadas.py:78-99 · README.md:20 · 03-remediacion-y-migracion/RESET-MASIVO.sql:4,11-12

Por qué duele

Cada clon, laptop y agente que lee el repo tiene la contraseña de la BD que contiene 1,9 M de contraseñas en claro. Un secreto "pendiente de rotación" en un doc de julio sigue vivo en agosto. La respuesta al incidente crea un activo nuevo de datos de tarjeta fuera de todo control. Y un tercero que entre por la puerta indicada ("leer primero") se lleva la cifra equivocada.

Así se hace

En documentación, un secreto se cita prefijo…sufijo siempre; el valor completo, si hace falta para IR, en el gestor de secretos referenciado por ruta; gitleaks en pre-commit también aquí. PII de terceros enmascarada, IOCs del atacante en su propia sección. Un export selecciona lo que el mensaje necesita (email, últimos 4, marca), a destino cifrado con caducidad, dueño y fecha de borrado escritos. Un NUMEROS-OFICIALES.md con valor, unidad, ventana, query y fecha; el resto lo enlaza. Commit y push al cierre de cada jornada; los supuestos etiquetados [SUPUESTO] como ya hace el informe maestro; las mutaciones masivas con un runner que deja registro (run-sql.py), nunca con el portapapeles.

Regla. El repo de incidente no es una excepción a las reglas de secretos y PII: es el peor lugar para un secreto. Un número que sale de la empresa tiene un solo dueño, una query y una fecha.

31 · El estado del pago lo dice el cliente, y nada es idempotente

alta[home] [pll] [exp] · segunda pasada
Así está
// routes/api.php:103 [home] — fuera del grupo auth:api
Route::post('ecommerce/orders/{order_id}/process-transaction', …);
// OrderController.php:2418-2422 (y :2628, :2724, :2916)
$transaction = request()->kr_answer != null ? json_decode(request()->kr_answer) : [];
if ($transaction->orderStatus == 'PAID') { … $payment->PagoEstado = 'Pagado'; $reservation->EstadoId = 1; }

Ninguna verificación de kr-hash con la clave HMAC de Izipay en todo [home] (contraste: [pll] tiene verifyKrHash bien hecho); no se lee el estado previo antes de escribir (el mismo IPN reenviado se procesa de nuevo). Los informes del incidente lo documentan como patrón: tres de las cuatro apps de pago aceptan callbacks sin firma; una calcula el HMAC y lo ignora; en otra la "hash key" es la palabra password; y /signature firma cualquier monto (vicio 7). Además tres tablas de log guardan request()->all() de pagos.

Por qué duele

Cualquiera puede POSTear un JSON con orderStatus:"PAID" y confirmar la reserva o el pedido sin pagar. Y un webhook reintentado por la pasarela duplica efectos.

Así se hace

Verificar la firma con hash_equals (o consultar el estado a la API de la pasarela server-to-server); idempotencia por transactionUuid único antes de escribir; el endpoint de IPN separado del que llama el navegador; el log de pago guarda identificadores y estados, no el payload.

Regla. El estado de un pago lo dice la pasarela verificada (firma o consulta), nunca el cuerpo que manda el cliente; todo webhook es idempotente por id de transacción.

32 · Escrituras sin transacción ni bloqueo, colas que no corren

alta[home] · segunda pasada
Así está

0 lockForUpdate/FOR UPDATE/GET_LOCK en todo app/. La asignación de mesa es update + create + save sin DB::transaction ni comprobación de que la mesa esté libre (check-then-insert sin lock). En ReservationController::store() el commit está en la línea 1469 pero el método sigue hasta la 1775 haciendo save/create/update fuera de la transacción. QUEUE_DRIVER=sync con 101 clases ShouldQueue; 9 $schedule->command() activos sin ningún schedule:run ni cron en Dockerfile/cloudbuild.l-home-restaurantes: Libraries/ReservationTable.php:34-56 · Api/Book/Reservation/ReservationController.php:1185,1469,1636-1736 · config/queue.php:18 · Console/Kernel.php:359-366

Por qué duele

Dos peticiones simultáneas asignan la misma mesa; un fallo a mitad deja ReservaMesa en estado 3 sin la fila nueva. Los mails y push se envían dentro del request (latencia y fallos acoplados al checkout) y las tareas "programadas" dependen de un cron que no existe en Cloud Run (los robots muertos del incidente).

Así se hace

DB::transaction(fn () => …) envolviendo toda la unidad (mesa + reserva + etiquetas), lockForUpdate() o restricción única (MesaId, Fecha, Hora) que haga imposible el duplicado; efectos externos después del commit; QUEUE_CONNECTION=redis|database con un worker real y Cloud Scheduler para el schedule, o quitar ShouldQueue si se decide síncrono.

Regla. Una unidad de negocio = una transacción; lo que debe ser único lo garantiza la BD, no un if; si algo se declara asíncrono o programado, existe el proceso que lo ejecuta y una alerta si deja de correr.

33 · El código sabe que existe Perú

media[home] [pll] [exp] [libro] · segunda pasada
Así está

[home]: S/ ×238, +51 ×51, DNI ×64, 'PEN' ×78, Pais == 'PE' ×91, mesa247.pe ×1.162, America/Lima ×115, 36 case 'PE'|'CL'|'EC'|'CO' dispersos, 43 throw new Exception('texto en español') sin i18n (pese a 1.433 usos de _i()). [pll] S/ ×114; [exp] filtros de precio en soles para todos los países; [libro] "+51 Perú" como default, moment.locale('es') ×19.l-home-restaurantes: Traits/Ecommerce/ToGo/Order.php:556 · Traits/Payment/Payment.php:1734 · Traits/Util.php:148 · l-librodereservas: Data.json:38

Por qué duele

La plataforma opera en cuatro países y es multitenant: cada literal es un bug de país o un deploy por cambiar un teléfono. Multipaís ya costó un PR por país en el widget.

Así se hace

Local/Pais como entidad con moneda, símbolo, teléfono de soporte, dominio y zona; formateo con NumberFormatter/Intl; todo texto visible por _i(); un linter que marque Exception(' con tilde o ñ.

Regla. País, moneda, zona, teléfono y dominio son datos del tenant; el código no sabe que existe Perú; un texto que ve un usuario nunca es un literal.

34 · Dependencias con CVE público, ramas dev en producción y SDKs copiados a mano

alta[home] [pll] [exp] [libro] · segunda pasada
Así está

Versiones exactas (gracias a vendor/ commiteado): laravel/framework 5.4.36 (CVE-2018-15133: RCE por deserialización de cookie si APP_KEY se filtra, y el .env está commiteado), guzzle 6.5.5/6.3.3 (CVE-2022-29248 y familia), symfony/http-* 3.4.x (sin soporte de seguridad desde 2021), lcobucci/jwt 3.3.0 (CVE-2021-41106, lo usa Passport), firebase/php-jwt 4.0.0, phpoffice/phpexcel 1.8.2 (abandonado, XXE), intervention/image "dev-main" (rama móvil en producción); whoops y tinker en require; SDK de PayU copiado a mano (516 KB) sin versión; 9 curl_init + 36 Guzzle. [libro]: axios con CVE-2023-45857 documentado en los informes.

Por qué duele

No son teóricos: deserialización de cookie con APP_KEY filtrada es RCE en el backend que ya fue comprometido. Un SDK copiado no recibe parches ni se sabe qué versión es.

Así se hace

composer audit / npm audit en CI, bloqueantes; composer.lock versionado y vendor/ fuera; ramas dev-* prohibidas; herramientas de desarrollo en require-dev; SDKs solo por gestor de dependencias; una librería HTTP por proyecto.

Regla. La CI falla con CVE conocido; nada dev-* en producción; código de terceros solo vía gestor de dependencias.

35 · Rendimiento que se paga en Cloud Run: sin paginación, sin cache, sin config:cache, sesión en disco

media[home] [pll] [exp] · segunda pasada
Así está

[home]: 251 ->get() vs 3 paginate() en Api/; 34 SELECT *; 102 LIKE '%…%' (y relaciones guardadas como ids separados por coma en una columna, 6 sitios); 0 Cache::remember; 1.692 rutas compiladas en cada request porque ningún Dockerfile hace config:cache/route:cache (los *:clear están comentados); 18 vistas con ?v={{ time() }}. [pll]/[exp]: sesión y cache file por defecto en un servicio con autoescalado y sin afinidad (causa documentada del 401 a media reserva).

Por qué duele

Respuestas y memoria crecen con la tabla; LIKE '%x%' no usa índice; el cache-buster por segundo desactiva el CDN; y con min-instances=10 al 14 % de CPU, cada request caro es dinero (la caída del 24-jul fue contención, no tráfico).

Así se hace

Paginación obligatoria en listados; columnas explícitas; tablas pivote en vez de CSV en columna; Cache::remember('clave:v1', ttl, fn) con Redis; config:cache + route:cache en el Dockerfile (ya se cumple env() solo en config/); assets versionados por hash (mix()); SESSION_DRIVER=redis|database.

Regla. Ningún endpoint devuelve una colección sin límite; la imagen se construye con config y rutas cacheadas; ningún estado entre requests vive en el disco del contenedor.

36 · API sin convención, imagen sin dueño, README sin contenido

media[home] [pll] [exp] [libro] · segunda pasada
Así está

Rutas snake_case ×161 vs kebab ×37 vs camelCase ×5; 10 GET que mutan (mark-ipn, send_emails, assign); el typo experiencies en 21 rutas ya es contrato público; v1/v2/v3 reimplementados por copia; 0 API Resources (203 'success' => TRUE a mano); Usuario/UsuarioSistema con $hidden que no incluye PasswordPlain ni Salt (hoy salvado por select() explícito en 5 sitios; 36 find/first a una línea del leak). Docker: COPY . ./, npm install --force ×2 en la imagen PHP, chmod -R 775, 0 USER/HEALTHCHECK en cuatro repos, el sidecar de seguridad desplegado con :latest en cada build del backend, --allow-unauthenticated sin --ingress, min/max/ingress fuera del repo. README de [home] = 1 línea; 33 docblocks "Display a listing of the resource."; 85 TODO|HACK con nombre de cliente y sin ticket. [libro]: 76 onClick en div/span, 14 <img> sin alt, 296 .map vs 177 key, 0 expiración de token en cliente.

Por qué duele

Los clientes acoplan a nombres con typo y a formas de respuesta distintas por endpoint; el modelo sin $hidden correcto es un leak a una línea; un cambio del enforcer se cuela en un deploy del backend sin revisión; y cada ingeniero (o cada IA) reconstruye el contexto desde cero.

Así se hace

Convención escrita (kebab en URL, snake en JSON), Resources como contrato, verbos HTTP correctos, $hidden con todo lo sensible; imagen multi-stage, no-root, por digest, con la config del servicio versionada (Terraform o service.yaml); README con qué es / cómo correr / variables / cómo desplegar; cada HACK con ticket y caducidad; en el frontend, <button>, alt, key estable y expiración en el interceptor.

Regla. La API tiene una convención escrita y una clase de respuesta por recurso; ninguna propiedad del servicio de producción se configura solo en la consola; un repo sin README de "cómo correr" no está listo para otro humano.

G · m-b-core frente a su propio estándar

m-b-core es, con diferencia, el mejor repo del ecosistema (estándar escrito, 4.931 tests, gates de cobertura, CI real, secretos desde Secret Manager en el pipeline, comentarios honestos). Justamente por eso importa lo que no cumple: son los vicios que van a definir la próxima década si entran ahora.

37 · Endpoints con PII sin autenticación "porque el gateway autentica arriba"

alta[m-b-core] · Opus (verificado)
Así está
# services/api/app/libro/auth_compat.py:3-8
The libro endpoints are migrated **without** auth validation (see plan, rule "sin migración de auth").
… read the `user_id` claim from the JWT … **without verifying its signature**.
The gateway upstream is responsible for actually authenticating.

Los 8 endpoints de libro/routes.py (/v1/locals/{local_id}/reservations, /diners/search, …) devuelven nombre, email, teléfono y alergias de comensales sin Depends de auth ni comprobación de tenencia; el router se monta sin dependencies=. POST /v1/profiling/summary recibe un reservation_id arbitrario, arma un dossier conductual del comensal y lo manda a OpenAI (con LangSmith trazando), sin auth: IDOR + fuga de PII a un tercero + coste sin límite. El propio prompt del sistema pide "no incluir datos personales" mientras el builder inyecta email y nombre.m-b-core: services/api/app/libro/routes.py:105 · router.py:29-30 · profiling_engine/routes.py:42 · summarizer.py:74,204

Por qué duele

Es la lógica que ya falló en la escalada bookAuth de agosto: delegar la autorización a un componente que no está en el mismo repo ni verificado en el despliegue, mientras el propio Cloud Run es alcanzable directamente. Alergias es dato de salud.

Así se hace

APIRouter(dependencies=[Depends(get_current_user)]) en el router de libro + assert_can_access_local(user, local_id) resolviendo los locales del UsuarioSistema desde la BD, no desde el token; si el gateway es de verdad el único ingreso, demostrarlo con --ingress=internal-and-cloud-load-balancing e IAM, no con un comentario; en profiling, auth + pertenencia y no enviar PII al LLM.

Regla. Un endpoint que devuelve PII no delega su autorización a algo que no está en el mismo repo ni verificado en el despliegue; todo endpoint que recibe un id de negocio como única entrada necesita, además de auth, comprobación de pertenencia.

38 · La nueva casa repite los vicios viejos: .env versionados, admin key estática, "UTC menos 5"

alta[m-b-core] · Fable+Opus
Así está

env/local.env y env/staging.env versionados con valores reales (URL de la MySQL legacy de prod sobre IP pública, DB_URL con password, JWT_SECRET, OPENAI_API_KEY, SEND_API_KEY…), 20 commits de historial, y el .gitignore los desexcluye a propósito (!/env/local.env), aunque el pipeline ya lee secretos de Secret Manager. 65 endpoints admin en staging (incluido un ejecutor SQL sobre Postgres y MySQL legacy, proxy HTTP, exports, BigQuery) detrás de una api-key estática compartida, comparada con != en 23 sitios (0 compare_digest), con el guard copiado 19 veces; la API de prod publica /docs y /openapi.json (worker y bridge sí los apagan). engine.compute() con now = datetime.now() naive por defecto y una ruta que lo llama sin now; libro/reservation_detail.py:891: datetime.utcnow() + timedelta(hours=-5) ("approximate by shifting UTC to Lima"), el vicio 4 literal; date.today() a 170 líneas del comentario que explica por qué no usarlo; EstadoId = 1 incrustado 153 veces en SQL. MD5 legacy verificado con == y sin rehash (el legacy ya rehashea).m-b-core: env/local.env · .gitignore:211-215 · services/api/app/admin/_deps.py:13-21 · main.py:37-45 · availability_engine/engine.py:601 · listings/routes.py:1032,1083 · libro/reservation_detail.py:891 · auth/service.py:70-84

Por qué duele

Un atacante ya enumeró /openapi.json y /v1/admin/* el 16 de agosto: con el mapa público y una clave compartida que además está en git, no hay identidad, revocación individual ni auditoría de quién ejecutó qué SQL. El bug de las 19:00 vuelve por la puerta de atrás en el motor que se supone lo corrige.

Así se hace

Solo *.env.template en git y rotar todo lo expuesto; identidad por persona para /v1/admin/* (IAP / IAM / OIDC) o claves por operador con hash y auditoría; hmac.compare_digest; una sola dependencia de auth en shared/; /docs solo en staging; compute(now) obligatorio o default now_local(config); ruff DTZ en select; constante ESTADO_ACTIVO; una única verificación de credenciales en tiempo constante que migra a bcrypt en cada login.

Regla. Si el .gitignore necesita una excepción para dejar entrar un .env, el diseño está mal; la superficie admin se autentica por persona, no por secreto compartido; ningún "ahora" sin la zona del restaurante, y el linter lo impide.

39 · Gates que cubren un módulo y una rama de producción sin protección

alta[m-b-core] · Fable+Opus
Así está

origin/prod (8-may) y origin/staging (12-ago) divergen 1.072 commits; CONTRIBUTING.md admite "branch protection no enforced… GitHub permitirá saltárselas, no lo hagas"; make deploy-force hace git commit --allow-empty -m "force deploy [skip ci]" && git push origin prod: un target del repo que salta la CI a propósito; 273 ramas remotas de agentes y experimentos. En CI, mypy cubre 1 de 32 módulos de la API (nada de bridge, worker, shared); los tests de bridge y worker no corren en CI; pytest.ini ignora globalmente DeprecationWarning (el utcnow() deprecado nunca avisa); el gate de cobertura exime justo los archivos más grandes y su sustituto es un workbench manual contra producción; sin lock de transitivas. Además: 118 os.getenv fuera de settings.py (que documenta un rate limiter "in-memory" que es Redis y no usa el pydantic-settings instalado); archivos de 3.044, 2.612 y 1.618 líneas con una ruta de 469 (el controlador de 4.000 líneas naciendo de nuevo); 41 except Exception (6 solo pass) y 30 de 62 logs con f-string en vez de extra={}, casi todo en ai/; subprocess.run(pdftoppm) dentro de un async def del worker; Dockerfiles sin USER.m-b-core: CONTRIBUTING.md:5-12 · Makefile:205-217 · .github/workflows/ci.yml:38 · pytest.ini filterwarnings · shared/settings.py:12-32 · libro/reservation_detail.py · availability_engine/routes.py:1082 · worker/app/modules/menus/router.py:105,206

Por qué duele

Es "merge = deploy sin gate" con otro traje: la protección depende de la buena voluntad, deploy-force es el atajo que las reglas prohíben, y con 1.072 commits de divergencia una promoción staging→prod es un big-bang irrevisable. Un tipo mal en 3.044 líneas no lo ve nadie; el warning que avisaría está silenciado.

Así se hace

Proteger prod/staging con la herramienta (1 review + CI verde + sin push directo; pagar el plan si hace falta); borrar deploy-force (redeploy = trigger manual sobre el mismo SHA); promoción staging→prod frecuente y pequeña; mypy incremental por módulo (uno por mes); filterwarnings por warning concreto con motivo; tests de bridge/worker en CI; pip-compile/uv lock; BaseSettings; partir flexible_search y reservation_detail; except Exception solo con noqa: BLE001 — motivo y log; ruff BLE+G+DTZ+S; asyncio.to_thread; USER app.

Regla. La rama de producción está protegida por la herramienta, no por el README; nunca hay un comando en el repo que salte la CI; el gate crece con el repo y los warnings se silencian uno a uno con motivo.

H · web202101, gateway y apisocial (los que faltaban)

Auditados por Opus en esta ronda (48 hallazgos; muchos confirman los 23 vicios en otro repo). Aquí solo lo nuevo. Verificación puntual mía en los marcados; el resto queda como hallazgo del analista con su evidencia.

40 · Claves privadas y datos de comensales versionados dentro del DocumentRoot

alta[l-gateway] · Opus
Así está

Seis certificados APNs con la clave privada sin cifrar (application/notifications/*.pem, incluidos los de producción) y 74 CSV con 7.306 filas de reservas de comensales (nombre, teléfono, email) en application/csv/, versionados; el DocumentRoot es la raíz del repo y el único Deny del .htaccess cubre el literal .env (por eso .env.production se sirvió con 200 durante el incidente). Además index.php:207-214 escribe el cuerpo crudo de cada request (tokens y PII) a un archivo dentro del docroot, sin rotación. Y en origin/master_mesa, la rama que llega a prod, ExceptionHook.php:21 sigue con dd($msg): el fix existe solo en una rama sin mergear.l-gateway: application/notifications/apns-*.pem · application/csv/*.csv · .htaccess:6-9 · index.php:207-214 · application/hooks/ExceptionHook.php:21 (master_mesa)

Por qué duele

Apache sirve .pem, .csv y .txt como estáticos: el gate PHP no cubre archivos. Aunque no se haya comprobado por HTTP, los .pem están en el historial y hay que rotarlos igual. Y un fix de seguridad que no está en la rama de despliegue no es un fix.

Así se hace

DocumentRoot en public/; certificados en Secret Manager y rotados; CSV borrados y tratados como incidente de datos personales; el hook mergeado a la rama de deploy; el servidor niega dotfiles y extensiones no servibles por patrón, no por literal.

Regla. Nada que no sea servible públicamente vive debajo del DocumentRoot; los certificados no se versionan nunca; un fix de seguridad que no está en la rama de despliegue no existe.

41 · SQL interpolado justo donde más duele: en la autorización y en el manejador de errores

alta[l-gateway] · Opus
Así está
// application/core/MY_Controller.php:187-195 — autorización de usuarios externos
->where("(Local.Id = '{$localId}' OR Local.Alias = '{$slugLocal}')")   // desde $this->input->post()
// application/hooks/ExceptionHook.php:58-59 — el logger de excepciones
$sql = "INSERT INTO LogSistema (Url, …, Data, …) VALUES ('{$url}', …, '{$data}', …)";  // $url y $data sin escapar

Además: token de servicio md5($id . time()) en columna compartida y canjeado por URL; 25 usos de md5 como aleatoriedad; 229 dump() en 32 archivos; Cache-Control: public, max-age=300, s-maxage=900 en todas las respuestas JSON del gateway, incluidas las autenticadas (api_helper.php:209-212); 22 date_default_timezone_set a mitad de request; credenciales de la BD de prod como fallback en database.php; CodeIgniter 2.1.4 (2012), 7.537 de 12.220 commits "no message".

Por qué duele

Una inyección en la propia comprobación de "¿este usuario puede tocar este local?"; y basta provocar una excepción cualquiera para tener ejecución SQL en el logger, con Host/REQUEST_URI/$_POST controlados por el atacante. Un CDN intermedio puede guardar la respuesta autenticada de un comensal y servírsela a otro.

Así se hace

Bindings de CI en ambos sitios; Cache-Control: private, no-store por defecto y cacheo explícito solo en catálogos; tokens con random_bytes, TTL y un solo uso; zona horaria explícita por operación, nunca cambiando el estado global del proceso.

Regla. El código de seguridad y el de manejo de errores son los que más cuidado necesitan, no los que se escriben rápido; el default de una API autenticada es no-store.

42 · Login sin throttle, backdoor de debug y observabilidad comentada en el sitio público

alta[l-web202101] · Opus
Así está
// routes/web.php:426-428 — sin throttle; el único limitador apunta a routes/api.php, que está vacío
Route::post('login/loginEP', 'UserController@login');
// RestaurantController.php:98-102
if (isset($debug) && $debug == 1) { dd($local); }      // ?debug=1 en la ficha pública

Cero rate limiting en 239 rutas web (login, registro, recuperar contraseña); Sentry como dependencia con todas las capturas comentadas (Handler.php:51-55); GlobalApply registrado dos veces (2 llamadas HTTP y 40 View::share por pasada) con un die() ante sys_getloadavg() >= 60; 39 clientes Guzzle sin timeout y 15 catch que convierten el fallo del upstream en 404; tres modelos con namespace roto que dejan caídas api/countries|cities|phonecodes sin que nadie lo note; rand(0,1) fabricando "1000 puntos" y likes; date('Y-m-'.'30') ×5; token constante de fallback; 184 MB de binarios versionados; 328 commits "no message"; un hook pre-commit que reescribe una vista en cada commit; vistas de 4.500 líneas.

Por qué duele

Con 1,6 M de contraseñas en claro en la BD legacy, un login sin throttle es el frente natural de credential stuffing. Un ?debug=1 público vuelca la respuesta del API interno. Y sin Sentry activo, los errores del sitio más visible no los ve nadie.

Así se hace

->middleware('throttle:10,1') en la definición de cada ruta de credenciales (no en un grupo que quizá no se use) + limitador por email+IP; borrar el debug; descomentar Sentry o quitar la dependencia; un middleware una vez; timeouts.

Regla. Todo endpoint que valide credenciales lleva throttle explícito en su definición; ningún parámetro de query cambia el modo de depuración de una respuesta pública; observabilidad comentada es observabilidad ausente.

43 · Un token que no caduca, viaja en la URL y a veces lo elige el cliente

alta[l-apisocial] · Opus
Así está
// routes/web.php:14,18 — GET, sin auth, sin throttle: devuelven nombre, email, foto, IP, socialId
Route::get('facebook/info', …); Route::get('google/info', …);   // ?soctoken=… (sin caducidad, reutilizable, en la query)
// Controller.php:69-75 — en el flujo popup, el "secreto" lo manda el cliente
return !empty($clientToken) ? $clientToken : Str::random(40);
// y el postMessage usa targetOrigin '*' bajo un comentario que dice lo contrario

.env.example con secretos reales de Facebook y Google; allowedOrigins: ['*']; la rama por defecto (pre_prod) no es la desplegada: Dockerfile y cloudbuild.yaml solo existen en master_mesa_docker_prod, 4 commits por delante con cambios funcionales.

Por qué duele

Un atacante elige el valor, induce a la víctima a completar el login y consulta /google/info?soctoken=<valor>. El token queda en historial, Referer y logs del balanceador. Y quien clona el repo por defecto no sabe cómo se despliega.

Así se hace

Token de un solo uso con TTL corto, generado siempre en servidor, canjeado por POST en el cuerpo y borrado al canjearlo; entregado solo por postMessage con targetOrigin explícito; la rama por defecto es la que se despliega.

Regla. El servidor nunca acepta como secreto un valor que eligió el cliente; un identificador que da acceso a datos personales caduca, se usa una vez y no viaja en la URL; la rama por defecto del repo es la que se despliega.

Cómo leer todo esto sin desanimarse

Son 43 vicios en 13 repos. Ninguno lo inventó una persona; son el resultado de años de "que salga hoy" sin un lugar donde estuviera escrito el criterio. La mayoría se resuelven con tres herramientas que no cuestan nada (validación al borde, un cliente HTTP único, un Handler de excepciones) y una regla de proceso (nada sin PR ni CI). Y en los mismos repos ya está la forma correcta de casi todo: verifyKrHash, las 58 FormRequests, PasswordHasher, Origins.js, la serie PLL-*. La guía solo pide que lo bueno deje de ser la excepción.

Herramientas

Checklist de Pull Request

Se pega en .github/pull_request_template.md de cada repo. El autor marca antes de pedir revisión; el revisor lo usa como guion. Si una casilla no aplica, se dice por qué.

Autor

  • El PR hace una cosa y el título dice cuál. El cuerpo explica por qué y cómo probarlo.
  • No hay secretos, tokens, URLs de producción ni datos reales en el diff (revisé git diff completo, no solo mis archivos).
  • Ningún token, credencial o dato sensible sale en HTML, JS inline, localStorage, logs ni respuestas de error.
  • Toda entrada externa se valida al borde (Form Request / validate()); nada de confiar en el cliente.
  • Los errores devuelven el código HTTP correcto (400/401/403/404/409/422/500) y se registran con contexto; no hay catch vacíos ni 200 con error dentro.
  • Ninguna fecha se crea sin zona explícita; lo que se muestra al usuario se convierte a la zona del país.
  • No hay SQL armado con concatenación o interpolación; todo parametrizado o vía query builder.
  • La lógica de negocio nueva está en un servicio/acción reutilizable, no en el controlador ni en la vista.
  • Hay tests para la regla nueva o el bug arreglado, y pasan en local: vendor/bin/phpunit (o npm test).
  • No quedan dd(), dump(), var_dump, console.log, código comentado ni archivos _old.
  • Si toqué config, migraciones, colas o cron: documenté qué env/infra necesita el deploy y cómo se revierte.
  • Puedo explicar cada línea del diff, incluidas las que escribió la IA.

Revisor

  • Leí todo el diff, no solo los archivos "importantes".
  • Busqué las cuatro cosas caras: secreto en el diff, token hacia el navegador, SQL crudo, error tragado.
  • Pregunté "¿qué pasa si…?" al menos una vez: entrada vacía, duplicada, red caída, dos veces seguidas, otro país.
  • La CI está verde y no se desactivó ningún test para lograrlo.
  • El cambio es del tamaño que se puede revisar bien. Si no, pedí partirlo.
  • Sé qué hace el deploy de este merge y cómo se revierte.

Definición de listo

Un cambio está terminado cuando cumple todo esto. Antes, está avanzado.

  • PR mergeado tras revisión de otra persona y CI verde.
  • Tests que demuestran la regla o el fix, corriendo en la CI.
  • Desplegado a producción, verificado con evidencia (log, /health, la pantalla) y con rollback conocido.
  • Sin secretos, sin tokens en el navegador, sin dd(), sin código muerto.
  • Documentado lo no obvio: README si cambia cómo se corre, DECISIONES.md si hubo una elección, ticket con enlace al PR.
  • El autor puede explicar cada línea, y otro miembro del equipo podría mantenerlo.

Trabajar con Claude en estos repos

La IA escribe rápido y bien; también repite con confianza los vicios que ya están en el repo, porque aprende del contexto que le damos. Las reglas de arriba aplican igual al código generado, y se agregan estas:

  1. Un CLAUDE.md por repo con las reglas de esta guía.

    Así la IA parte con el criterio del equipo, no con el promedio de internet. Plantilla abajo.

  2. Pedidos del tamaño de un commit.

    "Agrega validación de fecha en ReservaRequest con su test" sí. "Arregla el módulo de reservas" no.

  3. Lee el diff completo antes de aceptar.

    Si es demasiado para leer, el pedido era demasiado grande.

  4. Test primero; que la IA implemente.

    Tú describes el comportamiento con un test; la IA lo hace pasar. Y nunca le permitas cambiar el test para que pase.

  5. Ninguna dependencia nueva sin preguntar por qué.

    Cada composer require / npm install es una decisión del equipo.

  6. Nada de secretos ni datos de clientes en el prompt.

    Ni el .env, ni un dump, ni un token "para que entienda el formato". Se anonimiza o no se pega.

  7. Cuando falle, primero tú.

    Lee el traceback, formula una hipótesis, y recién pregunta con la hipótesis. Iterar el mismo error 20 veces sin entenderlo no es debugging.

CLAUDE.md (plantilla para cada repo)
# <nombre del repo>

Laravel 5.5 / PHP 7.x (o React 15). Repo de producción de Mesa247.
Regla base: código que no pueda explicar el autor no se mergea.

## Reglas para ti
- Propón el cambio más pequeño que resuelva el pedido; explica cada línea.
- Nunca pongas secretos, tokens ni URLs de producción en código, vistas,
  JS, .env.example ni ejemplos. Config solo desde config/*.php con env().
- Nunca imprimas tokens en Blade/JS ni los guardes en localStorage.
- Errores: código HTTP correcto + log con contexto. Nada de catch vacío
  ni de 200 con {"error"}.
- Fechas siempre con zona explícita (Carbon::now(config('app.timezone_pais'))
  o la zona del local); nunca now()/date() a secas.
- SQL solo parametrizado o vía Eloquent/Query Builder.
- Lógica de negocio en app/Services (o Actions), no en controladores/vistas.
- Todo cambio de comportamiento trae test en tests/. No modifiques tests
  para que pasen; si un test está mal, dilo y espera confirmación.
- No agregues dependencias sin preguntar.
- Antes de decir "listo": vendor/bin/phpunit y el linter deben pasar.
- Responde en español.
Así noHaz que funcione el checkout, me sale error 500.Sin diagnóstico, sin límite, va a parchar el síntoma (y probablemente con un catch vacío).
Así síEl 500 sale en CheckoutController@store línea 142 cuando Redis no responde: el throttle usa cache. Propón cómo hacer que la caída de cache no tumbe la ruta, sin quitar el throttle, y explica el trade-off.Diagnóstico tuyo, restricción explícita, decisión tuya.
Así noAgrega un endpoint que devuelva el token del usuario para usarlo desde el JS.Le estás pidiendo que reproduzca el vicio que causó el incidente.
Así síEl JS de la vista necesita listar reservas del usuario logueado. Diseña un endpoint same-origin autenticado por sesión que llame a la API con el token del lado del servidor. El token no debe llegar al navegador.Restricción de seguridad dicha antes, no descubierta después.

Plan de saneamiento: 30 · 60 · 90 días

No se arregla todo de golpe. Este orden va de lo que más daño evita a lo que más deuda paga. Cada ítem es un PR (o varios chicos), con dueño y fecha.

Primeros 30 días · cerrar sangrados

  • Rotar todo secreto que haya tocado git y sacarlo del repo (incl. .env, JSON de service accounts, .env.example con valores reales). Agregar gitleaks o similar a la CI.
  • Quitar todo token de HTML/JS/localStorage: los widgets hablan con su propio backend same-origin; el backend habla con la API.
  • Eliminar URLs crudas *.run.app del frontend; todo por el balanceador.
  • Plantilla de PR con el checklist; regla de rama protegida: 1 revisión obligatoria + CI verde en todos los repos.
  • CI mínima: phpunit + php -l + composer validate antes de construir la imagen. Si no hay tests, al menos que la suite vacía corra y el pipeline exista.
  • Barrido de dd()/dump()/var_dump/console.log y código muerto.
  • Segunda ronda: rotar los secretos completos que viven en m-incidente (informes) y en m-b-core/env/*.env, y sacarlos del árbol; auth + pertenencia en libro/ y profiling/summary (vicio 37); una sola fuente del ip-enforcer con test del bypass XFF y alarma cuando banned_ips se vacía (27–28); _LEGACY_SQL_INSTANCE obligatoria en el pipeline y máscara real en alembic/env.py (29); firma kr-hash + idempotencia en process-transaction (31); SSLProxyVerify require y CSP en la plantilla de vhosts (24–25); mergear a la rama de deploy el ExceptionHook sin dd() y sacar los .pem/CSV del docroot del gateway (40); throttle en el login de web202101 (42).

Días 30–60 · corregir los vicios recurrentes

  • Fechas: zona explícita en un helper único por país; barrido de now()/date(); test de la trampa de las 19:00.
  • Errores: Handler que mapea excepciones de dominio a HTTP; barrido de catch vacíos y de 200 con error.
  • Config: env() solo en config/; APP_DEBUG=false y LOG_LEVEL sano en prod, verificados en el pipeline.
  • Análisis estático: phpstan/larastan nivel 1 en CI, subir un nivel por mes; php-cs-fixer o pint con la config del equipo. En el React: eslint con la config existente, cero warnings nuevos.
  • Separar merge de deploy: deploy desde tag o con aprobación manual en Cloud Build; staging obligatorio para los widgets.
  • Segunda ronda: Terraform con defaults privados, state en GCS y sin -auto-approve en prod (29); branch protection real en m-b-core, borrar deploy-force, promoción staging→prod pequeña y frecuente, ruff DTZ/BLE/G/S y mypy por módulo (38–39); GitOps de la VM con --delete, rollback completo y known_hosts fijo (26); transacción + lock en la asignación de mesas y worker real para colas (32); composer audit bloqueante (34); NUMEROS-OFICIALES.md y política de exports de PII en m-incidente (30).

Días 60–90 · pagar deuda estructural

  • Extraer servicios de los controladores gigantes (empezando por los que tocan reservas y pagos); un test por regla extraída.
  • Ruta de actualización: Laravel 5.4/5.5 y PHP 7 están fuera de soporte; plan por repo (5.5 → 6 → 8 → 10 vía Shift o a mano), con la suite de tests como red. React 15 → 18 con el mismo enfoque.
  • Observabilidad: logs JSON con request id, /health con versión, alertas de 5xx.
  • DECISIONES.md por repo, retro mensual de esta guía: qué regla sobra, qué vicio nuevo apareció.

Lo que está bien (y es la vara para el resto)

Reconocerlo importa por dos razones: para que nadie lea esta guía como "todo está mal", y porque son los ejemplos que hay que copiar dentro del propio repo, no de un tutorial.

env() solo en config/

En los cuatro repos PHP: 222 usos en config/ de [home] y prácticamente cero en app/; hasta hay un comentario que explica por qué ("para sobrevivir a config:cache"). Es la práctica correcta y ya es hábito.

IzipayController::verifyKrHash() [pll]

Valida tipos, restringe algoritmo, prueba solo claves de config, hash_equals, falla cerrado, y el docblock explica el porqué. El modelo de "cómo se verifica una firma". El único Log::warning útil de la app está justo ahí.

La serie PLL-* y los PRs fix/ldr-NN

Julio–agosto 2026: CSRF restaurado con justificación, CSPRNG para tokens de sesión, rawurlencode, JSON_HEX_*, cookies secure+SameSite, master token vía Secret Manager, checksums en el Dockerfile; en [libro], Origins.js con lista blanca y tests, interceptor único de token, retry solo en GET, Sentry que redacta PII. PRs chicos, con ticket, con mensajes que explican. Así se trabaja.

58 FormRequests y PasswordHasher [home]

RequestCapacity con reglas por método HTTP; PasswordHasher con hash_equals y rehash a bcrypt; bindings en ChargebackController; 273 ->with() de eager loading. La técnica correcta ya existe en el repo; la guía solo la vuelve obligatoria.

Infra explicada en el propio archivo

cloudbuild/*.yaml y los Dockerfiles documentan decisiones y cicatrices ("NUNCA usar --image sin --container… revisiones 00374-00378"), tuning de OPcache y MPM con el problema que resuelven, TrustProxies con el porqué, nginx que conserva el prefijo con comentario. NOTAS-MIGRACION-LB.md describe síntoma → causa → fix → cómo detectarlo en otro repo.

Multi-tenant acotado [exp] y llamadas concurrentes que degradan [exp]

VerifyHost resuelve el tenant por sufijos configurables (extenderlo a .ec/.co fue tocar config, no lógica); WF-03 hace llamadas concurrentes con un solo cliente Guzzle y Promise\settle, degradando a [] sin romper, con comentario. Falta solo el timeout.

m-b-core como estándar de la casa

CLAUDE.md con 10 guidelines, TESTING.md con árbol de decisión y "mock boundary" (y se cumple: 144 tests patchean en el binding correcto), 4.931 tests con gates por módulo, tests dentro del docker build, ruff+mypy+Sonar en cada PR, CORS que cita el incidente, refresh tokens con jti y revocación, sql_guard + transacción read-only en el workbench, secretos desde Secret Manager en el pipeline, actions por SHA, comentarios que dicen la verdad ("MD5 dictated by legacy contract"). Que lo bueno del core sea la vara.

Los repos de operación, cuando aciertan

Escritura idempotente de bans con Create (nunca sobreescriben un ban humano); umbrales del detector calibrados con tráfico real y explicados constante por constante (config.go es casi un ADR); tests antes de build en los tres Go; imágenes distroless nonroot; el enforcer que documenta el canario; l-network-apply con configtest + backup; webs-vhosts como idea (datos + plantilla → build versionado con validación, 0 drift verificado). Falta cumplirlo, no diseñarlo.

El informe del incidente y el SQL de reset

INFORME-INCIDENTE-mesa247.md etiqueta cada afirmación [MEDIDO]/[SUPUESTO], tiene "si solo se hacen 3 cosas", timeline, IOCs, negativos medidos y huecos abiertos: la vara de honestidad para cualquier informe técnico. RESET-MASIVO.sql (preflight, backup pinneado, lotes por PK, rollback) y run-sql.py (--dry-run, --yes para escrituras, enmascarado forzoso) traen el patrón correcto listo para generalizar.

Recursos, pocos y elegidos