Auditoría QA

← Volver a documentación

Auditoría QA — SECOP Analize

CampoValor
Fecha2026-05-11
AuditorQA + Security Audit (asistido por IA)
Versión auditadabranch principal, commit local actual
EntornoLocal desarrollo (bin/start.sh)
MóduloStack completo: Sinatra web + servicio Node + pipeline IA + import diario
Roles probadosN/A — herramienta single-user local sin auth
Severidad de hallazgos4 🔴 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

ComponenteArchivoLíneasNotas
Sinatra weblib/secop/web.rb4377 rutas públicas
Vistas ERBlib/secop/views/*.erb4 archivos, ~1200 líneasescape_html: true + erubi
Modelos DBlib/secop/db.rb, proceso*.rb~150Sequel ORM, SQLite WAL
Servicio Nodebin/anexos-service.mjs626HTTP server interno :5678
Pipeline batchbin/anexos.mjs~580CLI alternativo
Import SODAbin/import-api.mjs~280Incremental con watermark
Auth captchabin/secop2-login.mjs~75Headed Playwright
Cronconfig/com.secop.import-daily.plistlaunchd 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)

GemaVersiónComentario
sinatra4.2.1OK
rack3.xOK
rack-protection4.2.1activa por default — bueno
sequel5.103.0OK, sin CVEs conocidos
erubi1.13.1OK
sqlite3 (gem)última⚠ compilada con case_sensitive_like activo (afecta LIKE)

Patrones de riesgo (análisis estático)

  • ✅ Cero ocurrencias de eval, find_by_sql con interpolación, system() con input del usuario, mass assignment sin filtrado.
  • Process.spawn se usa con array form (sin shell). Args son constantes (NODE_BIN, PROJECT_DIR). Sin inyección.
  • ✅ Solo 2 usos de <%== (raw output) en detail.erb — ambos en inline_md() y render_ia_report() que escapan via CGI.escapeHTML previamente.
  • ⚠ 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 /)

#CasoURLHTTPTiempoResultadoSeveridad
1Home cold/200380msOK, 50 procesos
2Home warm/20017msOK
3Filtro Solo IA/?con_resumen=120016ms12 resultados
4Filtro adjudicados/?adjudicado=12003.4 sOK pero lento🟡
5SECOP I/?secop=I20020msOK
6SECOP II/?secop=II200102msOK
7secop inválido/?secop=INVALID2003msLista vacía (no rechaza)🟡
8tipo_proceso inexistente/?tipo_proceso=NOEXISTE2005msLista vacía🟢
9page enorme/?page=9999999992001.6sOFFSET masivo → lento🟡
10page negativa/?page=-120017msDefault page=1 (silencio)🟢
11page no numérica/?page=abc20017msDefault page=1🟢
12sort campo inválido/?sort=hacker_field&dir=asc20018msFallback a default sort🟢
13XSS reflejado/?q=<script>...200905 sTexto escapado ✅ pero query MUY lenta🔴 G1
14SQL injection literal/?q=' OR 1=1--200~30sSequel parametriza ✓ pero LIKE lento🔴 G1
15cuantia_min no numérica/?cuantia_min=abc(timeout)>10s'abc'.to_f = 0.0, query full scan🟡
16cuantia inviable/?cuantia_max=999999999999999(timeout)>10sSin bounds check🟡
17Fecha inválida/?fecha_desde=invalid-date(timeout)>10sSequel acepta string, LIKE corre lento🟡
18Fecha imposible/?fecha_desde=2026-13-99(timeout)>10sIdem🟡

3.2 Detalle proceso (GET /proceso/:id)

#CasoURLHTTPTiempoResultadoSeveridad
19ID existente con análisis/proceso/CO1.REQ.1041134220038msRenderiza reporte estructurado
20ID existente sin análisis/proceso/CO1.REQ.10336177200~50msMuestra botón "Procesar"
21ID inexistente/proceso/NO_EXISTE4045msOK
22Path traversal/proceso/../../etc/passwd4043msSinatra route rejecta
23Comilla SQL/proceso/foo'OR1=1--4043msSinatra rejecta el slug raro

3.3 API on-demand

#CasoURLHTTPResultadoSeveridad
24Status proceso válidoGET /proceso/X/status200JSON {status:...}
25Status proceso inválidoGET /proceso/INVALID/status200JSON {status:"idle"}🟢
26POST procesar sin CSRF tokenPOST /proceso/X/procesar303Dispara job IA🔴 G2
27POST auth/refresh sin CSRFPOST /auth/refresh200Abre Chrome+captcha🔴 G2
28GET sobre POST endpointGET /auth/refresh404OK
29DELETE no permitidoDELETE /proceso/X404OK

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:

  1. 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' ); ``

  1. Cambiar q= para usar MATCH en lugar de LIKE.
  2. 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/procesar con curl -X POST SIN cookie ni token → HTTP 303, job IA arrancado.
  • POST /auth/refresh igual → 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:

  1. Habilitar CSRF de Rack::Protection:

``ruby use Rack::Protection::AuthenticityToken ``

  1. O al menos validar header Origin / Referer para asegurar mismo origen.
  2. O usar SameSite=Strict en 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:

  1. Suite mínima de smoke tests con rack-test:

``ruby gem 'rack-test', group: :test gem 'minitest' ``

  1. Tests para cada ruta verificando código 200 + estructura básica.
  2. Tests del renderer render_ia_report con casos problemáticos (markdown con ---, listas raras, etc.).
  3. 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:

  1. Configurar Puma con workers 2 (modo cluster) + threads 0:8 para aislar fallos.
  2. Añadir worker_timeout 60 para auto-recovery.
  3. Considerar force_shutdown_after 30 para no esperar requests congelados.
  4. O servir con unicorn que es más predictible para apps single-process.

🟡 Mediano

#### M1 — Filtros sin validación silenciosamente caen a defaults

InputComportamiento actualEsperado
secop=INVALIDLista vacía (200)400 Bad Request
tipo_proceso=NOEXISTELista vacía (200)400 o sugerencia
cuantia_min=abcto_f = 0, filtro cuantia >= 0 aplica a todo400
page=-1 o page=abcCae silenciosamente a page=1400
sort=campo_inexistenteFallback a sort por defecto400
fecha_desde=2026-13-99Pasa como string a Sequel, comparación lexicográfica errónea400

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

#HallazgoRecomendación
L1PER_PAGE = 50 hardcodedConfigurable via env o param
L2SECOP_APP_TOKEN opcional pero sin instrucciones de obtención en READMELinkear https://data.gov.co para registrarse
L3Strings interpolados en log files (/tmp/secop-anexos-loop.log.batch.$iter)Usar printf -v o struct
L4Versión del modelo Ollama hardcoded (qwen2.5:14b) en múltiples lugaresCentralizar en una constante / env
L5bin/probe-secop2*.mjs son scripts de debug que quedaronMover a bin/debug/ o eliminar
L6bin/import (scraper Ruby legacy) no se usaEliminar o documentar como deprecated
L7Sin Content-Security-Policy headerAñ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

QueryPlanTiempo
q=software (4 col LIKE)SCAN procesos28-32 s
cuantia_min=10M AND cuantia_max=50M AND secop=IIidx_procesos_secop_cuantia + TEMP B-TREE30+ s
con_resumen=1 (partial idx)idx_procesos_resumen_fecha<50 ms ✅
secop=IIidx_procesos_secop_fecha<100 ms ✅
estado='Publicado' AND cuantia<=2Bidx_procesos_estado_fecha + filter~1.6 s

P50/P95 estimado de endpoints

EndpointP50P95
GET / (sin filtros)17ms400ms
GET /proceso/:id30ms50ms
GET /?con_resumen=116ms50ms
GET /?q=...28s60s+
GET /?adjudicado=13.4s5s
POST /proceso/:id/procesarinmediato (async)
GET /proceso/:id/status10ms (vía servicio)50ms

6. Errores 500 capturados

Durante la auditoría: 0 errores 500 visibles.

Históricos resueltos (commits anteriores):

  • SyntaxError en ERB por uso de <%== sin erubi → resuelto instalando erubi.
  • NoMethodError: undefined method 'to_f' for QualifiedIdentifier en where { cuantia <= params['cuantia_max'].to_f } → resuelto capturando valor antes del bloque virtual.
  • NoMethodError: undefined method 'sort!' for nil en 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

PrioridadAcciónEsfuerzoHallazgo
P0Implementar FTS5 + reemplazar LIKE %x% en búsqueda libre4 hG1
P0Habilitar CSRF en endpoints POST1 hG2
P1Suite mínima de tests (rack-test, 30 casos)6 hG3
P1Configurar Puma con worker + worker_timeout30 minG4
P1Validación de params (sec.0 dedicado)3 hM1
P2Capar page al máximo real15 minM2
P2Rack::Attack throttle1 hM3
P2Capa de error handlers genéricos en producción1 hM4
P2Healthcheck en Sinatra30 minM7
P3Compresión de extracted_text2 hM8
P3Persistencia de jobs3 hM9
P4CSP header30 minL7
P4Limpiar scripts legacy30 minL5, 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

IDEstadoEvidencia
G1✅ ResueltoFTS5 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✅ Resueltobefore 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✅ Resueltotest/ con test_helper.rb, web_test.rb (151 líneas) y helpers_test.rb (135 líneas). Cobertura: rutas principales + helpers de render.
G4✅ Resueltoconfig/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

IDEstadoEvidencia
M1✅ ResueltoValidació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✅ ResueltoRack::Attack con SimpleMemoryStore custom — throttle por IP + endpoint, sin dependencia de Redis.
M4✅ Resueltoset :show_exceptions, ENV['RACK_ENV'] == 'production' ? false : :after_handler. Bloques error do…end (404 y 500) devuelven HTML genérico en prod.
M5✅ Resueltobin/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✅ Resueltodb.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✅ ResueltoColumna 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✅ ResueltoTabla 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

IDEstadoNotas
L1🟡 Sin cambioPER_PAGE = 50 sigue hardcoded; impacto bajo, no prioritario.
L2🟡 Sin cambioREADME sin instrucción para obtener SECOP_APP_TOKEN.
L3🟡 Sin cambioStrings interpolados en logs siguen igual; sin impacto operativo.
L4🟡 Sin cambioqwen2.5:14b sigue hardcoded en 3 archivos; centralizar es nice-to-have.
L5✅ Resueltobin/probe-secop2*.mjs no existen (limpieza prev.).
L6✅ ResueltoBorrados: bin/import, lib/secop/scraper.rb, bin/scrape.mjs. Cleanup en lib/secop.rb y README.md.
L7✅ ResueltoCSP 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 con resumen_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 /hoy unificado.
  • 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étricaPre-auditPost-remediación
GET /?q=software28-32 s<500 ms (FTS5)
GET /?page=9999991.6 s<50 ms (cap MAX_PAGE)
Tamaño proceso_anexos.extracted_text14.5 MB3.5 MB (gz)
/healthz latencia222 ms
Tests0286 líneas
Puma modesingle2 workers + worker_timeout
Logs sin rotaciónrotació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.