
El Pipeline como Vector de Ataque Crítico
Un pipeline CI/CD es mucho más que automatización: es el corazón del software delivery moderno y un punto crítico de exposición si no se protege adecuadamente.
El Costo de la Inseguridad
- SolarWinds (2020): Los atacantes comprometieron el pipeline de build, no la aplicación final
- Gartner 2025: Proyecta que el 45% de organizaciones sufrirá ataques a la cadena de suministro
- Realidad actual: Menos del 10% de las empresas monitorea la seguridad en su SDLC
Esta guía proporciona un enfoque práctico basado en frameworks reconocidos:
- OWASP Top 10 CI/CD
- NIST SP 800-204D
- SLSA (Supply Chain Levels for Software Artifacts)
DevOps vs DevSecOps: El Cambio de Paradigma
DevOps Tradicional
Desarrollo → Testing → Deploy → [Seguridad al final]
Problema: Descubrir vulnerabilidades en producción es costoso y peligroso.
DevSecOps Moderno
[Seguridad] → Desarrollo → [Seguridad] → Testing → [Seguridad] → Deploy → [Seguridad]
Ventaja: Detección temprana mediante Shift-Left Security.
Arquitectura de un Pipeline CI/CD Seguro
┌─────────────────────────────────────────────────────────────┐
│ 1. DEVELOPER ENVIRONMENT │
│ ✓ Pre-commit hooks (secretos, SAST) │
│ ✓ Signed commits (GPG/SSH) │
└──────────────────┬──────────────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────────────┐
│ 2. SOURCE CODE MANAGEMENT (GitHub/GitLab) │
│ ✓ Branch protection │
│ ✓ Mandatory code review │
│ ✓ Secrets scanning (gitleaks) │
└──────────────────┬──────────────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────────────┐
│ 3. CI PIPELINE: Build & Test │
│ ✓ SAST (Static Analysis) │
│ ✓ SCA (Dependency scanning) │
│ ✓ Container scanning │
│ ✓ SBOM generation │
└──────────────────┬──────────────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────────────┐
│ 4. SECURITY GATES │
│ ✓ DAST (Dynamic testing) │
│ ✓ Policy enforcement (OPA) │
│ ✓ Image signing (Cosign) │
└──────────────────┬──────────────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────────────┐
│ 5. DEPLOYMENT & RUNTIME │
│ ✓ Signature verification │
│ ✓ Runtime monitoring │
│ ✓ Continuous compliance │
└─────────────────────────────────────────────────────────────┘
Fase 1: Protección del Código Fuente
1.1 Control de Acceso Granular (RBAC)
Principio: Least Privilege + Defense in Depth
# GitHub/GitLab Branch Protection
branch_protection:
main:
required_reviews: 2
require_signed_commits: true
require_status_checks: true
restrict_push_access:
- admins
- ci-service-account
dismiss_stale_reviews: true
develop:
required_reviews: 1
require_signed_commits: true
Implementación crítica:
- ✅ Nadie puede hacer merge sin revisión (ni siquiera admins)
- ✅ Commits firmados criptográficamente (GPG)
- ✅ Audit logs habilitados
- ✅ MFA obligatorio para todos
1.2 Detección Automatizada de Secretos
Problema: API keys, tokens y contraseñas filtradas en Git.
Solución: Pre-commit hooks + CI scanning
# Instalación local (pre-commit)
brew install gitleaks
gitleaks detect --source . --verbose
# Pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
echo "❌ SECRET DETECTED - Commit bloqueado"
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
Pipeline CI/CD:
# GitLab CI
secret-detection:
stage: validate
image: zricethezav/gitleaks:latest
script:
- gitleaks detect --source . --verbose --exit-code 1
allow_failure: false # Bloquea el pipeline
Herramientas recomendadas:
- gitleaks: Rápido, bajo falsos positivos
- TruffleHog: Detección por entropía
- GitGuardian: SaaS con integraciones
1.3 Gestión Centralizada de Secretos
❌ NUNCA hacer esto:
# INSEGURO
variables:
DATABASE_PASSWORD: "my-secret-123"
API_KEY: "ghp_xxxxxxxxxxxx"
✅ Solución: HashiCorp Vault
# 1. Configurar Vault
vault secrets enable -version=2 kv
# 2. Crear política de acceso
vault policy write ci-pipeline - <<EOF
path "kv/data/ci/*" {
capabilities = ["read"]
}
EOF
# 3. Crear AppRole para CI
vault write auth/approle/role/ci-pipeline \
token_ttl=1h \
token_max_ttl=4h \
policies="ci-pipeline"
# 4. Guardar secretos
vault kv put kv/ci/database password="secure-pass"
vault kv put kv/ci/github token="ghp_xxxx"
Integración en Pipeline:
# GitLab CI con Vault
build:
stage: build
before_script:
# Autenticación con Vault
- export VAULT_TOKEN=$(vault write -field=token
auth/approle/login
role_id=$ROLE_ID
secret_id=$SECRET_ID)
# Obtener secretos
- export DB_PASS=$(vault kv get -field=password kv/ci/database)
script:
- docker build --build-arg DB_PASS=$DB_PASS -t myapp .
Alternativa Kubernetes: External Secrets Operator
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
secretStoreRef:
name: vault-backend
target:
name: app-credentials
data:
- secretKey: database-password
remoteRef:
key: kv/ci/database
property: password
Fase 2: Análisis Automatizado de Seguridad (CI)
2.1 SAST (Static Application Security Testing)
Detecta: Vulnerabilidades en código fuente sin ejecutarlo.
Herramientas por lenguaje:
- Python: Bandit, Semgrep
- JavaScript/Node: ESLint + security plugins, Semgrep
- Java: SpotBugs, Checkmarx
- Multi-lenguaje: SonarQube, Semgrep
# GitLab CI - SAST Multi-herramienta
sast:
stage: test
image: returntocorp/semgrep:latest
script:
# Semgrep con reglas OWASP
- semgrep --config=p/owasp-top-ten src/ --json -o semgrep.json
# SonarQube (opcional)
- sonar-scanner \
-Dsonar.projectKey=myapp \
-Dsonar.sources=./src \
-Dsonar.host.url=$SONAR_URL \
-Dsonar.token=$SONAR_TOKEN
artifacts:
reports:
sast: semgrep.json
Ejemplo de vulnerabilidad detectada:
# ❌ VULNERABLE: SQL Injection
query = f"SELECT * FROM users WHERE id = {user_id}"
db.execute(query)
# ✅ CORRECTO: Prepared statement
query = "SELECT * FROM users WHERE id = ?"
db.execute(query, (user_id,))
2.2 SCA (Software Composition Analysis)
Detecta: Vulnerabilidades conocidas en dependencias (CVEs).
# GitLab CI - SCA con Snyk
dependency-scan:
stage: test
image: snyk/snyk:python
script:
# Autenticación
- snyk auth $SNYK_TOKEN
# Escaneo con threshold
- snyk test --severity-threshold=high --json > snyk-report.json
# Generar SBOM (Software Bill of Materials)
- snyk sbom --format=cyclonedx > sbom.json
artifacts:
reports:
dependency_scanning: snyk-report.json
cyclonedx: sbom.json
allow_failure: false
Alternativas:
- Dependabot (GitHub): Automatiza PRs de actualización
- Trivy: Open-source, rápido
- OWASP Dependency-Check: Gratuito, soporte amplio
2.3 Container Image Scanning
Detecta: Vulnerabilidades en imágenes Docker.
# GitLab CI - Trivy Container Scan
container-scan:
stage: security
image: aquasec/trivy:latest
services:
- docker:dind
script:
# Build
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# Scan con múltiples severidades
- trivy image --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Generar reporte JSON
- trivy image --format json -o trivy-report.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# BLOQUEAR si hay críticos
- trivy image --exit-code 1 --severity CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
artifacts:
reports:
container_scanning: trivy-report.json
Best Practice – Imágenes Base Minimales:
# ❌ INSEGURO: Superficie de ataque grande
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 nodejs
# ✅ SEGURO: Distroless o Alpine
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage final: imagen minimal
FROM gcr.io/distroless/python3
COPY --from=builder /root/.local /app/.local
COPY . /app
USER nonroot:nonroot
CMD ["python", "app.py"]
Fase 3: Seguridad del Entorno de Build
3.1 Aislamiento de Runners
Riesgo: Runners comprometidos = pipeline comprometido.
Configuración segura GitLab Runner:
# /etc/gitlab-runner/config.toml
[[runners]]
name = "secure-docker-runner"
url = "https://gitlab.company.com"
[runners.docker]
image = “alpine:latest” privileged = false # ❌ NUNCA usar privileged disable_cache = false # Seguridad adicional cap_drop = [“ALL”] cap_add = [“NET_BIND_SERVICE”] security_opt = [“no-new-privileges:true”] # Volúmenes temporales volumes = [“/cache”, “/builds:/builds:rw”] tmpfs = [“/tmp:rw,noexec,nosuid”]
Kubernetes – Network Policies:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ci-runner-isolation
namespace: gitlab-ci
spec:
podSelector:
matchLabels:
app: gitlab-runner
policyTypes:
- Ingress
- Egress
egress:
# Solo permitir conexiones a servicios necesarios
- to:
- podSelector:
matchLabels:
app: gitlab-server
ports:
- protocol: TCP
port: 443
- to:
- podSelector:
matchLabels:
app: vault
ports:
- protocol: TCP
port: 8200
3.2 Ephemeral Runners
Mejor práctica: Destruir runners después de cada ejecución.
# GitLab CI - Runners efímeros en Kubernetes
variables:
KUBERNETES_CPU_REQUEST: "1"
KUBERNETES_MEMORY_REQUEST: "512Mi"
KUBERNETES_POD_ANNOTATIONS_1: "container.apparmor.security.beta.kubernetes.io/build=runtime/default"
build:
tags:
- kubernetes
- ephemeral
script:
- docker build -t myapp .
Fase 4: DAST (Dynamic Application Security Testing)
Detecta: Vulnerabilidades en runtime (SQL injection, XSS, etc.)
# GitLab CI - OWASP ZAP
dast:
stage: security
image: owasp/zap2docker-stable:latest
services:
- name: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
alias: test-app
variables:
DAST_WEBSITE: "http://test-app:8080"
script:
# Esperar a que la app inicie
- until curl -s $DAST_WEBSITE/health; do sleep 5; done
# Baseline scan (rápido)
- zap-baseline.py -t $DAST_WEBSITE -J zap-report.json
# Full scan (más profundo - solo en main)
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
zap-full-scan.py -t $DAST_WEBSITE -J zap-full.json
fi
artifacts:
reports:
dast: zap-report.json
allow_failure: true # DAST puede tener falsos positivos
Alternativas:
- Burp Suite: Profesional, costoso
- Nuclei: Rápido, basado en templates
- Arachni: Open-source
Fase 5: Policy as Code (Infraestructura Segura)
Objetivo: Prevenir configuraciones inseguras automáticamente.
OPA (Open Policy Agent) para Terraform
# opa-policies/security.rego
package terraform.security
# Denegar bases de datos sin encriptación
deny[msg] {
resource := input.resource.aws_db_instance[name]
not resource.storage_encrypted
msg := sprintf("❌ Database '%s' must enable encryption at rest", [name])
}
# Denegar buckets S3 públicos
deny[msg] {
resource := input.resource.aws_s3_bucket[name]
resource.acl == "public-read"
msg := sprintf("❌ S3 bucket '%s' cannot be public", [name])
}
# Denegar security groups sin restricciones
deny[msg] {
resource := input.resource.aws_security_group[name]
rule := resource.ingress[_]
rule.cidr_blocks[_] == "0.0.0.0/0"
rule.from_port == 0
msg := sprintf("❌ Security group '%s' allows unrestricted access", [name])
}
# Requerir tags obligatorios
required_tags := ["Environment", "Owner", "CostCenter"]
deny[msg] {
resource := input.resource[type][name]
type == "aws_instance"
tag := required_tags[_]
not resource.tags[tag]
msg := sprintf("❌ EC2 instance '%s' missing required tag: %s", [name, tag])
}
Pipeline Integration:
# GitLab CI - Policy Enforcement
infrastructure-validation:
stage: validate
image: openpolicyagent/opa:latest
script:
- terraform init
- terraform plan -out=tfplan
- terraform show -json tfplan > tfplan.json
# Validar con OPA
- opa eval -d opa-policies/ -i tfplan.json 'data.terraform.security.deny' -f pretty
# Fallar si hay violaciones
- |
VIOLATIONS=$(opa eval -d opa-policies/ -i tfplan.json 'data.terraform.security.deny | length' -f raw)
if [ "$VIOLATIONS" != "0" ]; then
echo "❌ Policy violations detected: $VIOLATIONS"
exit 1
fi
allow_failure: false
Fase 6: Firma y Verificación de Artefactos
6.1 Firma de Imágenes con Cosign (Sigstore)
Objetivo: Garantizar integridad y procedencia.
# GitLab CI - Signing Pipeline
sign-and-push:
stage: deploy
image: gcr.io/projectsigstore/cosign:latest
services:
- docker:dind
script:
# Build
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Firmar imagen
- cosign sign --key env://COSIGN_KEY $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Generar attestation (SBOM)
- cosign attest --key env://COSIGN_KEY \
--predicate sbom.json \
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
6.2 Verificación en Deployment
# Kubernetes Admission Controller (Kyverno)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: enforce
background: false
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "registry.company.com/*"
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
Pipeline Verification:
deploy:
stage: deploy
script:
# Verificar firma antes de deploy
- cosign verify --key $COSIGN_PUBLIC_KEY $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Verificar attestation
- cosign verify-attestation \
--key $COSIGN_PUBLIC_KEY \
--type slsaprovenance \
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Deploy solo si verificación exitosa
- kubectl set image deployment/myapp container=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Fase 7: Monitoreo y Auditoría Continua
7.1 Logging Centralizado (SIEM)
# GitLab CI - Log Shipping
.post-pipeline:
stage: .post
script:
- |
curl -X POST https://siem.company.com/api/logs \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H "Content-Type: application/json" \
-d @- << EOF
{
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"pipeline_id": "$CI_PIPELINE_ID",
"project": "$CI_PROJECT_PATH",
"commit_sha": "$CI_COMMIT_SHA",
"commit_author": "$CI_COMMIT_AUTHOR",
"branch": "$CI_COMMIT_BRANCH",
"status": "$CI_JOB_STATUS",
"duration_seconds": "$CI_JOB_DURATION",
"runner": "$CI_RUNNER_ID",
"triggered_by": "$GITLAB_USER_LOGIN"
}
EOF
when: always
7.2 Detección de Anomalías
anomaly-detection:
stage: .post
script:
# Alertar en horarios inusuales
- |
HOUR=$(date +%H)
if [ $HOUR -lt 6 ] || [ $HOUR -gt 22 ]; then
echo "⚠️ Pipeline ejecutado fuera de horario laboral"
curl -X POST $SLACK_WEBHOOK -d "{\"text\":\"Unusual pipeline at $HOUR:00\"}"
fi
# Detectar cambios sensibles
- |
git diff HEAD~1 --name-only | grep -E '(\.gitlab-ci\.yml|vault|secret)' && \
echo "⚠️ Cambios detectados en configuración sensible"
# Alertar en cambios de permisos
- |
if git diff HEAD~1 .gitlab-ci.yml | grep -E '(privileged|allow_failure.*false)'; then
echo "⚠️ Cambios críticos en CI config"
fi
when: always
Fase 8: Compliance y Frameworks
8.1 OWASP Top 10 CI/CD Mapping
| # | Riesgo | Mitigación |
|---|---|---|
| 1 | Insufficient Credential Hygiene | Vault, secrets scanning, rotación automática |
| 2 | Poisoned Pipeline Execution | Signed commits, code review, RBAC estricto |
| 3 | Insecure System Configuration | Hardening runners, least privilege |
| 4 | Insecure Dependencies | SCA continuo, SBOM, dependency pinning |
| 5 | Insufficient Logging | SIEM, audit trails, alertas anomalías |
| 6 | Insecure Artifact Storage | Registry privado, autenticación, firma |
| 7 | Insecure Build Process | Contenedores aislados, policy-as-code |
| 8 | Inadequate Access Control | RBAC, MFA, Just-In-Time access |
| 9 | Unsafe Artifact Provisioning | Cosign, SBOM, provenance verification |
| 10 | Ungoverned 3rd Party Services | Inventory, approval workflows, SLA reviews |
8.2 SLSA Framework (Level 3 Implementation)
# GitLab CI - SLSA Provenance
provenance:
stage: deploy
image: gcr.io/projectsigstore/cosign:latest
script:
# Generar provenance
- |
cat > provenance.json << EOF
{
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [{
"name": "$CI_REGISTRY_IMAGE",
"digest": {
"sha256": "$(docker inspect --format='{{.Id}}' $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA | cut -d: -f2)"
}
}],
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicate": {
"builder": {
"id": "https://gitlab.company.com/builder/$CI_RUNNER_ID"
},
"buildType": "https://gitlab.com/gitlab-ci/v1",
"invocation": {
"configSource": {
"uri": "$CI_PROJECT_URL",
"digest": {"sha1": "$CI_COMMIT_SHA"},
"entryPoint": ".gitlab-ci.yml"
}
},
"metadata": {
"buildStartedOn": "$CI_PIPELINE_CREATED_AT",
"buildFinishedOn": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"completeness": {
"parameters": true,
"environment": true,
"materials": true
},
"reproducible": false
},
"materials": [{
"uri": "$CI_PROJECT_URL",
"digest": {"sha1": "$CI_COMMIT_SHA"}
}]
}
}
EOF
# Firmar provenance
- cosign attest --key env://COSIGN_KEY \
--predicate provenance.json \
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Pipeline Completo: Ejemplo Productivo (Python)
# .gitlab-ci.yml - Pipeline Seguro Completo
stages:
- validate
- test
- build
- security
- deploy
- monitor
variables:
REGISTRY: registry.company.com
IMAGE: $REGISTRY/$CI_PROJECT_PATH
TAG: $CI_COMMIT_SHORT_SHA
DOCKER_TLS_CERTDIR: "/certs"
# ============ STAGE 1: VALIDATE ============
secrets-detection:
stage: validate
image: zricethezav/gitleaks:latest
script:
- gitleaks detect --source . --verbose --exit-code 1
allow_failure: false
lint-code:
stage: validate
image: python:3.11
script:
- pip install black flake8 mypy
- black --check src/
- flake8 src/ --max-line-length=100
- mypy src/ --strict
allow_failure: false
# ============ STAGE 2: TEST ============
sast:
stage: test
image: python:3.11
script:
- pip install bandit semgrep
- bandit -r src/ -f json -o bandit-report.json
- semgrep --config=p/security-audit src/ --json -o semgrep-report.json
artifacts:
reports:
sast:
- bandit-report.json
- semgrep-report.json
dependency-scan:
stage: test
image: snyk/snyk:python
script:
- snyk auth $SNYK_TOKEN
- snyk test --severity-threshold=high --json > snyk-report.json
- snyk sbom --format=cyclonedx > sbom.json
artifacts:
reports:
dependency_scanning: snyk-report.json
cyclonedx: sbom.json
allow_failure: false
unit-tests:
stage: test
image: python:3.11
script:
- pip install -r requirements-dev.txt
- pytest tests/ -v --cov=src/ --cov-report=xml
coverage: '/TOTAL.*\s+(\d+%)$/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
# ============ STAGE 3: BUILD ============
build-image:
stage: build
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $REGISTRY
script:
# Multi-stage Dockerfile
- |
cat > Dockerfile << 'EOF'
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
RUN useradd -m -u 1001 appuser && \
apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
COPY --chown=appuser:appuser . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF
- docker build -t $IMAGE:$TAG .
- docker push $IMAGE:$TAG
# ============ STAGE 4: SECURITY ============
container-scan:
stage: security
image: aquasec/trivy:latest
script:
- trivy image --severity HIGH,CRITICAL --exit-code 0 $IMAGE:$TAG
- trivy image --format json -o trivy-report.json $IMAGE:$TAG
- trivy image --exit-code 1 --severity CRITICAL $IMAGE:$TAG
artifacts:
reports:
container_scanning: trivy-report.json
dast:
stage: security
image: owasp/zap2docker-stable:latest
services:
- name: $IMAGE:$TAG
alias: test-app
variables:
TARGET_URL: "http://test-app:8000"
script: