Módulo 5: Security Scanning y Rollback Inteligente

Security Scanning con Claude Code en el Pipeline

Security Scanning con Claude Code en el Pipeline

Descripción

Esta cápsula te enseña a configurar Claude Code como gate de seguridad pre-merge: un análisis automático que detecta patrones reales de vulnerabilidad en el diff de cada PR. No es un linter de seguridad genérico — es análisis con contexto que entiende qué cambia el código y predice el impacto en seguridad.

La diferencia clave con la cápsula 02 del Módulo 2 (code review general): aquí el foco es estrechamente seguridad. SQL injection, XSS, secrets hardcodeados, validación faltante en inputs externos, dependencias con CVEs conocidos. Patrones reconocibles que el modelo puede identificar con alta precisión cuando se le da el prompt correcto.

Al terminar, vas a tener un workflow que escanea cada PR buscando vulnerabilidades específicas, aplica policies graduales (critical bloquea, high warning, medium informa), y produce reportes accionables que distinguen señal de ruido.


Por Qué Security Scanning Específico (vs Code Review General)

CODE REVIEW GENERAL (Módulo 2):
→ Detecta variedad amplia: bugs, naming, refactoring, etc.
→ Severity mezclado: 1 issue crítico entre 20 sugerencias
→ Prompt diseñado para captura amplia
→ Falsos positivos tolerables (suggest-only)

SECURITY SCAN (este módulo):
→ Detecta una categoría específica: vulnerabilidades
→ Severity homogénea: si lo flagea, importa
→ Prompt diseñado para precisión alta en una categoría
→ Falsos positivos costosos (puede bloquear merge)

Por qué separarlo: un prompt que pide "haz code review" es bueno detectando muchas cosas pero mediocre detectando vulnerabilidades específicas. Un prompt enfocado en seguridad detecta más SQL injections — pero se pierde refactoring opportunities.

La regla: tener dos jobs separados en el pipeline. El de code review (Módulo 2) y el de security scan (este módulo). Cada uno con su prompt optimizado.


El Catálogo de Patrones a Detectar

1. INJECTION
   - SQL injection (string concat en queries)
   - Command injection (subprocess con input no sanitizado)
   - LDAP/NoSQL injection
   - Template injection (SSTI)

2. XSS / OUTPUT ENCODING
   - HTML render sin escape
   - innerHTML con datos del usuario
   - JSON injection en respuestas

3. SECRETS HARDCODEADOS
   - API keys en código
   - Passwords en config files
   - JWT secrets en strings literales

4. VALIDACIÓN FALTANTE
   - Inputs externos sin validación de tipo/rango
   - Path traversal (file paths sin normalize)
   - SSRF (URLs externas no validadas)

5. AUTH/AUTHZ
   - Endpoints sensibles sin auth check
   - Authorization bypassed (missing role check)
   - Insecure direct object reference (IDOR)

6. CRYPTO
   - Algoritmos deprecated (MD5, SHA1 para passwords)
   - Random no criptográficamente seguro
   - Comparación de secrets sin timing-safe equals

7. DEPENDENCIAS
   - Versiones con CVEs conocidos
   - Dependencies abandonadas

8. ERROR HANDLING SENSIBLE
   - Stack traces expuestos a clientes
   - Mensajes de error que filtran lógica interna

Cada categoría tiene patrones reconocibles que el modelo aprende a identificar.


El Prompt Especializado

"""scripts/security_scan.py — security scan con Claude Code."""
import json
import os
import re
import sys
from pathlib import Path
from anthropic import Anthropic


SECURITY_SCAN_PROMPT = """Eres un auditor de seguridad analizando un diff de código.
Tu único objetivo es identificar vulnerabilidades de seguridad reales.

CATEGORÍAS A DETECTAR:

1. INJECTION
   - SQL: string concatenation en queries SQL
   - Command: subprocess/exec con input no sanitizado
   - Template: f-strings o templates con input del usuario
   - NoSQL: queries sin parametrización

2. XSS
   - innerHTML, dangerouslySetInnerHTML, document.write con input
   - Render server-side sin escape (Jinja2 sin autoescape, etc.)

3. SECRETS HARDCODEADOS
   - Patrones: sk-..., ghp-..., AKIA..., -----BEGIN PRIVATE KEY-----
   - API keys, passwords, tokens en strings literales
   - JWT secrets como constantes

4. VALIDACIÓN FALTANTE
   - Path traversal: paths sin normalize() o validation contra ../
   - SSRF: URLs externas sin validar contra allowlist
   - Inputs externos sin validación de tipo, rango, formato

5. AUTH/AUTHZ
   - Endpoints sensibles sin decorator/middleware de auth
   - Falta de role check antes de acción privilegiada
   - IDOR: acceso a recursos sin verificar ownership

6. CRYPTO
   - MD5, SHA1 para passwords (deberían ser bcrypt, argon2, scrypt)
   - random.random() para tokens (debería ser secrets module)
   - Comparación de secrets con == (debería ser hmac.compare_digest o similar)

7. DEPENDENCIES
   - Versiones específicas con CVEs conocidos
   - Paquetes abandonados conocidos

8. ERROR HANDLING SENSIBLE
   - return de stack traces a clientes
   - Logging de información sensible

REGLAS DE SEVERIDAD:
- CRITICAL: bug confirmado o exposure clara (SQL injection, secret hardcodeado, auth missing)
- HIGH: pattern peligroso pero requiere contexto adicional para confirmar
- MEDIUM: deuda de seguridad (crypto deprecated, validation faltante)
- LOW: buena práctica que falta pero no es vulnerabilidad explotable

REGLAS PARA REPORTAR:
- Solo flagea si VES el pattern en el diff
- NO inferir vulnerabilidades por nombres de archivo/función
- NO flagear "podría haber un problema" — flagear "hay este problema específico"
- Si dudas, márcalo como medium-severity, no critical

OUTPUT: JSON con esta estructura:
{
  "findings": [
    {
      "category": "injection|xss|secrets|validation|auth|crypto|dependencies|error_handling",
      "severity": "critical|high|medium|low",
      "path": "ruta/al/archivo",
      "line": 42,
      "title": "SQL injection en query de usuarios",
      "description": "Descripción técnica del problema",
      "evidence": "El código en cuestión (snippet)",
      "remediation": "Cómo corregirlo"
    }
  ]
}

Si no hay findings, devuelve {"findings": []}.

DIFF A ANALIZAR:

{diff}

"""


def run_security_scan(diff: str) -> list[dict]:
    """Ejecutar el scan y retornar findings."""
    client = Anthropic()
    
    response = client.messages.create(
        model=os.environ.get("CLAUDE_MODEL", "claude-sonnet-5"),  # Sonnet para precision
        max_tokens=4000,
        messages=[
            {"role": "user", "content": SECURITY_SCAN_PROMPT.format(diff=diff[:30000])},
        ],
    )
    
    text = response.content[0].text.strip()
    text = re.sub(r"^```(?:json)?\n?", "", text)
    text = re.sub(r"\n?```$", "", text)
    
    try:
        data = json.loads(text)
        return data.get("findings", [])
    except json.JSONDecodeError as e:
        print(f"WARNING: respuesta no es JSON válido: {e}", file=sys.stderr)
        return []


def main() -> int:
    diff_file = Path("filtered_diff.txt")
    if not diff_file.exists() or not diff_file.read_text().strip():
        print("No hay diff que escanear.")
        Path("security_findings.json").write_text(json.dumps({"findings": []}))
        return 0
    
    diff = diff_file.read_text()
    findings = run_security_scan(diff)
    
    # Aggregate por severity
    by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
    for f in findings:
        by_severity[f.get("severity", "low")] += 1
    
    print(f"\nSecurity scan complete:")
    print(f"  Critical: {by_severity['critical']}")
    print(f"  High:     {by_severity['high']}")
    print(f"  Medium:   {by_severity['medium']}")
    print(f"  Low:      {by_severity['low']}")
    
    # Detalle de critical/high
    for f in findings:
        if f.get("severity") in ["critical", "high"]:
            print(f"\n  {f['severity'].upper()}: {f['title']}")
            print(f"    {f['path']}:{f['line']}")
            print(f"    {f['description']}")
    
    # Guardar reporte
    report = {
        "findings": findings,
        "summary": by_severity,
    }
    Path("security_findings.json").write_text(json.dumps(report, indent=2))
    
    # Exit code según severity
    return apply_security_policy(by_severity)


def apply_security_policy(by_severity: dict) -> int:
    """Decidir exit code según severity policy."""
    # Política gradual:
    # - Critical: bloquea (exit 1)
    # - High: warning visible pero no bloquea (exit 0)
    # - Medium/Low: solo informa (exit 0)
    
    if by_severity["critical"] > 0:
        print("\n❌ BLOQUEO: critical findings detectados")
        return 1
    
    if by_severity["high"] > 0:
        print("\n⚠️  WARNING: high severity findings — revisar antes de mergear")
        return 0
    
    if by_severity["medium"] > 0 or by_severity["low"] > 0:
        print("\n💡 INFO: findings menores detectados")
        return 0
    
    print("\n✅ Sin findings de seguridad")
    return 0


if __name__ == "__main__":
    sys.exit(main())

El Workflow

# .github/workflows/security-scan.yml
name: Security Scan

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: read
  pull-requests: write
  security-events: write

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      
      - run: pip install anthropic requests
      
      - name: Extract filtered diff
        run: |
          git diff origin/${{ github.base_ref }}...HEAD \
            --diff-filter=ACMR \
            -- '*.py' '*.js' '*.ts' '*.tsx' '*.go' '*.rb' '*.java' \
            > filtered_diff.txt
      
      - name: Run security scan
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: python scripts/security_scan.py
      
      - name: Post findings to PR
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: python scripts/post_security_findings.py
      
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: security-findings
          path: security_findings.json

Notas clave:

  • if: always() en el step de post → publica findings incluso si el scan reporta critical (bloquea exit 1)
  • --diff-filter=ACMR → solo Added/Copied/Modified/Renamed (no Deleted)
  • Filtro de extensiones para enfocar en código fuente

Publicar Findings al PR

"""scripts/post_security_findings.py — postear findings al PR."""
import json
import os
import sys
from pathlib import Path
import requests


SEVERITY_EMOJI = {
    "critical": "🚨",
    "high": "⚠️",
    "medium": "💡",
    "low": "ℹ️",
}

CATEGORY_EMOJI = {
    "injection": "💉",
    "xss": "🌐",
    "secrets": "🔑",
    "validation": "🛡️",
    "auth": "🔐",
    "crypto": "🔒",
    "dependencies": "📦",
    "error_handling": "🪲",
}


def build_summary(findings: list, summary: dict) -> str:
    """Construir el comment del PR."""
    if not findings:
        return """## 🔒 Security Scan

✅ **Sin vulnerabilidades detectadas en este PR.**

---
*Análisis automatizado con Claude Code*
"""
    
    body = f"""## 🔒 Security Scan

| Severity | Count |
|----------|-------|
| 🚨 Critical | {summary.get('critical', 0)} |
| ⚠️ High | {summary.get('high', 0)} |
| 💡 Medium | {summary.get('medium', 0)} |
| ℹ️ Low | {summary.get('low', 0)} |

"""
    
    if summary.get('critical', 0) > 0:
        body += "**🚨 Este PR tiene findings críticos. El merge está bloqueado hasta resolverlos.**\n\n"
    
    # Detalle por severity (críticos y high primero)
    for severity in ["critical", "high", "medium", "low"]:
        sev_findings = [f for f in findings if f.get("severity") == severity]
        if not sev_findings:
            continue
        
        body += f"### {SEVERITY_EMOJI[severity]} {severity.title()}\n\n"
        for f in sev_findings:
            cat_emoji = CATEGORY_EMOJI.get(f.get("category", ""), "🔍")
            body += f"#### {cat_emoji} {f['title']}\n"
            body += f"**Location:** `{f['path']}:{f['line']}`\n\n"
            body += f"**Description:** {f['description']}\n\n"
            
            if f.get("evidence"):
                body += f"**Evidence:**\n```\n{f['evidence']}\n```\n\n"
            
            body += f"**Remediation:** {f['remediation']}\n\n---\n\n"
    
    body += "*Análisis automatizado con Claude Code. Marker: `<!-- security-scan-bot -->`*\n"
    return body


def find_existing_comment(comments: list, marker: str = "security-scan-bot") -> dict | None:
    """Encontrar comment existente del bot."""
    for c in comments:
        if marker in c.get("body", ""):
            return c
    return None


def main() -> int:
    findings_file = Path("security_findings.json")
    if not findings_file.exists():
        print("ERROR: security_findings.json no encontrado.", file=sys.stderr)
        return 1
    
    data = json.loads(findings_file.read_text())
    findings = data.get("findings", [])
    summary = data.get("summary", {})
    
    body = build_summary(findings, summary)
    
    # Postear o actualizar comment
    repo = os.environ["GITHUB_REPOSITORY"]
    pr_number = os.environ["PR_NUMBER"]
    token = os.environ["GITHUB_TOKEN"]
    
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }
    
    list_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
    existing = requests.get(list_url, headers=headers).json()
    bot_comment = find_existing_comment(existing)
    
    if bot_comment:
        # Update
        update_url = f"https://api.github.com/repos/{repo}/issues/comments/{bot_comment['id']}"
        r = requests.patch(update_url, headers=headers, json={"body": body})
    else:
        # Create
        r = requests.post(list_url, headers=headers, json={"body": body})
    
    if r.status_code in [200, 201]:
        print(f"Security findings publicados al PR")
        return 0
    
    print(f"ERROR posteando comment: {r.status_code}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Policies Graduales

La regla más importante: no todo bloquea. Policies graduales según severity:

SECURITY_POLICY = {
    # Severity → acción
    "critical": "block",        # bloquea merge
    "high": "warn",             # warning visible, no bloquea
    "medium": "inform",         # solo informa
    "low": "inform",            # solo informa
}

CATEGORY_OVERRIDES = {
    # Para ciertas categorías, severity efectiva es más alta
    "secrets": "block",         # secrets siempre bloquean
    "auth": "block_if_high",    # auth missing es critical
}

Por qué es importante: si todo bloquea, el equipo desactiva el bot o usa overrides constantemente. Policies graduales mantienen el bot útil sin ser obstrucción.

Configuración del policy

- name: Run security scan with policy
  env:
    SECURITY_POLICY_CRITICAL: "block"
    SECURITY_POLICY_HIGH: "warn"
    SECURITY_POLICY_MEDIUM: "inform"
  run: python scripts/security_scan.py

Override Mechanism

Aún con policies graduales, vas a tener falsos positivos eventuales. El override mechanism permite mergear cuando el equipo confirma que el bot se equivocó:

- name: Check override label
  id: override
  run: |
    if [[ "${{ contains(github.event.pull_request.labels.*.name, 'security-scan-override') }}" == "true" ]]; then
      echo "override=true" >> $GITHUB_OUTPUT
    fi

- name: Run security scan
  if: steps.override.outputs.override != 'true'
  run: python scripts/security_scan.py

Con la label security-scan-override aplicada al PR, el scan se skipea. Importante: esta label debería requerir aprobación de un security lead, no aplicarse libremente.

Auditoría del override

- name: Log override usage
  if: steps.override.outputs.override == 'true'
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    # Postear comment al PR explicando el override
    # Notificar a security team via webhook
    python scripts/log_override.py

Cualquier uso de override genera un registro auditable: quién lo aplicó, cuándo, justificación (idealmente en el comment).


Comparación con Otras Herramientas

SCANNERS BASADOS EN REGLAS (Snyk Code, Semgrep, Bandit):
✅ Velocidad alta
✅ Reglas establecidas y testeadas
✅ Integración con SARIF (estándar)
❌ False positives en patrones contextuales
❌ No entienden lógica del código
❌ Limitados a patrones conocidos

CLAUDE CODE COMO SCANNER (este módulo):
✅ Entiende contexto y semántica
✅ Detecta patterns que reglas estáticas no
✅ Explica el "por qué" del finding
❌ Más lento que scanners estáticos
❌ Más caro por scan
❌ Puede tener inconsistencia entre runs (mitigable)

No es uno o el otro. Mejor patrón: usar ambos, con scanners estáticos como primera línea (rápidos, baratos) y Claude Code como segunda línea (profundo, contextual). La cápsula 03 desarrolla esta integración.


Trampas Comunes

Error 1: Mismo prompt para code review general y security scan

Síntoma: El bot detecta naming issues mezclados con vulnerabilidades. La señal de seguridad se diluye.

Por qué pasa: Reusar el bot del Módulo 2 esperando que funcione bien para seguridad.

Cómo corregir: Prompt específico para seguridad como el de esta cápsula. Solo categorías de vulnerabilidad, severity homogénea.

Error 2: Bloquear todo lo que el bot flagea

Síntoma: Equipo frustrado porque PRs sin issues reales se bloquean. Empieza a usar overrides constantemente.

Por qué pasa: Sin policies graduales, todo critical/high/medium bloquea.

Cómo corregir: Solo critical bloquea. High = warning visible. Medium/low = informe. Calibrar según falso positive rate del equipo.

Error 3: No tener override mechanism

Síntoma: Falso positive bloquea merge urgente. No hay forma de skipear.

Por qué pasa: Diseño "estricto" sin escape hatch.

Cómo corregir: Label security-scan-override con auditoría. Permite mergear cuando es necesario, deja registro.

Error 4: Reusar el diff completo (no filtered)

Síntoma: El scan tarda mucho y/o pierde precisión por context lleno.

Por qué pasa: Pasar todo el diff incluyendo archivos no-código (markdown, yaml, etc.).

Cómo corregir: Filtrar por extensiones de código fuente antes del scan. La cápsula 02 del Módulo 2 cubre el filtrado.

Error 5: Sin notificación de critical findings al security team

Síntoma: Critical finding bloquea merge, developer lo investiga solo, posiblemente decide override mal.

Por qué pasa: El bot publica el finding pero solo el author del PR lo ve.

Cómo corregir: Critical findings → notificación al canal de #security en Slack. Decisión sobre override no debe ser solo del author del PR.


Diagnóstico

Pregunta 1: ¿Tu prompt de security scan está separado del prompt de code review?

Si es el mismo, la precisión en seguridad es subóptima. Prompt dedicado da mejores findings.

Pregunta 2: ¿Aplicas policies graduales (critical bloquea, high warning) o todo bloquea?

Todo bloquea = equipo frustrado. Graduales = útil sin ser obstrucción.

Pregunta 3: ¿Tienes un mecanismo de override auditable?

Sin override, falsos positives generan crisis. Con override sin auditoría, el bot pierde efectividad.

Pregunta 4: ¿Combinas Claude Code con scanners estáticos (Snyk, Bandit) o usas solo uno?

Ambos = mejor cobertura. Solo uno = gaps.

Pregunta 5: ¿Critical findings notifican al security team o solo al author del PR?

Solo al author = decisión potencialmente sesgada. Con notification al security team = mejor governance.


Ejercicios

Ejercicio 1: Prompt especializado (Fácil)

Toma el prompt de code review del Módulo 2 y deriva un prompt específico de seguridad. Compáralos: ¿qué cambia?

Ejercicio 2: Implementar el scan con policies (Medio)

Implementa:

  1. Script Python con el prompt de seguridad
  2. Policies graduales (critical → exit 1, high → warning, medium/low → info)
  3. Reporte JSON

Probarlo con un PR que tenga un SQL injection obvio y verifica que detecta y bloquea.

Ejercicio 3: Override con auditoría (Difícil)

Implementa el flujo:

  1. Label security-scan-override skipea el scan
  2. Cualquier uso de la label genera comment en el PR explicando
  3. Notificación a Slack al security team
  4. Log de overrides para audit posterior

Resumen

  • Security scan separado del code review general — prompt enfocado en vulnerabilidades específicas
  • 8 categorías estándar: injection, XSS, secrets, validation, auth, crypto, dependencies, error_handling
  • Policies graduales: critical bloquea, high warning, medium/low informa
  • Override mechanism auditado mantiene el bot útil sin ser obstrucción
  • Combinar con scanners estáticos (Snyk, Bandit) para mejor cobertura — cápsula 03
  • Notificar al security team en critical findings, no solo al author del PR

Próxima cápsula: 03 — Integración con herramientas de seguridad existentes. Tu scan con Claude Code es bueno pero no único. Aprendes a combinarlo con Snyk, Dependabot, audit tools — cómo orquestar todas las herramientas en un pipeline coherente.


Recursos Adicionales

  1. OWASP Top 10 — Las 10 categorías más comunes de vulnerabilidades web
  2. CWE Top 25 — Common Weakness Enumeration
  3. SANS Top 25 — Lista complementaria
  4. Bandit (Python) — Linter de seguridad estático
  5. Semgrep — Reglas de seguridad multi-lenguaje
  6. Anthropic API best practices for security — Patterns aplicables