Saltar al contenido

Escaneos de seguridad que nadie ignora

El escaneo de seguridad tarda 21 minutos, inunda cada merge request con hallazgos sobre código que nadie tocó y el equipo aprende a pasarlo de largo. SAST diff-aware con Semgrep, un umbral de severidad para OSV-Scanner y el formato de reporte que devuelve los hallazgos al widget del MR.

6 min de lectura
  • gitlab
  • ci-cd
  • security

La parte 11 sacó del camino a la señal lenta e inestable (los E2E) y la puso en un schedule donde puede fallar sin bloquear a nadie. El escaneo de seguridad de ese mismo proyecto tenía el problema inverso: corría en cada merge request, tardaba 21 minutos y reportaba hallazgos que en su mayoría vivían en código que el autor del MR jamás había tocado. El equipo hizo lo que cualquier equipo hace con una alarma que suena todo el día: aprendió dónde estaba el widget y dejó de leerlo.

Un escaneo que nadie lee es peor que no tener escaneo, porque quema 21 minutos por pipeline y de regalo deja la sensación cómoda de que alguien está vigilando. El objetivo de esta parte es recuperar esa atención: los templates administrados de GitLab donde alcanzan, un job de Semgrep que solo mira el diff para el caso donde no alcanzan, y un scanner de dependencias con un umbral de severidad para que hable únicamente cuando importa.

Empieza por los templates administrados

GitLab mantiene jobs de escaneo listos para usar, publicados como templates. Con dos líneas tienes SAST y secret detection:

include:
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml

El template de SAST revisa el repositorio, decide qué analizadores aplican y, para la mayoría de lenguajes, corre Semgrep con reglas que GitLab mantiene por ti. El de secret detection corre Gitleaks sobre los commits recientes. Ambos suben sus resultados como reportes de seguridad, y eso es justo lo que hace que los hallazgos aparezcan en la pestaña Security del pipeline y en el widget del merge request, donde GitLab muestra lo que el MR introdujo o resolvió en lugar del backlog completo.

Para un repo chico o mediano, quédate aquí. Dos líneas, cero mantenimiento, reglas actualizadas por GitLab y reportes conectados correctamente. Reemplazar un scanner solo se justifica cuando puedes señalar el problema concreto que el template no resuelve; el nuestro era el tiempo de escaneo y el ruido en un repo grande, y únicamente afectaba a SAST.

Secret detection lo dejamos tal cual vino. Es rápido y, en ramas de feature, escanea los commits entre el merge base y el head, es decir, lo que estás por mergear. Hay una variable que vale la pena conocer: con SECRET_DETECTION_HISTORIC_SCAN: "true", una corrida barre el historial completo de git. Hazlo una sola vez, justo después de habilitar el template; de ahí en adelante basta con escanear lo nuevo.

SAST diff-aware con Semgrep

El SAST administrado fue otra historia. En un repo de varios cientos de miles de líneas era el culpable de los 21 minutos, y casi todos sus hallazgos apuntaban a código anterior al merge request. Los dos síntomas comparten la misma raíz: el job escanea el repositorio completo en cada pipeline.

Como el template ya corre Semgrep por debajo, reemplazamos el job por nuestra propia invocación y la limitamos a los archivos que el MR de verdad cambió: el diff contra git merge-base con la rama principal.

La trampa está en el clone. Los runners clonan shallow (los proyectos nuevos traen una profundidad de 20 commits por defecto) y git merge-base necesita suficiente historia compartida para encontrar el punto donde la rama se separó. GIT_DEPTH se puede subir por job; con 200 nos alcanzó para casi todas las ramas que vimos. Casi: por eso el script cae a un escaneo completo cuando no logra resolver el merge base, en lugar de fallar o, peor, de no escanear nada sin avisar:

#!/bin/sh
set -e
 
report() {
  semgrep scan --config p/default --gitlab-sast \
    --output gl-sast-report.json "$@"
}
 
git fetch --depth "${GIT_DEPTH:-200}" origin "$CI_DEFAULT_BRANCH"
 
if base=$(git merge-base "origin/$CI_DEFAULT_BRANCH" HEAD); then
  changed=$(git diff --name-only --diff-filter=d "$base" HEAD)
  if [ -n "$changed" ]; then
    report $changed
  else
    mkdir -p .ci-empty && report .ci-empty
  fi
else
  echo "merge base not found; falling back to a full scan"
  report .
fi

Ahí hay tres detalles que se ganan su lugar. --diff-filter=d excluye los archivos borrados, que Semgrep intentaría abrir sin éxito. Cuando el diff queda vacío, escaneamos un directorio vacío: es la forma más floja de producir un reporte válido con cero vulnerabilidades, y evita que la subida del artifact se queje por un archivo faltante. Y el fallback convierte el peor caso (una rama más vieja que GIT_DEPTH) en un escaneo lento pero correcto.

El escaneo completo tampoco desapareció. Se mudó al pipeline programado de la parte 11, donde correr 21 minutos contra todo el repo no le estorba a nadie.

Este cambio, más que cualquier ajuste de reglas, fue lo que arregló la dinámica del cuento del lobo. Antes, los hallazgos eran en su mayoría problemas preexistentes en archivos que el MR nunca abrió, e ignorarlos era la respuesta racional. El problema nunca fue la precisión del scanner sino su alcance. Cuando los hallazgos empezaron a señalar líneas que el autor acababa de escribir, los ingenieros los leyeron, y la mayoría se corregía antes de que un reviewer abriera el MR.

Devolver los hallazgos al widget del MR

Escanear los archivos correctos es la mitad del trabajo; la otra mitad es dónde aterrizan los hallazgos, porque uno que solo existe en el log del job se lee una vez, con suerte. Semgrep emite de forma nativa el formato de reporte SAST de GitLab (el flag --gitlab-sast que ya está en el script), y declarar ese archivo bajo artifacts:reports:sast hace que GitLab trate a tu job como si fuera uno de sus propios scanners: los hallazgos aparecen en el reporte de seguridad del merge request y en la pestaña Security del pipeline.

sast-diff:
  stage: quality
  image: semgrep/semgrep:1.78.0
  variables:
    GIT_DEPTH: '200'
  script:
    - sh ci/semgrep-diff.sh
  artifacts:
    when: always
    reports:
      sast: gl-sast-report.json
  rules:
    - if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH

Es la misma familia artifacts:reports de la parte 3: allá era junit anotando tests fallidos sobre el MR, acá es sast alimentando el widget de seguridad. Dos detalles del job: when: always sube el reporte aunque más adelante decidas que el job falle cuando encuentra algo, y la cláusula de rules (parte 5) lo apaga en la rama principal, que ya queda cubierta por el escaneo completo programado.

Dependencias con OSV-Scanner

SAST lee tu código; el tercer frente son las dependencias. Para eso usamos OSV-Scanner, que revisa los lockfiles contra la base de datos de vulnerabilidades OSV.dev y corre en segundos. Dejarlo en un job que falle cuando debe y sobreviva cuando no debe nos tomó tres rondas de ajustes, y las tres merecen quedar por escrito.

La primera: la ruta del binario. Entre una versión de la imagen y la siguiente, el ejecutable se movió (una lo tenía en /osv-scanner, otra bajo /usr/local/bin). Un pequeño loop de descubrimiento sale más barato que perseguirlo en cada upgrade:

for path in /osv-scanner /usr/local/bin/osv-scanner; do
  [ -x "$path" ] && scanner="$path" && break
done
[ -n "$scanner" ] || { echo "osv-scanner binary not found" >&2; exit 1; }

Ese traslado, dicho sea de paso, es el mejor argumento para fijar versiones: si corres scanners con :latest, el pipeline cambia de comportamiento de un día para otro sin que exista ningún commit que lo explique. La parte 1 planteó este argumento con las imágenes de build; con los scanners aplica doble, porque su trabajo es precisamente cambiar el veredicto de tu pipeline. Fija el tag (ghcr.io/google/osv-scanner:v1.8.5) y actualiza a propósito.

La segunda: los códigos de salida. Como casi todos los scanners, OSV-Scanner termina con código distinto de cero cuando encuentra algo, y bajo set -e eso mata el job antes de que tu lógica de gating llegue a correr. Apagar set -e para todo el script esconde fallas reales; mejor permite explícitamente los códigos que entiendes y trata el resto como error:

set +e
"$scanner" --format json --lockfile pnpm-lock.yaml > osv-report.json
status=$?
set -e
 
case "$status" in
  0) echo "no known vulnerabilities" ;;
  1) echo "vulnerabilities found; gating on severity" ;;
  *) echo "osv-scanner failed (exit $status)" >&2; exit "$status" ;;
esac

La tercera: el umbral de severidad. El reporte JSON agrupa las vulnerabilidades y cada grupo trae un max_severity, un puntaje CVSS codificado como string ("7.5"). En jq, comparar un string con un número no da error: usa orden por tipos, y ahí cualquier string queda por encima de cualquier número, así que un >= 7 ingenuo clasifica todos los hallazgos como bloqueantes. tonumber? // 0 resuelve eso y también los grupos donde el campo ni siquiera viene:

high=$(jq '[.results[].packages[].groups[]?.max_severity
  | tonumber? // 0] | map(select(. >= 7)) | length' osv-report.json)
 
if [ "$high" -gt 0 ]; then
  echo "blocking: $high finding(s) with CVSS >= 7.0" >&2
  exit 1
fi
echo "findings below threshold; not blocking"

Dónde poner el umbral es una decisión de cada equipo. Nosotros bloqueamos merges con hallazgos de 7.0 hacia arriba (high y critical) y dejamos el resto visible en el log; probamos un umbral más bajo una vez y lo único que logramos fue reconstruir el muro de hallazgos que toda esta parte intenta evitar.

Con qué te quedas

El SAST ahora lee solo el diff y reporta en el mismo widget que los scanners administrados, los secretos quedan cubiertos por el template, y las dependencias solo bloquean por encima de una severidad que elegiste a propósito. Todo esto te dice qué está vulnerable hoy; no hace nada con la pila creciente de dependencias desactualizadas que va a producir los hallazgos de mañana. Mantenerlas al día automáticamente, con bots que abren los MRs de upgrade por ti, es la otra mitad del trabajo, y de eso se ocupa la parte 13, la última de la serie.

Referencias