Escalade de privilèges IAM (la chaîne en trois maillons)¶
En une phrase. Le compte de la CI a besoin de créer des rôles pour monter le cluster. Or qui peut créer un rôle peut se fabriquer un rôle administrateur, puis l'emprunter. On ne peut pas retirer ces droits : on les encadre.
Issue #203, sortie de #185 parce qu'elle ne se traite pas de la même façon.
1. Le constat, mesuré¶
Le 2026-09-05, aws iam simulate-principal-policy sur le compte
fastapi-eks-gitlab-ci-infra :
iam:AttachUserPolicy allowed <- sur son PROPRE compte
iam:CreateUser allowed
iam:CreateAccessKey allowed
Il pouvait donc s'attacher AdministratorAccess en un appel d'API.
Ce que ça rend cosmétique
Tant que c'est vrai, réduire ec2:* en quarante actions nommées ne sert à rien.
Une clé volée ne s'arrête pas à la liste des actions autorisées : elle s'en
donne d'autres.
La moitié « utilisateur » a été fermée par !357 : les neuf actions d'écriture sur
les utilisateurs IAM sont retirées, la stack ephemeral n'en déclarant aucun.
C'est la moitié « rôle » qui est difficile.
2. La chaîne, et pourquoi on ne peut pas la couper à la source¶
flowchart LR
A["1. iam:CreateRole<br/>créer un rôle vide"] --> B["2. iam:AttachRolePolicy<br/>y coller AdministratorAccess"]
B --> C["3. iam:PassRole<br/>donner ce rôle à une<br/>ressource qu'on démarre"]
C --> D["Administrateur du compte,<br/>sans jamais toucher<br/>à sa propre politique"]
style D fill:#ffdddd,stroke:#cc0000
Chaque droit pris seul est banal. C'est la chaîne qui ouvre la porte.
Et aucun des trois ne peut être supprimé : la stack ephemeral déclare cinq
rôles IAM, dont ceux d'IRSA sur lesquels repose ESO. Sans iam:CreateRole, le
montage échoue.
La différence avec les actions utilisateur
Les actions sur les utilisateurs ne servaient à rien, donc on les a retirées.
Celles-ci servent. C'est toute la difficulté, et la raison d'une issue
séparée : !357 réduisait une liste d'actions, ce qui est sûr. Ici on ajoute
des conditions, ce qui peut casser un montage.
3. Casser un seul maillon suffit¶
Pas besoin de fermer les trois. Si AttachRolePolicy ne peut plus coller qu'une
liste blanche, créer un rôle et le passer n'apporte plus aucun privilège.
Trois conditions ont été posées, une par maillon.
Maillon 1 — CreateRole borné par le nom¶
Resource = "arn:aws:iam::<compte>:role/fastapi-eks-*"
Les cinq rôles de la stack portent tous ce préfixe. La CI ne touche plus aucun autre rôle du compte.
Maillon 2 — AttachRolePolicy borné par liste blanche¶
Condition = {
ArnLike = {
"iam:PolicyARN" = [
"arn:aws:iam::aws:policy/AmazonEKSClusterPolicy",
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
"arn:aws:iam::<compte>:policy/fastapi-eks-*"
]
}
}
iam:PolicyARN est la seule clé de condition acceptée par AttachRolePolicy.
C'est pour ça que ces deux actions sont dans un bloc à part : les autres actions de
rôle ne la comprennent pas.
AdministratorAccess n'est pas dans la liste. Il devient impossible à coller.
Maillon 3 — PassRole borné par service destinataire¶
Condition = {
StringEquals = {
"iam:PassedToService" = [
"eks.amazonaws.com",
"ec2.amazonaws.com",
"vpc-flow-logs.amazonaws.com"
]
}
}
Un rôle du projet ne peut plus être donné à n'importe quel service : ni à une Lambda, ni à un job Glue.
4. Avant / après, sur le scénario d'attaque¶
flowchart TB
subgraph AV["AVANT"]
A1["clé CI volée"] --> A2["CreateRole<br/>n'importe quel nom"]
A2 --> A3["AttachRolePolicy<br/>AdministratorAccess"]
A3 --> A4["PassRole<br/>vers n'importe quoi"]
A4 --> A5["admin du compte"]
end
subgraph AP["APRÈS"]
B1["clé CI volée"] --> B2["CreateRole<br/>seulement fastapi-eks-*"]
B2 --> B3["AttachRolePolicy<br/>REFUSÉ hors liste blanche"]
B3 --> B4["chaîne rompue"]
end
style A5 fill:#ffdddd,stroke:#cc0000
style B3 fill:#d4edda,stroke:#155724
style B4 fill:#d4edda,stroke:#155724
5. Le trou qui reste, écrit pour que personne ne le croie fermé¶
Une policy inline est un jeu de droits écrit directement dans le rôle, au lieu d'être une policy nommée référencée par son ARN.
iam:CreateRole plus iam:PutRolePolicy permettent toujours de fabriquer un rôle
à policy inline large. Aucune condition sur un ARN de policy ne mord là : le
document n'a pas d'ARN.
flowchart LR
X["CreateRole<br/>fastapi-eks-quelquechose"] --> Y["PutRolePolicy<br/>policy INLINE : tout autoriser"]
Y --> Z["le rôle a tous les droits<br/>sans qu'aucun ARN<br/>n'ait été attaché"]
style Z fill:#fff3cd,stroke:#856404
La seule parade est une permissions boundary : un plafond de droits imposé au
rôle à sa création, par une condition iam:PermissionsBoundary sur CreateRole.
Elle n'est pas encore posée, et ce n'est pas un oubli. Elle exige d'ajouter
permissions_boundary sur les cinq aws_iam_role de la stack ephemeral, avec un
ARN construit par convention puisqu'il n'existe aucun terraform_remote_state dans
ce dépôt (ADR 033). Un ARN faux, et tous les CreateRole échouent. Ça mérite
son propre cycle de validation.
Ce que #193 apporte en attendant
Un rôle fabriqué par ce chemin aurait secretsmanager:GetSecretValue. Mais
depuis l'extension de la politique de ressource aux sept secrets, un principal
non nommé est refusé au niveau du secret. Les deux barrières se complètent :
voir Politique de ressource sur les secrets.
6. Le piège de la clé iam:PassedToService¶
C'est le point le plus subtil de cette issue, et il vaut au-delà d'elle.
On ne peut pas observer cette clé avant l'appel. Trois valeurs s'en approchent, aucune n'est elle :
| Source | Ce que c'est | Exemple mesuré le 2026-09-06 |
|---|---|---|
CloudTrail eventSource |
le service appelé | eks.amazonaws.com pour CreateNodegroup |
Trust policy Principal.Service |
le service qui assume | ec2.amazonaws.com pour eks-nodes-role |
iam:PassedToService |
ni l'un ni l'autre | renseigné par l'API, et pas par toutes |
Les trois coïncident souvent. Pas toujours. Sur eks-nodes-role, les deux premières
divergent déjà.
La liste des trois services est donc une hypothèse, exactement comme
ec2:TerminateInstances dans INC-076. Elle est
écrite comme telle dans le code.
Pourquoi pas StringEqualsIfExists¶
Il existe un opérateur qui laisse passer quand la clé est absente. Il n'a pas été retenu, délibérément.
StringEquals clé absente -> REFUS -> le montage casse, on le VOIT
StringEqualsIfExists clé absente -> autorisé -> la condition existe et ne mord pas
Le second transforme un risque de casse en risque de fausse sécurité : une condition posée, visible dans le code, lue comme une protection, et inopérante sur le chemin qui compte.
Le principe
Un garde-fou muet est pire qu'un garde-fou absent. Mieux vaut échouer bruyamment que protéger silencieusement.
7. Vérifier¶
La seule validation possible est un montage complet, et rien d'autre.
Ni simulate-principal-policy, qui évalue sur un moteur distinct de celui de l'API
réelle. Ni une relecture de CloudTrail, qui ne montre que ce qui a déjà été appelé
sous l'ancienne politique.
C'est pour ça que le done-when de #203 dit « un montage complet passe avec les conditions en place », et c'est le protocole joué le 2026-09-06 :
1. apply de la condition sur terraform/persistent
2. relire la policy côté AWS (get-policy-version), pas le succès de l'apply
3. teardown complet
4. REMONTAGE <- le test
5. le cluster remonte, ou la condition est fausse et on le sait tout de suite
L'étape 5 est le point : jouer le cycle le jour où on écrit la condition, plutôt que de léguer un montage cassé à la session suivante.
Voir aussi¶
- Politique de ressource sur les secrets — la barrière complémentaire, au niveau du secret
docs/incidents/sprints/sprint7.md— INC-076, le montage cassé par une politique réduite sur un motif faux- ADR 034 — Terraform ne fabrique plus les clés d'accès de la CI, ce qui rend les actions
iam:*AccessKey*retirables