Caso práctico: modelo de amenazas de una app de 3 capas
El modelo de amenazas completo de Citas, de los activos al riesgo residual, y una plantilla para hacer el tuyo.
La teoría del modelado de amenazas cabe en cuatro preguntas. Lo difícil es aplicarla a un sistema real sin acabar con un documento de 40 páginas que nadie vuelve a abrir. En esta nota modelamos Citas de principio a fin: una SaaS de reserva de citas para clínicas pequeñas, construida en tres capas sobre AWS. El resultado es un modelo de amenazas que cabe en el repositorio, se revisa en las PR y te dice qué hacer el lunes por la mañana. Al final tienes una plantilla para hacer lo mismo con tu propia aplicación.
Seguimos el marco de las cuatro preguntas: ¿en qué estamos trabajando?, ¿qué puede salir mal?, ¿qué vamos a hacer al respecto? y ¿hicimos un buen trabajo?
1. ¿En qué estamos trabajando?#
Alcance y supuestos#
Dentro del alcance: la SPA (S3 + CloudFront + AWS WAF), la autenticación con Amazon Cognito, la API Node.js en ECS Fargate detrás de un ALB, el worker que consume SQS y envía recordatorios por SNS/SES, RDS PostgreSQL, el bucket citas-adjuntos, Secrets Manager y el despliegue desde GitHub Actions.
Fuera del alcance: la seguridad física e hipervisor de AWS (es su parte del modelo de responsabilidad compartida), los dispositivos de las clínicas y la infraestructura interna de SES.
Supuestos (si alguno deja de ser cierto, el modelo hay que revisarlo):
- Todo el tráfico va cifrado con TLS 1.2 o superior, también entre el ALB y la API.
- Producción, staging y log-archive son cuentas separadas bajo AWS Organizations, con SCPs.
- CloudTrail organizacional escribe en la cuenta log-archive, a la que el equipo de producto no tiene acceso de escritura.
- Cada usuario pertenece a una clínica, y la clínica viaja como claim (
custom:clinica_id) en el JWT de Cognito.
Activos#
| Activo | Dónde vive | Propiedad que más importa |
|---|---|---|
| PII de pacientes (nombre, teléfono, email, notas) | RDS, adjuntos en S3 | Confidencialidad, integridad |
| Agenda de citas | RDS | Integridad, disponibilidad (una cita borrada es un paciente sin atender) |
| Credenciales y sesiones | Cognito, rol de tarea ECS, rol de despliegue, Secrets Manager | Confidencialidad |
| Registros de auditoría | CloudWatch Logs, CloudTrail en log-archive | Integridad, no repudio |
Actores de amenaza#
- Externo oportunista: credential stuffing contra el login y escaneo masivo de buckets y APIs.
- Externo dirigido: phishing a recepcionistas para entrar con una cuenta legítima.
- Insider de una clínica: un usuario autenticado que intenta ver pacientes de otra clínica. En una SaaS multi-tenant es el actor que más se olvida.
- Cadena de suministro: una dependencia npm o una GitHub Action comprometidas.
DFD y límites de confianza#
El diagrama de flujo de datos (DFDDiagrama de flujo de datosDiagrama con entidades externas, procesos, almacenes y flujos de datos con dirección; es el modelo habitual sobre el que se buscan amenazas.) de nivel 1 tiene dos entidades externas (personal de la clínica y paciente, que solo recibe recordatorios), cuatro procesos (CloudFront/WAF, ALB, API y worker) y cuatro almacenes (RDS, S3, SQS y Secrets Manager). Lo importante son los flujos que cruzan un límite de confianzaLímite de confianzaFrontera donde cambia el nivel de confianza o de privilegio; todo lo que la cruza debe autenticarse, autorizarse, validarse y, normalmente, cifrarse.:
- LC1: Internet → borde de AWS (navegador → CloudFront/WAF).
- LC2: borde → VPC (CloudFront → ALB → API).
- LC3: aplicación → datos (API → RDS y API → S3).
- LC4: GitHub → cuenta de producción (despliegue vía OIDC). No aparece en la animación, pero es igual de real.
Paso 1 de 6Arquitectura
Citas en tres capas: el navegador llega a CloudFront con WAF, el ALB enruta a la API en Fargate y los datos viven en RDS y en el bucket de adjuntos.
2. ¿Qué puede salir mal?#
Recorremos cada elemento con STRIDE (amenazas por elemento) y anotamos solo amenazas concretas, con su mitigación en AWS. Impacto y probabilidad en escala alto/medio/bajo.
| ID | Elemento | STRIDE | Amenaza | Impacto × Prob. | Mitigación en AWS | Estado |
|---|---|---|---|---|---|---|
| T01 | Personal de la clínica | S | Credential stuffing o phishing para entrar con una cuenta real | Alto × Alta | MFA obligatorio en Cognito, threat protection (plan Plus), regla ATP de AWS WAF en el login | Mitigada |
| T02 | Personal de la clínica | R | Una recepcionista niega haber cancelado una cita | Medio × Media | Log de auditoría por acción (quién, qué, cuándo) en tabla append-only y CloudWatch Logs con 1 año de retención | Aceptada |
| T03 | CloudFront / WAF | D | DDoS de capa 7 contra el endpoint de disponibilidad | Medio × Media | AWS Shield Standard, reglas rate-based de WAF, caché de disponibilidad en CloudFront | Mitigada |
| T04 | SPA en S3 | T | Modificación del bundle JS servido a los usuarios | Alto × Baja | Bucket solo accesible por CloudFront (OAC), escritura solo desde el rol de CI, versionado, CSP | Mitigada |
| T05 | API | T | SQL injection en la búsqueda de citas | Alto × Media | Consultas parametrizadas, AWSManagedRulesSQLiRuleSet, SAST en CI |
Mitigada |
| T06 | API | I | IDOR/BOLA: una clínica lee pacientes de otra | Alto × Alta | clinica_id sacado del JWT y nunca del request, Row-Level Security en PostgreSQL, tests de autorización |
En curso |
| T07 | API (rol de tarea) | E | El rol de tarea ECS tiene s3:* y rds:* |
Alto × Media | Rol mínimo (Get/Put solo en citas-adjuntos/<clinica>/*), IAM Access Analyzer de accesos no usados |
Mitigada |
| T08 | API → RDS | I | Credenciales de la base de datos en variables de entorno o en el código | Alto × Media | Secrets Manager con rotación, inyectado como secrets en la task definition |
Mitigada |
| T09 | RDS | I | Snapshot compartido con una cuenta externa | Alto × Baja | Clave KMS del cliente con key policy restrictiva, SCP sobre rds:ModifyDBSnapshotAttribute, alerta en EventBridge |
Mitigada |
| T10 | RDS | D | Borrado accidental o malicioso de la base de datos | Alto × Baja | Deletion protection, backups automáticos y AWS Backup en otra cuenta con Vault Lock | Mitigada |
| T11 | S3 citas-adjuntos |
I | Bucket u objetos públicos | Alto × Media | Block Public Access en la cuenta, URLs prefirmadas de 5 minutos, SSE-KMS | Mitigada |
| T12 | S3 citas-adjuntos |
T | Subida de archivos maliciosos como adjunto | Medio × Media | Validación de tipo y tamaño, GuardDuty Malware Protection for S3, prefijo de cuarentena | En curso |
| T13 | SQS (worker) | S | Mensajes falsos en la cola que disparan recordatorios fraudulentos a pacientes | Medio × Baja | Política de cola: solo el rol de la API puede sqs:SendMessage; cifrado SSE |
Mitigada |
| T14 | GitHub → AWS (LC4) | E | Un workflow de otro repositorio asume el rol de despliegue | Alto × Media | Trust policy OIDC con sub limitado a repo:clinicas/citas:environment:prod, revisión obligatoria en el entorno |
Mitigada |
| T15 | CloudTrail / logs | R | El atacante desactiva o borra los registros para cubrir su rastro | Alto × Baja | Trail organizacional en log-archive, S3 Object Lock, SCP que deniega cloudtrail:StopLogging y cloudtrail:DeleteTrail, hallazgos de GuardDuty |
Mitigada |
3. ¿Qué vamos a hacer al respecto?#
Priorización: impacto × probabilidad#
| Impacto \ Probabilidad | Baja | Media | Alta |
|---|---|---|---|
| Alto | T04, T09, T10, T15 | T05, T07, T08, T11, T14 | T01, T06 |
| Medio | T13 | T02, T03, T12 | — |
| Bajo | — | — | — |
El orden de trabajo sale solo: primero T01 y T06, después la fila alto × media. T06 está «en curso», así que es la prioridad número uno del trimestre. La mitigación combina dos capas: la API toma la clínica del token y PostgreSQL lo impone aunque la API tenga un bug.
ALTER TABLE citas ENABLE ROW LEVEL SECURITY;ALTER TABLE citas FORCE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_por_clinica ON citas USING (clinica_id = current_setting('app.clinica_id', true)::uuid) WITH CHECK (clinica_id = current_setting('app.clinica_id', true)::uuid);export async function withTenant<T>(claims: CognitoClaims, fn: (tx: Tx) => Promise<T>) { const clinicaId = claims['custom:clinica_id']; if (!clinicaId) throw new ForbiddenError('token sin clínica'); return db.transaction(async (tx) => { // is_local = true: la variable solo vive dentro de esta transacción await tx.query("SELECT set_config('app.clinica_id', $1, true)", [clinicaId]); return fn(tx); });}Riesgos aceptados#
Aceptar un riesgoRiesgoCombinación de la probabilidad de que una amenaza se materialice y del impacto que tendría. Se mitiga, transfiere, evita o acepta. es una decisión legítima siempre que sea explícita: tiene responsable, justificación y fecha de revisión. Si no lo escribes, no lo has aceptado: lo has ignorado.
- id: T02 riesgo: Repudio de cancelaciones por parte del personal de la clínica decision: aceptado justificacion: > El impacto es operativo (discusión interna en la clínica), no de seguridad de datos. Firmar cada acción del usuario tiene un coste desproporcionado para este riesgo. control_parcial: log de auditoría append-only (90 días en caliente, 1 año en S3) responsable: tech-lead de Citas revisar: 2027-04-014. ¿Hicimos un buen trabajo?#
Una amenaza «mitigada» sin evidencia es una opinión. Cada mitigación debe tener un control verificable:
| Amenazas | Cómo se valida | Frecuencia |
|---|---|---|
| T05, T06 | Tests de integración de autorización en CI (un usuario de la clínica A no lee ni modifica recursos de la clínica B) y DAST contra staging | Cada PR / semanal |
| T07, T14 | aws accessanalyzer validate-policy en CI y hallazgos de accesos externos y no usados de IAM Access Analyzer |
Cada PR / continuo |
| T11 | Reglas de AWS Config s3-account-level-public-access-blocks-periodic y s3-bucket-level-public-access-prohibited |
Continuo |
| T15 | Hallazgo de GuardDuty Stealth:IAMUser/CloudTrailLoggingDisabled enrutado a alertas |
Continuo |
| T01 | Game day: credential stuffing simulado contra staging para comprobar el bloqueo | Semestral |
| Todas | Pentest externo con el modelo de amenazas como entrada | Anual y tras cambios grandes |
# Lista solo errores y avisos de seguridad; en CI, falla el job si la salida no está vacíaaws accessanalyzer validate-policy \ --policy-type IDENTITY_POLICY \ --policy-document file://iam/api-task-role.json \ --query 'findings[?findingType==`ERROR` || findingType==`SECURITY_WARNING`]'Mantenerlo vivo#
Un modelo de amenazas envejece en cuanto cambia la arquitectura. Para que no muera:
- Guárdalo en
threat-model/dentro del repositorio, en Markdown, junto al código que describe. Herramientas como Threat Composer de AWS o OWASP Threat Dragon exportan a JSON versionable. - Añade a la plantilla de PR una pregunta que obligue a revisarlo cuando toca:
### Seguridad
- [ ] ¿Añade o cambia un flujo que cruza un límite de confianza?- [ ] ¿Toca PII, credenciales o permisos IAM?- [ ] Si alguna respuesta es sí: `threat-model/` actualizado y amenaza enlazada (Txx).- Revísalo cada trimestre y después de cada incidente. Un incidente que el modelo no contemplaba es la mejor señal de que le falta una rama.
Tu turno
Modela una app de 3 capas que ya hayas construido
Elige una aplicación de tres capas (presentación, aplicación, datos) que hayas construido o mantenido. Tómate 90 minutos y rellena la plantilla. No busques la perfección: busca las 3–5 amenazas que más te preocupan y un control verificable para cada una.
- ¿Qué activo haría más daño perder: datos personales, dinero, disponibilidad?
- ¿Dónde están tus límites de confianza y qué flujos los cruzan?
- ¿Tu app es multi-tenant? ¿Qué impide que un cliente lea los datos de otro?
- ¿Qué amenaza aceptarías hoy, quién firma esa decisión y cuándo la revisarías?
- ¿Qué evidencia podrías enseñar mañana de que cada mitigación funciona?
# Modelo de amenazas: <nombre de la app>
Versión: 1.0 · Fecha: AAAA-MM-DD · Responsable: <nombre>
## 1. ¿En qué estamos trabajando?
### Alcance
- Dentro: ...- Fuera: ...
### Supuestos
- ...
### Activos
| Activo | Dónde vive | Propiedad que más importa (C/I/A) || ------ | ---------- | --------------------------------- |
### Actores de amenaza
- ...
### DFD (nivel 1) y límites de confianza
- Entidades externas: ...- Procesos: ...- Almacenes: ...- Flujos que cruzan límites: LC1 ..., LC2 ..., LC3 ...
## 2. ¿Qué puede salir mal?
| ID | Elemento | STRIDE | Amenaza | Impacto × Prob. | Mitigación | Estado || --- | -------- | ------ | ------- | --------------- | ---------- | ------ || T01 | | | | | | |
## 3. ¿Qué vamos a hacer al respecto?
### Prioridades (impacto × probabilidad)
### Riesgos aceptados (responsable, justificación, fecha de revisión)
## 4. ¿Hicimos un buen trabajo?
### Validación por amenaza (test, regla, alerta o revisión)
### Próxima revisión: AAAA-MM-DDReto
¿Por qué el IDOR entre clínicas (T06) no lo detiene AWS WAF?
▸Mostrar solución
Porque la petición maliciosa es indistinguible de una legítima: usuario autenticado, formato correcto, sin patrones de ataque. Solo cambia un identificador de recurso. La defensa está en la autorización a nivel de objeto: tomar la clínica del token, comprobar la pertenencia en cada acceso y, como segunda capa, Row-Level Security en la base de datos.
Reto
Has marcado T11 (bucket público) como mitigada. ¿Qué evidencia debería existir?
▸Mostrar solución
Block Public Access activado a nivel de cuenta, una SCP que impide desactivarlo
(s3:PutAccountPublicAccessBlock), las reglas de AWS Config en estado compliant y una alerta si
alguna deja de cumplirse. Y, como prueba funcional, que el objeto solo sea accesible mediante una
URL prefirmada de corta duración.
En resumen#
- Un modelo de amenazas útil responde a las cuatro preguntas y cabe en el repositorio: alcance, activos, actores, DFD con límites de confianza, amenazas, prioridades, riesgos aceptados y validación.
- Las amenazas se anotan por elemento con STRIDE y siempre con una mitigación concreta y un estado.
- En una SaaS multi-tenant, la amenaza más grave suele ser de autorización (IDOR/BOLA), y ningún control perimetral la resuelve.
- Prioriza con impacto × probabilidad. Lo que no mitigues, acéptalo por escrito, con responsable y fecha.
- «Mitigada» significa que existe un control que una máquina puede verificar. Revisa el modelo en cada PR que cambie la arquitectura.
Referencias#
- Threat Modeling Manifesto: las cuatro preguntas y los valores del modelado de amenazas.
- OWASP, Threat Modeling Cheat Sheet.
- OWASP API Security Top 10 2023, API1: Broken Object Level Authorization.
- AWS, Threat Composer, herramienta de modelado de amenazas.
- AWS, Threat protection en Amazon Cognito.
- AWS, Sharing a DB snapshot for Amazon RDS.
- AWS, GuardDuty Malware Protection for S3.
- PostgreSQL, Row Security Policies.