Lab: Hardening de Workflows GitHub Actions — Permisos, Pinning y Secretos

Descripción General

GitHub Actions se ha convertido en la plataforma de CI/CD más ampliamente adoptada tanto para software de código abierto como comercial. Esa popularidad la convierte en la superficie de ataque número uno en el panorama CI/CD. Los workflows mal configurados filtran secretos de forma rutinaria, otorgan permisos excesivos e incorporan código de terceros que puede ser manipulado silenciosamente.

En este lab práctico, endurecerás un workflow de GitHub Actions deliberadamente inseguro utilizando las tres técnicas de mayor impacto disponibles actualmente:

  1. Permisos mínimos — restringir el GITHUB_TOKEN únicamente a los scopes que cada job realmente necesita.
  2. SHA pinning — referenciar cada action de terceros por su SHA de commit inmutable en lugar de un tag mutable.
  3. Protección de secretos — delimitar los secretos a environments con puertas de aprobación y prevenir fugas a través de pull requests basados en forks.

Al finalizar el lab, tendrás una plantilla de workflow de nivel producción que puedes incorporar en cualquier repositorio.

Requisitos Previos

  • Una cuenta de GitHub con permisos para crear repositorios.
  • Familiaridad básica con la sintaxis YAML de GitHub Actions (triggers, jobs, steps).
  • El CLI gh instalado (opcional pero útil para consultar SHAs de actions).

Configuración del Entorno

Crear un Repositorio de Prueba

Crea un nuevo repositorio público en GitHub llamado gha-hardening-lab. Puedes hacerlo a través de la interfaz o con el CLI:

gh repo create gha-hardening-lab --public --clone
cd gha-hardening-lab

Inicializa un proyecto Node.js mínimo para que el workflow tenga algo que construir:

npm init -y
cat <<'EOF' > index.js
console.log("Hello from the hardening lab");
EOF
git add -A && git commit -m "Initial commit" && git push

El Workflow Inicial (Inseguro)

Crea el archivo .github/workflows/build.yml con el siguiente contenido. Este workflow es intencionalmente inseguro — no tiene bloque de permissions, usa tags mutables y expone secretos de forma demasiado amplia:

# .github/workflows/build.yml  — Punto de partida INSEGURO
name: Build

on:
  push:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .

Haz commit y push de este archivo. Se ejecutará correctamente, pero tiene al menos cinco problemas de seguridad que corregirás en los ejercicios siguientes.

Ejercicio 1: Permisos Mínimos

El Problema con los Permisos por Defecto

Cuando un workflow no declara un bloque permissions, el GITHUB_TOKEN recibe los permisos por defecto del repositorio. Para la mayoría de los repositorios, esto significa acceso de lectura y escritura a cada scope — contents, packages, issues, pull requests, deployments y más. Si un atacante compromete cualquier step en ese workflow, hereda todos esos permisos.

El principio de mínimo privilegio exige que otorgues únicamente los permisos que cada job realmente requiere, y nada más.

Paso 1 — Establecer un Valor por Defecto Restrictivo a Nivel Superior

Añade una clave permissions a nivel superior inmediatamente después del bloque on:. Esto establece el valor por defecto para cada job en el workflow:

permissions:
  contents: read

Si deseas comenzar con el valor por defecto más restrictivo posible y luego otorgar permisos por job, puedes usar un mapa vacío:

permissions: {}

Paso 2 — Añadir Permisos por Job

Cada job puede sobrescribir el valor por defecto a nivel de workflow. Otorga únicamente lo que el job necesita:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read       # check out code
      actions: read        # read workflow metadata
    steps:
      - uses: actions/checkout@v4
      # ...

Si un segundo job necesita subir un asset de release, le otorgarías contents: write únicamente a ese job — nunca a nivel de workflow.

Antes y Después

Antes (inseguro):

name: Build
on:
  push:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install

Después (endurecido):

name: Build
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      actions: read
    steps:
      - uses: actions/checkout@v4
      - run: npm install

Verificar los Permisos Efectivos

Después de que el workflow se ejecute, abre el job en la pestaña Actions. Haz clic en el ícono de engranaje en la parte superior derecha del log del job y selecciona “Set up job”. Expande esa sección para ver los permisos exactos del GITHUB_TOKEN que fueron otorgados. Confirma que solo aparecen contents: read y actions: read.

También puedes consultar los permisos programáticamente dentro de un step:

- name: Print token permissions
  run: |
    curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
      https://api.github.com/repos/${{ github.repository }} \
      | jq '.permissions'

Ejercicio 2: Fijar Actions por SHA

Por Qué los Tags Son Peligrosos

Cuando escribes uses: actions/checkout@v4, estás referenciando un tag de Git. Los tags son mutables — el mantenedor de la action (o un atacante que comprometa su cuenta) puede eliminar y recrear el tag apuntando a un código completamente diferente. Tu workflow entonces ejecutaría silenciosamente el nuevo código en su siguiente ejecución. El SHA pinning elimina este riesgo porque un SHA de commit es inmutable.

Paso 1 — Encontrar el SHA de una Action

Usa el CLI gh para resolver un tag a su SHA de commit:

# Resolver actions/checkout@v4 a un SHA de commit
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'

Si el tag es anotado (la mayoría lo son), el comando anterior devuelve el SHA del objeto tag. Necesitas desreferenciarlo al commit:

TAG_SHA=$(gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha')
gh api repos/actions/checkout/git/tags/$TAG_SHA --jq '.object.sha'

Alternativamente, visita el repositorio de la action en GitHub, haz clic en el tag y copia el SHA de commit completo desde la URL o el encabezado del commit.

Paso 2 — Fijar las Actions Comunes

Reemplaza cada tag mutable con el SHA completo de 40 caracteres. Siempre añade un comentario al final con la versión para facilitar la lectura:

steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
    with:
      node-version: 20
  - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
    with:
      path: ~/.npm
      key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
    with:
      name: build-output
      path: dist/

Paso 3 — Automatizar las Actualizaciones de SHA con Dependabot

Fijar por SHA significa que ya no recibirás actualizaciones automáticas basadas en tags. Dependabot resuelve esto abriendo pull requests cada vez que una action fijada publica una nueva versión.

Crea el archivo .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    commit-message:
      prefix: "ci"

Después de hacer push de este archivo, Dependabot escaneará tus workflows semanalmente y abrirá PRs para actualizar los SHAs fijados. Cada PR muestra el diff del código de la action, dándote la oportunidad de revisar antes de hacer merge.

Si prefieres Renovate en lugar de Dependabot, añade un archivo renovate.json en la raíz del repositorio:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "github-actions": {
    "enabled": true
  }
}

Ejercicio 3: Protección de Secretos

Repository Secrets vs. Environment Secrets

GitHub ofrece dos niveles de almacenamiento de secretos:

  • Repository secrets — disponibles para cada workflow y cada job en el repositorio. Convenientes pero excesivamente amplios.
  • Environment secrets — disponibles únicamente para jobs que declaren explícitamente environment: <nombre>. Este es el enfoque recomendado para credenciales sensibles.

Paso 1 — Crear un Environment con Reglas de Protección

En tu repositorio, navega a Settings → Environments y crea un environment llamado production. Habilita las siguientes reglas de protección:

  1. Required reviewers — añade al menos un miembro del equipo que debe aprobar los deployments.
  2. Wait timer — opcionalmente añade un retraso (por ejemplo, 5 minutos) para dar tiempo a los revisores.
  3. Deployment branches — restringe únicamente a main.

Ahora añade tu DEPLOY_TOKEN como secreto dentro de este environment, no a nivel de repositorio.

Paso 2 — Referenciar el Environment en Tu Workflow

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

La declaración environment: production significa que este job se pausará y esperará a que un revisor lo apruebe antes de que cualquier step se ejecute. El secreto DEPLOY_TOKEN solo está disponible dentro de este environment — no puede ser accedido por otros jobs o workflows que no declaren este environment.

Paso 3 — Entender el Comportamiento con Forks

Los secretos no están disponibles para workflows disparados por eventos pull_request desde forks. Esta es una frontera de seguridad crítica. Si creas un workflow que depende de secretos durante las verificaciones de PR, fallará para contribuidores externos:

# Este step fallará para PRs basados en forks porque DEPLOY_TOKEN está vacío
- name: Authenticated API call
  run: |
    curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/health
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Esto es por diseño — evita que forks maliciosos exfiltren tus secretos.

Paso 4 — El Peligro de pull_request_target

El trigger pull_request_target se ejecuta en el contexto del repositorio base, lo que significa que tiene acceso a los secretos. Esto es extremadamente peligroso si también haces checkout del código head del PR:

# PELIGROSO — NO HAGAS ESTO
on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Checks out UNTRUSTED code
      - run: npm install  # Executes attacker-controlled code with access to secrets
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Un atacante puede modificar package.json para incluir un script postinstall que exfiltre DEPLOY_TOKEN. Nunca combines pull_request_target con un checkout del head del PR a menos que hayas validado y aislado explícitamente el código.

Alternativa segura: Usa el trigger estándar pull_request para workflows de build y test. Reserva pull_request_target únicamente para workflows de etiquetado o comentarios que nunca ejecuten código del PR.

Resumen de Mejores Prácticas

  • Almacena secretos sensibles en environments, no a nivel de repositorio.
  • Añade required reviewers y restricciones de branch a cada environment que contenga credenciales de producción.
  • Usa el trigger pull_request para CI. Evita pull_request_target a menos que comprendas completamente las implicaciones de confianza.
  • Diseña workflows de forma que los jobs que necesitan secretos estén separados de los jobs que ejecutan código no confiable.

Ejercicio 4: Hardening Adicional

Prevenir Ejecuciones Duplicadas con Concurrency

Sin una política de concurrency, hacer push de múltiples commits en rápida sucesión genera múltiples ejecuciones del workflow que desperdician recursos y pueden causar condiciones de carrera durante el deployment. Añade un bloque concurrency a nivel de workflow:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Esto cancela cualquier ejecución en progreso para el mismo workflow y branch cuando se hace push de un nuevo commit.

Establecer Límites de Timeout

Un job colgado puede consumir minutos de runner indefinidamente. Siempre establece un timeout explícito:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15

Elige un valor que dé a tu build suficiente margen pero prevenga procesos descontrolados. Para la mayoría de builds de Node.js o Go, 10 a 20 minutos es generoso.

Restringir los Triggers del Workflow

Evita triggers sin restricciones que se disparen en cada branch:

# Demasiado amplio — se ejecuta en cada push a cada branch
on:
  push:

En su lugar, delimita los triggers a los branches que importan:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Esto reduce las ejecuciones innecesarias y limita la superficie de ataque para ataques de inyección basados en branches.

Ejecución Condicional para Steps Sensibles

Usa condiciones if: para prevenir que steps sensibles se ejecuten en contextos donde no deberían:

- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: ./deploy.sh
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Esto asegura que el step de deploy solo se ejecute en pushes a main, nunca en pull requests u otros branches, incluso si el job en sí fue disparado.

El Workflow Final Endurecido

A continuación se muestra el workflow endurecido completo junto al original. Cada mejora de seguridad está anotada con un comentario.

Original (Inseguro)

name: Build

on:
  push:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .

Endurecido

name: Build

# HARDENED: Scoped triggers — only main branch, safe PR trigger
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# HARDENED: Restrictive default permissions for all jobs
permissions:
  contents: read

# HARDENED: Cancel duplicate runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    # HARDENED: Explicit timeout
    timeout-minutes: 15
    # HARDENED: Per-job permissions (least privilege)
    permissions:
      contents: read
      actions: read
    steps:
      # HARDENED: All actions pinned by SHA
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        # HARDENED: No secrets exposed in the build/test job
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    # HARDENED: Only runs on push to main
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    # HARDENED: Secrets gated behind environment with required reviewers
    environment: production
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Rompiéndolo (Fallo Intencional)

Para consolidar tu comprensión, rompe deliberadamente el workflow endurecido y observa las consecuencias.

Prueba 1 — Eliminar el Bloque de Permissions

Elimina la clave permissions: a nivel superior y los permisos por job. Haz push y ejecuta el workflow. Seguirá teniendo éxito, pero si inspeccionas el step de setup del job, verás que el token ahora tiene acceso de lectura y escritura a cada scope. Un step comprometido podría hacer push de código, eliminar branches o modificar releases.

Prueba 2 — Usar una Action Sin Fijar

Cambia una action de vuelta a una referencia por tag:

- uses: actions/checkout@v4

El workflow seguirá ejecutándose. Pero si el tag v4 alguna vez se mueve a un commit malicioso, tu workflow ejecutará ese código sin advertencia. No hay rastro de auditoría — el tag simplemente resuelve a un SHA diferente. Fija de nuevo al SHA después de esta prueba.

Prueba 3 — Acceder a Secretos de Producción desde un PR

Crea un feature branch y abre un pull request. El job deploy no se ejecutará debido a la condición if:. Incluso si eliminas la condición, el secreto DEPLOY_TOKEN del environment está protegido por el environment production, que restringe el deployment al branch main y requiere aprobación de un revisor. El valor del secreto estará vacío en el contexto del PR.

Este es exactamente el comportamiento que deseas — los secretos nunca están disponibles en contextos no confiables.

Limpieza

Cuando hayas terminado el lab, elimina el repositorio de prueba para evitar acumular desorden en tu cuenta:

gh repo delete gha-hardening-lab --yes

Si usaste un fork de un proyecto existente, puedes restablecerlo en su lugar:

git checkout main
git reset --hard origin/main
git push --force

Conclusiones Clave

  • Siempre declara un bloque permissions. Establece un valor por defecto restrictivo a nivel de workflow y otorga scopes adicionales por job solo según sea necesario.
  • Fija cada action de terceros por su SHA completo. Los tags son mutables y pueden ser redirigidos silenciosamente a código malicioso.
  • Usa Dependabot o Renovate para mantener los SHAs fijados actualizados automáticamente.
  • Almacena secretos sensibles en environments con required reviewers y restricciones de branch — nunca a nivel de repositorio.
  • Usa pull_request, no pull_request_target, para workflows que construyan o prueben código de PRs. El trigger pull_request_target otorga acceso a secretos a código potencialmente no confiable.
  • Añade concurrency, timeout-minutes y triggers delimitados por branch para reducir el desperdicio de recursos y reducir la superficie de ataque.

Próximos Pasos

Continúa construyendo tu conocimiento de seguridad CI/CD con estas guías relacionadas: