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.htmlserá suficiente) y un archivo.gitlab-ci.ymlen 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
- Navega a GitLab > New Project > Create blank project.
- Nómbralo
secure-pipeline-lab, establece la visibilidad como Private e inicializa con un README. - En Settings > Repository > Protected branches, confirma que
mainestá 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
- Ve a Settings > CI/CD > Variables > Expand > Add variable.
- Crea las siguientes variables:
| Clave | Valor (ejemplo) | Protected | Masked | Hidden |
|---|---|---|---|---|
DEPLOY_TOKEN |
glpat-xxxxxxxxxxxxxxxxxxxx |
Sí | Sí | No |
DB_PASSWORD |
S3cur3P@ssw0rd!2024 |
Sí | Sí | Sí |
API_KEY |
sk-test-abc123def456 |
No | Sí | 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
- Push en
main— el trabajo de despliegue se ejecuta yDEPLOY_TOKENse inyecta (el log muestra[MASKED]). - Crea una rama
feature/test-vars, haz push de un commit — el trabajo de despliegue no se ejecuta (las rules lo restringen amain). Incluso si modificas las rules para permitirlo,DEPLOY_TOKENyDB_PASSWORDestán vacíos porque la rama no está protegida. 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
- Navega a Operate > Environments > New environment.
- Crea dos entornos:
stagingyproduction.
Paso 2 — Proteger el Entorno de Producción
- Ve a Settings > CI/CD > Protected environments (disponible en Premium/Ultimate, o en el nivel gratuito auto-gestionado).
- Selecciona
production. - En Allowed to deploy, restringe a
Maintainers(o un usuario específico). - En Required approvals, establece en 1 (o más, dependiendo de tu política).
- 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
- Un push a
mainactiva el pipeline. deploy-stagingse ejecuta automáticamente.deploy-productionmuestra un botón Play en la interfaz del pipeline.- 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).
- 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
- Ve a Settings > CI/CD > Token Access.
- Cambia Limit access to this project a Enabled.
- 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).
- 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
- Ejecuta el pipeline.
test-token-allowedtiene éxito y clona el proyecto permitido. test-token-deniedfalla con 403 Forbidden porquerestricted-projectno 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_eventdesde un fork se ejecutan automáticamente con permisos limitados. - Las variables protegidas nunca se inyectan en pipelines de MR desde forks.
CI_JOB_TOKENen 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— prefiererules: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:
- Eliminar o archivar el proyecto de prueba: Ve a Settings > General > Advanced > Delete project.
- 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). - 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_protectedpara 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: manualcon 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:
- Modelos de Ejecución de CI/CD y Supuestos de Confianza — Comprende las implicaciones de seguridad de diferentes arquitecturas de CI/CD y dónde se encuentran los límites de confianza.
- Separación de Funciones y Privilegio Mínimo en Pipelines de CI/CD — Aprende cómo diseñar pipelines donde ningún rol o token individual tenga más acceso del necesario.