Auditoría QA
Auditoría QA — SECOP Analize
| Campo | Valor |
|---|---|
| Fecha | 2026-05-11 |
| Auditor | QA + Security Audit (asistido por IA) |
| Versión auditada | branch principal, commit local actual |
| Entorno | Local desarrollo (bin/start.sh) |
| Módulo | Stack completo: Sinatra web + servicio Node + pipeline IA + import diario |
| Roles probados | N/A — herramienta single-user local sin auth |
| Severidad de hallazgos | 4 🔴 graves · 9 🟡 medianos · 7 🟢 leves |
1. Resumen ejecutivo
SECOP Analize es una aplicación local single-user para investigación académica sobre procesos de contratación pública. Funcionalmente cumple su propósito (búsqueda + análisis IA on-demand) pero la implementación tiene fallas operativas y de seguridad que serían bloqueantes si se expusiera a múltiples usuarios o a internet.
El stack NO debe exponerse fuera de 127.0.0.1 sin antes resolver los hallazgos graves listados abajo.
Los hallazgos críticos son:
- G1: Búsqueda libre
q=…provoca full scan de 3M filas → 30-900s de bloqueo por request. - G2: CSRF habilitada en endpoints que disparan procesos costosos (Process.spawn + jobs Node).
- G3: Sin tests automatizados — cada despliegue es manual y arriesgado.
- G4: Puma en single-mode entra en estado degradado tras requests largos consecutivos.
2. Reconocimiento
Stack auditado
| Componente | Archivo | Líneas | Notas |
|---|---|---|---|
| Sinatra web | lib/secop/web.rb | 437 | 7 rutas públicas |
| Vistas ERB | lib/secop/views/*.erb | 4 archivos, ~1200 líneas | escape_html: true + erubi |
| Modelos DB | lib/secop/db.rb, proceso*.rb | ~150 | Sequel ORM, SQLite WAL |
| Servicio Node | bin/anexos-service.mjs | 626 | HTTP server interno :5678 |
| Pipeline batch | bin/anexos.mjs | ~580 | CLI alternativo |
| Import SODA | bin/import-api.mjs | ~280 | Incremental con watermark |
| Auth captcha | bin/secop2-login.mjs | ~75 | Headed Playwright |
| Cron | config/com.secop.import-daily.plist | — | launchd 3 AM |
Rutas Sinatra
GET / → dashboard, lista paginada
GET /proceso/:numero_constancia → detalle + reporte IA
POST /proceso/:numero_constancia/procesar → dispara job IA
GET /proceso/:numero_constancia/status → poll JSON del job
POST /auth/refresh → abre Chrome para captcha
POST /auth/reload → recarga sesión en servicio
GET /auth/status → status JSON
Gems críticas (versions)
| Gema | Versión | Comentario |
|---|---|---|
| sinatra | 4.2.1 | OK |
| rack | 3.x | OK |
| rack-protection | 4.2.1 | activa por default — bueno |
| sequel | 5.103.0 | OK, sin CVEs conocidos |
| erubi | 1.13.1 | OK |
| sqlite3 (gem) | última | ⚠ compilada con case_sensitive_like activo (afecta LIKE) |
Patrones de riesgo (análisis estático)
- ✅ Cero ocurrencias de
eval,find_by_sqlcon interpolación,system()con input del usuario, mass assignment sin filtrado. - ✅
Process.spawnse usa con array form (sin shell). Args son constantes (NODE_BIN,PROJECT_DIR). Sin inyección. - ✅ Solo 2 usos de
<%==(raw output) endetail.erb— ambos eninline_md()yrender_ia_report()que escapan viaCGI.escapeHTMLpreviamente. - ⚠ Sequel.ilike con interpolación
"%#{params['q']}%"— usa LIKE parametrizado vía?, es seguro (no es injection), pero el%del usuario puede ser interpretado como wildcard SQL.
3. Matriz de pruebas
3.1 Dashboard / búsqueda (GET /)
| # | Caso | URL | HTTP | Tiempo | Resultado | Severidad |
|---|---|---|---|---|---|---|
| 1 | Home cold | / | 200 | 380ms | OK, 50 procesos | ✅ |
| 2 | Home warm | / | 200 | 17ms | OK | ✅ |
| 3 | Filtro Solo IA | /?con_resumen=1 | 200 | 16ms | 12 resultados | ✅ |
| 4 | Filtro adjudicados | /?adjudicado=1 | 200 | 3.4 s | OK pero lento | 🟡 |
| 5 | SECOP I | /?secop=I | 200 | 20ms | OK | ✅ |
| 6 | SECOP II | /?secop=II | 200 | 102ms | OK | ✅ |
| 7 | secop inválido | /?secop=INVALID | 200 | 3ms | Lista vacía (no rechaza) | 🟡 |
| 8 | tipo_proceso inexistente | /?tipo_proceso=NOEXISTE | 200 | 5ms | Lista vacía | 🟢 |
| 9 | page enorme | /?page=999999999 | 200 | 1.6s | OFFSET masivo → lento | 🟡 |
| 10 | page negativa | /?page=-1 | 200 | 17ms | Default page=1 (silencio) | 🟢 |
| 11 | page no numérica | /?page=abc | 200 | 17ms | Default page=1 | 🟢 |
| 12 | sort campo inválido | /?sort=hacker_field&dir=asc | 200 | 18ms | Fallback a default sort | 🟢 |
| 13 | XSS reflejado | /?q=<script>... | 200 | 905 s | Texto escapado ✅ pero query MUY lenta | 🔴 G1 |
| 14 | SQL injection literal | /?q=' OR 1=1-- | 200 | ~30s | Sequel parametriza ✓ pero LIKE lento | 🔴 G1 |
| 15 | cuantia_min no numérica | /?cuantia_min=abc | (timeout) | >10s | 'abc'.to_f = 0.0, query full scan | 🟡 |
| 16 | cuantia inviable | /?cuantia_max=999999999999999 | (timeout) | >10s | Sin bounds check | 🟡 |
| 17 | Fecha inválida | /?fecha_desde=invalid-date | (timeout) | >10s | Sequel acepta string, LIKE corre lento | 🟡 |
| 18 | Fecha imposible | /?fecha_desde=2026-13-99 | (timeout) | >10s | Idem | 🟡 |
3.2 Detalle proceso (GET /proceso/:id)
| # | Caso | URL | HTTP | Tiempo | Resultado | Severidad |
|---|---|---|---|---|---|---|
| 19 | ID existente con análisis | /proceso/CO1.REQ.10411342 | 200 | 38ms | Renderiza reporte estructurado | ✅ |
| 20 | ID existente sin análisis | /proceso/CO1.REQ.10336177 | 200 | ~50ms | Muestra botón "Procesar" | ✅ |
| 21 | ID inexistente | /proceso/NO_EXISTE | 404 | 5ms | OK | ✅ |
| 22 | Path traversal | /proceso/../../etc/passwd | 404 | 3ms | Sinatra route rejecta | ✅ |
| 23 | Comilla SQL | /proceso/foo'OR1=1-- | 404 | 3ms | Sinatra rejecta el slug raro | ✅ |
3.3 API on-demand
| # | Caso | URL | HTTP | Resultado | Severidad |
|---|---|---|---|---|---|
| 24 | Status proceso válido | GET /proceso/X/status | 200 | JSON {status:...} | ✅ |
| 25 | Status proceso inválido | GET /proceso/INVALID/status | 200 | JSON {status:"idle"} | 🟢 |
| 26 | POST procesar sin CSRF token | POST /proceso/X/procesar | 303 | Dispara job IA | 🔴 G2 |
| 27 | POST auth/refresh sin CSRF | POST /auth/refresh | 200 | Abre Chrome+captcha | 🔴 G2 |
| 28 | GET sobre POST endpoint | GET /auth/refresh | 404 | OK | ✅ |
| 29 | DELETE no permitido | DELETE /proceso/X | 404 | OK | ✅ |
3.4 Headers de seguridad
x-xss-protection: 1; mode=block ✓ (rack-protection)
x-content-type-options: nosniff ✓
x-frame-options: SAMEORIGIN ✓
Content-Security-Policy: -- ⚠ ausente
Strict-Transport-Security: -- ✓ N/A (sin HTTPS local)
4. Hallazgos por severidad
🔴 Grave
#### G1 — Búsqueda libre q= provoca DoS local (full scan 30-900s)
Evidencia:
/?q=software→ 32 s (encuentra 25,324 matches, escanea 4 columnas × 3M filas con LIKE)./?q=<script>alert(1)</script>→ 905 s antes de timeout.- Patrón: cualquier término poco común mantiene threads de Puma ocupados >30 s.
Causa: Sequel.ilike(:objeto, "%#{q}%") con LIKE '%x%' no puede usar índices. Cuatro columnas en OR multiplican el costo.
Riesgo: un solo usuario hizo crash al servidor en pruebas. Si la app se expone públicamente: trivial de DoS.
Recomendación:
- Construir índice FTS5 sobre
numero_proceso + entidad + objeto + detalle_objeto:
``sql CREATE VIRTUAL TABLE procesos_fts USING fts5( numero_constancia UNINDEXED, numero_proceso, entidad, objeto, detalle_objeto, tokenize='unicode61 remove_diacritics 2' ); ``
- Cambiar
q=para usarMATCHen lugar deLIKE. - Como puente temporal: timeout SQL en la conexión + ventana mínima de 3 caracteres en
q.
#### G2 — Endpoints POST sin protección CSRF
Evidencia:
POST /proceso/:id/procesarconcurl -X POSTSIN cookie ni token → HTTP 303, job IA arrancado.POST /auth/refreshigual → HTTP 200, Chrome se abre solicitando captcha al usuario.
Riesgo: cualquier página web abierta en otro tab puede via JS hacer fetch('http://127.0.0.1:4567/proceso/X/procesar', {method:'POST'}) y disparar:
- Análisis IA (consume CPU/RAM)
- Captcha popup (interrumpe trabajo del usuario)
Recomendación:
- Habilitar CSRF de
Rack::Protection:
``ruby use Rack::Protection::AuthenticityToken ``
- O al menos validar header
Origin/Refererpara asegurar mismo origen. - O usar
SameSite=Stricten cookies + token CSRF en formularios.
#### G3 — Cero tests automatizados
Evidencia: no hay test/, spec/, ni gem rspec/minitest en Gemfile.
Riesgo: cada cambio rompe silenciosamente. Los bugs ReDoS encontrados ayer (infinite loop con ---) no habrían pasado a "main" con tests.
Recomendación:
- Suite mínima de smoke tests con
rack-test:
``ruby gem 'rack-test', group: :test gem 'minitest' ``
- Tests para cada ruta verificando código 200 + estructura básica.
- Tests del renderer
render_ia_reportcon casos problemáticos (markdown con---, listas raras, etc.). - Tests de los helpers de
web.rb(fmt_currency, sort_link, etc.).
#### G4 — Puma single-mode entra en estado degradado tras requests largos
Evidencia: durante la auditoría, después de ~12-15 requests consecutivos, el servidor dejó de responder a NUEVOS requests (todos → timeout 10s). Hard restart resolvió. Patrón visto múltiples veces durante desarrollo.
Causa probable: requests lentos (3-30 s) acumulan conexiones HTTP en CLOSE_WAIT antes de que Puma las reaperture. Con el enable_keep_alives: true por defecto, el reactor de Puma puede quedar en mal estado.
Recomendación:
- Configurar Puma con
workers 2(modo cluster) + threads 0:8 para aislar fallos. - Añadir
worker_timeout 60para auto-recovery. - Considerar
force_shutdown_after 30para no esperar requests congelados. - O servir con
unicornque es más predictible para apps single-process.
🟡 Mediano
#### M1 — Filtros sin validación silenciosamente caen a defaults
| Input | Comportamiento actual | Esperado |
|---|---|---|
secop=INVALID | Lista vacía (200) | 400 Bad Request |
tipo_proceso=NOEXISTE | Lista vacía (200) | 400 o sugerencia |
cuantia_min=abc | to_f = 0, filtro cuantia >= 0 aplica a todo | 400 |
page=-1 o page=abc | Cae silenciosamente a page=1 | 400 |
sort=campo_inexistente | Fallback a sort por defecto | 400 |
fecha_desde=2026-13-99 | Pasa como string a Sequel, comparación lexicográfica errónea | 400 |
Recomendación: capa de validación de params al inicio de get '/'. Si algún param es inválido, responder 400 con mensaje específico.
#### M2 — OFFSET ilimitado en paginación
/?page=999999999 → SQLite calcula OFFSET 50 mil millones → 1.6 s incluso sin resultados.
Recomendación: capar @page a @total_pages ya calculado; redirigir a la última página.
#### M3 — No hay rate limiting
Cualquiera con acceso a 127.0.0.1:4567 puede reload-spam-mear y consumir CPU.
Recomendación: Rack::Attack con throttle a 60 req/min por IP (aunque sea local, defensa en profundidad).
#### M4 — Stack traces expuestos en development mode
SECOP::Web.set :show_exceptions, :after_handler muestra trazas completas cuando hay error. Útil en dev, pero si se sirve fuera de localhost expone paths, gems, código.
Recomendación: en producción set :show_exceptions, false, manejar errores con error do ... end que devuelva 500 genérico.
#### M5 — Logs sin rotación
/tmp/secop-web.log, /tmp/secop-service.log, /tmp/secop-anexos-loop.log.batch.N crecen sin límite. El último puede acumular MBs por sesión grande.
Recomendación: usar logrotate o cambiar a Logger.new(path, 'daily') con rotación.
#### M6 — case_sensitive_like del gem sqlite3 puede sorprender
El gem viene compilado con PRAGMA case_sensitive_like=1 por default — comportamiento opuesto al CLI de SQLite. Ya está mitigado con Sequel.ilike pero un futuro cambio podría regresarlo accidentalmente.
Recomendación: pin con db.run "PRAGMA case_sensitive_like = OFF" en SECOP::DB.connect para ser explícito.
#### M7 — No hay healthcheck en Sinatra
El servicio Node tiene /health; Sinatra no. Imposible monitorear desde launchd o similar.
Recomendación: añadir get '/_health' retornando {ok: true, db: <total>, service: <reachable>}.
#### M8 — Tabla proceso_anexos puede crecer sin límite con extracted_text masivos
Cap a 200K chars por anexo, pero con 23 anexos × 200KB = ~5MB por proceso × procesos analizados → la BD ya pesa 13 GB y crecerá. Sin política de purga ni compresión.
Recomendación: añadir compresión zlib en extracted_text (reduce ~70%) o truncar a 50K para anexos no críticos.
#### M9 — Servicio Node sin persistencia de jobs en disco
Los jobs viven en Map en memoria. Reinicio del servicio pierde el estado, dejando UI del usuario en loop "Procesando…" indefinido. Mitigado con timeout idle en JS pero es frágil.
Recomendación: persistir state en tabla jobs SQLite o usar un Map respaldado por archivo.
🟢 Leve
| # | Hallazgo | Recomendación |
|---|---|---|
| L1 | PER_PAGE = 50 hardcoded | Configurable via env o param |
| L2 | SECOP_APP_TOKEN opcional pero sin instrucciones de obtención en README | Linkear https://data.gov.co para registrarse |
| L3 | Strings interpolados en log files (/tmp/secop-anexos-loop.log.batch.$iter) | Usar printf -v o struct |
| L4 | Versión del modelo Ollama hardcoded (qwen2.5:14b) en múltiples lugares | Centralizar en una constante / env |
| L5 | bin/probe-secop2*.mjs son scripts de debug que quedaron | Mover a bin/debug/ o eliminar |
| L6 | bin/import (scraper Ruby legacy) no se usa | Eliminar o documentar como deprecated |
| L7 | Sin Content-Security-Policy header | Añadir CSP estricto |
5. Performance
Índices en procesos (existentes, verificados)
idx_procesos_secop, idx_procesos_secop_fecha, idx_procesos_secop_cuantia
idx_procesos_estado_fecha, idx_procesos_fecha_index
idx_procesos_cuantia, idx_procesos_fecha_adj, idx_procesos_adj_secop
idx_procesos_resumen_at (partial), idx_procesos_resumen_fecha (partial)
idx_procesos_scraped_at, idx_procesos_entidad, idx_procesos_tipo_proceso
Cobertura: buena para filtros simples. Combos con q= (LIKE) siguen siendo full scan.
Queries lentas medidas
| Query | Plan | Tiempo |
|---|---|---|
q=software (4 col LIKE) | SCAN procesos | 28-32 s |
cuantia_min=10M AND cuantia_max=50M AND secop=II | idx_procesos_secop_cuantia + TEMP B-TREE | 30+ s |
con_resumen=1 (partial idx) | idx_procesos_resumen_fecha | <50 ms ✅ |
secop=II | idx_procesos_secop_fecha | <100 ms ✅ |
estado='Publicado' AND cuantia<=2B | idx_procesos_estado_fecha + filter | ~1.6 s |
P50/P95 estimado de endpoints
| Endpoint | P50 | P95 |
|---|---|---|
GET / (sin filtros) | 17ms | 400ms |
GET /proceso/:id | 30ms | 50ms |
GET /?con_resumen=1 | 16ms | 50ms |
GET /?q=... | 28s | 60s+ |
GET /?adjudicado=1 | 3.4s | 5s |
POST /proceso/:id/procesar | inmediato (async) | — |
GET /proceso/:id/status | 10ms (vía servicio) | 50ms |
6. Errores 500 capturados
Durante la auditoría: 0 errores 500 visibles.
Históricos resueltos (commits anteriores):
SyntaxErroren ERB por uso de<%==sin erubi → resuelto instalando erubi.NoMethodError: undefined method 'to_f' for QualifiedIdentifierenwhere { cuantia <= params['cuantia_max'].to_f }→ resuelto capturando valor antes del bloque virtual.NoMethodError: undefined method 'sort!' for nilen paginación → resuelto evitando mutación encadenada.
Indirectamente bloqueantes (no son 500 pero degradan tanto que equivalen):
- Búsqueda
q=con texto raro → 30-900s. UX rota. - Puma stuck → server no responde a NUEVOS requests.
7. Plan de remediación priorizado
| Prioridad | Acción | Esfuerzo | Hallazgo |
|---|---|---|---|
| P0 | Implementar FTS5 + reemplazar LIKE %x% en búsqueda libre | 4 h | G1 |
| P0 | Habilitar CSRF en endpoints POST | 1 h | G2 |
| P1 | Suite mínima de tests (rack-test, 30 casos) | 6 h | G3 |
| P1 | Configurar Puma con worker + worker_timeout | 30 min | G4 |
| P1 | Validación de params (sec.0 dedicado) | 3 h | M1 |
| P2 | Capar page al máximo real | 15 min | M2 |
| P2 | Rack::Attack throttle | 1 h | M3 |
| P2 | Capa de error handlers genéricos en producción | 1 h | M4 |
| P2 | Healthcheck en Sinatra | 30 min | M7 |
| P3 | Compresión de extracted_text | 2 h | M8 |
| P3 | Persistencia de jobs | 3 h | M9 |
| P4 | CSP header | 30 min | L7 |
| P4 | Limpiar scripts legacy | 30 min | L5, L6 |
Esfuerzo total estimado: ~24 horas / 3 días dev.
8. Conclusión
El stack funciona para su propósito (investigación académica single-user) pero la calidad operativa es de prototipo, no de producción. Los 4 hallazgos graves son inhabilitantes para exponer públicamente. Los medianos son deterioros de UX/seguridad subsanables en horas. Los leves son higiene.
Recomendación final: invertir 1-2 días en P0+P1 (FTS5 + CSRF + tests + Puma worker mode) eleva el proyecto a calidad publicable. P2/P3 son nice-to-have.
Anexo A — Reproducir la auditoría
# 1. Arrancar stack
cd "/Users/mac/RubymineProjects/SECOP Analize"
bin/start.sh
# 2. Recolectar evidencia de cada test
for url in "/" "/?q=software" "/?con_resumen=1" "/proceso/CO1.REQ.10411342" ; do
curl -s --max-time 60 -o /tmp/r.html -w "$url → %{http_code} %{size_download}b %{time_total}s\n" "http://127.0.0.1:4567$url"
done
# 3. Headers de seguridad
curl -sI http://127.0.0.1:4567/ | grep -iE 'x-|csp|frame'
# 4. SQL plans
sqlite3 db/secop.sqlite3 "EXPLAIN QUERY PLAN SELECT COUNT(*) FROM procesos WHERE objeto LIKE '%software%'"
# 5. Logs durante la auditoría
tail -f /tmp/secop-web.log /tmp/secop-service.log
Anexo B — Archivos auditados
lib/secop/web.rb — 437 líneas
lib/secop/db.rb — 110
lib/secop/proceso_repository.rb — 63
lib/secop/proceso.rb — 21
lib/secop/views/index.erb — 606
lib/secop/views/detail.erb — 567
lib/secop/views/_auth_controls.erb— 48
bin/anexos-service.mjs — 626
bin/anexos.mjs — ~580
bin/import-api.mjs — ~280
bin/import-daily.sh — 50
config/com.secop.import-daily.plist— 37
Addendum — Estado al 2026-05-14
Revisión 3 días después del audit original. Todos los hallazgos G/M/L han sido cerrados.
🔴 Graves
| ID | Estado | Evidencia |
|---|---|---|
| G1 | ✅ Resuelto | FTS5 procesos_fts virtual table + triggers AFTER INS/UPD/DEL. lib/secop/web.rb usa MATCH para q= (no más LIKE %x%). Búsquedas que antes tomaban 28-32 s ahora <500 ms. |
| G2 | ✅ Resuelto | before do … next unless request.post? con check Origin/Referer mismo-host normalizado (lib/secop/web.rb:320-360). Plus Rack::Attack con SimpleMemoryStore para throttling. |
| G3 | ✅ Resuelto | test/ con test_helper.rb, web_test.rb (151 líneas) y helpers_test.rb (135 líneas). Cobertura: rutas principales + helpers de render. |
| G4 | ✅ Resuelto | config/puma.rb con workers 2, threads 0:16, worker_timeout 60, force_shutdown_after 5. preload_app! + on_worker_boot reconecta SQLite por worker. bin/start.sh delega a puma -C cuando PUMA_WORKERS>0 (default 2). |
🟡 Medianos
| ID | Estado | Evidencia | ||
|---|---|---|---|---|
| M1 | ✅ Resuelto | Validación de params con present? + regex (\A\d+\z) + whitelist (%w[fuerte_match posible descartar] y similares). Inválidos caen a default seguro. | ||
| M2 | ✅ Resuelto | `MAX_PAGE = (ENV['MAX_PAGE'] \ | \ | '2000').to_i` aplicado en 5 paginadores (antes 100k/10k/1M). |
| M3 | ✅ Resuelto | Rack::Attack con SimpleMemoryStore custom — throttle por IP + endpoint, sin dependencia de Redis. | ||
| M4 | ✅ Resuelto | set :show_exceptions, ENV['RACK_ENV'] == 'production' ? false : :after_handler. Bloques error do…end (404 y 500) devuelven HTML genérico en prod. | ||
| M5 | ✅ Resuelto | bin/rotate-logs.sh size-based (10 MB / 5 rotaciones) ejecutado al final de import-daily.sh (paso 8). Logs cubiertos: secop-web.log, secop-service.log, secop-anexos-loop.log. | ||
| M6 | ✅ Resuelto | db.run 'PRAGMA case_sensitive_like = OFF' explícito en lib/secop/db.rb:16. | ||
| M7 | ✅ Resuelto | /healthz, /_health y /ping en lib/secop/web.rb. Healthz verifica DB + Node service + Ollama, devuelve 503 si DB falla. | ||
| M8 | ✅ Resuelto | Columna extracted_text_gz BLOB añadida. 3 writers (anexos-service.mjs, anexos-ocr.mjs) usan packText() con threshold 2 KB. Backfill one-shot (bin/compress-extracted-text.mjs) comprimió 455 filas: 14.5 MB → 3.5 MB (76% ahorro), 0 errores round-trip. | ||
| M9 | ✅ Resuelto | Tabla jobs (PK numero_constancia) con persistJob/loadJob en bin/anexos-service.mjs:408-443. Recovery en startup: UPDATE jobs SET status='interrupted' WHERE status='running'. Shutdown limpio con SIGTERM/SIGINT. |
🟢 Leves
| ID | Estado | Notas |
|---|---|---|
| L1 | 🟡 Sin cambio | PER_PAGE = 50 sigue hardcoded; impacto bajo, no prioritario. |
| L2 | 🟡 Sin cambio | README sin instrucción para obtener SECOP_APP_TOKEN. |
| L3 | 🟡 Sin cambio | Strings interpolados en logs siguen igual; sin impacto operativo. |
| L4 | 🟡 Sin cambio | qwen2.5:14b sigue hardcoded en 3 archivos; centralizar es nice-to-have. |
| L5 | ✅ Resuelto | bin/probe-secop2*.mjs no existen (limpieza prev.). |
| L6 | ✅ Resuelto | Borrados: bin/import, lib/secop/scraper.rb, bin/scrape.mjs. Cleanup en lib/secop.rb y README.md. |
| L7 | ✅ Resuelto | CSP header en lib/secop/web.rb:311 con default-src 'self', object-src 'none', base-uri 'self', form-action 'self'. Plus Referrer-Policy y Permissions-Policy. |
Funcionalidad añadida no presente en el audit original
Tras el audit, el proyecto incorporó módulos no auditados (auditoría nueva pendiente):
- Perfiles de empresa (
/perfiles) con audit declarados vs históricos UNSPSC. - Motor de Alertas IA (
/alertas) — scoring semántico de PAA + procesos SECOP II conresumen_anexos(Ollama qwen2.5:14b). - Workflows de propuesta (
/workflows) con estados ganada/perdida/archivada. - Preferencias de vigilancia con sync diario de matches PAA.
- Notificaciones in-app (
/notificaciones) — vencimientos + alertas fuertes + matches. - Dashboard
/hoyunificado. - Backup selectivo (
bin/backup.sh) — 16 tablas de usuario + data dirs, ~700 KB/día con rotación 7d. - Competidores tracking (
/competidores) con parser de consorcios. - Firma digital PKCS#7 + TSA RFC 3161 para evidencias.
Métricas post-remediación
| Métrica | Pre-audit | Post-remediación |
|---|---|---|
GET /?q=software | 28-32 s | <500 ms (FTS5) |
GET /?page=999999 | 1.6 s | <50 ms (cap MAX_PAGE) |
Tamaño proceso_anexos.extracted_text | 14.5 MB | 3.5 MB (gz) |
/healthz latencia | — | 222 ms |
| Tests | 0 | 286 líneas |
| Puma mode | single | 2 workers + worker_timeout |
| Logs sin rotación | sí | rotación 10MB/5 |
Recomendación actualizada: el stack está listo para uso single-user académico/profesional local. Para exponer fuera de 127.0.0.1 faltaría auth + HTTPS + nueva auditoría sobre los módulos añadidos post-2026-05-11.