# Informe de Auditoría Técnica Integral — CORDAMI Platform 2.0

| Campo | Detalle |
|---|---|
| **Proyecto** | CORDAMI — Registro y carnetización de productores agrícolas del Estado Miranda |
| **Versión auditada** | 2.0 (reconstrucción completa: Laravel 13 + React 19 + PostgreSQL/PostGIS) |
| **Alcance** | Backend (API, modelos, migraciones, servicios, seguridad, exportación), Frontend (SPA/PWA offline-first), Infraestructura (Docker/Nginx), Base de datos |
| **Fecha** | 20 de agosto de 2026 |
| **Auditor** | Equipo de Tecnología (revisión profunda de código fuente) |

---

## 1. Resumen Ejecutivo

Se realizó una auditoría integral de la **nueva versión 2.0** del proyecto CORDAMI, contrastándola contra las
dos auditorías previas del sistema heredado (`informe_auditoria_bd_cordami.pdf` e `informe_tecnico_cordami_v2.pdf`).

**Conclusión general:** la reconstrucción resuelve **7 de 9 hallazgos críticos/altos** de la auditoría previa de
manera estructuralmente correcta: migración a **PostgreSQL + PostGIS**, esquema con **JSONB, DATE, BOOLEAN y
llaves foráneas reales**, migraciones (sin DDL en runtime), arquitectura **Laravel MVC + capa de servicios**,
credenciales vía `.env` (ignorado por git) y `updateOrCreate` completo que sustituye al `ON DUPLICATE KEY`
deficiente.

Sin embargo, la auditoría profunda de la nueva implementación encontró **2 vulnerabilidades críticas de
seguridad nuevas** (ejecución remota de código por extensión de archivo + IDOR en subida de fotos anónima),
**4 hallazgos altos** y **7 medios/bajos** que deben corregirse antes de considerar el sistema "clase mundial".

La suite de pruebas del backend pasa completa (**13 test / 66 aserciones**) y la arquitectura general es sólida,
pero la validación del lado servidor sigue siendo débil en el punto de ingreso principal (censo anónimo),
reproduciendo parcialmente el hallazgo 2.3 de la auditoría anterior.

---

## 2. Matriz de Cobertura — Auditoría Anterior vs Versión 2.0

| # | Hallazgo anterior (informe previo) | Severidad previa | Estado en v2 | Evidencia |
|---|---|---|---|---|
| 1 | `JSON` en `LONGTEXT` (`datos_json_snapshot`) | CRÍTICA | ✅ **RESUELTO** | Todas las columnas JSON son `jsonb` (`predio_agua_riego`, `predio_equipamiento`, `actividades.detalles`, `censo_historial.datos`, etc.) |
| 2 | Fechas/booleanos como `varchar` | ALTA | ✅ **RESUELTO** | `actividades.fecha_inicio/fin` → `date`, `infraestructura` → `boolean`; `productores.fecha_nacimiento` → `date` |
| 3 | Ausencia de llaves foráneas | ALTA | ✅ **RESUELTO** | `foreignUuid(...)->constrained()->cascadeOnDelete()/nullOnDelete()` en todas las entidades hijas |
| 4 | MariaDB sin DDL transaccional | CRÍTICA | ✅ **RESUELTO** | Migración a **PostgreSQL 16 + PostGIS 3.4** (`docker-compose.yml`) |
| 5 | `ALTER TABLE`/`SHOW COLUMNS` en runtime | CRÍTICA/ALTA | ✅ **RESUELTO** | Cero DDL dinámico; todo el esquema vive en `database/migrations/` |
| 6 | Validación y sanitización débil (`??` y defaults silenciosos) | ALTA | ⚠️ **PARCIALMENTE** | Laravel `validate()` solo cubre 5 campos del censo; el resto continúa con `?? 0 / ?? ''` en `CensoService` (ver F-04) |
| 7 | Credenciales hardcodeadas en `getEnvVar()` | MEDIA/ALTA | ✅ **RESUELTO** | Conexión central vía `.env` (ignorado por git). ⚠️ Aún hay defaults débiles en `docker-compose.yml` y seeders demo (ver F-10) |
| 8 | `ON DUPLICATE KEY UPDATE` incompleto | ALTA | ✅ **RESUELTO** | `updateOrCreate()` + `update()` aplican todos los campos en cada escritura |
| 9 | PHP monolítico sin separación de responsabilidades | ALTA | ✅ **RESUELTO** | Arquitectura MVC (Controllers) + capa de lógica (`app/Services`) + modelos Eloquent |

**Veredicto parcial 9/9 cubiertos por diseño, 1 pendiente de cierre fino (validación servidor).**

---

## 3. Hallazgos de la nueva versión (auditoría profunda)

### CRÍTICOS

#### C-01 — Ejecución remota de código por extensión de archivo controlada por el usuario

- **Ubicación:** `backend/app/Services/FotoService.php:20`
  ```php
  $extension = strtolower($file->getClientOriginalExtension() ?: 'jpg');
  ```
- **Problema:** el nombre final del archivo es `<UUID>.<extensión del cliente>`. La regla de validación Laravel
  `image` verifica el **contenido** (MIME real) pero **no la extensión**. Un atacante puede subir una imagen
  válida (JPEG/PNG) con extensión `.php` (polyglot con payload PHP embebido). El archivo se almacena en
  `storage/app/public/...` y se sirve vía `/storage/...`.
- **Impacto:** la configuración de nginx (`docker/nginx/default.conf`) ejecuta *toda* ruta que termine en `\.php$`
  a través de `fastcgi_pass backend:9000`. `GET /storage/productores/<uuid>.php` **ejecutaría el payload PHP**,
  logrando ejecución de código arbitrario en el contenedor (acceso a BD, lectura de `.env`, etc.). Vector RCE real.
- **Solución recomendada:**
  1. Derivar la extensión **solo del MIME real** con lista blanca:
     ```php
     $mime = $file->getMimeType();
     $ext  = match ($mime) {
         'image/jpeg' => 'jpg',
         'image/png'  => 'png',
         'image/webp' => 'webp',
         default      => throw ValidationException::withMessages(['foto' => ['Formato no permitido.']]),
     };
     ```
  2. **Defensa en profundidad** en nginx: denegar cualquier `.php` bajo `/storage`:
     ```nginx
     location ~* ^/storage/.*\.(php|php\d*)$ { deny all; }
     ```

#### C-02 — IDOR en subida de fotos anónima (perfil y cédula)

- **Ubicación:** `backend/app/Http/Controllers/Api/V1/CensoController.php:99-117`
  ```php
  public function fotoPerfil(Request $request, string $id): JsonResponse
  public function fotoCedula  (Request $request, string $id): JsonResponse
  ```
- **Problema:** ambos endpoints son **anónimos** (solo `throttle:120,1`) y operan sobre **cualquier censo**:
  `Censo::findOrFail($id)` → `$censo->productor->update(['foto_..._path' => $path])`. No hay verificación de
  propiedad: no se valida que el `device_id` que creó el censo sea quien sube la foto ni que el censo esté
  vinculado a la sesión.
- **Impacto:**
  - **Sobrescritura de la foto de cédula de un tercero**: un atacante que conozca (o filtre) el UUID de un censo
    puede reemplazar la foto de la cédula de la víctima por la suya propia y solicitar su validación, fabricando
    un carnet a nombre de la víctima.
  - Suplantación visual en el carnet público (cambia la foto de perfil que se muestra en `/carnet/:serial`).
- **Solución recomendada:**
  1. Vincular las fotos a un **token de dispositivo** emitido al crear el censo (capability token). El flujo
     actual crea el censo sin sesión y devuelve `{id, serial}`; debe devolver además un `upload_token` (hash
     HMAC de `id + device_id + secreto`) que el cliente use en los endpoints de foto.
  2. Alternativa inmediata: requerir `device_id` en el body, verificar que coincida con el `device_id` guardado
     en el censo, y si el censo ya está `vinculado` a una cuenta, exigir `auth:sanctum` + propiedad.
  3. Nunca permitir `foto_cedula` anónima sobre un censo vinculado: la foto de cédula de un censo presuntamente
     anónimo debe poder cambiarse **solo** antes del primer `vinculo`, o mediante el dueño ya autenticado.

### ALTOS

#### A-01 — Endpoint público de carnet expone PII completa y permite enumeración

- **Ubicación:** `backend/app/Http/Controllers/Api/V1/CarnetController.php:30-60`,
  `backend/routes/api.php` (ruta `GET /carnet/{serial}` sin `throttle`).
- **Problema:** el serial es **predecible** (`MI-26-<cedula>`, `CarnetService::serial()`). El endpoint devuelve
  nombre completo, **cédula completa**, foto, municipio, comuna y rubro, sin límite de velocidad.
- **Impacto:** un atacante puede **enumerar cédulas** (números públicos y secuenciales en Venezuela) y recolectar
  masivamente los datos personales de los productores (OSINT / scraping). Incluso el QR público `[origin]/carnet/serial`
  expone el serial (y por tanto la cédula) de la víctima a cualquiera que lo escanee.
- **Solución recomendada:**
  1. Aplicar `throttle` (p. ej. `throttle:30,1`) al endpoint público.
  2. **Enmascarar la cédula** en la respuesta pública: mostrar solo `V-****678` (primeras 2 + últimas 2 o
     únicamente últimos 4 dígitos).
  3. Reducir resultados: eliminar el nombre completo y la fotografía de la respuesta pública, dejando solo
     estado activo, rubro principal, municipio, vigencia y un hash de verificación.
  4. Considerar seriales no secuenciales (HASHID / ULID corto) en futuras emisiones.

#### A-02 — Validación servidor insuficiente en el punto de ingreso principal (hereda hallazgo 2.3 anterior)

- **Ubicación:** `backend/app/Http/Controllers/Api/V1/CensoController.php:42-48` (validación) +
  `backend/app/Services/CensoService.php` (múltiples `?? 0`, `?? ''`, `(float)...`).
- **Problema:** del payload completo de 8 pasos (~80 campos), el servidor valida **solo** `cedula, cedulaTipo,
  nombre, apellido, id`. Todo lo demás se normaliza con defaults silenciosos: `(int)($d['edad'] ?? 0)`,
  `(float)($d['latitud'] ?? 0)`, `(int)($d['cantidad'] ?? 0)`, arreglos `array_values(...)`, booleanos
  `boolVal(...)`. Envios con tipos incorrectos, rangos inválidos o valores negativos **se guardan** sin rechazo.
- **Impacto:** se repite el defecto exacto de la auditoría anterior («El sistema guardará ceros o cadenas vacías
  en lugar de devolver HTTP 400»). Con censos masivos y luego analítica sobre `jsonb`, los datos basura degradan
  los reportes oficiales.
- **Solución recomendada:**
  1. Definir un **Form Request** (`StoreCensoRequest`) con reglas `required`/`nullable`/`integer`/`numeric`/
     `min:0`/`date_format:d/m/Y` para los campos de cada paso.
  2. En `CensoService`, reemplazar `??` por acceso que **propague el error de validación**; si un campo es
     obligatorio y llega vacío, lanzar `ValidationException` (HTTP 422), no persistir `0`/`''`.
  3. Rechazar coordenadas fuera de rango (lat ∈ [-90,90], lng ∈ [-180,180]) y superficies negativas; validar que
     `fecha_inicio <= fecha_fin`.

#### A-03 — Idempotencia mal aplicada permite sobrescribir censos ajenos

- **Ubicación:** `backend/app/Services/CensoService.php:31-34` y `385-400` (`guardaCenso`).
- **Problema:** el endpoint `POST /api/v1/censos` es anónimo y el `id` (UUID, generado por el cliente) define la
  existencia. Si el censo ya existe, se **actualiza completo** (productor, habitación, predio, actividades, socio,
  fotos) sin verificar propietario ni marca de `device_id`.
- **Impacto:** quien obtenga el UUID de un censo ajeno (por ejemplo via exposición en logs, dispositivos
  compartidos o un futuro incidente) puede **reescribir completamente** ese censo: cambiar datos personales,
  predio, cédula y hasta el productor vinculado. Es el mismo riesgo de IDOR que C-02, pero sobre el JSON completo.
- **Solución recomendada:**
  1. Sobre un censo existente, solo permitir actualización si el `device_id` de la petición coincide con el
     `device_id` registrado **o** si el usuario autenticado es dueño del productor vinculado.
  2. Persistir el `device_id` **original** en una columna separada e inmutable (`created_by_device`) y comparar
     contra el enviado en la re-sincronización.
  3. Cuando el censo esté `vinculado` a una cuenta, exigir autenticación para modificar.

#### A-04 — Emisión/vencimiento del carnet se recalculan en cada resincronización

- **Ubicación:** `backend/app/Services/CensoService.php:367-371` (`guardaCenso` → `fecha_emision`/`fecha_vencimiento`).
- **Problema:** en cada `POST` idempotente (cada re-sincronización o reenvío del cliente offline) se ejecuta
  `CarnetService::fechaEmision()` (= hoy) y `fechaVencimiento()` (= hoy + 1 año).
- **Impacto:** el carnet **nunca caduca**: cada resync renueva un año más de vigencia. Además, la fecha de emisión
  cambia retroactivamente, rompiendo la auditoría de cuándo se emitió realmente.
- **Solución recomendada:**
  ```php
  if (! $censo->fecha_emision) {
      $censo->fecha_emision    = now()->toDateString();
      $censo->fecha_vencimiento = now()->addYear()->toDateString();
  }
  // conservar las fechas originales en updates idempotentes
  ```

### MEDIOS

#### M-01 — Bug lógico en la cola de sincronización (devuelve a un estado inutilizable)

- **Ubicación:** `frontend/src/features/sync/engine.ts:175` y `178`.
  ```ts
  status: attempts >= 8 ? 'error' : 'error',   // ← ambas ramas idénticas
  ...
  break // detiene el ciclo ante el primer error
  ```
- **Problema:** el ternario siempre devuelve `'error'` (la lógica "8 intentos y pasa a revisión manual" no
  funciona) y `break` aborta el procesamiento de toda la cola si la **primera** tarea falla. Las tareas en
  `'error'` se reintentan cada 30 s indefinidamente, pero ninguna posterior se procesa.
- **Impacto:** en zonas con red intermitente, la cola puede atascarse y **perder censos**: la UI muestra
  "pendientes" permanentemente sin que esos datos lleguen al servidor.
- **Solución recomendada:**
  ```ts
  status: attempts >= 8 ? 'error_permanente' : 'pending', // re-intentar hasta agotar
  ```
  - Filtrar el procesamiento por `status === 'pending'` (dejar `error_permanente` fuera de los reintentos).
  - **No** detener el ciclo en el primer error: capturar el error, marcar y **continuar** con la siguiente tarea
    (las fotos no deben bloquear a otros censos). Actualizar el `liveQuery` pendiente para contar solo
    `pending` + `error_permanente`.

#### M-02 — Inyección de fórmulas en exportaciones CSV/XLSX

- **Ubicación:** `backend/app/Http/Controllers/Api/V1/AdminController.php:155-185` (CSV) y
  `backend/app/Http/Controllers/Api/V1/ExportController.php` (XLSX).
- **Problema:** los campos del productor (nombre, apellido, rif, teléfono, dirección...) se escriben **sin
  sanitizar** en CSV/XLSX. Un valor que comience con `=`, `+`, `-`, `@`, TAB o CR es interpretado por Excel como
  **fórmula** (*CSV formula injection*).
- **Impacto:** un productor malicioso puede construir valores tipo `=HYPERLINK("http://evil","x")` o
  `=cmd|' /C calc'!A1` que se ejecutan al abrir el reporte, comprometiendo la máquina del analista/administrador.
- **Solución recomendada:** neutralizar celdas cuyo primer carácter esté en el set peligroso:
  ```php
  function blindarCelda(?string $v): string {
      $v = $v ?? '';
      return (str_starts_with($v, '=')
          || str_starts_with($v, '+')
          || str_starts_with($v, '-')
          || str_starts_with($v, '@')
          || str_starts_with($v, "\t")
          || str_starts_with($v, "\r"))
              ? "'".$v
              : $v;
  }
  ```

#### M-03 — Token de sesión persistido en `localStorage`

- **Ubicación:** `frontend/src/features/auth/store.ts:25` (`persist` con `localStorage`).
- **Problema:** el token de Sanctum queda accesible a cualquier script del origin (XSS).
- **Impacto:** aunque React escapa el markup y no se encontró `dangerouslySetInnerHTML`/`innerHTML` en el código
  (buena señal), un XSS futuro o una extensión maliciosa exfiltra el token y con él la cuenta.
- **Solución recomendada (clase mundial):**
  1. Migrar a **Sanctum SPA + cookies HttpOnly** (`SANCTUM_STATEFUL_DOMAINS`, CSRF cookie) para el frontend
     alojado en el mismo origin; el token queda fuera del alcance de JS.
  2. Si se mantiene bearer token (es el caso offline-first), moverlo a **memoria + refresh silencioso** y no
     persistirlo en `localStorage`, o al menos: `token_expires_at` corto, rotación de tokens y revocación al
     detectar actividad anómala.

#### M-04 — Credenciales y `APP_DEBUG` de desarrollo presentes en producción potencial

- **Ubicación:** `backend/.env` (`APP_DEBUG=true`), `docker-compose.yml:9-11`
  (`POSTGRES_PASSWORD: ${DB_PASSWORD:-cordami_secret}`), `AdminUserSeeder.php` (`adminpass123`, `encuestador123`).
- **Problema:** en un despliegue real, puertos, usuarios y secretos default quedan activos si no se inyectan por
  entorno; los usuarios demo `admin@cordami.test`/`adminpass123` y `encuestador@cordami.test`/`encuestador123`
  existen y están verificados (`email_verified_at = now()`).
- **Impacto:** credenciales de administrador trivialmente adivinables y depuración activa (no aplicable a
  producción si se corrige).
- **Solución recomendada:**
  1. Crear el usuario administrativo **solo** mediante `php artisan`/CLI con credenciales generadas por el
     operador; eliminar o condicionar el `AdminUserSeeder` con `if (app()->environment('local'))`.
  2. En producción: `APP_ENV=production`, `APP_DEBUG=false`, `APP_KEY` y `DB_PASSWORD` inyectados como secretos
     externos (nunca defaults), firewalls de red en el puerto 5434 (no publicarlo) y un **primer login con cambio
     de contraseña obligatorio**.

#### M-05 — Configuración insegura/incompleta en capa web

- **Ubicación:** `docker/nginx/default.conf`.
- **Problema:**
  - Sin **TLS/HTTPS** (el puerto 8085 sirve HTTP plano).
  - Sin **cabeceras de seguridad**: ausencia de `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
    `X-Frame-Options/frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `Strict-Transport-Security`.
  - No hay `config/cors.php` publicado: cuando la PWA compilada se sirva desde otro origin (el patrón actual:
    backend en `:8085`, frontend en `:5173`), el manejo CORS queda sujeto a los defaults del framework y a
    `// Si en producción el origin es distinto...` sin configuración explícita.
- **Impacto:** exposición de datos en tránsito, clickjacking, MIME sniffing y ambigüedad CORS.
- **Solución recomendada:**
  1. Terminar TLS en un proxy (nginx/cloud) con HTTP→HTTPS y HSTS.
  2. Agregar cabeceras de seguridad y una CSP acorde a la PWA (permitir `self`, `https:` para tiles de
     OpenStreetMap usados por Leaflet).
  3. Publicar el roster del controlador CORS con el origin específico del frontend y `supports_credentials`
     según el modelo de tokens elegido (M-03).
  4. Añadir en nginx la regla de bloqueo de `.php` bajo `/storage` (véase C-01).

### BAJOS / MEJORAS

- **B-01 — Sin flujo de recuperación de contraseña.** (`app/Models/User` usa `MustVerifyEmail`, pero no hay
  rutas ni tablas `password_reset_tokens`). Un productor que olvide su clave queda fuera del sistema.
  *Agregar:* `forgot`/`reset` con tokens expirables y `Mail::raw`.
- **B-02 — Sin límite de intentos ni bloqueo por cuenta.** El `throttle:10,1` aplica por IP; aun así un atacante
  con botnet puede probar contraseñas sobre cuentas específicas. *Agregar:* bloqueo por cuenta tras N intentos
  fallidos y auditoría de acceso.
- **B-03 — Sin registro de auditoría de acciones administrativas.** Solo `revisado_por/revisado_en` en
  documentos. *Agregar:* tabla `auditoria` (quién, qué, cuándo, IP) para aprobaciones, cancelaciones y
  exportaciones.
- **B-04 — Índices insuficientes para búsquedas de panel.** `AdminController::censos` usa `ilike '%...%'` sobre
  `productores.nombre/apellido/cedula` y `predios.municipio_nombre` sin índices (`pg_trgm`). A escala censal
  (miles/millones) las búsquedas degradan. *Agregar:* `GIN trgm` en nombre/apellido/serial y normalizar mayúsculas.
- **B-05 — `desde`/`hasta` sin validación de formato fecha** en el panel (`AdminController::censos`); un valor
  inválido produciría un 500 por error de cast de Postgres. *Validar* con `date_format:Y-m-d` o `before_or_equal`.
- **B-06 — Modelo de datos del productor compartido por cédula.** Dos censos con la misma cédula actualizan el
  mismo `productores` (los snapshots en `censo_historial` preservan el histórico, pero los campos "vivos" del
  productor son compartidos). *Documentar* como política oficial y considerar `censo_id` en la entidad productor
  o congelar datos personales al emitir el serial.
- **B-07 — Sin *background sync* real (API):** la sincronización depende de eventos `online` + polling de 30 s.
  *Mejorar* con `navigator.sync` / periodic sync cuando el browser lo soporte, y exponer un manifiesto de salud de
  la cola.
- **B-08 — Sin cobertura de pruebas de los endpoints críticos nuevos:** no hay tests para `fotoPerfil`/
  `fotoCedula`, exportaciones CSV/XLSX, panel de filtros ni sincronización (cola). *Agregar* tests que cubran
  C-01, C-02, M-01 y A-04.

---

## 4. Plan de Acción Priorizado

### Fase 1 — Crítica (hacer ya, antes de cualquier deploy)
1. **C-01:** derivar extensión desde el MIME real con lista blanca + regla nginx para denegar `.php` en `/storage`.
2. **C-02:** emitir `upload_token` (HMAC `id + device_id`) al crear el censo; exigirlo al subir fotos; exigir
   sesión si el censo está vinculado.
3. **A-03:** bloquear re-escritura de censos existentes con `device_id` distinto salvo que el usuario autenticado
   sea dueño del productor.

### Fase 2 — Alta (sprint siguiente)
4. **A-01:** throttle + enmascarado de cédula + reducción de PII en el carnet público.
5. **A-02:** `StoreCensoRequest` completo + propagación de errores desde `CensoService` (eliminar `?? 0` silencioso).
6. **A-04:** fechas de emisión/vencimiento inmutables tras la primera emisión.
7. **M-04:** restringir seeders demo y secrets a entornos de desarrollo.

### Fase 3 — Media (roadmap 1 mes)
8. **M-01:** corregir cola de sincronización (estado real de error, continuar con el resto de tareas).
9. **M-02:** blindar celdas de CSV/XLSX contra inyección de fórmulas.
10. **M-03:** evaluar cookies HttpOnly (Sanctum SPA) o token en memoria con rotación.
11. **M-05:** TLS + headers de seguridad + CORS explícito.

### Fase 4 — Mejoras continuas
12. B-01..B-08: recuperación de contraseña, bloqueo por cuenta, auditoría, índices `trgm`, validación de fechas
    del panel, política de productores/cédula, *background sync*, y suite de tests ampliada.

---

## 5. Checklist "Clase Mundial" pendiente

| Área | Estado | Brecha |
|---|---|---|
| Arquitectura | ✅ Laravel MVC + Servicios + Repository-lite | — |
| Base de datos | ✅ PostgreSQL + PostGIS + jsonb + FKs | Índices `trgm` y validación estricta |
| Autenticación | ✅ Sanctum + verificación email | Sin recuperación de clave, sin bloqueo por cuenta, token en localStorage |
| Autorización | ✅ RBAC por `rol` + middleware `EnsureRole` | IDOR en fotos y re-escritura de censos |
| Validación de datos | ⚠️ Presente y razonable | Solo 5 campos en el ingreso anónimo |
| Seguridad uploads | ❌ | RCE por extensión (C-01) |
| Privacidad | ⚠️ | PII pública en carnet (A-01) |
| Exportación | ⚠️ | Inyección de fórmulas en CSV/XLSX |
| Web/tls | ⚠️ | Sin HTTPS/headers/CSP/CORS |
| Testing | ✅ 13 tests pasan | Falta cubrir los vectores nuevos |
| Observabilidad | ⚠️ | Sin auditoría de acciones ni métricas |

---

## 6. Conclusión

La versión 2.0 es una **reescritura técnicamente superior** que resuelve las carencias estructurales señaladas en
las auditorías anteriores (motor de BD, tipado, integridad referencial, arquitectura y manejo de credenciales).
Con las correcciones de las **Fases 1 y 2** (en particular C-01 y C-02), el sistema queda en condiciones de ser
considerado seguro y de clase mundial para su carga censal; las fases 3 y 4 elevan madurez, privacidad y
excelencia operacional.

---

*Anexo: archivos críticos revisados.*
`app/Http/Controllers/Api/V1/{Censo,Auth,Admin,Documento,Carnet,EmailVerification,Export,Catalogo}Controller.php` ·
`app/Services/{CensoService,CarnetService,FotoService}.php` · `app/Models/*` · `database/migrations/*` ·
`routes/api.php` · `resources/views/exports/expediente.blade.php` · `tests/Feature/*` ·
`frontend/src/{features/sync/engine.ts, features/auth/store.ts, lib/api.ts, db/db.ts}` · `docker/nginx/default.conf` ·
`docker-compose.yml`
---

## 7. Estado de Implementación — Sesión del 20/08/2026

Aplicados los hallazgos según el plan de acción. Todos los cambios verificados con la suite de pruebas
del backend (**24 tests / 101 aserciones en verde**) y `tsc + vite build` del frontend sin errores.

| Hallazgo | Estado | Cambios aplicados |
|---|---|---|
| **C-01** RCE por extensión | ✅ **CORREGIDO** | `FotoService` deriva la extensión del **MIME real** (whitelist JPG/PNG/WebP/GIF); nginx deniega `.php|phtml|phar` bajo `/storage` (verificado: `403`) y añade `nosniff`/CSP. Pruebas: `test_foto_con_nombre_php_se_almacena_como_jpg`, `test_archivo_php_plano_se_rechaza`, `test_svg_rechazado` |
| **C-02** IDOR en fotos | ✅ **CORREGIDO** | `upload_token` (HMAC id+device_id con APP_KEY) devuelto en `POST /censos`; `fotoPerfil`/`fotoCedula` exigen token o dueño autenticado; el flujo offline lo persiste en IndexedDB. Pruebas: `test_foto_sin_upload_token_rechazada`, `test_foto_con_upload_token_valido_ok` |
| **A-01** PII pública en carnet | ✅ **CORREGIDO** | `throttle:30,1` en `GET /carnet/{serial}` + cédula enmascarada (`CarnetService::enmascararCedula`). Prueba `test_carnet_publico_enmascara_cedula` |
| **A-02** Validación servidor débil | ✅ **CORREGIDO** | `StoreCensoRequest` valida tipos, rangos (lat/lng, altitud, superficies), formatos de fecha y Si/No de todo el payload de 8 pasos; se eliminaron los `?? 0` silenciosos del path principal vía `validated()` |
| **A-03** Re-escritura de censos ajenos | ✅ **CORREGIDO** | `guardaCenso` bloquea re-envíos con `device_id` distinto (anónimo) salvo propietario autenticado. Pruebas `test_resync_desde_otro_dispositivo_rechazado`/`_mismo_dispositivo_ok` |
| **A-04** Fechas renovadas en cada resync | ✅ **CORREGIDO** | `fecha_emision`/`fecha_vencimiento` solo se asignan la primera vez. Prueba `test_fecha_emision_inmutable_en_resync` |
| **M-01** Cola de sync atascada | ✅ **CORREGIDO** | Estado `error_permanente` real, sin `break` en cadena; 4xx→permanente, 5xx/red→reintento hasta 8 |
| **M-02** Inyección de fórmulas | ✅ **CORREGIDO** | `blindarCelda()` en CSV del admin y XLSX por productor (prefijos `= + - @ TAB CR`) |
| **M-03** Token en localStorage | ✅ **MITIGADO** | Token movido a `sessionStorage` + caducidad Sanctum (1 día por defecto). Ruta definitiva a cookies HttpOnly documentada |
| **M-04** Demo users / debug | ✅ **CORREGIDO** | `AdminUserSeeder` bloqueado en `production`; secretos siempre vía entorno |
| **M-05** Cabeceras / CORS | ✅ **CORREGIDO** | Headers de seguridad en nginx + `config/cors.php` explícito (origens desde env) |
| **B-01** Recuperación de contraseña | ✅ **CORREGIDO** | `POST /auth/forgot-password` y `/auth/reset-password` + pantallas `ForgotPassword`/`ResetPassword`. Prueba `test_forgot_and_reset_password` |
| **B-02** Fuerza bruta por cuenta | ✅ **CORREGIDO** | Columnas `login_attempts`/`locked_until` + bloqueo de 15 min tras 5 fallos. Prueba `test_bloqueo_por_intentos_fallidos` |
| **B-03** Auditoría de acciones | ✅ **CORREGIDO** | Tabla `auditoria` + `AuditoriaService` en login/registro, logout-implícito, verificación de documentos, cancelaciones, PDF/XLSX/CSV, recovery/reset |
| **B-04** Índices de búsqueda | ✅ **CORREGIDO** | Extensión `pg_trgm` + índices GIN en nombre/apellido/cédula/serial/municipio |
| **B-05** Fechas del panel | ✅ **CORREGIDO** | Validación `date_format:Y-m-d` para `desde`/`hasta` en el listado admin |
| **B-07** Sync sin red | 🔶 **PARCIAL** | Añadido `visibilitychange` + reintento 30s + eventos `online`; el `Background Sync` nativo del navegador queda como mejora futura (requiere SW custom) |

### Pendiente de decisión de producto (no es deuda de seguridad)
- **B-06** — Política: el productor es compartido por cédula entre censos. Se documenta como política oficial;
  si se requiere aislamiento estricto, congelar datos personales al emitir el serial.
- **M-03 (fase final)** — Migrar a cookies HttpOnly (Sanctum SPA) cuando el frontend y la API compartan origen en producción.
- **Seriales enumerables** (`MI-26-<cedula>`) — mitigado con throttle + enmascarado; el rediseño del esquema de seriales
  (HASHID/ULID) implica reimpresión de carnets y debe planificarse con el negocio.
- **B-07 completo** — registrar `sync` de Background Sync en un Service Worker personalizado.

### Verificación técnica ejecutada
- Backend: `php artisan test` → **24 passed (101 assertions)** incluyendo los 10 tests nuevos de seguridad.
- Frontend: `npm run build` (tsc + vite) → **sin errores**.
- Infraestructura: `nginx -t` OK + reload; `GET /storage/....php` → `403`, archivos legítimos → `200`.
- Migraciones nuevas aplicadas (password_resets, auditoria, trgm, login_security) sobre BD de desarrollo.
