portal-security-audit
Run the pre-PR and pre-deploy security checklist for the Portal Centralizado de Datos Financieros — endpoint scope coverage, 401/403 permission matrix, admin self-protection, secret and prompt leakage
SKILL.md.claude/skills/portal-security-audit/SKILL.md
GitHubPortal Security Audit Skill
Rules — NON-NEGOTIABLE
- 100% de endpoints de datos con
Security(get_current_user, scopes=[...]). Un endpoint de datos sin scopes bloquea el PR. - Matriz de permisos verificada por pruebas: 401 sin/mal token (
WWW-Authenticate: Bearer), 403 autenticado sin permiso, parametrizado por los 4 roles (docs/security.mdes la fuente). - Un admin NO puede desactivarse ni degradarse a sí mismo;
username/emailduplicados → 409. - Contraseñas jamás en respuestas, logs ni trazas; prompts crudos jamás en logs — solo
llm.prompt_hashSHA-256. - Secrets fuera del repo:
.env.local(gitignored) en dev, Secret Manager en prod. Pydantic Settings estricto: la app no arranca sinDATABASE_URL/GEMINI_API_KEY/JWT_SECRET_KEY. - Login con hash dummy anti-timing: si el usuario no existe se verifica igualmente contra un hash falso (pwdlib), para que la duración no delate usernames válidos.
- JWT en cookie httpOnly (nunca localStorage); CORS restringido a los orígenes del frontend.
- El agente hereda el Bearer del usuario en cada tool call: jamás un token de servicio con más permisos.
Scope creep guard (R11)
FUERA del MVP — rechazar en review y documentar como trabajo futuro en docs/security.md:
| Propuesta | Veredicto |
|---|---|
| Refresh tokens | NO — expiración 30 min + re-login limpio (decisión de alcance por escrito) |
| Recuperación de contraseña | NO — el admin resetea vía CRUD de usuarios |
| OAuth/SSO externo | NO — OAuth2 password flow local únicamente |
| RLS por fila en PostgreSQL | NO — RBAC por scopes a nivel endpoint basta para el MVP |
Verificación de cobertura de scopes
# Todo router de datos debe usar Security(...) — el diff de estas listas debe ser vacio
grep -rLn "Security(get_current_user" backend/app/api/ \
--include="*.py" | grep -v "auth.py\|__init__\|healthz" \
&& echo "FAIL: router sin scopes" || echo "OK"
# Ningun endpoint con Depends pelado donde deberia haber scopes
grep -rn "Depends(get_current_user)" backend/app/api/ && echo "WARN: revisar si requiere scopes"
Scans de fuga
# scripts/security_audit.sh
set -euo pipefail
echo "=== 1. Secrets en repo ==="
gitleaks detect --no-banner --redact
echo "=== 2. Contrasenas/prompts en logs ==="
grep -rEn "logger\.\w+\(.*(password|hashed_password|prompt=|messages=)" backend/ ml/ \
&& exit 1 || echo "OK"
echo "=== 3. Password en response models ==="
grep -rn "hashed_password" backend/app/models/ | grep -i "out\|response\|public" \
&& exit 1 || echo "OK"
echo "=== 4. Deps con CVEs ==="
poetry run pip-audit --strict && pnpm audit --audit-level=high
echo "=== 5. Ruff security ==="
poetry run ruff check --select=S backend/ ml/
Hash dummy anti-timing (patrón exigido en login)
# backend/app/core/auth.py
DUMMY_HASH = password_hash.hash("dummy-timing-guard")
async def authenticate(username: str, password: str) -> AppUser | None:
user = await get_user(username)
hashed = user.hashed_password if user else DUMMY_HASH
valid = password_hash.verify(password, hashed) # SIEMPRE se verifica algo
return user if (user and valid and not user.disabled) else None
Reglas de negocio del CRUD admin
# Un admin no puede auto-degradarse ni auto-desactivarse
if target.id == current_user.id and (body.role != "admin" or body.disabled):
raise HTTPException(status_code=409, detail="admin_cannot_self_demote")
Soft delete únicamente (disabled = true); cambios de rol surten efecto en el siguiente request (get_current_user valida usuario activo).
Pre-PR / Pre-Deploy Checklist
-
gitleakssin findings;.env.localgitignored - 100% endpoints de datos con
Security(scopes=...)(grep de cobertura limpio) - Matriz 401/403 por rol en verde (skill
portal-testing) - Auto-degradación/desactivación de admin bloqueada (409) y probada
- 409 en
username/emailduplicados - Cero contraseñas en respuestas/logs/trazas; cero prompts crudos (solo hash)
- Hash dummy anti-timing presente en login
- Cookie httpOnly + CORS restringido a orígenes conocidos
- Tools del agente propagan el Bearer del usuario (sin token de servicio)
-
pip-audit/pnpm auditsin Critical/High;ruff --select=Slimpio - Sin scope creep R11 (refresh/recovery/OAuth/RLS) — futuro en
docs/security.md