Skip to content

Sprint 7 — Remédiation & Fiabilité

INC-069 : la couverture de sécurité annoncée par la CI ne couvrait presque rien

Sévérité : High Statut : ✅ Résolu — correctif #178

Contexte : Découvert le 2026-08-17 en instruisant #178, ouverte la veille sur un tout autre motif : semgrep-sast était le dernier job injustifié d'un merge documentaire, à 83 s pour 57 % du temps cumulé du pipeline. En cherchant à réduire ce coût, l'instruction a montré que le problème n'était pas le prix du job mais ce qu'il achetait.

Fichiers : .gitlab-ci.yml

Symptôme : Aucun. C'est le cœur de l'incident. Tous les pipelines étaient verts, tous les jobs de sécurité affichaient success, et le tableau de bord du dépôt annonçait trois analyses configurées : SAST, détection de secrets et analyse des dépendances. Rien n'a jamais alerté.

Cause : Trois défauts indépendants, réunis par un même mécanisme, les jobs config-only des templates GitLab dont les rules: ne sont pas surchargeables depuis le projet.

1. Les analyses ne tournaient jamais sur une merge request. Mesure du 2026-08-17 : pipeline de MR 2765858917 à 7 jobs, pipeline post-merge 2765881063 à 15 jobs. semgrep-sast et secret_detection n'apparaissaient que dans le second. En cause, leur dernière règle applicable :

- if: $CI_COMMIT_BRANCH

CI_COMMIT_BRANCH n'existe pas dans un pipeline de merge request : la branche s'y nomme CI_MERGE_REQUEST_SOURCE_BRANCH_NAME. Toutes les autres règles exigent $AST_ENABLE_MR_PIPELINES == "true", variable jamais posée. Les deux analyses ne pouvaient donc rien bloquer avant un merge, seulement constater après.

2. dependency_scanning n'a jamais tourné, et ne le pouvait pas. Absent du pipeline de MR comme du pipeline post-merge. Le job dependency_scanning défini dans le projet, avec ses DS_EXCLUDED_PATHS et son allow_failure: true, est un placeholder du template :

dependency_scanning:
  rules:
    - when: never

Les vrais jobs sont les gemnasium-*-dependency_scanning, et leurs onze règles exigent toutes $GITLAB_FEATURES =~ /\bdependency_scanning\b/, une fonctionnalité du plan Ultimate. Sur un projet GitLab Free, ils ne sont jamais générés. Aucun message ne le signale.

3. Le SAST analysait surtout du code qui n'est pas le nôtre. Trace du job 15934576086 :

Scanning 19666 files with 592 Code rules
Findings: 829 (829 blocking)
Timeout when running bandit on venv/lib/python3.11/site-packages/pymdownx/twemoji_db.py
venv/: found 20061 matching artifact files and directories

19 666 fichiers analysés pour un dépôt qui en trace 287. Un venv/ de 20 061 fichiers, absent du dépôt mais présent dans l'espace de travail du runner, était dans le périmètre. La cause : SAST_EXCLUDED_PATHS avait été surchargée sans reprendre $DEFAULT_SAST_EXCLUDED_PATHS, ce qui écrase les exclusions du template, et sans mentionner venv/, node_modules/ ni site/.

Les 829 findings, eux, ne sont lisibles nulle part : le tableau de bord de sécurité qui les afficherait est également Ultimate.

Fix :

  • AST_ENABLE_MR_PIPELINES: "true" en variable globale : semgrep-sast et secret_detection tournent désormais sur les pipelines de merge request, là où ils peuvent encore bloquer.
  • SAST_EXCLUDED_PATHS reprend $DEFAULT_SAST_EXCLUDED_PATHS et ajoute venv/, node_modules/, site/.
  • Template Security/Dependency-Scanning et job dependency_scanning retirés, avec la raison écrite dans le fichier. La couverture des dépendances est assurée par trivy-fs-scan, bloquant, vérifié le même jour sur backend/requirements.txt (pip) et frontend/package-lock.json (npm).
  • DEPENDENCY_SCANNING_DISABLED retirée du workflow:, devenue morte.
  • secret_detection volontairement non filtré par chemin : un secret peut apparaître dans n'importe quel fichier, y compris une page de documentation, et le job coûte 16 s.

Validation : À mesurer sur le pipeline de la MR du correctif, sur trois critères qui discriminent : semgrep-sast et secret_detection présents dans un pipeline de MR alors qu'ils en étaient absents, nombre de fichiers analysés par semgrep très inférieur à 19 666, et aucun job dependency_scanning fantôme dans la liste.

Leçon : Un dispositif de sécurité se vérifie par ce qu'il a analysé, pas par sa présence dans un fichier de configuration. Les trois analyses étaient déclarées, nommées, versionnées, et deux d'entre elles rendaient un verdict vert. Aucune ne protégeait une merge request, et la troisième n'avait jamais démarré.

Trois questions suffisent à démasquer ce genre de couverture, et aucune ne se lit dans le YAML : le job a-t-il tourné ? sur quel périmètre ? à quel moment par rapport à la décision qu'il est censé éclairer ? Ici les réponses étaient : jamais en MR, sur 98 % de code tiers, et après le merge.

Second enseignement, sur les fonctionnalités payantes : une fonctionnalité indisponible dans son plan n'échoue pas, elle ne s'exécute pas. dependency_scanning était configuré avec soin, ses exclusions écrites, son allow_failure réglé, et tout cela portait sur un job qui ne pouvait pas exister. Même famille que CODEOWNERS qui n'empêche pas de merger en Free, ou que le champ weight qui accepte douze PUT sans rien appliquer.


INC-070 : deux pièges sur la route du MFA obligatoire, dont un qui verrouille dehors

Date : 2026-08-29 Contexte : issue #183, constat AW-9 de l'audit DevSecOps. Poser une politique IAM qui refuse les actions de iamadmin en l'absence de MFA.

Aucun des deux pièges n'a coûté d'incident, parce que les deux ont été vus avant l'apply. Ils sont écrits ici pour ça : ils se paient très cher si on les découvre après.

Piège 1 : sts:GetSessionToken manquait dans la liste des exclusions

L'issue listait quatre actions à exclure du refus, toutes des actions d'enrôlement MFA. Le rapport d'audit, lui, en listait bien une cinquième : sts:GetSessionToken. L'omission est née au moment de transcrire le rapport en issue.

sts:GetSessionToken est l'appel qui échange une clé d'accès de longue durée contre une session MFA. Il se fait nécessairement sans contexte MFA, puisque c'est lui qui l'établit.

Le refuser ferme la boucle : plus aucune action sans MFA, et plus aucun moyen d'obtenir le MFA. Ni terraform apply, ni le teardown, ni le retrait de la politique. Seul le compte root en sortirait.

Leçon. Une politique de refus se relit par sa porte de sortie, pas seulement par ce qu'elle bloque. La question n'est pas « qu'est-ce que j'interdis », c'est « avec ce qui reste autorisé, puis-je encore revenir en arrière ».

Corollaire sur la traçabilité : un constat recopié perd de l'information à chaque copie. Ici le rapport était juste et l'issue fausse. Quand une action de remédiation se transcrit, la relecture se fait contre la source, pas contre le souvenir de la source.

Piège 2 : sts:GetCallerIdentity réussit même quand une politique le refuse

Le premier réflexe pour vérifier un refus est aws sts get-caller-identity. C'est un mauvais témoin, et de la pire façon : il rend vert quoi qu'il arrive.

AWS documente que cet appel ne requiert aucune permission, et qu'un Deny explicite ne l'empêche pas de répondre, parce que la même information est retournée dans les deux cas.

Un test négatif construit dessus conclurait « la politique ne mord pas » alors qu'elle mord. Même famille que les critères qui ne discriminent pas : le témoin est vert avant comme après.

Le test correct demande une action réelle, ici iam:ListUsers.

La preuve retenue

Deux tests joués avec la clé statique brute, jeton de session neutralisé par env -u AWS_SESSION_TOKEN :

Test Commande Attendu Obtenu
Négatif aws iam list-users refus AccessDenied, avec la politique nommée
Positif aws iam list-mfa-devices --user-name iamadmin passe la liste s'affiche, la porte de sortie tient

Le message de refus cite explicit deny in an identity-based policy: .../fastapi-eks-force-mfa. La cause est donc nommée par AWS, pas déduite par nous. C'est mieux qu'un AccessDenied nu, qui aurait pu venir de n'importe quelle autre politique.

Un troisième point, plus discret

Après l'apply, aws iam list-users passait encore. Premier réflexe : la politique ne mord pas.

En réalité la session en cours était déjà une session MFA (aws-vault, 12 h), donc aws:MultiFactorAuthPresent valait true et le refus ne devait pas se déclencher. Le comportement observé était le comportement correct.

Ce qui a tranché : aws sts get-caller-identity comme diagnostic d'identité, et non comme preuve de refus. Le même appel est inutile dans un rôle et parfait dans l'autre.

Leçon. Avant de conclure qu'un contrôle est inefficace, vérifier sous quelle identité et avec quel type d'identifiants la commande a été jouée. Un test négatif joué depuis le mauvais contexte ne teste rien.


INC-071 : deux limites de plan en une soirée, et un vert qui ne répondait pas à la question

Date : 2026-08-29 Contexte : issue #190, constat EK-15. Objectif : interdire le git push direct sur develop, la branche que suit ArgoCD, pour que tout passe par une demande de fusion.

Rien n'a été cassé. L'incident est écrit pour les quatre pièges rencontrés, dont deux sont structurels et reviendront.

Le point de départ : le réglage annoncé comme trivial ne l'était pas

L'issue disait « c'est un réglage, pas un chantier ». Vérification avant d'agir : le pipeline pousse lui-même sur develop, job update-image-tag, write-back GitOps.

Passer le push à No one aurait donc arrêté la chaîne de déploiement.

Leçon. Avant de fermer un chemin d'écriture, chercher qui l'emprunte déjà. Un garde-fou qui casse le fonctionnement nominal n'est pas un garde-fou, c'est une panne programmée.

Piège 1 : le commentaire du dépôt affirmait ce qui ne pouvait pas être vrai

Le .gitlab-ci.yml déclarait :

GITLAB_PUSH_TOKEN = Project Access Token (role Maintainer, scope write_repository)

Mesure : projects/:id/access_tokens et groups/yk-devops/access_tokens renvoient des listes vides. La variable portait en réalité un jeton personnel, donc le pipeline poussait sous une identité humaine.

Tentative d'en créer un vrai :

POST projects/:id/access_tokens
400 Bad request - User does not have permission to create project access token

L'appelant est Owner. Ce n'est donc pas un problème de rôle : GitLab Free n'autorise pas les jetons d'accès de projet ni de groupe. Le commentaire ne décrivait pas un oubli, il décrivait une chose impossible dans ce plan.

Leçon. Un commentaire qui nomme un mécanisme est une affirmation vérifiable. Celui-ci survivait depuis des mois parce que personne n'avait interrogé l'API pour le confronter. Même famille que l'action 7 de l'audit.

Piège 2 : la limite de plan qui ferme la remédiation par le haut

Contournement retenu, déjà le motif en place pour renovatebotyk : un compte machine séparé, yk-gitops-bot, ajouté en Maintainer, avec un jeton fine-grained limité à ce seul projet, permissions Repository / Code en lecture et push, expirant le 2027-03-31.

Cette partie fonctionne, et elle est prouvée plus bas. Mais la dernière étape a été refusée :

PATCH protected_branches/develop
['Push access levels user must be blank']

GitLab Free n'accepte que des rôles dans les autorisations de push, jamais un utilisateur nommé. Désigner un robot précis est Premium.

Réglage disponible yk-gitops-bot pousse ykadi pousse
Maintainers, l'état actuel
No one ❌ write-back cassé

Aucun réglage ne bloque l'humain sans bloquer le robot, ykadi étant Owner du groupe.

Septième limite de plan de ce dépôt, après weight, les liens de blocage entre issues, CODEOWNERS, dependency_scanning, le tableau de bord de sécurité et les jetons de projet.

Leçon. Une limite de plan peut fermer la remédiation d'un constat, pas seulement une commodité. Quand cela arrive, le constat se documente en atténuation partielle, il ne se ferme pas. Un constat fermé à tort est pire qu'un constat ouvert : il éteint la vigilance sans avoir supprimé le risque.

À noter tout de même : ces deux limites-ci échouent franchement. C'est un progrès sur weight, qui acceptait douze PUT sans rien appliquer, et sur dependency_scanning, configuré avec soin pour un job qui ne pouvait pas exister.

Piège 3 : un vert qui ne répondait pas à la question posée

Les 19 jobs du pipeline étaient verts, update-image-tag compris, au moment même où la remédiation était bloquée. Le refus venait d'un réglage de projet, modifié par l'API, sans qu'aucun pipeline soit impliqué.

Leçon. Le vert du pipeline répond à « est-ce que ça marche ». Il ne répond pas à « est-ce qu'un contournement reste possible ». Deux questions, un seul indicateur, et il n'en couvre qu'une. Voir portee-d-un-vert-quand-sur-quoi-ou.

Piège 4 : l'auteur d'un commit ne dit pas qui l'a poussé

Prédiction faite avant le test : « tu verras yk-gitops-bot comme auteur du commit de write-back ». Faux.

Le job pose git config user.name "GitLab CI" en dur, donc l'auteur est toujours GitLab CI <ci@devopsyouss.com>, quel que soit le jeton employé. git log ne dit rien de l'identité qui a pousse.

La preuve se lit dans les événements de push du projet :

2026-08-29T18:09:10  par yk-gitops-bot  ref=develop  commits=1

Tous les push antérieurs sont par ykadi.

Leçon. Auteur, committer et pousseur sont trois identités distinctes. Un audit qui veut savoir qui a écrit dans une branche doit lire les événements de push, jamais git log, qui ne porte que des champs déclarés par le client.

Ce qui a été acquis

Avant Après
Identité du write-back PAT personnel d'un Owner compte machine yk-gitops-bot
Portée du jeton tous les projets de cet Owner ce seul projet
Permissions scope de compte Repository / Code : lecture + push
Expiration 2026-09-30 2027-03-31

Ce qui reste ouvert et assumé : ykadi peut toujours pousser directement sur develop. Le risque résiduel est la discipline du seul humain du projet, pas un tiers. EK-15 reste ouvert avec cette mention.

Reste à faire : révoquer l'ancien PAT personnel gitops-writeback, et le prouver. Tant qu'il répond, il existe deux identifiants capables de pousser au lieu d'un, ce qui est pire qu'avant et non meilleur. Voir rotation-secret-preuve.


INC-072 : quatre alarmes, dont une qui a sonné pour rien, et six fois le tarif pendant cinq mois

Sévérité : Medium Statut : 🟡 Partiellement traité — la cause AWS est corrigée (#186), les deux autres alarmes restent muettes

Contexte : Découvert le 2026-08-30 en instruisant #186. L'issue avait été ouverte sur un mail AWS reçu le 2026-08-27 annonçant la fin du support standard de Kubernetes 1.32. En vérifiant la date par l'API plutôt que par le mail, le décalage a sauté aux yeux, puis il a fait apparaître un motif que le projet portait déjà deux fois.

Fichiers : terraform/ephemeral/variables.tf, terraform/modules/eks/variables.tf, .gitlab-ci-infra.yml

Symptôme : Aucun, dans les trois cas. Pas d'échec, pas de page rouge inattendue, pas de notification. Le projet fonctionnait exactement comme d'habitude.

Cause : Trois mécanismes indépendants, un même résultat : le signal existe, et personne n'agit.

1. Le signal arrive du fournisseur, et il arrive tard

aws eks describe-cluster-versions, relevé du 2026-08-30 :

1.32   fin du support standard   2026-03-23

Le mail AWS est arrivé le 2026-08-27, soit cinq mois après la bascule. Entre les deux dates, chaque session avec le cluster monté a été facturée au tarif du support étendu.

Support Tarif par cluster
Standard 0,10 $/h
Étendu 0,60 $/h

Six fois le tarif, bascule automatique à la date, aucun signal côté projet. Le modèle de coût de docs/infra-eks-summary.md continuait d'annoncer 0,10 $/h, et personne n'avait de raison d'en douter.

Le surcoût a été mesuré, il n'est pas théorique. Relevé Cost Explorer du 2026-08-30, groupé par USAGE_TYPE :

2026-05-01
   EUW3-AmazonEKS-Hours:extendedSupport    18,08 USD    36,15 h
   EUW3-AmazonEKS-Hours:perCluster          3,62 USD    36,15 h
2026-06-01
   EUW3-AmazonEKS-Hours:extendedSupport    18,07 USD    36,14 h
   EUW3-AmazonEKS-Hours:perCluster          3,61 USD    36,14 h

AWS facture deux lignes distinctes sur les mêmes heures : le tarif de base à 0,10 $/h, et le supplément de support étendu à 0,50 $/h. Le total revient bien à 0,60 $/h.

Mai Juin Total
Heures de cluster 36,15 h 36,14 h 72,29 h
Tarif de base 3,62 $ 3,61 $ 7,23 $
Supplément de support étendu 18,08 $ 18,07 $ 36,15 $

83 % de la facture EKS de ces deux mois était du supplément, pour un service strictement identique.

Et la facture le disait, en toutes lettres. La ligne s'appelle littéralement extendedSupport. Elle était présente et nommée dès la facture de mai, quatre mois avant le mail AWS.

Une alarme de budget existait, elle s'est déclenchée, et elle n'a servi à rien. C'est la partie la plus instructive de cet incident, et elle contredit la première conclusion qu'on est tenté d'en tirer.

Le budget devops-portfolio-budget est fixé à 45 $ par mois, avec quatre seuils exprimés en pourcentage (ThresholdType non renseigné, donc PERCENTAGE par défaut) :

Seuil Montant réel Franchi en mai (~28 $) Franchi en juin (~38 $)
ACTUAL > 10 % 4,50 $ oui oui
ACTUAL > 30 % 13,50 $ oui oui
FORECASTED > 40 % 18,00 $ oui oui
FORECASTED > 80 % 36,00 $ probablement oui

Les courriels sont bien arrivés, et ils ont été lus. Ce qui a manqué n'est ni la détection, ni la transmission, ni l'attention.

Ce qui a manqué, c'est l'information. L'alerte dit « tu as dépassé tel montant ». Elle ne dit pas « tu paies six fois le tarif ». Ces deux phrases produisent la même courbe de dépense, et rien ne permettait de les distinguer :

  • la documentation du dépôt annonçait 0,10 $/h, donc le tarif n'était pas en cause ;
  • dépasser un seuil de budget sur un laboratoire qu'on utilise davantage est banal ;
  • l'interprétation « j'ai monté le cluster plus souvent ce mois-ci » était donc à la fois raisonnable et fausse.

Un budget mesure combien on dépense, pas si on dépense au bon prix. Aucun seuil, si bas soit-il, ne répond à la seconde question : un tarif multiplié par six sur un petit usage reste un petit montant.

Rien dans le dépôt ne pouvait le détecter non plus. La version épinglée dans le Terraform ne porte pas sa date de péremption, et le modèle de coût de la documentation n'est comparé à aucune facture.

Note ouverte : juillet et août n'affichent aucune heure EKS, alors que le cluster a été monté sur ces deux mois (multi-environnements livré le 2026-07-25, mesures sous charge le 2026-07-31, teardown le 2026-08-16). À instruire, soit un décalage de facturation, soit une lecture incomplète.

2. Le signal est là tous les soirs, et il est devenu du décor

Le teardown programmé de 21 h ne détruit plus rien depuis le 2026-08-06. Le pipeline est créé avec zéro job, ce que GitLab marque failed et non skipped, sans erreur YAML.

Vingt-trois soirs de rouge consécutifs. Aucun n'a déclenché de réaction, parce qu'un rouge qui revient tous les jours à la même heure cesse d'être lu comme une information. Le cluster survivant la nuit est devenu l'effet de bord d'un mécanisme cassé, pas une propriété documentée. Suivi en #174.

3. Le signal est là tous les matins, il est correct, et il attend

Le scan de registre de 8 h échoue chaque matin depuis le 2026-08-26 : des CVE corrigeables sont apparues sur l'image déployée, une alerte Slack part, le job sort en exit 1.

Ce job fait exactement son travail. C'est la réponse qui manque. Détecter n'est pas remédier, et le projet l'avait écrit noir sur blanc dans le commentaire du job avant de s'en apercevoir.

Validation : La cause du point 1 est corrigée par !330 : le cluster passe en 1.35, dont le support standard court jusqu'au 2027-03-27. Les points 2 et 3 restent ouverts, et c'est le fond du problème : les deux étaient déjà connus et documentés avant cet incident.

Leçon. Une supervision n'a de valeur que si elle produit une action. Trois formes distinctes de la même panne apparaissent ici :

Forme Ce qui manque
Le signal vient du fournisseur il arrive quand le fournisseur le décide, pas quand le fait se produit
Le signal est permanent il devient du décor, la répétition détruit sa valeur d'information
Le signal est correct et ignoré ce n'est plus un problème de détection mais de traitement
Le signal est reçu, lu, et ne porte pas la bonne information le destinataire agit correctement sur ce qu'il lit, et passe à côté

La quatrième forme est la plus intéressante, parce qu'elle résiste à la correction évidente. On ne la traite ni en ajoutant une alarme, ni en la rendant plus visible, ni en étant plus attentif : l'alerte disait vrai, elle a été lue, et la déduction qui en a été faite était raisonnable.

Elle se traite en changeant la question posée. Un budget répond à « combien ». Il faut un contrôle qui réponde à « est-ce le bon prix », c'est-à-dire qui compare la dépense à un profil attendu plutôt qu'à un plafond. C'est le rôle d'AWS Cost Anomaly Detection, qui signale un écart par rapport à l'habitude, quel que soit le montant, et qui est gratuit.

Corollaire général : quand une alerte se déclenche sans produire d'action, vérifier d'abord si elle porte l'information qui déclencherait cette action. Le réflexe inverse, abaisser le seuil ou ajouter un destinataire, n'aurait rien changé ici.

Second corollaire, payé au prix d'une MR à retirer : avant d'ajouter un contrôle, mesurer ce que fait celui qui existe déjà. Les deux premiers essais de correction ont été écartés pour la même raison, un contrôle déjà en place et déjà silencieux. Le second l'a été après avoir été écrit, relu et mergé, parce que la vérification a été faite au moment de l'apply au lieu de l'être avant la première ligne de code.

Le correctif a été trouvé au troisième essai, et les deux premiers instruisent

Essai 1, écarté d'emblée : un seuil de budget de plus. Quatre existent, ils se sont déclenchés, les courriels ont été lus. Un cinquième aurait dit la même chose.

Essai 2, écrit puis retiré : AWS Cost Anomaly Detection. L'idée paraissait juste : un contrôle qui compare la dépense à un profil appris plutôt qu'à un plafond. Le module Terraform a été écrit, relu et mergé (!332).

terraform apply l'a refusé : Limit exceeded on dimensional spend monitor creation. AWS n'autorise qu'un moniteur dimensionnel par compte, et il en existait déjà un.

La vérification qui aurait dû précéder l'écriture :

Default-Services-Monitor        DIMENSIONAL / SERVICE
Default-Services-Subscription   DAILY -> une adresse valide

Posés par AWS, actifs pendant toute la période, avec un destinataire. La détection d'anomalie tournait déjà.

Relevé du 2026-08-30 sur la fenêtre disponible, juin à août (GetRecentAnomalies ne conserve que 90 jours glissants, mai est définitivement hors d'atteinte) :

Service Impact Détecté
AWS Secrets Manager 0,06 $ oui
Amazon VPC 0,06 $ oui
Amazon RDS 0,11 $ oui
Amazon EC2 0,24 à 0,30 $ oui, cinq fois
Amazon EBS 0,52 $ oui
Amazon EKS 18,07 $ jamais

Douze anomalies signalées entre six centimes et cinquante-deux centimes, et silence complet sur le seul poste qui portait un vrai surcoût.

La raison est structurelle, et elle vaut d'être comprise. La détection compare une dépense à son propre profil, pas les services entre eux. Secrets Manager coûte quelques centimes de façon régulière : passer de 0,03 à 0,09 $ est un écart énorme en proportion. EKS, lui, oscille entre 0 et 22 $ selon qu'on monte le cluster ou non, avec des mois entiers à zéro. Il n'a aucun profil stable, donc rien ne peut en sortir.

L'architecture éphémère rend le poste de dépense principal invisible à ce contrôle. C'est un coût caché du détruire-et-recréer, à mettre en face de ses avantages.

Pire : le module écarté posait un seuil de bruit à 5 $ d'impact. Les douze anomalies réelles font toutes moins de 0,52 $. Il les aurait toutes filtrées sans rien attraper à la place, rendant le détecteur plus silencieux qu'avant.

Essai 3, livré : un budget filtré sur la ligne de facturation

Aucun contrôle générique de coût ne pouvait attraper ce cas. Ce qui était détectable, c'est la ligne elle-même :

EUW3-AmazonEKS-Hours:extendedSupport

Elle est nommée sans ambiguïté, et son existence seule est le signal. Son montant n'a aucune importance : un dollar de support étendu veut dire la même chose que cinquante.

Le correctif est donc un budget MONTHLY filtré sur ce type d'usage, avec une limite de 1 $ et un seuil de notification à 1 %, soit un centime. Il ne mesure pas combien on dépense en support étendu, il dit qu'on en dépense.

terraform/modules/cost-guardrail, stack persistent.

Contrôle Ce qu'il filtre Ce qu'il voit Verdict sur ce cas
Budget global 45 $ rien un montant total a sonné, sans informer
Détection d'anomalie un profil appris un écart au profil aveugle sur un poste irrégulier
Budget filtré le type d'usage l'existence d'une ligne sonne au premier centime

Limite assumée : la valeur du filtre est régionale et exacte, AWS Budgets ne connaît pas le « commence par ». Un changement de région la rend muette, et rien ne le signalera.

Reste à faire : fermer #174 pour le teardown, et traiter ou accepter explicitement les CVE du scan de 8 h. Fermer #174 pour le teardown. Traiter les CVE du scan de 8 h ou décider explicitement de les accepter, ce que le VEX permet déjà (ADR 020) — mais l'accepter est une décision, et laisser sonner n'en est pas une.

SEC-008 : les secrets de production sont en clair dans le state Terraform, et la CI pouvait le lire et l'écrire

Sévérité : High Statut : 🟡 Partiellement résolu — l'accès est fermé (#198, parade 1), le stockage en clair demeure Date : 2026-09-01 Fichiers : terraform/modules/iam/main.tf, terraform/modules/secrets/main.tf

Contexte : trouvé en lisant le code, à la fin d'une session consacrée à tout autre chose. Le constat ne figure dans aucun des 48 points du rapport d'audit DevSecOps : celui-ci parle du bucket de state (AW-5, HTTPS et MFA) mais jamais de ce que le bucket contient.

Cause : Terraform ne crée pas un emplacement de secret, il génère la valeur. random_password.db_env et random_password.secret_key_env produisent les mots de passe, les poussent dans Secrets Manager via aws_secretsmanager_secret_version, et en gardent une copie en clair dans le state. L'attribut sensitive masque l'affichage, jamais le stockage.

Il existe donc deux exemplaires de chaque secret de production : celui de Secrets Manager, protégé par IAM, et celui du state, protégé par la seule policy du bucket S3.

Le point n'est pas déduit, il est mesuré. Un mini-projet Terraform jetable reproduisant le motif de modules/secrets a été appliqué, puis son state relu : result y figure en clair, 32 caractères — alors que le state déclare lui-même sensitive_attributes : [bcrypt_hash, result]. Terraform sait que le champ est sensible et l'écrit quand même.

Aggravant : la policy terraform_ci, portée par l'utilisateur fastapi-eks-gitlab-ci-infra, accordait s3:GetObject, s3:PutObject et s3:DeleteObject sur yk-devops-terraform-state/*tout le bucket, donc le state persistent. Un principal non administrateur lisait les sept secrets de production. Et il pouvait écrire et supprimer ce state : celui de la stack qui porte ECR, IAM, CloudTrail, le budget et le garde-fou de teardown. Un state perdu ne se rejoue pas, les ressources continuent d'exister côté AWS sans que Terraform les connaisse.

Onze lignes plus bas dans le même fichier, un commentaire affirmait : « Pas de GetSecretValue ici : seul le role ESO lit la valeur, pas la CI. » Ce commentaire était faux, et il l'était deux fois : par le state persistent qu'il ignorait, et par le state ephemeral que la CI écrit elle-même, lequel porte l'attribut password de aws_db_instance en clair.

Correctif : la policy est restreinte au seul préfixe que la CI applique, yk-devops-terraform-state/fastapi-eks/ephemeral/*. L'ARN nu du bucket reste pour s3:ListBucket, qui ne porte que des noms de clés. Vérifié avant de proposer : aucun terraform_remote_state dans le dépôt, et ephemeral lit les secrets par data "aws_secretsmanager_secret" par nom. Rien ne dépendait de l'accès retiré.

Mesure, avant et après, à identité constante (fastapi-eks-gitlab-ci-infra) :

Appel Avant Après
GetObject sur persistent/terraform.tfstate 122 634 octets rendus AccessDenied
PutObject sous persistent/ autorisé AccessDenied
GetObject sur ephemeral/terraform.tfstate 183 octets 183 octets
PutObject + DeleteObject sous ephemeral/ autorisé autorisé
ListBucket 2 objets 2 objets

Les deux refus sont nommés par leur cause : « because no identity-based policy allows the s3:GetObject action ». Le volet positif compte autant que le négatif : sans lui, la mesure prouverait seulement qu'on a cassé quelque chose.

Reste à faire : l'accès est fermé, le stockage en clair demeure. La seule façon de ne pas stocker une valeur est de ne pas l'avoir : retirer random_password et aws_secretsmanager_secret_version du Terraform. lifecycle { ignore_changes = [secret_string] } ne suffit pas — il empêche de modifier, pas de lire, et le refresh réécrit la valeur dans le state à chaque plan. Restent aussi ouverts : le mot de passe RDS dans le state ephemeral, l'arbitrage KMS sur le bucket de state, et le versioning qui conserve les anciennes versions, donc les mêmes secrets. Suivi en #198.

Leçon. Un commentaire n'est pas un contrôle. Celui-ci décrivait une propriété de sécurité réelle au moment où il a été écrit — la CI n'a effectivement pas GetSecretValue — et il est devenu faux sans que rien ne bouge autour de lui, parce qu'il parlait de l'API en ignorant le fichier. Un contrôle se relit par ce qu'il laisse passer, pas par ce qu'il déclare interdire.

Corollaire sur les preuves, payé le jour même. Le premier test de suppression affichait delete-object : ok alors qu'aucun objet n'avait été créé : S3 renvoie un succès sur la suppression d'une clé inexistante. Le test aurait été vert avec ou sans la permission. C'est le motif du curl de #189 et celui de #180 : une mesure ne vaut que si son résultat aurait été différent avant le correctif.


INC-073 : une parade contre un SPOF qui n'en retirait aucun, et qui en a ajouté un second

Sévérité : Medium Statut : ✅ Résolu — correctif #201

Contexte : Découvert le 2026-09-02, en cherchant pourquoi sast-gate restait pending sur le pipeline de MR 2812925225 (MR !345) alors que le runner .112 venait justement d'être rallumé pour cette MR. La question posée était « pourquoi ce job en particulier est-il sorti du self-hosted ? », et la réponse écrite dans le dépôt ne répondait pas.

Fichiers : .gitlab-ci.yml

Symptôme : Un pipeline vert à trois jobs sur quatre, et le quatrième en file depuis plus de quinze minutes sans runner assigné. Le runner self-hosted était allumé, le dépôt sain, le quota de minutes à 5 sur 400, et status.gitlab.com annonçait les runners Linux hébergés opérationnels. Rien ne désignait une cause.

pending   sast-gate         queued=1862 s   runner=aucun
success   secret_detection  queued=3,85 s
success   semgrep-sast      queued=3,10 s
success   pipeline-scope    queued=2,98 s

Cause : sast-gate portait le tag saas-linux-small-amd64, donc un runner partagé GitLab.com, au lieu d'hériter du default: tags: [ubuntu] comme tout le reste du pipeline. La justification, en commentaire dans le fichier :

  # Sans tag ubuntu : le runner self-hosted est un SPOF (#159), et ce job n'a besoin
  # ni du reseau AWS ni du cache local.

Cette justification ne discrimine pas. « Le .112 est un SPOF » vaut pour tous les jobs du pipeline, donc ne désigne personne. « Ce job n'a besoin ni du réseau AWS ni du cache local » dit qu'il peut partir, pas pourquoi il devait. C'est un critère d'éligibilité présenté comme un critère de décision.

Et le tag ne retirait aucun SPOF :

semgrep-sast   tag: ubuntu (herite du default:)   -> runner .112
      |
      | needs: { job: semgrep-sast, artifacts: true }
      v
sast-gate      tag: saas-linux-small-amd64        -> pool GitLab.com

sast-gate déclare needs: - job: semgrep-sast, artifacts: true, et semgrep-sast n'a pas de tag propre : il hérite du default:, donc du .112. Si le .112 est éteint, l'artefact n'existe pas et le gate ne démarre jamais. Le job ne pouvait pas être plus disponible que celui dont il dépend.

Le tag n'ajoutait donc qu'une seconde dépendance d'infrastructure. Le 2026-09-02, c'est elle qui a bloqué le merge, .112 allumé.

Ce qui a été écarté par la mesure, avant de conclure :

Hypothèse Mesure Verdict
Quota de minutes épuisé 5 / 400 depuis le 2026-09-01 écartée
Incident GitLab.com « CI/CD - Hosted runners on Linux » opérationnel écartée
Tag retiré ou renommé le même tag servi en 0,077 s le jour même à 06:08 UTC (registry-scan) écartée
Pipelines de MR non éligibles sast-gate parti en 0,48 s et 0,67 s la veille sur les MR !343 et !344 écartée

Ce qui restait : une file d'attente côté GitLab.com, non déclarée, sur laquelle le projet n'a aucune prise.

Aggravant. sast-gate porte allow_failure: false, et le projet exige un pipeline vert pour merger (only_allow_merge_if_pipeline_succeeds, actif depuis le 2026-07-31). Un job qui ne démarre pas n'échoue pas : il ne se passe rien, indéfiniment, et aucune MR du dépôt ne peut être mergée. La panne n'a pas de message.

Mesure finale : le job n'a jamais obtenu de runner. Il a été annulé à 1862 s, soit 31 minutes de file. Sur le pipeline du correctif, le même job a été servi par le .112 en 0,165 s, le queued le plus bas des treize jobs.

Fix : Retrait du bloc tags: de sast-gate, qui repasse sur ubuntu comme le job dont il dépend. Le pipeline n'a plus qu'une seule dépendance runner au lieu de deux, et la disponibilité augmente strictement.

registry-scan conserve son tag, parce que son motif discrimine vraiment :

registry-scan sast-gate
Motif premier le .112 est souvent éteint à 8 h, quand le schedule part aucun motif propre
Dépend d'un job sur .112 non, aucun needs oui, needs avec artefact
Motif secondaire horloge juste, évite le 401 ECR après pause ESXi

Son commentaire a été réécrit pour dire l'heure en premier : c'est elle qui justifie le tag, pas le SPOF.

Validation : glab ci lint valide, et le tag de sast-gate a été vérifié par relecture du YAML chargé (tags absent, donc default: appliqué) et non par relecture du diff. La preuve finale est un pipeline de MR où sast-gate est servi par le .112 avec un queued comparable aux autres jobs.

Leçon. Une parade contre un point unique de défaillance doit se juger sur le chemin complet, pas sur le job qu'on déplace. sast-gate dépendait du .112 par son needs : le sortir du .112 ne l'en affranchissait pas, ça lui ajoutait un second point de défaillance. La règle qui discrimine, et qui manquait : un job ne peut pas être plus disponible que ce dont il dépend.

Corollaire sur les commentaires de configuration. Le commentaire disait vrai (.112 est bien un SPOF, ce job n'a bien besoin de rien d'AWS) et justifiait pourtant une décision fausse. Un motif qui s'applique à tous les cas n'explique aucun cas particulier : quand une exception est posée, le commentaire doit dire ce qui distingue, pas ce qui est vrai partout. C'est le même défaut que critere-qui-ne-discrimine-pas, cette fois dans la documentation d'une décision plutôt que dans une mesure.

Rappel de périmètre : #159 reste entier. Ce correctif ne réduit pas le SPOF, il arrête de prétendre le réduire.

INC-074 : le montage EKS bloqué par une permission que le code exigeait depuis #189

Sévérité : High Statut : ✅ Résolu — correctif #189

Contexte : Découvert le 2026-09-04, au premier montage depuis la livraison des flow logs VPC de #189. La case restante de cette issue était notée « invérifiable sans VPC » : elle l'a été au premier VPC.

Fichiers : terraform/modules/iam/main.tf, terraform/modules/vpc/main.tf

Symptôme : infra-start échoue à 67 secondes, après avoir créé VPC, quatre subnets, IGW, EIP et NAT Gateway :

AccessDeniedException: User: arn:aws:iam::199167114788:user/ci/fastapi-eks-gitlab-ci-infra
is not authorized to perform: logs:CreateLogGroup
on resource: arn:aws:logs:eu-west-3:...:log-group:/aws/vpc/fastapi-eks-flow-logs
because no identity-based policy allows the logs:CreateLogGroup action

Cause : #189 a ajouté au module vpc un aws_cloudwatch_log_group, un rôle IAM et un aws_flow_log, sans étendre la policy terraform_ci de l'utilisateur qui applique la stack. Mesure du 2026-09-04 : grep "logs:" terraform/modules/iam/main.tf renvoyait zéro occurrence. ec2:* étant accordé, la création du flow log lui-même serait passée ; seul CloudWatch Logs manquait.

Le code et les droits ont divergé sans qu'aucun contrôle ne puisse le voir. tfsec lit des fichiers, il ne compare pas ce qu'une stack demande à ce qu'une identité permet. modules/vpc vit dans la stack ephemeral, qui n'existe qu'entre deux montages : la divergence n'était observable qu'en montant un VPC.

Aggravant, et la moitié la plus facile à oublier : la liste des permissions manquantes contient logs:DeleteLogGroup. Sans elle, le correctif aurait permis de monter une infrastructure qu'infra-stop n'aurait pas su détruire. La panne se serait déplacée du montage au teardown, c'est-à-dire au moment où l'on paie.

Fix : deux statements ajoutés à terraform_ci. CloudWatchLogsFlowLogs porte les actions d'écriture (CreateLogGroup, DeleteLogGroup, PutRetentionPolicy, TagResource, UntagResource, ListTagsForResource), limitées au seul log group du projet. CloudWatchLogsDescribe porte DescribeLogGroups sur *, cette API listant sans cibler : la restreindre produirait un AccessDenied au refresh de chaque plan.

Appliqué sur persistent depuis l'identité iamadmin, via l'image Terraform 1.15 épinglée dans la CI — le binaire local est en 1.14.3 et ne peut pas lire un state écrit en 1.15.

Validation : la policy a été relue côté AWS (get-policy-version, passée en v6) et non dans le diff. Puis infra-start rejoué : success, cluster fastapi-eks-cluster en 1.35 ACTIVE.

Pour les flow logs, la preuve retenue n'est pas le statut ACTIVE de la ressource, qui dit seulement qu'elle est configurée : le module avertit lui-même que sans son rôle, « la ressource se crée et n'écrit jamais rien ». La preuve est 7 log streams portant des événements récents dans /aws/vpc/fastapi-eks-flow-logs, rétention 90 jours.

Leçon. Une ressource ajoutée à une stack est aussi une permission ajoutée à l'identité qui l'applique, et les deux vivent dans deux fichiers que rien ne relie. La règle qui manquait : quand une MR ajoute une ressource dans un module, vérifier la policy de la CI dans la même MR, verbe par verbe, suppression comprise.

INC-075 : le garde-fou de teardown, un apply de la CI l'aurait rendu muet sans le dire

Sévérité : High Statut : ✅ Résolu — variable corrigée le 2026-09-04

Contexte : Trouvé le 2026-09-04 en instruisant INC-074, sur le plan de la stack persistent joué avant d'appliquer le correctif de policy.

Fichiers : variable CI TF_VAR_cost_alert_email, terraform/modules/teardown_guardrail, terraform/modules/cost_guardrail

Symptôme : un plan censé ne toucher qu'une policy IAM annonçait :

Plan: 1 to add, 2 to change, 1 to destroy.

# module.teardown_guardrail.aws_sns_topic_subscription.email must be replaced
~ endpoint = (sensitive value) # forces replacement
# module.cost_guardrail.aws_budgets_budget.extended_support will be updated in-place

Cause : la variable CI TF_VAR_cost_alert_email portait une adresse différente de celle enregistrée dans le state, et donc de l'abonnement SNS réellement confirmé. Mesure : l'abonnement actif côté AWS faisait 22 caractères et était CONFIRMÉ ; la variable CI en faisait 21. Deux adresses, une seule confirmée.

Ce que ça coûtait. Un abonnement SNS par courriel recréé repart en PendingConfirmation : il n'envoie plus rien tant que le destinataire n'a pas cliqué le lien de confirmation. Le premier apply de persistent par la CI aurait donc rendu le garde-fou de teardown muet, sans message d'erreur et sans qu'aucun job n'échoue. Le pipeline serait resté vert.

La dérive était dormante : elle n'appartenait pas au changement en cours, elle attendait le prochain apply de cette stack, quel qu'il soit.

Aggravant : c'est précisément le garde-fou de #174, dont le critère de fermeture est « courriel reçu pour de vrai ». Appliquer sans regarder aurait cassé le test le jour même où il devait enfin être joué.

Fix : variable CI réalignée sur l'adresse de l'abonnement confirmé. Le plan suivant est retombé à 0 to add, 1 to change, 0 to destroy, la seule policy du correctif INC-074.

Validation : l'abonnement a été relu côté AWS (sns list-subscriptions-by-topic) : toujours le même ARN, toujours CONFIRMÉ, jamais remplacé. La preuve est la permanence de l'abonnement d'origine, pas le succès de l'apply.

Leçon. Un plan se lit en entier, y compris les lignes qui ne parlent pas du changement qu'on vient d'écrire. Ici, deux des trois ressources touchées étaient antérieures au travail en cours, et la plus dangereuse était masquée derrière (sensitive value) : le diff n'affichait aucune des deux adresses, seulement forces replacement. Quand un plan annonce un remplacement sur une valeur masquée, l'ancienne valeur se lit côté fournisseur avant d'appliquer, jamais dans le diff.

Corollaire : une alerte dont l'abonnement peut être réinitialisé par un apply de routine n'est pas un contrôle tant que rien ne vérifie qu'elle est encore confirmée. C'est le motif garde-fou-documentaire-vs-controle, cette fois sur le canal de l'alerte plutôt que sur la règle.


INC-076 : la politique CI réduite laisse le cluster naître sans un seul nœud

Sévérité : High Statut : ✅ Résolu — deux apply sur persistent le 2026-09-06, policy v7v9

Contexte : premier montage joué sous la politique réduite de #185 (!357, !359), le 2026-09-06 à 11h32. C'est le montage que le done-when de #185 réclamait.

Fichiers : terraform/modules/iam/main.tf (policy fastapi-eks-terraform-ci-policy)

Symptôme : le plan de contrôle EKS se crée normalement en 7 min 33 s, les cinq rôles IRSA aussi, puis les deux node groups échouent et bootstrap est marqué skipped. Un cluster facturé, sans un seul nœud.

Premier pipeline (2824141008) :

Error: creating EKS Node Group (fastapi-eks-cluster:fastapi-eks-core)
InvalidRequestException: You are not authorized to launch instances with this
launch template. Encoded authorization failure message: x7FK20SIZLS1TIv-...

Second pipeline (2824163096), après le premier correctif :

InvalidRequestException: Failed to validate if SLR:
AWSServiceRoleForAmazonEKSNodegroup already exists
due to missing permissions for 'iam:GetRole'

Cause : AWS évalue les droits de l'appelant pour des actions que le service exécute lui-même. Deux manques distincts, une seule cause profonde.

  1. ec2:RunInstances avait été retiré au motif que « les instances sont gérées par EKS ». Le motif est faux : un CreateNodegroup qui référence un launch template fait évaluer ec2:RunInstances sur la clé qui appelle, pas sur le rôle du service.
  2. iam:GetRole était présent mais scopé sur role/fastapi-eks-*. Le rôle lié au service (service-linked role) que CreateNodegroup va lire vit sous role/aws-service-role/eks-nodegroup.amazonaws.com/, hors du préfixe.

Le premier manque a été établi par la trace, pas par hypothèse :

aws sts decode-authorization-message --encoded-message <...>
  action       = RunInstances
  resource     = arn:aws:ec2:eu-west-3:199167114788:instance/*
  allowed      = false
  explicitDeny = false      -> implicitDeny : l'action manquait, rien ne la refusait

Fix : deux blocs ajoutés à la policy CI.

  • EC2Instances : ec2:RunInstances + ec2:TerminateInstances sur *. Le joker est structurel : RunInstances évalue sur huit types de ressources à la fois (instance, volume, network-interface, subnet, security-group, launch-template, image, key-pair), et un scoping partiel échoue sur la première non couverte.
  • IAMServiceLinkedRolesRead : iam:GetRole seul, sur role/aws-service-role/*. Lecture pure, sur des rôles que seul AWS écrit. Pas de iam:CreateServiceLinkedRole : les trois SLR du compte existent déjà, vérifié sous iamadmin.

Validation : la policy a été relue côté AWS après chaque apply, jamais au succès de l'apply : get-policy-version sur la version par défaut, v8 puis v9, chacune portant le Sid attendu. Puis le montage a été rejoué et est allé au bout.

Ce qui reste à trancher. ec2:TerminateInstances est posé par symétrie avec les Delete* du bloc voisin, et non par mesure : rien ne prouve encore que DeleteNodegroup l'exige de l'appelant. La trace CloudTrail du premier teardown le dira, et il sera retiré s'il n'y figure pas.

Leçon. Une politique réduite depuis une trace CloudTrail ne couvre que ce qui a déjà été appelé sous l'ancienne politique. Les appels qu'un service fait au nom de l'appelant n'apparaissent pas comme des appels de l'appelant, et aucun simulate-principal-policy ne les invente. Seul un montage complet, from scratch, discrimine. C'est la septième forme du « vert qui ne prouve rien » : ici le vert était celui d'un audit de trace, complet sur son propre périmètre et aveugle en dehors.

Suite du 2026-09-06 — le mécanisme observé, et la question de TerminateInstances tranchée

Deux points restaient ouverts à la clôture de l'incident. Le cycle teardown + remontage joué le même jour pour valider #203 les a réglés tous les deux.

Le mécanisme n'est plus une déduction. Dans la trace du remontage :

RunInstances   Client.DryRunOperation   (deux fois, un par node group)

DryRunOperation n'est pas un refus : c'est ce qu'AWS renvoie quand un appel --dry-run passe l'autorisation. EKS exécute donc bien un RunInstances en dry-run contre les droits de l'appelant pendant CreateNodegroup. Corollaire utile : la CI ne lance jamais d'instance pour de vrai, elle ne fait que passer un test de droit.

ec2:TerminateInstances n'est pas appelé par la CI. Sur la trace du teardown, les deux seuls appels portent Username = AutoScaling. L'action était autorisée et disponible pendant tout le destroy, et la clé de la CI ne s'en est pas servie. Un nœud malade est remplacé par l'Auto Scaling Group du node group, sans qu'aucun pipeline ne tourne.

Il a pourtant été conservé, et scopé plutôt que retiré. Le destroy a exercé DeleteNodegroup, jamais UpdateNodegroupVersion : c'est la limite n°2 du commentaire de méthode de #185, ce qui n'a pas été exercé est absent de la trace.

si la CI ne l'appelle jamais si un chemin non exercé l'appelle
retirer rien ne change ✗ ça casse
scoper par tag rien ne change ✓ ça marche, sur les nœuds du cluster
laisser sur * rien ne change ✓ ça marche, sur tout le compte

La condition retenue est ec2:ResourceTag/eks:cluster-name, un tag qu'EKS pose lui-même sur chaque instance de node group. Une clé volée ne peut plus détruire que les nœuds de ce cluster, au lieu de toute instance EC2 du compte.

Ce que le scoping ne prouve pas

Ni le retrait ni le scoping n'est validable par un montage, puisque la CI n'appelle pas cette action. Le scoping est préféré parce qu'il ne casse rien si l'hypothèse est fausse, là où le retrait casse. C'est un argument de conséquence, pas une preuve de plus.

Le niveau de preuve du scoping, et un test qui ne discrimine pas

Le sens positif est réel. Avec la clé de la CI, sur un nœud du cluster :

$ aws ec2 terminate-instances --instance-ids i-080b4617f1ec9a8a8 --dry-run
DryRunOperation: Request would have succeeded, but DryRun flag is set.

C'est ce qui garantit que la condition ne casse rien.

Le sens négatif l'est aussi, mais il a fallu s'en donner les moyens. Premier essai, sur un identifiant dérivé d'une instance réelle avec un caractère changé :

$ aws ec2 terminate-instances --instance-ids i-080b4617f1ec9a8a9 --dry-run
InvalidInstanceID.NotFound: The instance ID does not exist

Ce n'est pas UnauthorizedOperation. AWS résout l'existence de la ressource avant d'évaluer la condition IAM : l'absence d'instance masque la réponse, et le test ne tranche rien. Deux erreurs, une seule répond à la question posée.

La parade a été de fabriquer la cible manquante : une t3.nano jetable, créée sans le tag, visée avec la clé de la CI, puis détruite dans la foulée.

$ aws ec2 terminate-instances --instance-ids i-080b4617f1ec9a8a8 --dry-run   # noeud du cluster
DryRunOperation: Request would have succeeded, but DryRun flag is set.

$ aws ec2 terminate-instances --instance-ids i-0f2e6416b048ce612 --dry-run   # sans le tag
UnauthorizedOperation: You are not authorized to perform this operation.
User: .../fastapi-eks-gitlab-ci-infra is not authorized to perform:
ec2:TerminateInstances on resource: .../i-0f2e6416b048ce612

Même identité, même seconde, même action. Seule la cible change.

Sens Niveau
terminer un nœud du cluster réel, DryRunOperation
refuser une instance réelle sans le tag réel, UnauthorizedOperation

La simulation, jouée avant, disait la même chose et n'aurait pas suffi :

simulate-principal-policy, ec2:TerminateInstances
  contexte eks:cluster-name=fastapi-eks-cluster  -> allowed
  contexte eks:cluster-name=un-autre-cluster     -> implicitDeny

Elle était pourtant correcte cette fois, --resource-arns et la clé de contexte fournis, ce qui manquait à la sixième forme du 2026-09-05. Correcte, et quand même insuffisante : elle interroge le moteur d'évaluation, pas l'API.

Septième forme du critère qui ne discrimine pas

D'un genre nouveau. Les six précédentes portaient sur un vert qui ne prouve rien. Celle-ci porte sur un rouge : InvalidInstanceID.NotFound ressemble à un refus sans en être un.

Les deux échouent, les deux ont un exit code non nul, et un seul répond à la question posée. Lire le code d'erreur, jamais seulement l'échec.

Corollaire pratique : quand la cible d'un test négatif n'existe pas, la fabriquer. Une instance jetable a coûté deux minutes et quelques centimes, et transformé une simulation en preuve.