La règle de Logi-Express tient en une phrase du DPO : « Aucune donnée hors de l'Union européenne. » Le module platform la vérifie (startswith(var.region, "europe-")). Mais un nouveau module, écrit par une autre équipe, crée un bucket avec location = "US" en dur ; un autre crée un secret en réplication auto, que Google réplique où il veut. La validation d'un module ne protège que ce module. La règle de l'entreprise doit s'appliquer à tout ce que Terraform s'apprête à créer, quel que soit le module qui le crée : c'est-à-dire au plan.
Le mécanisme
flowchart LR P["terraform plan -out=dev.tfplan"] --> J["terraform show -json<br/>plans/dev.json"] J --> C["conftest test --policy policies"] R["policies/terraform.rego<br/>deny / warn"] --> C C -- "deny vide" --> OK["passe"] C -- "un deny" --> KO["FAIL + message, code 1"] T["policies/terraform_test.rego"] --> V["conftest verify<br/>(tests des règles)"]
Le plan JSON contient, pour chaque ressource, resource_changes[].change.after : les valeurs résolues de ce qui sera créé. Variables, sorties de modules, default_labels du provider : tout y est calculé. Une règle sur le plan voit donc ce que ni le code source ni checkov ne voient :
| Contrôle | Sur le code (checkov) | Sur le plan (conftest) |
|---|---|---|
bucket en US passé par une variable | invisible | location = "US" |
label cost-center posé par default_labels | invisible | présent dans terraform_labels |
| ressource supprimée | analysée quand même | ignorée (actions = ["delete"]) |
OPA (Open Policy Agent) évalue des règles écrites en Rego, un langage déclaratif : une règle deny produit un ensemble de messages ; si l'ensemble est vide, la politique passe. conftest applique ces règles à des fichiers (JSON, YAML, HCL).
La mise en œuvre
package main
allowed_regions := {"europe-west1", "europe-west4", "europe-west9"}
required_labels := {"formation-lab", "env", "managed-by", "cost-center"}
# Ressources créées ou modifiées par ce plan (suppressions et no-op ignorées).
changes contains rc if {
some rc in input.resource_changes
some action in rc.change.actions
action in {"create", "update"}
}
deny contains msg if {
some rc in changes
some field in ["location", "region"]
loc := lower(object.get(rc.change.after, field, ""))
loc != ""
not loc in allowed_regions
msg := sprintf("%s : région %q non autorisée (UE uniquement)", [rc.address, loc])
}
# terraform_labels = labels de la ressource + default_labels du provider.
deny contains msg if {
some rc in changes
labels := rc.change.after.terraform_labels
missing := required_labels - {k | some k, _ in labels}
count(missing) > 0
msg := sprintf("%s : labels obligatoires absents %v", [rc.address, sort(missing)])
}
deny contains msg if {
some rc in changes
rc.type == "google_cloud_run_v2_service"
rc.change.after.ingress == "INGRESS_TRAFFIC_ALL"
rc.change.after.terraform_labels.env == "prod"
msg := sprintf("%s : ingress ouvert à Internet interdit en prod", [rc.address])
}Le fichier complet a 10 règles deny et 1 warn : régions, répliques de secret, réplication auto, labels, allUsers/allAuthenticatedUsers (en member et en members), IP publique de VM (access_config) et de Cloud SQL (ipv4_enabled), public_access_prevention, ingress Internet en prod (refusé) et hors prod (averti). lower() sur la région : GCS renvoie EUROPE-WEST1, la plupart des API europe-west1.
Une politique est du code : elle a ses tests, sans Terraform ni cloud.
test_bucket_hors_ue_refuse if {
deny["google_storage_bucket.b : région \"us\" non autorisée (UE uniquement)"] with input as bucket({
"location": "US",
"public_access_prevention": "enforced",
"terraform_labels": ok_labels,
})
}
test_suppression_ignoree if {
count(deny) == 0 with input as {"resource_changes": [{
"address": "google_storage_bucket.old",
"type": "google_storage_bucket",
"change": {"actions": ["delete"], "after": null},
}]}
}make setup télécharge conftest 0.71.1 dans .tools/ et vérifie son empreinte SHA-256 contre checksums.txt de la publication. make policy produit les deux plans puis applique conftest, et checkov en mode plan :
make test # inclut conftest verify
make policy # plans dev et prod réels, puis conftest test et checkov sur les JSONRésultat obtenu (2026-10-16)
$ make policy
prod : Plan: 18 to add, 0 to change, 0 to destroy.
dev : Plan: 13 to add, 0 to change, 0 to destroy.
22 tests, 22 passed, 0 warnings, 0 failures, 0 exceptions
checkov plans/dev.json : Passed checks: 19, Failed checks: 0, Skipped checks: 0
checkov plans/prod.json : Passed checks: 29, Failed checks: 0, Skipped checks: 022 = 11 règles × 2 plans. Puis un plan de prod avec l'ingress ouvert, que les modules acceptent (l'ingress n'est pas validé dans cloud-run-service, c'est voulu : le dev en a besoin) :
$ terraform -chdir=infra/envs/prod plan -var ingress=INGRESS_TRAFFIC_ALL -out=plans/bad.tfplan ...
$ conftest test --policy policies plans/bad.json
FAIL - plans/bad.json - main - module.platform.module.service[0].google_cloud_run_v2_service.this : ingress ouvert à Internet interdit en prod
11 tests, 10 passed, 0 warnings, 1 failure, 0 exceptionsAvec -var region=us-central1, le plan n'arrive même pas jusqu'à conftest : la validation du module platform l'arrête (Error: Invalid value for variable). Les deux protections se complètent : le module refuse ce qu'il sait refuser, la politique attrape le reste.
Ce qui casse en production
- La règle qui ne voit rien. Une règle sur
rc.change.after.labelsau lieu deterraform_labels: les labels posés pardefault_labelsn'y figurent pas, toutes les ressources « manquent » decost-center, quelqu'un désactive la règle. Parade : un test avec une ressource conforme (test_bucket_conforme), qui doit produire zérodeny. - Valeurs inconnues au plan.
afterne contient pas les valeurs(known after apply); elles sont dansafter_unknown. Une règlerc.change.after.namesur un nom calculé ne se déclenche jamais. Parade : écrire les règles sur des attributs connus au plan (région, labels, ingress), et le documenter. - Les suppressions refusées. Sans le filtre
changes, un plan qui supprime un vieux bucket enUSest bloqué, alors qu'il corrige le problème. Parade : ne contrôler quecreateetupdate, et tester ce cas (test_suppression_ignoree). - Rego qui change sous vos pieds. OPA 1.0 a rendu obligatoires
ifetcontains; une politique écrite pour 0.x échoue au parsing. Parade : épingler la version de conftest (ici par SHA-256), etconftest verifyen CI.
La règle de décision
Dans un module : les validations propres à son contrat. Dans checkov : les bonnes pratiques de sécurité génériques. Sur le plan, avec OPA : les règles de l'entreprise (régions, labels, exposition), parce qu'elles portent sur des valeurs résolues et doivent s'appliquer à tout module, y compris ceux que vous n'avez pas écrits. deny pour ce qui ne se négocie pas, warn pour ce qui se tolère hors prod.
Pratique
Une équipe ajoute un module qui crée un bucket avec location = var.location, passée à "US" dans sa racine. Le module n'a pas de validation et checkov passe. Où l'erreur est-elle arrêtée dans cette chaîne ?
La valeur n'existe qu'après résolution des variables : c'est le plan qui la contient. "US" est une localisation GCS valide, donc tflint et validate n'ont aucune raison de la refuser ; checkov ne contrôle pas une liste de régions propre à l'entreprise.
Vous ajoutez la règle « labels obligatoires ». Sur le premier plan réel, elle échoue sur google_project_iam_member et google_secret_manager_secret_version. Ces ressources n'acceptent pas de labels. Que faites-vous ?
Une ressource sans labels possibles ne peut pas être conforme : la règle doit ne porter que sur celles qui en ont, ce que fait labels := rc.change.after.terraform_labels (la règle ne s'évalue pas si l'attribut est absent). La désactiver ou la passer en warn perd le contrôle là où il compte.
Exercice. Écrivez une règle qui refuse toute création de google_service_account_key (une clé JSON de compte de service), et son test.
Voir la solution
deny contains msg if {
some rc in changes
rc.type == "google_service_account_key"
msg := sprintf("%s : clé de compte de service interdite (utilisez la fédération d'identité)", [rc.address])
}test_cle_de_compte_refusee if {
some msg in deny with input as {"resource_changes": [{
"address": "google_service_account_key.k",
"type": "google_service_account_key",
"change": {"actions": ["create"], "after": {}},
}]}
contains(msg, "clé de compte")
}make test doit afficher 9 tests, 9 passed. Une clé JSON est un secret de longue durée qui finit dans un dépôt ou un poste ; la CI de ce projet n'en a pas besoin (leçon 4).
- Je produis le plan JSON et j'y trouve les valeurs résolues d'une ressource.
- J'écris une règle Rego
denysur les créations et modifications seulement. - Je teste chaque règle avec un cas conforme et un cas refusé (
conftest verify). - Je répartis les contrôles entre validation de module, checkov et politique sur le plan.