Módulo 8: Capstone The Andes Cargo Security Gate

5. Recorrido end-to-end: un cambio que el gate detiene

Descripción

Esta es la lección que prueba la tesis completa de esta guía. Un intento real, con una motivación completamente creíble —"necesito que AppServerRole deje de fallar al subir archivos, voy a abrirle el acceso"—, ensancha el permiso de S3 de ese rol a "Action": "*". El gate lo detiene en policy-check, antes de que iac-scan o verify-artifact corran siquiera un solo step. No es una promesa: es act pull_request corrido de verdad, con el log completo como evidencia, y la ausencia total de los otros dos jobs en ese log como la prueba de que nunca se dispararon.

Conexión con el módulo

El Módulo 1, lección 3, usó exactamente este patrón —s3:* cuando el código solo llama s3:GetObject— como el ejemplo técnico de Elevation of Privilege en STRIDE. El Módulo 4, lección 7, escribió la política que lo detecta. El Módulo 4, lección 8, ya demostró que esa política falla contra un plan con esa violación, a mano, en tu terminal. Esta lección no repite esa prueba — la lleva un nivel más arriba: el mismo tipo de cambio, ahora dentro de un pipeline real, con dos jobs completos (iac-scan, verify-artifact) que nunca llegan a correr como consecuencia directa.


Analogía: el pasajero sin pasaporte válido, en la primera fila

Volviendo al aeropuerto de la lección 1: un pasajero sin documentos válidos no llega al detector de metales para que lo rechacen ahí — nunca sale de la fila de control de documentos. Nadie escanea su equipaje "por si acaso pasaba el primer control". El sistema entero está diseñado para que ese pasajero nunca gaste el tiempo de los controles siguientes, ni el suyo propio ni el de la fila detrás de él. Esta lección es esa escena exacta, con un plan de Terraform en el rol del pasajero.


Paso 1 — El cambio: "solo quiero que deje de fallar la subida"

Un desarrollador de Andes Cargo está depurando un error de permisos en AppServerRole —algo que, en la vida real, pasa constantemente, y casi nunca con mala intención—. En vez de identificar el verbo exacto que falta, resuelve el problema de la forma más rápida posible: abrir el permiso por completo.

   statement {
     sid       = "AppServerBucketAccess"
     effect    = "Allow"
-    actions   = ["s3:GetObject", "s3:PutObject"]
+    actions   = ["*"]
     resources = ["arn:aws:s3:::andes-cargo-shipment-docs/*"]
   }
git diff -- iam.tf

Qué esperar (literal):

diff --git a/iam.tf b/iam.tf
index 9fd7080..d31a915 100644
--- a/iam.tf
+++ b/iam.tf
@@ -35,7 +35,7 @@ data "aws_iam_policy_document" "app_server_inline" {
   statement {
     sid       = "AppServerBucketAccess"
     effect    = "Allow"
-    actions   = ["s3:GetObject", "s3:PutObject"]
+    actions   = ["*"]
     resources = ["arn:aws:s3:::andes-cargo-shipment-docs/*"]
   }
 }

Una línea, igual que el cambio de la lección 4 —y esa similitud es exactamente el punto—: ningún revisor humano, mirando un diff de una línea en medio de un Pull Request con quince archivos cambiados, tiene garantizado notar la diferencia entre ["s3:GetObject", "s3:PutObject"] y ["*"] a simple vista. Es precisamente el tipo de cambio que un gate automático existe para atrapar cuando la revisión humana, ocupada o apurada, no lo hace.

git add -A
git commit -m "Widen AppServerRole S3 access while debugging an upload issue"

Paso 2 — conftest, a mano, primero: confirmando la violación antes del pipeline

Antes de correr el gate completo, confirma en aislamiento —el mismo hábito de "prueba primero en pequeño" que ya usaste en el Módulo 4— que esta política sí dispara:

terraform plan -out=tfplan -input=false
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 "AppServerBucketAccess" allows Action "*" — scope it to the specific actions this role needs

4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptions
echo $?
1

El mensaje es exacto, y señala exactamente lo que cambió. module.app_server_role.aws_iam_role_policy.this —el mismo address que verías con terraform state show—, el Sid exacto del statement ("AppServerBucketAccess", el mismo que este cambio tocó), y la razón en una frase clara. Nada de esto es nuevo comparado con el Módulo 4, lección 8 —es la misma política, el mismo tipo de violación—; lo que sí es nuevo es lo que pasa a continuación.


Paso 3 — El gate completo: policy-check falla, y ahí termina todo

act pull_request -e .github/act-events/pr-event.json

Qué esperar (literal — ejecutado para escribir esta lección, sin ningún recorte adicional a partir del punto donde el resultado se decide):

[ci/policy-check] ⭐ Run Main Evaluate the policy library against the plan
[ci/policy-check]   | FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "AppServerBucketAccess" allows Action "*" — scope it to the specific actions this role needs
[ci/policy-check]   |
[ci/policy-check]   | 4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptions
[ci/policy-check]   ❌  Failure - Main Evaluate the policy library against the plan [381.776584ms]
[ci/policy-check] exitcode '1': failure
[ci/policy-check] ⭐ Run Complete job
[ci/policy-check]   ✅  Success - Complete job
[ci/policy-check] 🏁  Job failed
Error: Job 'policy-check' failed

Esto es todo. No hay más líneas. Lee esa salida con atención: no dice [ci/iac-scan] en ningún lado. No dice [ci/verify-artifact] en ningún lado. act termina el proceso completo con Error: Job 'policy-check' failed inmediatamente después de que ese job se marca como fallido — los otros dos jobs de la cadena nunca se disparan, no es que corran y fallen también, ni que se salten silenciosamente: la ejecución del workflow entero se detiene ahí, con un solo job evaluado de los tres.

Confírmalo tú mismo, sobre el log completo, no solo sobre el fragmento de arriba:

act pull_request -e .github/act-events/pr-event.json 2>&1 | grep -c "iac-scan\|verify-artifact"

Qué esperar (literal):

0

Cero. Ni una sola mención de iac-scan o verify-artifact en absolutamente ninguna línea de la salida completa del workflow —ni como job que arrancó, ni como job que se saltó, ni en ningún mensaje de sistema—. Es la diferencia entre "el gate revisó y rechazó" y "el gate nunca llegó a revisar lo que viene después", y es exactamente esa segunda afirmación la que esta lección demuestra con evidencia, no con una promesa de diseño.


Por qué esto importa: el costo que el equipo nunca pagó

Compáralo con lo que habría pasado sin needs: encadenado —el escenario que el Ejercicio 1 de la lección 2 ya te hizo predecir—: iac-scan habría instalado Trivy (segundos) y escaneado el proyecto completo; verify-artifact habría instalado cosign (segundos) y verificado un artefacto que, de todos modos, no tenía nada que ver con este cambio. Ninguno de los dos habría cambiado el resultado final —el PR de todos modos no se mergearía—, pero ambos habrían gastado tiempo real de runner, en cada corrida, de cada Pull Request rechazado, para siempre. El needs: de la lección 3 no es solo una cuestión de orden lógico: es la diferencia entre un gate que falla rápido de verdad y uno que solo parece fallar rápido porque el primer job que reporta también es el más barato, mientras los demás siguen corriendo de todos modos en segundo plano.


Paso 4 — Revertir el cambio de prueba

Igual que cada lección de esta guía que introdujo un cambio de prueba (Módulo 4, lección 6; Módulo 6, lección 7 y 8), revierte antes de cerrar:

git revert --no-edit HEAD
grep -A1 "actions   = " iam.tf | grep AppServerBucketAccess -A1

Qué esperar (literal — de vuelta al estado correcto):

    actions   = ["s3:GetObject", "s3:PutObject"]

Errores comunes

Buscar el fallo de iac-scan/verify-artifact en la salida, esperando verlos "también fallar". Qué pasa: alguien, leyendo la salida del Paso 3, busca activamente una línea de iac-scan o verify-artifact marcada con , y no la encuentra, y concluye que hubo un error en la ejecución de esta lección. Cómo detectarlo: si tu expectativa era ver tres jobs, los tres marcados de alguna forma (uno fallido, dos exitosos, o los tres fallidos). Cómo corregirlo: el resultado correcto de este needs: encadenado es que los otros dos jobs no aparecen en absoluto — ni exitosos ni fallidos, porque nunca arrancaron. Confundir "nunca corrió" con "corrió y falló silenciosamente" es exactamente el error que el grep -c del Paso 3 está diseñado para descartar con un número, no con una impresión visual de la salida.

Pensar que el mensaje de deny de conftest menciona el nombre AWS del permiso (s3:*) en vez de "*". Qué pasa: alguien, leyendo el mensaje allows Action "*", espera ver s3:* en su lugar, porque el cambio de negocio que motivó este PR era "necesito acceso a S3". Cómo detectarlo: si comparas el mensaje literal contra tu expectativa de "el error debería decir qué servicio se abrió de más". Cómo corregirlo: least-privilege-iam.rego (Módulo 4, lección 7) evalúa el valor exacto del campo Action de la política IAM, y en este cambio ese valor es el comodín total "*" —no "s3:*"—: quien editó iam.tf no acotó el permiso al servicio S3, abrió el statement a cualquier acción de cualquier servicio de AWS sobre ese recurso, un error más amplio del que la motivación original ("necesito subir archivos a S3") pretendía. Es, en sí mismo, un ejemplo real de cómo un atajo bajo presión de tiempo suele terminar siendo más amplio de lo que la necesidad original justificaba — exactamente el patrón que THREAT-MODEL.md documentó desde el Módulo 1.

Olvidar el Paso 4 y dejar AppServerRole ensanchado como estado final del laboratorio. Qué pasa: alguien confirma el Job failed esperado y sigue adelante a la lección 6 sin revertir. Cómo detectarlo: si conftest test tfplan.json -p policy/ sigue mostrando 1 failure al empezar la siguiente lección. Cómo corregirlo: la misma disciplina de higiene que cada proyecto de esta guía ya exigió — el estado final de tu laboratorio, al cerrar esta lección, debe tener las cuatro reglas de policy/ pasando, no el estado intermedio de la demostración de fallo.


Ejercicios

Ejercicio 1 — Modifica el cambio de esta lección para que viole no-public-buckets.rego en vez de least-privilege-iam.rego, y confirma que el gate se detiene en el mismo job de todos modos. En vez de ensanchar AppServerRole, elimina temporalmente el bloque aws_s3_bucket_public_access_block del módulo s3-bucket. Corre el gate completo. ¿En qué job se detiene, y por qué tiene sentido que sea el mismo?

Ver solución

Se detiene en policy-check, exactamente igual — porque las cuatro reglas de policy/ (una de no-destroy-shipments.rego, una de least-privilege-iam.rego, dos de no-public-buckets.rego) se evalúan todas dentro del mismo conftest test, en el mismo job, sin importar cuál específicamente dispare. policy-check no es "el job que revisa mínimo privilegio" — es "el job que evalúa toda la biblioteca de políticas de una sola corrida", exactamente como el Módulo 4, lección 8, ya estableció con su ejemplo de dos violaciones simultáneas. Cualquier violación de cualquiera de las tres políticas detiene el pipeline en el mismo punto, con el mismo mecanismo.

Ejercicio 2 — Predice qué mostraría grep -c "iac-scan\|verify-artifact" si needs: en iac-scan apuntara, por error, a un job que no existe (un typo, como needs: policy_check con guion bajo en vez de guion medio). ¿El pipeline fallaría de la misma forma, o de una forma distinta y más confusa?

Ver solución

De una forma distinta y más confusa: GitHub Actions (y act) validan la sintaxis del workflow antes de ejecutar cualquier job, y un needs: que apunta a un job_id inexistente es un error de validación del workflow completo, no un error de ejecución de un job específico. El resultado sería algo como Error: yaml: workflow is not valid o un mensaje equivalente sobre una referencia inválida, mostrado antes de que policy-check siquiera arrancara — ningún job correría en absoluto, ni el que falló de verdad ni los que dependían de él. Es un recordatorio de por qué la lección 3 verificó, con una corrida real y exitosa, que el needs: estaba bien escrito antes de usarlo para demostrar un fallo — un typo en needs: no se manifiesta como "el control de seguridad no funcionó", se manifiesta como "el workflow entero es inválido", un tipo de error completamente distinto.

Ejercicio 3 — Explica, a un compañero que solo vio el resultado final (🏁 Job failed), por qué el Paso 2 de esta lección (conftest a mano) no era redundante con el Paso 3 (el gate completo). ¿Qué confirma el Paso 2 que el Paso 3, por sí solo, no habría confirmado con la misma claridad?

Ver solución

El Paso 2 aísla la causa exacta —qué regla, qué mensaje, qué código de salida— fuera del ruido de un pipeline completo (instalación de herramientas, checkout, setup de Terraform). Si el Paso 3 hubiera fallado sin el Paso 2 previo, sería más difícil distinguir, de un vistazo, si el fallo vino de la política en sí o de algún problema de infraestructura del propio job (una instalación fallida, una variable de entorno faltante). Correr conftest a mano primero —el mismo hábito que el Módulo 4 estableció desde su primera política— confirma la causa raíz con precisión antes de verla reaparecer, idéntica, dentro del contexto más ruidoso de un job de CI real. Es la misma disciplina de depuración que cualquier ingeniero experimentado aplica: aislar antes de integrar.


Resumen y siguiente paso

Esta lección demostró, con evidencia ejecutada y no con una afirmación de diseño, la tesis central de este módulo: un intento real de ensanchar AppServerRole a "Action": "*" fue detenido por policy-check, y los otros dos jobs de la cadena —iac-scan, verify-artifactnunca se dispararon, confirmado con grep -c mostrando 0 menciones de ambos en el log completo del workflow. El mensaje de conftest señaló, con precisión, el address exacto y el Sid exacto del statement que causó el rechazo. Revertiste el cambio de prueba, dejando el laboratorio en el estado correcto para cerrar el módulo.

Con las lecciones 4 y 5, la tesis completa de esta guía queda demostrada de las dos formas que importan: lo que debería pasar, pasa; lo que debería detenerse, se detiene, y se detiene en el punto correcto, antes de gastar el tiempo de los controles que vienen después. La lección 6 cierra, en un solo lugar, la honestidad completa de los siete módulos anteriores más este: qué de todo esto corrió de verdad, y qué quedó representativo, con su razón exacta.

Recursos

  1. Este curso, Módulo 1, lección 3 — el origen del ejemplo s3:*/"Action": "*" como caso técnico de Elevation of Privilege, retomado aquí con evidencia de pipeline real.
  2. Este curso, Módulo 4, lecciones 7 y 8 — el origen de least-privilege-iam.rego y la primera demostración de su FAIL, en aislamiento, sin pipeline.
  3. GitHub Docs — jobs.<job_id>.needs — el mecanismo exacto que hace posible el resultado de esta lección.