Módulo 4: Policy As Code With Conftest
8. Proyecto: la biblioteca de políticas de Andes Cargo
Descripción
Las lecciones 6 y 7 escribieron tres políticas por separado, cada una probada de forma aislada. Este proyecto final del módulo las reúne en lo que de verdad son desde el primer momento en que un pipeline las use: una biblioteca, policy/ completo, evaluada de una sola corrida contra un plan — el resultado que un job de CI real vería, no tres comandos separados. Lo corres dos veces: contra el plan actual de Andes Cargo (las tres políticas pasan, a la vez), y contra un plan que las viola a propósito, con dos violaciones simultáneas (dos políticas fallan, la tercera sigue pasando, y los tres mensajes de deny quedan visibles en una sola salida).
Conexión con el módulo
Este es el cierre del módulo, y el mismo ejercicio de auditoría dirigida que cada proyecto de esta guía practica desde el Módulo 1: no "¿escribiste tres archivos .rego?", sino "¿puedes demostrar, con evidencia ejecutada, que las tres funcionan juntas, sin que una tape el fallo de otra?". RISK-MAP.md cierra aquí sus dos filas de este módulo con evidencia consolidada, no repetida lección por lección.
Paso 1 — policy/, completo, los tres archivos
find policy/ -type f
Qué esperar (literal):
policy/no-destroy-shipments.rego
policy/least-privilege-iam.rego
policy/no-public-buckets.rego
policy/no-destroy-shipments.rego (lección 6):
package main
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_dynamodb_table"
"delete" in rc.change.actions
table_name := object.get(rc.change.before, "name", rc.address)
table_name == "Shipments"
msg := sprintf(
"%s: destroying the Shipments table (name=%q) is forbidden — this plan must never be applied",
[rc.address, table_name],
)
}
policy/least-privilege-iam.rego (lección 7):
package main
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_iam_role_policy"
some action in ["create", "update"]
action in rc.change.actions
policy := json.unmarshal(rc.change.after.policy)
some statement in policy.Statement
statement.Effect == "Allow"
action_is_wildcard(statement.Action)
msg := sprintf(
"%s: statement %q allows Action \"*\" — scope it to the specific actions this role needs",
[rc.address, object.get(statement, "Sid", "<no Sid>")],
)
}
action_is_wildcard(action) if {
action == "*"
}
action_is_wildcard(action) if {
is_array(action)
"*" in action
}
policy/no-public-buckets.rego (lección 7):
package main
public_access_block_locked_down(pab) if {
pab.block_public_acls == true
pab.block_public_policy == true
pab.ignore_public_acls == true
pab.restrict_public_buckets == true
}
deny contains msg if {
bucket_changes := [rc |
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
"create" in rc.change.actions
]
pab_changes := [rc |
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
]
count(bucket_changes) > count(pab_changes)
some rc in bucket_changes
msg := sprintf(
"%s: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked",
[rc.address],
)
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
not public_access_block_locked_down(rc.change.after)
msg := sprintf(
"%s: block_public_acls, block_public_policy, ignore_public_acls and restrict_public_buckets must all be true",
[rc.address],
)
}
Los tres archivos comparten package main — no por descuido, sino porque conftest, al apuntarle a un directorio completo con -p policy/, carga todos los archivos .rego de ese paquete y evalúa todas las reglas deny de todos ellos en una sola corrida. Es exactamente ese comportamiento el que hace que una biblioteca de políticas escale: agregar una cuarta política, el día de mañana, significa agregar un cuarto archivo a este directorio — nada más cambia en cómo se invoca conftest.
Paso 2 — La biblioteca completa, contra el plan actual: las tres pasan
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/
Qué esperar (literal, ejecutado para escribir esta lección):
4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions
Cuatro tests, no tres — la cuenta exacta que ya viste en la lección 7: una regla de no-destroy-shipments.rego, una de least-privilege-iam.rego, y dos de no-public-buckets.rego (la que verifica que el bloque existe, y la que verifica que está bien configurado). Las cuatro pasan, en una sola corrida, contra el plan de quince recursos que cierra este módulo — el mismo plan que un job policy-check de un pipeline real evaluaría antes de dejar avanzar un merge.
echo $?
0
Paso 3 — Un plan que viola dos políticas a la vez, a propósito
Para demostrar que la biblioteca completa —no una política aislada— detecta múltiples problemas simultáneos, propone dos cambios reales a la vez: ensancha AppServerRole a "Action": "*" (violando least-privilege-iam.rego) y revierte temporalmente el aws_s3_bucket_public_access_block de la lección 7 (violando no-public-buckets.rego) — sin tocar Shipments, así que no-destroy-shipments.rego debería seguir pasando.
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/
Qué esperar (literal, ejecutado para escribir esta lección):
FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "BroadBucketAccess" allows Action "*" — scope it to the specific actions this role needs
FAIL - tfplan.json - main - module.shipment_docs_bucket.aws_s3_bucket.this: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked
4 tests, 2 passed, 0 warnings, 2 failures, 0 exceptions
echo $?
1
Lee este resultado con atención, porque es la prueba central de este proyecto: cuatro tests evaluados —los mismos cuatro del Paso 2—, dos pasaron (no-destroy-shipments.rego, que no tenía ninguna razón para disparar porque Shipments nunca se tocó, y la segunda regla de no-public-buckets.rego, que tampoco tiene nada que evaluar porque ningún bloque existe todavía en este plan), dos fallaron —una por cada violación real que introdujiste—. Ninguna política enmascaró a la otra; ninguna política disparó por un problema que no le correspondía. Este es exactamente el comportamiento que hace que una biblioteca de políticas sea confiable: cada regla reporta, con precisión, solo lo que le compete, y todas corren juntas sin interferirse.
Revierte ambos cambios antes de cerrar esta lección — iam.tf y s3.tf vuelven al estado correcto que la lección 7 dejó, y una corrida final de conftest test tfplan.json -p policy/ contra ese estado vuelve a mostrar 4 tests, 4 passed.
Cómo defender este trabajo en una entrevista
Un entrevistador técnico que revise policy/ no necesita que recites Rego de memoria. Necesita que puedas responder, sin dudar, tres tipos de pregunta:
- "¿Por qué estas tres políticas, y no otras?" — la respuesta vive en
RISK-MAP.md: dos de las tres (no-destroy-shipments.rego,no-public-buckets.rego) cierran hallazgos concretos y documentados desde el Módulo 1 (TM-06,TM-04); la tercera (least-privilege-iam.rego) convierte una corrección puntual del Módulo 2 (TM-07) en una regla que vigila para siempre, no en una verificación de una sola vez. - "¿Cómo sabes que funcionan, no solo que existen?" — la respuesta vive en las lecciones 6, 7 y en este proyecto: cada política tiene un
FAILreal, provocado a propósito, y unPASSreal, contra elplancorrecto — nunca "debería funcionar", siempre "corrió, y esto fue lo que pasó". - "¿Qué pasa si dos problemas ocurren en el mismo
plan?" — la respuesta es el Paso 3 de este proyecto: la biblioteca completa detecta ambos, sin que ninguno tape al otro, con mensajes independientes y precisos para cada uno.
Actualizando RISK-MAP.md: el resumen del módulo completo
Con TM-06 (lección 6) y TM-04 (lección 7) marcadas Resolved, RISK-MAP.md queda con cuatro de siete filas cerradas al cierre de este módulo:
| Order | ID | Control | Status |
|---|---|---|---|
| 1 | TM-01 | OIDC federation + scoped trust policy | Resolved (M2.5) |
| 2 | TM-07 | Least-privilege role tightening | Resolved (M2.7) |
| 3 | TM-05 | SSM Parameter Store / Secrets Manager | Open (Módulo 3) |
| 4 | TM-04 | no-public-buckets.rego | Resolved (M4.7) |
| 5 | TM-06 | no-destroy-shipments.rego | Resolved (M4.6) |
| 6 | TM-02 | SBOM + cosign sign-blob/verify-blob | Open (Módulo 6) |
| 7 | TM-03 | CloudTrail | Open (Módulo 7) |
El proyecto completo, en un vistazo
andes-cargo-infra/
├── THREAT-MODEL.md (M1)
├── RISK-MAP.md (M1 → 4/7 filas Resolved al cierre de M4)
├── s3.tf (M4.7 agregó aws_s3_bucket_public_access_block)
├── modules/
│ ├── s3-bucket/
│ ├── iam-role/
│ └── oidc-provider/ (M2)
└── policy/ ← completo de este módulo
├── no-destroy-shipments.rego (M4.6, cierra TM-06)
├── least-privilege-iam.rego (M4.7, vigila TM-07)
└── no-public-buckets.rego (M4.7, cierra TM-04)
Errores comunes
Correr las tres políticas por separado, en tres comandos, en vez de apuntar -p al directorio completo (de flujo, contradice el punto de este proyecto). Qué pasa: alguien, por costumbre de las lecciones 6 y 7, sigue corriendo conftest test tfplan.json -p policy/no-destroy-shipments.rego, después el mismo comando para cada archivo, en vez de -p policy/ una sola vez. Cómo detectarlo: si tu flujo de trabajo corre conftest más de una vez para revisar el mismo plan. Cómo corregirlo: -p policy/ (un directorio, no un archivo) carga y evalúa todos los .rego de ese paquete en una sola invocación — es, además, la única forma correcta de integrarlo en un pipeline real (Módulo 8 de esta guía completa), donde un job policy-check corre una vez, no una vez por política.
Interpretar 2 passed en el Paso 3 como que "la mitad de las políticas fallaron" (de lectura del resumen). Qué pasa: alguien lee 4 tests, 2 passed, ... 2 failures y concluye que la biblioteca "funciona a medias" o que hay un problema con la mitad de las reglas. Cómo detectarlo: si tu reacción al ver 2 passed de 4 es buscar qué está mal con las políticas que "no pasaron". Cómo corregirlo: 2 passed en este contexto específico es el resultado correcto — dos de las cuatro reglas (no-destroy-shipments.rego, y la segunda regla de no-public-buckets.rego) no tenían ninguna razón para disparar contra ese plan específico, porque ninguna de las dos condiciones que vigilan estaba presente. Un passed significa "esta regla no encontró ningún problema", no "esta regla es débil" — y las dos reglas que sí fallaron son, precisamente, las que correspondían a los dos cambios reales que introdujiste a propósito.
Dejar el cambio de prueba (AppServerRole ensanchado, bloque de acceso público borrado) sin revertir, "para la próxima lección" (de higiene del proyecto). Qué pasa: alguien termina el Paso 3, ve el FAIL esperado, y sigue adelante sin revertir iam.tf y s3.tf al estado correcto. Cómo detectarlo: si conftest test tfplan.json -p policy/ sigue mostrando fallas después de que pensabas haber terminado este proyecto. Cómo corregirlo: cada lección de este módulo que introdujo un cambio de prueba —la 7, y esta— revirtió ese cambio explícitamente antes de declarar el paso completo; el estado final de andes-cargo-infra/ al cierre de este proyecto es el correcto, con las tres políticas pasando, no el estado intermedio con violaciones a propósito.
Ejercicios
Ejercicio 1 — Corre la biblioteca completa contra un cuarto escenario: una destrucción de Shipments combinada con una política de mínimo privilegio violada, al mismo tiempo. Genera un plan que combine el escenario destructivo de la lección 6 (terraform plan -destroy) con el cambio de mínimo privilegio de la lección 7. ¿Cuántos tests fallarían, y cuáles?
Ver solución
Este caso específico tiene un matiz importante: terraform plan -destroy genera un plan que solo contiene destrucciones (Plan: 0 to add, 0 to change, N to destroy) — no puede, al mismo tiempo, describir la creación o modificación de una política IAM ensanchada, porque el modo -destroy no acepta cambios de HCL adicionales en la misma corrida (todo lo que no se destruye simplemente no aparece en el plan). Para combinar ambos escenarios en un plan real necesitarías, en cambio, quitar el recurso aws_dynamodb_table.shipments de la configuración (para que un plan normal, no en modo -destroy, calcule su eliminación) mientras el cambio de iam.tf sigue presente. Con esa combinación, esperarías 2 failures de 4 tests: no-destroy-shipments.rego dispararía por la tabla, least-privilege-iam.rego dispararía por la política ensanchada, y las dos reglas de no-public-buckets.rego seguirían pasando si el bloque de acceso público no se tocó. El punto de este ejercicio es notar que "combinar escenarios de prueba" no siempre es tan simple como sumar dos comandos — a veces exige entender, con precisión, qué puede y qué no puede coexistir dentro de un mismo plan.
Ejercicio 2 — Explica, a un compañero que solo vio el resumen de números, por qué 4 tests no cambia entre el Paso 2 y el Paso 3. Tu compañero nota que tanto el plan correcto como el plan con violaciones muestran 4 tests en el resumen, y pregunta por qué ese número es igual si el contenido de los dos plan es tan distinto.
Ver solución
El número de tests cuenta cuántas reglas deny existen en policy/ —una propiedad de las políticas mismas, fija mientras no agregues o quites un archivo .rego—, no cuántos problemas tiene un plan específico. Las mismas cuatro reglas se evalúan siempre, contra cualquier input que le des a conftest — lo que cambia entre un plan y otro es cuántas de esas cuatro evaluaciones terminan en passed versus failure, nunca cuántas reglas existen para evaluar. Es la misma distinción que ya viste en la lección 4: el número de tests sigue las reglas, no los archivos ni el contenido de lo que se evalúa.
Ejercicio 3 — Diseña, en prosa, una cuarta política que este proyecto no construyó, y justifica por qué no era necesaria todavía. Basándote en THREAT-MODEL.md (Módulo 1), describe en prosa una regla Rego que podrías escribir para un riesgo que este módulo no cubrió, y explica por qué las tres políticas de este proyecto fueron suficientes para las dos filas de RISK-MAP.md que le correspondían a este módulo específico.
Ver solución
Una respuesta razonable podría proponer, por ejemplo, una política que prohíba cualquier aws_lambda_function sin un timeout explícito, o una que exija que todo aws_dynamodb_table tenga point_in_time_recovery habilitado —ninguna de las dos corresponde a un hallazgo específico de THREAT-MODEL.md, así que serían políticas de buena práctica general, no de resolución de un riesgo documentado—. La razón por la que este proyecto no las construyó es la misma que RISK-MAP.md ya estableció desde el Módulo 1: este módulo tenía exactamente dos filas asignadas (TM-04, TM-06), más una tercera política (mínimo privilegio) que vigila, sin cerrar una fila nueva, un hallazgo ya resuelto en otro módulo. Escribir una cuarta política sin un hallazgo documentado que la respalde sería exactamente el error de alcance prematuro que la lección 7 de este módulo ya advirtió — cada política de esta guía existe porque un riesgo real, con evidencia, la necesita, no porque "sería una buena idea tenerla".
Resumen y siguiente paso
En este proyecto final reuniste policy/ completo —tres archivos .rego, cuatro reglas deny en total—, y confirmaste, con dos corridas reales de conftest test tfplan.json -p policy/, que las tres políticas funcionan juntas sin interferirse: 4 tests, 4 passed contra el plan correcto de Andes Cargo, 4 tests, 2 passed, 2 failures contra un plan con dos violaciones simultáneas, cada una detectada con su propio mensaje preciso. Cerraste TM-06 y TM-04 de RISK-MAP.md, dejando el documento con cuatro de siete filas resueltas.
Antes de cerrar este módulo deberías poder: correr conftest test <plan>.json -p policy/ desde cero, sin mirar ninguna lección anterior; explicar por qué el número de tests no cambia entre un plan correcto y uno con violaciones; y defender, frente a las tres preguntas de entrevista de esta lección, por qué estas tres políticas específicas —ni una más, ni una menos— eran las correctas para este módulo.
Con esto, el Módulo 4 de cloud-security-and-guardrails-guide queda completo: conftest instalado y verificado, tu primera política Rego escrita y corrida contra un YAML de prueba, el terraform plan explorado como JSON estructurado, y tres políticas reales protegiendo andes-cargo-infra/ — la promesa que terraform-and-iac-guide y cicd-and-gitops-on-aws-guide nombraron sin construir, cumplida de punta a punta, con evidencia ejecutada en cada paso. El Módulo 5 abre la siguiente capa de defensa: escaneo de infraestructura como código con las reglas que la comunidad de seguridad ya escribió, no solo las tres que tú escribiste aquí.
Recursos
- Conftest — Documentación oficial — la referencia completa de
conftest testcontra un directorio de políticas, el comando central de este proyecto. - Este módulo, lecciones 6 y 7 — el origen de cada una de las tres políticas reunidas en este proyecto, con su
FAILyPASSindividuales ya verificados. - Este curso, Módulo 1, lección 8 (
RISK-MAP.md) — el documento que este proyecto actualiza con las dos filas que este módulo cierra. src/paths/aws-cloud-ecosystem/VALIDACION.md— la auditoría de mercado que fijó el peso de este módulo: policy-as-code como el hueco explícito deterraform-and-iac-guide, cerrado aquí con evidencia ejecutada.