El 19 de marzo de 2026 estaba diseñando un pipeline de GitHub Actions para integrar herramientas de security scanning en un proyecto real. Eligiendo qué herramientas usar, cómo estructurar los jobs, qué permisos darle a cada step.

Ese mismo día me enteré de lo que le había pasado a Trivy.

Ya escribí sobre lo que ese incidente me enseñó sobre confianza implícita en dependencias — lo puedes leer acá. Este blog es diferente. No es sobre el análisis del ataque — es sobre lo que cambió en la práctica cuando llegó el momento de tomar decisiones técnicas reales con ese contexto encima. Las herramientas que elegí, las que descarté, los tradeoffs que acepté, y las sorpresas que no esperaba.

El 31 de marzo, cuando el pipeline ya estaba prácticamente terminado, llegó el ataque a axios.

Dos incidentes de supply chain en 12 días. Uno mientras diseñaba. Otro mientras construía. No fue contexto de fondo — fue el ambiente real en el que tomé cada decisión.

Esto es lo que aprendí.

El punto de partida: ¿por qué iam-audit?

Cuando llegó el momento de elegir sobre qué proyecto construir el pipeline, la respuesta fue rápida: iam-audit.

No porque fuera el más complejo ni el más crítico — sino porque tenía exactamente lo que necesitaba para que el ejercicio fuera real. Python con lógica de negocio que vale la pena analizar estáticamente. Un Dockerfile para construir y escanear una imagen. Terraform en infra/ para cubrir IaC. Un proyecto que ya estaba en producción, con decisiones de arquitectura reales y dependencias reales.

Un repo de ejemplo inventado para el ejercicio no habría servido. Necesitaba fricción real — el tipo de fricción que aparece cuando el código tiene historia y cuando la imagen tiene dependencias que alguien eligió por razones concretas.

iam-audit tenía todo eso.

El objetivo del pipeline no era cubrir todos los controles posibles de una vez. Era construir algo que funcionara, que tuviera sentido, y que dejara espacio para crecer. Cuatro categorías: análisis estático de código, scanning de secrets, scanning de imagen, y scanning de IaC. Un job por categoría. Cada uno independiente — si uno falla, los demás corren igual.

Lo que no sabía cuando arranqué es que cada una de esas cuatro decisiones iba a estar informada, de una forma u otra, por lo que estaba pasando en el ecosistema ese mismo mes.

La base antes de las herramientas: Dependabot

Antes de escribir una sola línea de YAML del pipeline, había una pregunta de fondo: ¿cómo mantengo actualizadas las herramientas que voy a integrar sin perder el control de qué versión corre en cada momento?

SHA pinning resuelve el problema de la mutabilidad — nadie puede redirigir el digest a otro contenido. Pero crea otro problema: un digest fijo no se actualiza solo. Si sale una versión nueva de Gitleaks con una corrección de seguridad importante, el pipeline va a seguir corriendo la versión vieja indefinidamente hasta que alguien se acuerde de actualizarla.

Dependabot cierra ese loop. Configurado para GitHub Actions, monitorea las dependencias del pipeline y abre un PR automáticamente cuando detecta una nueva versión — con el SHA actualizado, con el changelog, listo para revisar y aprobar.

# .github/dependabot.yml

version: 2

updates:

- package-ecosystem: "github-actions"

directory: "/"

schedule:

interval: "weekly"

labels:

- "dependencies"

- "security"

La combinación es la que cierra el ciclo completo: SHA pinning elimina la exposición a tags mutables, Dependabot elimina la fricción de mantener los SHAs actualizados manualmente. Sin Dependabot, el pipeline se vuelve estático y eventualmente desactualizado. Sin SHA pinning, el pipeline es vulnerable a force-push. Los dos juntos son la postura correcta.

Pero hay un detalle importante: Dependabot abre el PR, pero la decisión de aprobarlo es tuya. La automatización no reemplaza el criterio — lo asiste. Cuando llega un PR de Dependabot con una actualización de alguna Action del pipeline, el checklist que aplico antes de aprobarlo es este:

✅ Checklist de revisión para PRs de Dependabot
  • [ ] ¿Viene de Dependabot? — verificar que el PR fue abierto por la cuenta oficial de Dependabot, no por un usuario externo.
  • [ ] ¿Qué versión propone? — leer el diff completo. Saber exactamente qué cambió entre el SHA anterior y el nuevo.
  • [ ] ¿Fue publicado via CI/OIDC? — revisar la metadata del release. Una publicación legítima tiene provenance verificable; una comprometida puede no tenerla.
  • [ ] ¿Hay changelog? — leer qué cambió en la nueva versión. Correcciones de seguridad, breaking changes, cambios de comportamiento.
  • [ ] ¿El SHA es válido? — verificar que el commit existe en el repo oficial y pertenece a una rama real, no es un commit huérfano.
  • [ ] ¿Cuándo fue publicado? — esperar 24-48 horas antes de aprobar versiones muy recientes. Da tiempo a que la comunidad reporte problemas.
  • [ ] ¿La comunidad reportó algo? — buscar issues recientes en el repo de la herramienta antes de aprobar.
  • [ ] ¿El pipeline pasa con la nueva versión? — correr el workflow con el PR abierto antes de mergear. La última validación antes de aprobar.

Dependabot trae la actualización. El checklist decide si se aplica.

Primera decisión: Semgrep en lugar de Bandit

La primera pregunta fue simple: ¿qué uso para analizar el código Python estáticamente?

La respuesta obvia era Bandit. Es el estándar histórico para Python, está en todos los tutoriales, y arranca en dos minutos. Pero "está en todos los tutoriales" no es una razón suficiente para elegir una herramienta.

Semgrep hace lo mismo que Bandit y bastante más. Soporta múltiples lenguajes, tiene reglas de la comunidad activamente mantenidas, y es lo que la industria usa hoy en pipelines reales. Si el objetivo era aprender algo que sirviera más allá de este proyecto puntual, Semgrep ganaba por lejos.

La decisión de la herramienta fue fácil. Lo que no fue tan obvio fue cómo integrarla.

Semgrep tiene una GitHub Action oficial — semgrep/semgrep. La opción más rápida, la que aparece en la documentación, la que la mayoría usaría sin pensarlo dos veces. Después de lo que había pasado con Trivy, lo primero que intenté fue pinear la Action por digest — el mismo patrón que apliqué al resto del pipeline. El problema: la Action vive en un módulo deprecado, y la nueva versión no tiene documentación clara sobre cómo referenciarla por SHA. Después de un rato buscando sin encontrar nada concreto, tomé una decisión pragmática.

Si no puedo pinear la Action de forma confiable, la saco del pipeline. En su lugar, instalo Semgrep directamente via pip con verificación de hash — que es en realidad una forma más directa de garantizar la integridad del binario que voy a ejecutar:

- name: Run Semgrep

run: |

pip install semgrep==1.96.0 \

--require-hashes \

--hash=sha256:b55c70f4a8c1aaa8040e4ecb2d36f358f8c1320da6470b5b55ef78110901604a

semgrep --config p/python src/ --sarif --output semgrep-results.sarif || true

--require-hashes le dice a pip que solo instale el paquete si el hash del contenido coincide exactamente con el declarado. Si alguien modifica el paquete en PyPI — como pasó con axios semanas después — el hash no coincide y el pipeline falla antes de instalar nada. El concepto de pin digest se mantiene, solo que aplicado al gestor de paquetes en lugar de a la Action.

El || true al final no es descuido — es intencional. Si Semgrep encuentra findings, retorna exit code distinto de cero, lo que haría fallar el step antes de llegar al summary. Por ahora el pipeline está en modo auditoría: quiero ver qué encuentra, no que rompa el workflow en el primer scan.

Segunda decisión: Gitleaks para secrets

Para scanning de secrets la lista de opciones es más corta de lo que parece. TruffleHog, detect-secrets, Gitleaks. Las tres hacen lo mismo en esencia — buscan patrones que parecen credenciales en el código y en el historial de Git.

La diferencia que llevó a elegir Gitleaks fue práctica: tiene una GitHub Action oficial, activamente mantenida, con buena documentación y adopción real en la comunidad. detect-secrets está más orientado a pre-commit hooks que a pipelines de CI. TruffleHog es potente pero más pesado para lo que se necesitaba en este punto.

Gitleaks ganó por claridad y por fit con el contexto.

Ahora, después de Trivy, pinear la Action por SHA no era una opción — era el requisito mínimo:

- name: Run Gitleaks

uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7

Antes del 19 de marzo, referenciar una Action por tag — gitleaks/gitleaks-action@v2 — era lo que hacía el 90% de los pipelines que se ven en GitHub. Después del 19 de marzo, ese patrón tiene un nombre: superficie de ataque innecesaria. TeamPCP demostró que un tag puede redirigirse silenciosamente a un commit malicioso sin ningún cambio visible. El SHA es el único puntero que no miente.

Lo que también tiene de bueno la Action de Gitleaks es que escribe su propio resumen en el step summary de GitHub Actions automáticamente. No fue necesario escribir código extra para mostrar los resultados — algo que en otros jobs sí fue necesario y que se cuenta más adelante.

Tercera decisión: Grype + Syft en lugar de Trivy

Esta es la decisión más directa de todo el blog — y la más fácil de explicar.

Cuando llegó el momento de elegir el image scanner, Trivy era la respuesta obvia. Es la navaja suiza del ecosistema cloud-native: escanea imágenes, filesystems, IaC y repos en una sola herramienta. Es el estándar. Es lo que todo el mundo usa.

También era la herramienta comprometida.

No fue paranoia. No fue precaución excesiva. Fue que el 19 de marzo Trivy v0.69.4 estaba distribuyendo un infostealer a través de sus canales oficiales, y se estaba eligiendo un image scanner ese mismo mes. Usar Trivy en ese contexto no era solo un riesgo técnico — era ignorar deliberadamente información disponible.

La alternativa fue Grype con Syft. Grype es el scanner de vulnerabilidades de Anchore — escanea imágenes usando un SBOM generado por Syft. Son dos herramientas en lugar de una, pero juntas cubren exactamente lo mismo que Trivy para image scanning. Y lo más importante: en ese momento no tenían un incidente de supply chain abierto.

- name: Build Docker image

run: docker build -t iam-audit:test .

  • name: Run Grype
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2

with:

image: iam-audit:test

fail-build: false

fail-build: false porque el pipeline está en modo auditoría. El primer scan de una imagen que nunca fue escaneada antes va a tener findings — algunos reales, algunos falsos positivos, algunos que requieren contexto para evaluar. Romper el workflow en el primer run no tiene sentido. Primero se ve qué hay, después se decide qué bloquea.

Hay un detalle que vale la pena mencionar: Grype también tiene una Action oficial, y también está pineada por SHA. El mismo patrón de toda la vida del pipeline — no hay excepciones porque "es una herramienta conocida" o "es poco probable que la comprometan". Trivy también era una herramienta conocida.

Cuarta decisión: Checkov para IaC

El Terraform de infra/ necesitaba cobertura. Esa parte no generó demasiada discusión interna — el pipeline no estaría completo sin escanear la infraestructura como código.

La pregunta era qué herramienta usar. Las dos opciones principales en el ecosistema open source son Checkov y KICS. Checkov es de Bridgecrew — hoy parte de Palo Alto Networks — y tiene miles de reglas para Terraform, CloudFormation, Kubernetes y más. KICS es de Checkmarx, también open source, también muy completo.

Se eligió Checkov por una razón práctica: su GitHub Action está bien mantenida, tiene buena documentación, y es la que más aparece en pipelines reales de la comunidad. No fue una decisión filosófica profunda — fue que Checkov era la opción más sólida para arrancar.

- name: Run Checkov

uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf

with:

directory: infra/

soft_fail: true

soft_fail: true por la misma razón que fail-build: false en Grype. El Terraform de infra/ tiene recursos reales con decisiones de arquitectura que en algunos casos priorizan funcionalidad sobre el checklist de Checkov. Antes de bloquear el pipeline, hay que entender qué está encontrando y si cada finding es accionable en este contexto.

Ahora la ironía: días después de terminar el pipeline, TeamPCP comprometió Checkmarx KICS. La misma campaña que había empezado con Trivy el 19 de marzo llegó al escáner de IaC más popular del ecosistema el 23 de marzo. Si se hubiera elegido KICS en lugar de Checkov, habría que haber reemplazado la herramienta antes de que el pipeline tuviera una semana de vida.

No se sabía cuando se tomó la decisión. Pero refuerza algo que el incidente de Trivy ya había dejado claro: en este ecosistema, ninguna herramienta está exenta. La forma de mitigarlo no es elegir la herramienta "correcta" — es pinear por SHA, auditar regularmente, y estar dispuesto a reemplazar cualquier pieza del pipeline si el contexto cambia.

Lo que llegó el 31: axios

El pipeline estaba prácticamente terminado cuando el 31 de marzo llegó el ataque a axios.

axios es el cliente HTTP más usado en el ecosistema JavaScript — más de 100 millones de descargas semanales en npm. El 31 de marzo, alguien comprometió la cuenta del maintainer principal, jasonsaayman, cambió el email registrado a una dirección de ProtonMail controlada por el atacante, y publicó dos versiones maliciosas simultáneamente: axios@1.14.1 y axios@0.30.4. Ambas ramas — la 1.x y la 0.x — comprometidas en 39 minutos. [StepSecurity] [Elastic Security Labs]

El vector no fue un tag mutable ni una credencial retenida de un incidente previo. Fue más simple y más directo: compromiso de cuenta del maintainer. Alguien tomó control del npm account que tenía permisos de publicación y lo usó para distribuir malware.

El payload tampoco fue un infostealer que corría en paralelo con la herramienta legítima. Fue un RAT — Remote Access Trojan — entregado via postinstall hook a través de una dependencia inyectada: plain-crypto-js@4.2.1. El ataque estuvo pre-preparado con 18 horas de anticipación: primero publicaron una versión limpia de plain-crypto-js@4.2.0 para construir historial en el registry y evitar alarmas de "paquete nuevo sospechoso". Luego publicaron la versión maliciosa 4.2.1. Luego comprometieron axios para que la importara. [Socket]

Cualquier proyecto usando ^1.14.0 o ^0.30.0 — el rango más común en package.json — habría descargado la versión comprometida en su próximo npm install. Sin intervención manual. Sin señal visible.

Lo que resultó más interesante no fue el ataque en sí — sino la decisión que ya se había tomado sin pensar en este escenario específico.

pip install semgrep==1.96.0 --require-hashes --hash=sha256:... --require-hashes no es solo protección contra tags mutables. Es protección contra exactamente este tipo de ataque: alguien que toma control de una cuenta de publicación y reemplaza el paquete legítimo con uno malicioso. Si el contenido cambia, el hash cambia. Si el hash cambia, pip rechaza la instalación antes de ejecutar nada.

Esa decisión no se tomó pensando en axios — se tomó porque Trivy había dejado claro que las herramientas externas no son inmunes. Pero el principio que la guió era el mismo: no confiar en el nombre del paquete, confiar en el contenido verificado.

Dos incidentes distintos, dos vectores distintos, el mismo principio de fondo.

Las sorpresas durante la construcción

Ningún pipeline sale exactamente como lo planeaste. Este tampoco.

harden-runner: de audit a block

No estaba en el plan inicial agregar step-security/harden-runner al pipeline. Se encontró mientras se investigaba cómo hacer el pipeline más robusto después del incidente de Trivy, y se decidió integrarlo desde el principio en lugar de dejarlo para después.

Lo que hace es concreto: monitorea el egress de red del runner durante la ejecución y puede bloquearlo o auditarlo según la política que configures. Para empezar se usó egress-policy: audit — que registra todas las conexiones salientes sin bloquear nada todavía.

- name: Harden Runner

uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d

with:

egress-policy: audit

¿Por qué audit y no block desde el principio? Porque no se sabía exactamente a qué dominios necesitaba conectarse cada herramienta en runtime. Bloquear todo sin ese mapa previo habría roto el pipeline en el primer run. Primero se observa, luego se restringe.

Después de varios runs en modo audit, harden-runner había registrado todos los dominios a los que se conectaba el pipeline — PyPI, GitHub APIs, registros de contenedores, endpoints de Semgrep, Grype y Checkov. Con ese mapa en mano, se cambió la política a block con una lista explícita de dominios permitidos:

- name: Harden Runner

uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d

with:

egress-policy: block

allowed-endpoints: >

api.github.com:443

github.com:443

pypi.org:443

files.pythonhosted.org:443

anchore.io:443

dl.circleci.com:443

registry-1.docker.io:443

auth.docker.io:443

production.cloudflare.docker.com:443

Ahora cualquier conexión saliente que no esté en esa lista falla explícitamente. Si una herramienta comprometida intenta exfiltrar datos a un dominio externo — como hizo el infostealer de Trivy hacia scan.aquasecurtiy[.]org — el egress está bloqueado antes de que salga un solo byte.

Y sí — también está pineado por SHA.

Los summary tables no estaban en el plan

Cuando el pipeline empezó a correr, los resultados aparecían en los logs de cada step — escondidos entre el output de la herramienta, difíciles de leer, fáciles de ignorar. Gitleaks escribe su propio summary automáticamente. Los demás jobs no.

La solución fue agregar un step al final de cada job que parsea el output de la herramienta y lo escribe en $GITHUB_STEP_SUMMARY como tabla Markdown. Para Semgrep, el output es SARIF — JSON estructurado que se puede parsear con Python inline:

- name: Upload Semgrep results to summary

if: always()

run: |

echo "## 🔍 SAST — Semgrep Results" >> $GITHUB_STEP_SUMMARY

echo "| File | Line | Rule | Severity |" >> $GITHUB_STEP_SUMMARY

echo "|------|------|------|----------|" >> $GITHUB_STEP_SUMMARY

python3 -c "

import json

with open('semgrep-results.sarif') as f:

data = json.load(f)

results = data.get('runs', [{}])[0].get('results', [])

if not results:

print('| ✅ No findings | - | - | - |')

else:

for r in results:

loc = r.get('locations', [{}])[0].get('physicalLocation', {})

file = loc.get('artifactLocation', {}).get('uri', 'N/A')

line = loc.get('region', {}).get('startLine', 'N/A')

rule = r.get('ruleId', 'N/A')

level = r.get('level', 'N/A')

print(f'| {file} | {line} | {rule} | {level} |')

" >> $GITHUB_STEP_SUMMARY

if: always() garantiza que este step corre aunque el job anterior haya encontrado findings y retornado exit code distinto de cero. Sin eso, el summary nunca se escribe cuando más importa — precisamente cuando hay algo que mostrar.

Lo mismo para Grype y Checkov, adaptado al formato JSON que genera cada herramienta.

Parece un detalle cosmético. No lo es. Un pipeline que encuentra vulnerabilidades pero las presenta de forma ilegible termina siendo ignorado. La visibilidad no es opcional — es parte del diseño.

El mensaje a la comunidad

Si llegaste hasta acá esperando el pipeline perfecto, hay que ser honesto: no existe.

Lo que se construyó es un pipeline funcional, con decisiones razonables para el contexto en el que se tomaron, con herramientas que en este momento son las más confiables disponibles — y que pueden cambiar mañana si el ecosistema cambia. Eso no es una limitación del pipeline. Es la naturaleza del trabajo.

Lo que sí quedó claro en el proceso es que hay una diferencia importante entre construir un pipeline siguiendo un tutorial y construirlo con contexto real encima. Los tutoriales muestran el camino feliz. La realidad agrega fricción — una herramienta que cambia, un incidente que obliga a replantear una decisión, una dependencia que hay que reemplazar. Eso no es una señal de que algo salió mal. Es la señal de que se está aprendiendo con el ecosistema real, no con una foto fija de él.

SHA pinning en lugar de tags. Verificación de hash en instalaciones de paquetes. Dependabot para mantener los SHAs actualizados con criterio humano en cada PR. Modo auditoría antes de bloquear. harden-runner con egress policy para contener el blast radius si algo se compromete. Visibilidad en los resultados para que los findings no se pierdan en los logs. Ninguna de esas decisiones es compleja — todas tienen un costo de implementación bajo y un valor de contención alto cuando algo sale mal.

Lo que más queda de todo este proceso es que la seguridad del pipeline no es un estado que alcanzas — es una práctica que mantienes. Hoy el pipeline tiene estas herramientas y estas versiones. En seis meses va a tener otras. Lo que no debería cambiar son los principios que guían cada decisión: mínimo privilegio, verificación explícita, blast radius acotado.

No eres menos engineer por construir mientras todo pasa. No tienes que esperar a que el ecosistema se estabilice para empezar — nunca se va a estabilizar. Lo que sí puedes hacer es construir con los ojos abiertos, documentar las decisiones que tomaste y por qué, y estar dispuesto a reemplazar cualquier pieza del pipeline cuando el contexto lo justifica.

El código está en el repositorio gerardokaztro/iam-audit. El pipeline está en .github/workflows/security-pipeline.yml. Todo abierto, todo documentado, todo tuyo.

Sobre el autor

Gerardo Castro es AWS Security Hero y Cloud Security Engineer con foco en LATAM. Fundador y Lead Organizer del AWS Security Users Group LatAm. Cree que la mejor forma de aprender seguridad en la nube es construyendo cosas reales — no memorizando frameworks. Escribe sobre lo que construye, lo que encuentra, y lo que aprende en el camino.

🔗 GitHub: gerardokaztro

🔗 LinkedIn: gerardokaztro

Gerardo Castro

Gerardo Castro

AWS Security Hero · Founder, AWS Security Users Group LatAm

Cloud Security Engineer con foco en LATAM. Cree que la mejor forma de aprender seguridad en la nube es construyendo cosas reales — no memorizando frameworks.

Comentarios