Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Auditar un pipeline de IaC después del provider de Terraform troyanizado

El 8 de octubre, Zscaler ThreatLabz describió un provider de Terraform que se comporta exactamente como un provider normal de AWS y también, en el momento en que Terraform lo carga, jala un loader de un dominio parecido

El 8 de octubre, Zscaler ThreatLabz describió un provider de Terraform que se
comporta exactamente como un provider normal de AWS y también, en el momento en
que Terraform lo carga, jala un loader de un dominio parecido e instala un robador
de credenciales (reporte).

Este stack no usa Terraform. Usa AWS CDK, desplegado desde GitHub Actions sobre
runners ARM autoalojados. La pregunta de todos modos aplica: si un paquete en el
toolchain de infraestructura se volviera hostil mañana, ¿qué obtendría? Este post
es la auditoría que respondió esa pregunta, con los comandos, su salida, y los
arreglos rankeados por palanca. La mayoría todavía no están hechos.

Qué es un provider troyanizado, en términos de CDK

Un provider de Terraform es un binario que corre con las credenciales de nube que
sean que estén en su entorno. En CDK el equivalente es cada paquete en
infra/node_modules: cdk synth ejecuta tu app, tu app importa aws-cdk-lib y
cualquier otra cosa que instalaste, y todo eso corre como un solo proceso de Node.
El provider de Zscaler se disparaba al cargar. Un paquete de npm malicioso se puede
disparar al instalar (lifecycle scripts) o al importar (código de nivel superior).
Al importar basta aquí, porque el synth importa todo.

Así que la pregunta de la auditoría es: ¿qué credenciales y acceso de red existen
en el proceso que corre npm ci y cdk synth?

El punto de partida

El job de deploy:

- name: AWS credentials (OIDC)
  if: env.DEPLOY_ROLE_ARN != ''
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
    aws-region: ${{ env.AWS_REGION }}

- name: AWS credentials (legacy static keys)
  if: env.DEPLOY_ROLE_ARN == ''
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ env.AWS_REGION }}

# ... setup-node ...

- name: Deploy infra
  run: |
    cd infra
    npm ci
    npx cdk deploy MyApp-Backend --require-approval never --progress events

El orden importa: las credenciales se ponen primero, luego npm ci y luego el
synth corren dentro del mismo entorno.

Hallazgo 1: el rol de OIDC que nadie asume

El repo tiene un rol de OIDC, con alcance a main y al entorno production, con
una sesión de una hora. El workflow lo prefiere. Luego:

gh secret list
AWS_ACCESS_KEY_ID   2026-04-06
AWS_SECRET_ACCESS_KEY   2026-04-06
...

No hay un AWS_DEPLOY_ROLE_ARN, así que el if: siempre toma la rama de llaves
estáticas.

aws iam list-access-keys --user-name myapp-ci
aws iam get-access-key-last-used --access-key-id "$KEY_ID"
{ "Status": "Active", "CreateDate": "2026-04-06T04:03:15+00:00" }
{ "LastUsedDate": "2026-10-15T22:46:00+00:00", "ServiceName": "autoscaling" }

El camino seguro se escribió, se mergeó y se desplegó, y nada lo usa. Es fácil
ver cómo pasa eso. La migración estaba bloqueada por una restricción real: el rol
de OIDC todavía no cargaba cada permiso que los workflows de frontend, intelligence
y security-scan necesitan. Mientras tanto, cada grant nuevo fue a las dos
identidades hasta que el usuario pegó contra un techo fácil de pasar por alto:

Maximum policy size of 2048 bytes exceeded for user myapp-ci

Ese límite de 2048 bytes aplica a la suma de todas las inline policies de un
usuario, no a cada una. El usuario de CI estaba en 1943 bytes (95%) cuando falló.
El workaround fue una managed policy. El arreglo es terminar la migración, que quita
al usuario y su límite juntos.

Una llave estática así de vieja es lo que un robador de credenciales está buscando.
El payload en el reporte de Zscaler es un backdoor multiplataforma con robadores de
datos. En un runner, la llave se sienta en el entorno de cada proceso que el job de
deploy arranca.

Hallazgo 2: el install y el synth corren con credenciales de deploy

Las credenciales tienen alcance, pero solo en el papel. El grant de CDK de la
identidad de CI es nada más sts:AssumeRole sobre los roles de bootstrap:

actions: ["sts:AssumeRole", "sts:TagSession"],
resources: [`arn:aws:iam::${account}:role/cdk-hnb659fds-*-${account}-${region}`],

Uno de esos roles es el deploy role, que le entrega a CloudFormation el execution
role:

aws iam list-attached-role-policies \
  --role-name cdk-hnb659fds-cfn-exec-role-ACCOUNT-us-east-1 --output text
ATTACHEDPOLICIES    arn:aws:iam::aws:policy/AdministratorAccess AdministratorAccess

Ese es el default de cdk bootstrap. Cualquier código que corra en el paso de
deploy puede desplegar un stack de CloudFormation, y ese stack puede crear un
usuario de IAM con derechos de admin. La cadena es: npm ci o cdk synth →
credenciales del entorno → sts:AssumeRole sobre cdk-*-deploy-role →
CloudFormation → AdministratorAccess.

Lo que limita el radio de impacto hoy:

python3 -c "
import json; p = json.load(open('package-lock.json'))['packages']
print('packages', len(p) - 1)
print('install scripts', [k for k, v in p.items() if v.get('hasInstallScript')])
print('bundled', sum(1 for v in p.values() if v.get('inBundle')))
print('integrity missing', sum(1 for k, v in p.items() if k and not v.get('integrity') and not v.get('inBundle')))"
packages 49
install scripts []
bundled 20
integrity missing 0

49 paquetes, ninguno con install scripts, todos fijados por hash de integridad. Esa
es una superficie chica y verificable. Es suerte más que política, sin embargo:
nada detiene que el paquete 50 agregue un postinstall, y nada detendría que
código de nivel superior en cualquiera de los 49 corra en el synth.

El arreglo es dividir el job. El synth no necesita credenciales cuando los lookups
de contexto están cacheados en el cdk.context.json commiteado (este repo ya hace
eso para sus llamadas de HostedZone.fromLookup y Vpc.fromLookup). Así que:

  1. Job synth: sin credenciales de AWS, npm ci --ignore-scripts, cdk synth, sube cdk.out.
  2. Job deploy: descarga cdk.out, asume el rol de OIDC, corre cdk deploy --app cdk.out. Sin npm ci y sin código de app.

El job de deploy todavía corre la CLI de CDK, que es un paquete. Esa es la misma
confianza que le pones a terraform mismo, y mucho menos que "cada paquete en el
lockfile". El workflow está en la carpeta de código.

Hallazgo 3: un lockfile prueba igualdad, no seguridad

Tres alertas de Dependabot sobre brace-expansion en infra/ no las puede
arreglar este repo. La copia vulnerable viene dentro del tarball de aws-cdk-lib
como una de sus 20 dependencias bundleadas:

node_modules/aws-cdk-lib/node_modules/brace-expansion   "inBundle": true

Una entrada de overrides la deja sin cambios, y npm audit fix reporta un
arreglo que nunca aplica. El hash de integridad del lockfile cubre el tarball de
aws-cdk-lib, lo que significa que cubre la copia bundleada también, byte por
byte. Esa es la misma garantía que .terraform.lock.hcl da para un provider:
obtienes los bytes que obtuviste la vez pasada. El hash checa que los bytes no
cambiaron. No dice nada sobre si eran seguros. Si la primera descarga estuvo mal, el
lockfile fija los bytes malos.

Los controles de Terraform del escrito de Zscaler (checksums, lockfiles) son
necesarios, y solo llegan hasta ahí. Lo que de verdad reduce la exposición es una
allowlist corta de fuentes (un registry privado o un network mirror para los
providers, onlyBuiltDependencies para los build scripts de npm) más los hallazgos
2 y 4: limitar qué puede alcanzar el código una vez que corre.

Hallazgo 4: un runner de larga vida con derechos de deploy, corriendo código de PR

Los runners son autoalojados, de larga vida, y su instance profile espeja el deploy
role:

// Espeja los permisos de deploy del rol de OIDC para que los jobs se comporten
// idénticos en el runner nuevo.
instanceRole.addToPolicy(new iam.PolicyStatement({ sid: "ECRPushPull", /* ... */ }));
instanceRole.addToPolicy(new iam.PolicyStatement({ sid: "ECSDeploy", /* ... */ }));
instanceRole.addToPolicy(new iam.PolicyStatement({ sid: "PassECSRoles", /* ... */ }));
instanceRole.addToPolicy(new iam.PolicyStatement({ sid: "S3SyncSpaAndMarketing", /* ... */ }));

Los jobs corren directo en el host, así que cualquier proceso en cualquier job
puede obtener esas credenciales del instance metadata service. Un hop limit de 1 no
ayuda, porque el job no está en un contenedor. El CI de pull request corre en el
mismo pool:

gh api repos/acme/myapp/actions/runs/$CI_RUN/jobs \
  --jq '.jobs[]|[.name,.runner_name,.conclusion]|@tsv'
changes myapp-runner-eded276f1-1    success
frontend    myapp-runner-eded276f1-1    success
frontend-time-travel    myapp-runner-eded276f1-2    success

Esa corrida era un PR de Dependabot subiendo katex. El job frontend corrió
pnpm install --frozen-lockfile con el paquete subido, en una caja con push a ECR,
deploy a ECS y escritura a S3 sobre el bucket de la SPA de producción, a una
petición de metadata de distancia.

GitHub retiene los secretos del repositorio de las corridas disparadas por
Dependabot. Eso se puede ver en el mismo PR, donde el job que despierta el pool de
runners falló porque no tenía credenciales:

{"conclusion":"failure","labels":["ubuntu-latest"],"name":"wake"}

Los jobs de CI corrieron de todos modos, porque otro workflow ya había despertado
el pool. GitHub retuvo los secretos correctamente. El instance profile no es un
secreto, así que nunca se retuvo.
La subida de katex era benigna. El camino
estaba abierto.

Dos mitigaciones ya ayudan. pnpm 10 no corre ningún build script de dependencia a
menos que estén en allowlist, y la allowlist tiene tres entradas:

"pnpm": { "onlyBuiltDependencies": ["esbuild", "protobufjs", "@sentry/cli"] }

El pool también escala a cero entre workflows, así que un compromiso no persiste por
mucho. Ninguno de los dos cambios afecta lo que el código puede hacer al importar
durante la corrida de pruebas.

El arreglo: quítale los permisos de deploy al instance profile, para que solo pueda
leer el token de registro del runner, y deja que los jobs de deploy obtengan
credenciales a través de OIDC por job. Entonces un job de PR en la misma caja no
obtiene nada del metadata service que importe. Por separado, los runners
--ephemeral se aseguran de que las sobras de un job no puedan alcanzar las
credenciales del siguiente job.

Hallazgo 5: nadie lo vería

aws guardduty list-detectors
{ "DetectorIds": [] }

Para la detección, el reporte de Zscaler recomienda vigilar actividad de proceso
inesperada en las estaciones de trabajo de los desarrolladores y los sistemas de
CI/CD. En esta cuenta, nada vigila los runners ni las llamadas de API que vienen de
ellos. Una llave de CI robada usada desde otra IP se vería como la llave de CI.

La señal más barata sin servicios nuevos: la llave estática solo se usa desde las
IPs públicas del pool de runners. Una query de CloudTrail Lake o un script de
lookup-events que marque cualquier llamada de myapp-ci desde una dirección fuera
del pool atrapa el caso de la llave robada. Script en la carpeta de código. Los
hallazgos de EC2 e IAM de GuardDuty cubren más terreno. Ponle precio para tu cuenta
antes de habilitarlo.

Para lectores de Terraform y OpenTofu

No probado en este stack, porque no tiene Terraform. Estos son los equivalentes de
los controles de arriba.

Restringe de dónde pueden venir los providers, en la config de la CLI
(~/.terraformrc, o TF_CLI_CONFIG_FILE en CI):

provider_installation {
  network_mirror {
    url     = "https://terraform-mirror.example.com/providers/"
    include = ["registry.terraform.io/hashicorp/*"]
  }
  direct {
    exclude = ["registry.terraform.io/*/*"]
  }
}

Commitea el lockfile con hashes para cada plataforma que corre init, y falla el CI
si init lo cambiaría:

terraform providers lock -platform=linux_arm64 -platform=linux_amd64 -platform=darwin_arm64
terraform init -lockfile=readonly

Corre plan con credenciales que pueden leer pero no escribir, y corre apply en
un job separado con el rol de OIDC, como en el hallazgo 2. Un provider llamado
awsbeta debería fallar una revisión de required_providers de inmediato. La
config del mirror de arriba hace que falle al instalar.

Lecciones

  1. Audita el proceso, no el paquete. "¿Este paquete es malicioso?" no se puede responder. "¿Qué credenciales existen en el proceso que lo corre?" sí, y la respuesta decide el radio de impacto.
  2. Un camino seguro que nadie toma no es un control. El rol de OIDC pasó la revisión, se desplegó limpio, y no autenticó nada por meses. Checa qué corrió de verdad: get-access-key-last-used y gh secret list.
  3. Los lockfiles prueban igualdad, no seguridad. Son la herramienta correcta para "¿cambiaron los bytes?". Usa otra cosa para "¿deberían correr estos bytes?".
  4. El instance metadata es un secreto que ningún almacén de secretos retiene. Cualquier cosa que retiene secretos de las corridas no confiables (Dependabot, forks) se salta el instance profile.
  5. El default del bootstrap es admin. Los pipelines de CDK y de Terraform los dos tienden a terminar en un rol que puede hacer cualquier cosa. Saber dónde termina el tuyo antes de decidir cuánto importan los eslabones anteriores.
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.