Lab: Asegurando Pipelines de GitLab CI — Variables Protegidas, Runners y Entornos

Descripción General

GitLab CI es la segunda plataforma de CI/CD más utilizada en la industria, impulsando millones de pipelines en organizaciones de todos los tamaños. Su estrecha integración con el control de código fuente la hace excepcionalmente conveniente — pero esa misma integración crea una amplia superficie de ataque si los pipelines no se endurecen deliberadamente.

En este laboratorio práctico recorrerás seis ejercicios que aseguran progresivamente un pipeline de GitLab CI. Comenzarás con una configuración intencionalmente insegura donde cada variable es visible para cada rama, los runners compartidos manejan todos los trabajos y no hay puertas de entorno. Al final tendrás un pipeline que aplica acceso de variables con privilegio mínimo, runners con alcance definido, entornos protegidos con aprobaciones de despliegue, acceso restringido de CI_JOB_TOKEN, pipelines seguros de merge request y controles adicionales de endurecimiento incluyendo detección de secretos.

Cada comando, fragmento YAML y ruta de interfaz en este laboratorio está basado en GitLab 16.x / 17.x y funciona en el nivel gratuito de GitLab.com.

Requisitos Previos

  • Una cuenta de GitLab — el nivel gratuito en gitlab.com es suficiente para cada ejercicio.
  • Un proyecto de prueba que contenga una aplicación simple (incluso un solo archivo index.html será suficiente) y un archivo .gitlab-ci.yml en la raíz del repositorio.
  • Familiaridad básica con la sintaxis de GitLab CI: stages, jobs, scripts y rules.
  • (Opcional) Una máquina Linux o macOS si planeas registrar tu propio GitLab Runner en el Ejercicio 2.

Configuración del Entorno

Paso 1 — Crear un Nuevo Proyecto en GitLab

  1. Navega a GitLab > New Project > Create blank project.
  2. Nómbralo secure-pipeline-lab, establece la visibilidad como Private e inicializa con un README.
  3. En Settings > Repository > Protected branches, confirma que main está listada como rama protegida (esto es el valor predeterminado).

Paso 2 — Agregar una Aplicación Simple

Crea index.html en la raíz del repositorio:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Secure Pipeline Lab</title></head>
<body><h1>Hello, GitLab CI!</h1></body>
</html>

Paso 3 — Crear el Pipeline Inicial (Inseguro)

Agrega el siguiente .gitlab-ci.yml. Este es deliberadamente inseguro — es el punto de partida que endureceremos a lo largo del laboratorio:

# .gitlab-ci.yml — Punto de partida INSEGURO
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "DB_PASSWORD is $DB_PASSWORD"   # ¡Variable impresa en los logs!

test-job:
  stage: test
  script:
    - echo "Running tests..."
    - echo "API_KEY is $API_KEY"            # ¡Variable impresa en los logs!

deploy-job:
  stage: deploy
  script:
    - echo "Deploying to production..."
    - echo "DEPLOY_TOKEN is $DEPLOY_TOKEN" # ¡Variable impresa en los logs!

Este pipeline tiene varios problemas:

  • Todas las variables de CI/CD están disponibles para cada rama, incluyendo ramas creadas por colaboradores externos.
  • Las variables se imprimen directamente en los logs de los trabajos — cualquier persona con acceso a los logs puede leerlas.
  • Los trabajos se ejecutan en runners compartidos sin garantías de aislamiento.
  • No hay puertas de entorno — el trabajo de despliegue se ejecuta automáticamente en cada push.

Haz commit de este archivo en main y verifica que el pipeline se ejecuta. Ahora corrijamos cada uno de estos problemas.

Ejercicio 1: Variables Protegidas y Enmascaradas

Las variables de CI/CD de GitLab soportan tres indicadores de protección que reducen drásticamente el radio de explosión de una rama comprometida o un fork.

Comprendiendo los Tres Indicadores

Indicador Efecto
Protected La variable solo se inyecta en pipelines que se ejecutan en ramas o tags protegidos. Un pipeline activado desde una rama de funcionalidad o un fork nunca verá el valor.
Masked GitLab redacta el valor de la variable en los logs de los trabajos. Si un script imprime accidentalmente el valor, el log muestra [MASKED] en su lugar.
Hidden (GitLab 17+) El valor de la variable no puede revelarse en la interfaz después de su creación — ni siquiera por los maintainers del proyecto. Útil para secretos gestionados por un equipo de plataforma que los desarrolladores nunca deberían ver en texto plano.

Paso 1 — Crear Variables

  1. Ve a Settings > CI/CD > Variables > Expand > Add variable.
  2. Crea las siguientes variables:
Clave Valor (ejemplo) Protected Masked Hidden
DEPLOY_TOKEN glpat-xxxxxxxxxxxxxxxxxxxx No
DB_PASSWORD S3cur3P@ssw0rd!2024
API_KEY sk-test-abc123def456 No No

Paso 2 — Actualizar el Pipeline

# .gitlab-ci.yml — Ejercicio 1
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "API_KEY value length = ${#API_KEY}"  # Seguro: imprime la longitud, no el valor

test-job:
  stage: test
  script:
    - echo "Running tests..."
    # Intentando imprimir una variable enmascarada:
    - echo "DB_PASSWORD is $DB_PASSWORD"
    # La salida mostrará: DB_PASSWORD is [MASKED]

deploy-job:
  stage: deploy
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - echo "Token is $DEPLOY_TOKEN"
    # En main (protegida): Token is [MASKED]
    # En rama de funcionalidad: Token is 
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Paso 3 — Verificar el Comportamiento de Protección

  1. Push en main — el trabajo de despliegue se ejecuta y DEPLOY_TOKEN se inyecta (el log muestra [MASKED]).
  2. Crea una rama feature/test-vars, haz push de un commit — el trabajo de despliegue no se ejecuta (las rules lo restringen a main). Incluso si modificas las rules para permitirlo, DEPLOY_TOKEN y DB_PASSWORD están vacíos porque la rama no está protegida.
  3. API_KEY, que está enmascarada pero no protegida, está disponible en ambas ramas — su valor se redacta en los logs.

Lección clave: Siempre marca las credenciales de despliegue como Protected y Masked. Usa Hidden para secretos que los desarrolladores nunca deberían recuperar de la interfaz.

Ejercicio 2: Seguridad y Alcance de Runners

Los runners son los motores de cómputo que ejecutan tus trabajos de CI. Elegir el tipo correcto de runner — y definir su alcance correctamente — es una de las decisiones de seguridad más impactantes que puedes tomar.

Tipos de Runner

Tipo Alcance Postura de Seguridad
Instance (compartido) Disponible para cada proyecto en la instancia de GitLab Multi-tenant. Los trabajos de otros proyectos pueden ejecutarse en la misma máquina. Riesgo de fuga de datos a través del sistema de archivos compartido, socket de Docker o capas en caché.
Group Disponible para cada proyecto en un grupo específico Mejor aislamiento que los runners de instancia, pero aún compartido entre proyectos dentro del grupo.
Project Disponible para un solo proyecto Mejor aislamiento. Tú controlas la máquina, la configuración de Docker y el acceso a la red.

Paso 1 — Registrar un Runner Específico del Proyecto

En una máquina que controles (una VM, un servidor disponible o incluso un host Docker local), instala GitLab Runner y regístralo:

# Instalar GitLab Runner (Linux amd64)
sudo curl -L --output /usr/local/bin/gitlab-runner \
  https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
sudo chmod +x /usr/local/bin/gitlab-runner
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start

# Registrar el runner
# Encuentra tu token de registro: Settings > CI/CD > Runners > Expand > New project runner
sudo gitlab-runner register \
  --non-interactive \
  --url https://gitlab.com/ \
  --token "$RUNNER_TOKEN" \
  --executor docker \
  --docker-image alpine:latest \
  --description "secure-deploy-runner" \
  --tag-list "secure-deploy" \
  --access-level ref_protected

El indicador crítico es --access-level ref_protected. Esto le dice a GitLab que el runner solo aceptará trabajos de ramas o tags protegidos. Un pipeline activado por una rama de funcionalidad o un merge request de fork nunca se programará en este runner.

Paso 2 — Deshabilitar Runners Compartidos para Trabajos Sensibles

Ve a Settings > CI/CD > Runners y cambia Enable shared runners for this project a desactivado — o déjalos activados para stages no sensibles y usa tags para dirigir los trabajos sensibles a tu runner de proyecto.

Paso 3 — Actualizar el Pipeline con Selección de Runner Basada en Tags

# .gitlab-ci.yml — Ejercicio 2
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  # Se ejecuta en cualquier runner disponible (compartido está bien para builds)
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-job:
  stage: deploy
  tags:
    - secure-deploy            # Solo se ejecuta en runner(s) con este tag
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - |
      curl --fail --silent --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \
        https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/releases
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Dado que el runner secure-deploy está registrado con acceso ref_protected, este trabajo de despliegue solo se ejecutará en el runner específico del proyecto y solo cuando el pipeline se origine desde una referencia protegida.

Ejercicio 3: Entornos Protegidos y Aprobaciones de Despliegue

Incluso con variables protegidas y runners con alcance definido, es posible que desees una puerta humana antes de que el código llegue a producción. Los entornos protegidos de GitLab proporcionan exactamente eso.

Paso 1 — Crear Entornos

  1. Navega a Operate > Environments > New environment.
  2. Crea dos entornos: staging y production.

Paso 2 — Proteger el Entorno de Producción

  1. Ve a Settings > CI/CD > Protected environments (disponible en Premium/Ultimate, o en el nivel gratuito auto-gestionado).
  2. Selecciona production.
  3. En Allowed to deploy, restringe a Maintainers (o un usuario específico).
  4. En Required approvals, establece en 1 (o más, dependiendo de tu política).
  5. Agrega el/los aprobador(es) designado(s).

Paso 3 — Actualizar el Pipeline con Definiciones de Entorno

# .gitlab-ci.yml — Ejercicio 3
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual           # Requiere un clic humano
  allow_failure: false        # El pipeline permanece bloqueado hasta ser aprobado

Cómo Funciona la Aprobación

  1. Un push a main activa el pipeline.
  2. deploy-staging se ejecuta automáticamente.
  3. deploy-production muestra un botón Play en la interfaz del pipeline.
  4. Hacer clic en Play no ejecuta inmediatamente el trabajo — GitLab verifica las reglas de protección del entorno y presenta un diálogo de aprobación al/los aprobador(es) designado(s).
  5. Solo después de que se alcance el número requerido de aprobaciones, el trabajo comienza.

Esta puerta de dos capas — when: manual más aprobación de entorno — asegura que ninguna persona individual pueda enviar código directamente a producción sin revisión.

Ejercicio 4: Alcance de CI_JOB_TOKEN

Cada trabajo de GitLab CI recibe un token automático en la variable CI_JOB_TOKEN. Este token autentica solicitudes de API y Git como el proyecto del pipeline. Por defecto, su alcance es peligrosamente amplio.

El Riesgo

Sin restricciones, un trabajo en el Proyecto A puede usar CI_JOB_TOKEN para clonar o llamar a la API de cualquier otro proyecto en el mismo grupo (o instancia, dependiendo de la configuración). Si un colaborador malicioso inyecta un script en un trabajo de CI, puede exfiltrar código de repositorios no relacionados.

Paso 1 — Restringir el Alcance del Token

  1. Ve a Settings > CI/CD > Token Access.
  2. Cambia Limit access to this project a Enabled.
  3. En Allow CI job tokens from the following projects to access this project, agrega solo los proyectos que genuinamente necesitan acceso (un modelo de lista de permitidos).
  4. En Limit CI_JOB_TOKEN access to the following projects (saliente), agrega solo los proyectos que tu pipeline necesita alcanzar.

Paso 2 — Probar el Acceso

# .gitlab-ci.yml — Ejercicio 4
stages:
  - test

test-token-allowed:
  stage: test
  script:
    - echo "Cloning an allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/allowed-project.git
    - echo "Success — access permitted"

test-token-denied:
  stage: test
  script:
    - echo "Cloning a non-allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/restricted-project.git
    # Salida esperada: remote: HTTP Basic: Access denied
    # fatal: Authentication failed — 403 Forbidden
  allow_failure: true

Paso 3 — Verificar

  1. Ejecuta el pipeline. test-token-allowed tiene éxito y clona el proyecto permitido.
  2. test-token-denied falla con 403 Forbidden porque restricted-project no está en la lista de permitidos.

Lección clave: Siempre restringe CI_JOB_TOKEN al conjunto más pequeño de proyectos que tu pipeline realmente necesita. Trata el alcance predeterminado «abierto» como una mala configuración.

Ejercicio 5: Seguridad de Pipelines de Merge Request

Los pipelines de merge request (MR) se ejecutan cuando un colaborador abre o actualiza un merge request. Son esenciales para la calidad del código — pero también pueden ser un vector de ataque si no se configuran cuidadosamente.

El Riesgo

Cuando un colaborador externo hace fork de tu proyecto y abre un MR, GitLab puede ejecutar un pipeline en ese MR. Si el pipeline tiene acceso a variables protegidas o runners privilegiados, el código del colaborador podría exfiltrar secretos.

Paso 1 — Configurar Reglas de Pipeline de MR

# .gitlab-ci.yml — Ejercicio 5
stages:
  - validate
  - build
  - deploy

# --- Trabajos seguros para ejecutar en pipelines de MR (no se necesitan secretos) ---
lint:
  stage: validate
  script:
    - echo "Linting code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

unit-tests:
  stage: validate
  script:
    - echo "Running unit tests..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Trabajos que requieren secretos — nunca ejecutar en pipelines de MR ---
build-image:
  stage: build
  script:
    - echo "Building and pushing Docker image..."
    - echo "Using REGISTRY_TOKEN = $REGISTRY_TOKEN"  # Protected + Masked
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Cómo GitLab Maneja los Pipelines de MR desde Forks

  • Los pipelines activados por merge_request_event desde un fork se ejecutan automáticamente con permisos limitados.
  • Las variables protegidas nunca se inyectan en pipelines de MR desde forks.
  • CI_JOB_TOKEN en pipelines de fork tiene un alcance reducido — solo puede acceder al proyecto fuente (fork), no al destino.

Al separar tus trabajos en «seguros para MR» (lint, test) y «requieren secretos» (build, deploy), aseguras que los colaboradores puedan validar su código sin exponer credenciales.

Mejores Prácticas para Pipelines de MR

  • Nunca uses only/except — prefiere rules: para claridad y corrección.
  • Controla los trabajos dependientes de secretos con if: $CI_COMMIT_BRANCH == "main" (u otra referencia protegida).
  • Considera habilitar Pipelines must succeed en Settings > Merge requests para requerir que el pipeline de MR pase antes de fusionar.
  • Habilita Merged results pipelines para probar el resultado de la fusión en lugar de solo la rama fuente — esto detecta problemas de integración más temprano.

Ejercicio 6: Endurecimiento Adicional

Con variables, runners, entornos, tokens y pipelines de MR asegurados, varios controles adicionales llevan tu pipeline a una postura de seguridad de nivel producción.

Tiempos de Espera de Trabajos

Los trabajos sin límite pueden ser abusados para criptominería o usados para mantener acceso persistente. Establece tiempos de espera explícitos:

deploy-production:
  stage: deploy
  timeout: 10 minutes
  script:
    - echo "Deploying..."

Pipelines Interrumpibles

Prevén el desperdicio de recursos y limita la ventana para trabajos maliciosos de larga duración marcando los trabajos no críticos como interrumpibles:

lint:
  stage: validate
  interruptible: true     # Se cancela automáticamente si un pipeline más nuevo comienza
  script:
    - echo "Linting..."

Push Rules (Restringir la Creación de Pipelines)

En Settings > Repository > Push rules, puedes:

  • Rechazar commits sin firmar — asegura que cada commit esté firmado con GPG.
  • Restringir nombres de ramas — aplicar una convención de nombres (por ejemplo, feature/*, bugfix/*).
  • Prevenir el push de secretos — la regla de push integrada de GitLab puede bloquear archivos que coincidan con patrones comunes de secretos.

Detección de Secretos con GitLab SAST

Agrega la plantilla integrada de Detección de Secretos de GitLab para capturar credenciales accidentalmente comprometidas:

include:
  - template: Security/Secret-Detection.gitlab-ci.yml

Esto agrega un trabajo secret_detection que escanea cada commit en busca de API keys, tokens, contraseñas y otros patrones de secretos. Los resultados aparecen en la pestaña Security de los merge requests.

El Pipeline Endurecido Final

A continuación se muestra el .gitlab-ci.yml completo combinando cada control de seguridad de este laboratorio. Cada línea relevante para la seguridad está comentada.

# .gitlab-ci.yml — Pipeline de GitLab CI Completamente Endurecido

# Incluir el escáner de detección de secretos integrado de GitLab
include:
  - template: Security/Secret-Detection.gitlab-ci.yml  # Escanea secretos filtrados

stages:
  - validate
  - build
  - deploy

# --- Configuración predeterminada aplicada a todos los trabajos ---
default:
  timeout: 10 minutes        # Prevenir trabajos fuera de control/abusados

# --- Seguros para pipelines de merge request (no se requieren secretos) ---
lint:
  stage: validate
  interruptible: true        # Cancelar si un pipeline más nuevo comienza
  script:
    - echo "Linting source code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"  # Ejecutar en MRs
    - if: $CI_COMMIT_BRANCH == "main"                    # Ejecutar en main

unit-tests:
  stage: validate
  interruptible: true
  script:
    - echo "Running unit tests..."
    - echo "API_KEY length = ${#API_KEY}"  # Seguro: imprime solo la longitud
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Requiere secretos — solo se ejecuta en rama protegida ---
build-image:
  stage: build
  script:
    - echo "Building Docker image..."
    - echo "Authenticating to registry..."  # Usa REGISTRY_TOKEN (Protected + Masked)
  rules:
    - if: $CI_COMMIT_BRANCH == "main"       # Solo en rama protegida

# --- Despliegue a staging — automático en main ---
deploy-staging:
  stage: deploy
  environment:
    name: staging                            # Entorno rastreado
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# --- Despliegue a producción — manual + aprobación requerida ---
deploy-production:
  stage: deploy
  tags:
    - secure-deploy                          # Se ejecuta solo en runner específico del proyecto
  environment:
    name: production                         # Entorno protegido con aprobaciones
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
    - |
      curl --fail --silent \
        --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \   # Variable Protected + Masked
        --request POST \
        "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/deployments"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual                           # Requiere activación humana
  allow_failure: false                       # El pipeline se bloquea hasta ser aprobado
  timeout: 5 minutes                         # Tiempo de espera más estricto para despliegues

Limpieza

Después de completar el laboratorio, limpia los recursos de prueba:

  1. Eliminar o archivar el proyecto de prueba: Ve a Settings > General > Advanced > Delete project.
  2. Eliminar variables de CI/CD: Si planeas mantener el proyecto, ve a Settings > CI/CD > Variables y elimina las variables de prueba (DEPLOY_TOKEN, DB_PASSWORD, API_KEY).
  3. Desregistrar el runner de prueba:
# Listar runners registrados
sudo gitlab-runner list

# Desregistrar el runner de prueba
sudo gitlab-runner unregister --name "secure-deploy-runner"

# Opcionalmente eliminar GitLab Runner por completo
sudo gitlab-runner stop
sudo gitlab-runner uninstall
sudo rm /usr/local/bin/gitlab-runner

Conclusiones Clave

  • Protege y enmascara cada variable de secreto. Las variables protegidas solo se inyectan en ramas protegidas, y el enmascaramiento previene la exposición accidental en logs. Usa el indicador Hidden para secretos que nunca deberían ser legibles en la interfaz.
  • Define el alcance de los runners al nivel mínimo de confianza requerido. Usa runners específicos del proyecto con acceso ref_protected para trabajos de despliegue. Reserva los runners compartidos para pasos no sensibles de build y test.
  • Controla los despliegues a producción con protección de entorno y aprobaciones. Combinar when: manual con un entorno protegido y aprobadores requeridos asegura que ninguna persona individual pueda enviar a producción sin verificación.
  • Restringe CI_JOB_TOKEN a una lista de permitidos explícita. El alcance predeterminado es demasiado amplio. Limita tanto el acceso entrante como el saliente solo a los proyectos que tu pipeline realmente necesita.
  • Separa los trabajos de pipeline de MR de los trabajos de despliegue. Los trabajos de lint y test son seguros para pipelines de merge request; los trabajos de build y deploy que necesitan secretos solo deberían ejecutarse en ramas protegidas.
  • Añade controles adicionales en capas: tiempos de espera, trabajos interrumpibles, push rules y detección de secretos. Cada capa aborda un vector de ataque diferente y juntas crean defensa en profundidad.

Próximos Pasos

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