Distribuée
Cloud & AI Security
← Tous les insights

Posture cloud

Golden AMI : construire, distribuer et imposer des images EC2 durcies en production

Pipeline Golden AMI complet : base épinglée, durcissement avec EC2 Image Builder, publication par SSM Parameter, interdiction des autres images via SCP, Allowed AMIs et AWS Config.

· 11 min · #golden-ami#ec2#image-builder#scp#aws-config#inspector#hardening#aws

Dans la plupart des organisations que j’audite, la question « d’où vient l’AMI de cette instance ? » n’a pas de réponse simple. Un data "aws_ami" avec most_recent = true et un filtre sur le nom, une AMI communautaire trouvée en 2022 et jamais reconstruite, une image Marketplace dont personne ne relit les conditions, trois variantes d’Ubuntu selon l’équipe. Ça tourne, donc personne n’y touche.

Le problème, c’est que l’AMI est le point de départ de tout ce qui s’exécute sur EC2. Si elle est mal choisie, non patchée ou modifiée en dehors de tout processus, tout le reste de la chaîne (IAM, réseau, logging) protège un système déjà fragile. Une golden AMI est l’inverse : une image construite par vous, à partir d’une base connue, durcie, testée, versionnée, et la seule que vos comptes ont le droit de lancer.

Cet article décrit le pipeline complet tel que je le mets en place en mission : ce qu’on met dans l’image, comment on la construit avec EC2 Image Builder, comment on la distribue, et surtout comment on interdit le reste. Avec le Terraform, la SCP et la règle Config qui vont avec.

Pipeline Golden AMI en 5 étapes, de la base épinglée à l’enforcement par SCP et Config

La menace : ce que vous lancez sans le savoir

Trois scénarios reviennent systématiquement.

L’AMI « la plus récente » qui ne vient pas de chez vous. En février 2025, Datadog Security Labs a documenté l’attaque whoAMI : un attaquant publie une AMI publique dont le nom imite le pattern d’une image officielle (ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*). Tout code qui cherche une AMI par nom avec most_recent = true sans filtrer sur owners la sélectionne. Datadog a trouvé ce pattern dans du code de production, y compris chez AWS. Une instance lancée depuis cette AMI exécute ce que l’attaquant a mis dedans, avec le rôle IAM de l’instance.

L’instance jamais reconstruite. Une AMI de 2023 patchée « à la main » via SSM Patch Manager reste une AMI de 2023 au prochain autoscaling : la nouvelle instance démarre avec les CVE d’origine, puis se patche, si le patching fonctionne encore. Chaque scale-out est une fenêtre d’exposition.

La dérive silencieuse. Sans image commune, l’agent CloudWatch est présent en prod mais pas en staging, IMDSv2 est forcé sur les instances créées via le launch template mais pas sur celles créées via la console, l’agent SSM manque sur l’instance qui compte. Le jour de l’incident, on découvre que l’instance concernée n’était couverte par aucun outil.

Le contrôle contre ces trois scénarios est le même : une seule source d’AMI approuvées, reconstruite régulièrement, et un refus technique de tout le reste.

Ce qu’une golden AMI doit contenir

Le contenu varie selon le contexte, mais le socle est stable. Pour une organisation sur Amazon Linux 2023 ou Ubuntu LTS :

ÉlémentPourquoiComment le prouver
Base épinglée par owner (AWS, Canonical) et résolue via SSM public parameterÉliminer le name confusionowners explicite dans le code, pas de most_recent sur un nom
Tous les patchs au moment du buildNouvelle instance = 0 CVE connue au démarrageRapport Inspector par version d’AMI
Durcissement CIS Level 1 ou STIGConfiguration par défaut défendable en auditComposant de test Image Builder + rapport
IMDSv2 obligatoire (ImdsSupport = v2.0)Bloquer le vol de credentials via SSRFaws ec2 describe-images montre ImdsSupport: v2.0
Agents SSM, CloudWatch, éventuellement EDRObservabilité et patching garantis dès la première secondeInventaire SSM à 100 %
Volume racine chiffré avec une CMKConformité, contrôle de qui peut lire les snapshotsdescribe-images → Encrypted: true, KmsKeyId
Aucun secret, clé SSH, utilisateur par défautL’image finira partagée ou copiéeScan du filesystem au build
Tags GoldenAmi=true, Version, BuildDate, SourceEnforcement par tag et traçabilitéConfig rule approved-amis-by-tag
Date de dépréciation à J+30Forcer la rotationdescribe-images → DeprecationTime

Ce qu’on n’y met pas : le code applicatif. La golden AMI est une couche système. L’application arrive par le déploiement (container, artefact, user data), sinon on reconstruit une image à chaque release et le pipeline devient un goulot d’étranglement.

Construire avec EC2 Image Builder

EC2 Image Builder est gratuit (vous payez l’instance de build et le stockage), s’intègre à Organizations pour la distribution, et fournit des composants AWS managés utiles : update-linux, amazon-cloudwatch-agent-linux, stig-build-linux-medium, inspector-test-linux. Packer fait très bien le travail aussi ; Image Builder a l’avantage de ne pas demander de runner CI avec des droits EC2 larges.

Le pipeline en Terraform, réduit à l’essentiel. Compte dédié shared-services (ici 111122223333), région eu-west-3.

locals {
  org_arn      = "arn:aws:organizations::111122223333:organization/o-exampleorg"
  parent_image = "arn:aws:imagebuilder:eu-west-3:aws:image/amazon-linux-2023-x86/x.x.x"
}

# Composant custom : durcissement complémentaire + vérifications
resource "aws_imagebuilder_component" "hardening" {
  name     = "org-hardening"
  platform = "Linux"
  version  = "1.3.0"
  data = yamlencode({
    schemaVersion = 1.0
    phases = [
      {
        name = "build"
        steps = [
          {
            name   = "DisablePasswordAuth"
            action = "ExecuteBash"
            inputs = { commands = [
              "sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config",
              "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config",
            ] }
          },
          {
            name   = "RemoveDefaultKeysAndHistory"
            action = "ExecuteBash"
            inputs = { commands = [
              "rm -f /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys",
              "find / -name '.bash_history' -delete 2>/dev/null || true",
              "rm -f /etc/machine-id && touch /etc/machine-id",
            ] }
          },
        ]
      },
      {
        name = "validate"
        steps = [{
          name   = "NoSecretsOnDisk"
          action = "ExecuteBash"
          inputs = { commands = [
            "! grep -rIE 'AKIA[0-9A-Z]{16}|-----BEGIN (RSA|OPENSSH) PRIVATE KEY' /etc /home /root /opt 2>/dev/null",
          ] }
        }]
      },
      {
        name = "test"
        steps = [{
          name   = "SshHardened"
          action = "ExecuteBash"
          inputs = { commands = [
            "sshd -T | grep -q 'passwordauthentication no'",
            "sshd -T | grep -q 'permitrootlogin no'",
          ] }
        }]
      },
    ]
  })
}

resource "aws_imagebuilder_image_recipe" "golden" {
  name         = "golden-al2023"
  version      = "1.3.0"
  parent_image = local.parent_image

  block_device_mapping {
    device_name = "/dev/xvda"
    ebs {
      volume_size           = 20
      volume_type           = "gp3"
      encrypted             = true
      kms_key_id            = aws_kms_key.ami.arn
      delete_on_termination = true
    }
  }

  # Composants AWS managés, épinglés par version dans la vraie vie
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/update-linux/x.x.x" }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/stig-build-linux-medium/x.x.x" }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/amazon-cloudwatch-agent-linux/x.x.x" }
  component { component_arn = aws_imagebuilder_component.hardening.arn }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/inspector-test-linux/x.x.x" }

  systems_manager_agent { uninstall_after_build = false }
}

resource "aws_imagebuilder_infrastructure_configuration" "build" {
  name                          = "golden-build"
  instance_profile_name         = aws_iam_instance_profile.imagebuilder.name
  instance_types                = ["t3.medium"]
  subnet_id                     = var.build_subnet_id
  security_group_ids            = [aws_security_group.build_egress_only.id]
  terminate_instance_on_failure = true

  instance_metadata_options {
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  logging {
    s3_logs {
      s3_bucket_name = aws_s3_bucket.imagebuilder_logs.id
      s3_key_prefix  = "golden-al2023"
    }
  }
}

resource "aws_imagebuilder_distribution_configuration" "golden" {
  name = "golden-al2023-dist"

  distribution {
    region = "eu-west-3"

    ami_distribution_configuration {
      name       = "golden-al2023-{{ imagebuilder:buildDate }}"
      kms_key_id = aws_kms_key.ami.arn
      ami_tags = {
        GoldenAmi = "true"
        Source    = "imagebuilder/golden-al2023"
        Version   = "1.3.0"
        BuildDate = "{{ imagebuilder:buildDate }}"
      }
      launch_permission {
        organization_arns = [local.org_arn]
      }
    }

    # L'AMI courante est exposée aux consommateurs via un paramètre SSM
    ssm_parameter_configuration {
      parameter_name = "/golden/al2023/x86_64/latest"
      data_type      = "aws:ec2:image"
    }
  }
}

resource "aws_imagebuilder_image_pipeline" "golden" {
  name                             = "golden-al2023"
  image_recipe_arn                 = aws_imagebuilder_image_recipe.golden.arn
  infrastructure_configuration_arn = aws_imagebuilder_infrastructure_configuration.build.arn
  distribution_configuration_arn   = aws_imagebuilder_distribution_configuration.golden.arn
  enhanced_image_metadata_enabled  = true

  image_tests_configuration {
    image_tests_enabled = true
    timeout_minutes     = 60
  }

  schedule {
    # Tous les lundis 03h UTC, uniquement si une dépendance a changé (base ou composant)
    schedule_expression                = "cron(0 3 ? * MON *)"
    pipeline_execution_start_condition = "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE"
  }
}

Trois points qui font la différence à l’audit :

  • launch_permission.organization_arns partage l’AMI à toute l’organisation sans jamais la rendre publique. Une AMI publique par erreur est un incident à déclarer.
  • ssm_parameter_configuration met à jour /golden/al2023/x86_64/latest à chaque build. Les consommateurs n’ont plus d’ID d’AMI en dur.
  • inspector-test-linux fait tourner Amazon Inspector sur l’image de build. Si des findings critiques sortent, le pipeline échoue et l’AMI n’est pas distribuée. C’est la preuve « 0 CVE critique au build » que vous montrerez à l’auditeur.

Une dernière étape que Terraform ne couvre pas encore proprement : forcer IMDSv2 au niveau de l’image, pour que même une instance créée à la main hérite du bon réglage.

AMI_ID=$(aws ssm get-parameter --name /golden/al2023/x86_64/latest --query Parameter.Value --output text)
aws ec2 modify-image-attribute --image-id "$AMI_ID" --imds-support v2.0
aws ec2 enable-image-deprecation --image-id "$AMI_ID" \
  --deprecate-at "$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"

Ces deux commandes vivent dans une Lambda déclenchée par l’événement EventBridge EC2 Image Builder Image State Change (état AVAILABLE). L’AMI se déprécie seule 30 jours après sa création : elle disparaît des résultats describe-images par défaut, ce qui casse proprement le code qui ne suit pas le paramètre SSM.

Consommer : jamais un ID d’AMI en dur

Côté équipes, la règle est simple : l’AMI vient du paramètre SSM, point.

data "aws_ssm_parameter" "golden" {
  name = "/golden/al2023/x86_64/latest"
}

resource "aws_launch_template" "app" {
  name_prefix = "app-"
  image_id    = data.aws_ssm_parameter.golden.value

  metadata_options {
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  update_default_version = true
}

resource "aws_autoscaling_group" "app" {
  # ...
  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }

  instance_refresh {
    strategy = "Rolling"
    preferences { min_healthy_percentage = 90 }
  }
}

Les launch templates acceptent aussi directement image_id = "resolve:ssm:/golden/al2023/x86_64/latest" : l’ID est résolu au lancement, sans terraform apply. Pratique pour les ASG, moins pour la traçabilité (le plan Terraform ne montre plus le changement d’AMI). Je préfère la première forme en prod, la seconde en dev.

Avec instance_refresh, chaque nouvelle golden AMI déclenche un remplacement progressif des instances de l’ASG au prochain apply. C’est ce qui transforme « on a une nouvelle image » en « la flotte est à jour ».

Imposer : refuser tout ce qui n’est pas golden

Construire une golden AMI sans interdire les autres, c’est écrire une politique que personne ne lit. Trois mécanismes, complémentaires.

1. SCP : refus au RunInstances

Une SCP au niveau de l’OU Workloads refuse le lancement de toute instance dont l’image n’appartient pas au compte shared-services. La condition ec2:Owner porte sur la ressource image.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OnlyGoldenAmis",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*::image/ami-*",
      "Condition": {
        "StringNotEquals": {
          "ec2:Owner": "111122223333"
        }
      }
    }
  ]
}

Résultat : RunInstances avec une AMI communautaire, Marketplace ou construite à la main dans le compte échoue avec UnauthorizedOperation. Les appliances Marketplace légitimes (un pare-feu, un bastion éditeur) se traitent par exception : une deuxième déclaration Allow n’existe pas en SCP, donc soit on les place dans une OU dédiée sans cette SCP, soit on ajoute leur owner à la liste ec2:Owner. Documentez chaque exception, l’auditeur demandera la liste.

2. Allowed AMIs : le garde-fou natif EC2

Depuis fin 2024, EC2 propose un réglage de compte Allowed AMIs qui restreint la découverte et le lancement aux images de fournisseurs approuvés (amazon, aws-marketplace, aws-backup-vault ou des IDs de compte). Il a un mode audit qui ne bloque rien mais marque les AMIs non conformes dans describe-images (ImageAllowed: false), ce qui permet de mesurer l’impact avant d’activer.

# Phase 1 : mesurer
aws ec2 enable-allowed-images-settings --allowed-images-settings-state audit-mode
aws ec2 replace-image-criteria-in-allowed-images-settings \
  --image-criteria "ImageProviders=111122223333"

# Ce que ça bloquerait aujourd'hui
aws ec2 describe-instances \
  --query "Reservations[].Instances[].[InstanceId,ImageId]" --output text \
  | while read id ami; do
      allowed=$(aws ec2 describe-images --image-ids "$ami" \
        --query "Images[0].ImageAllowed" --output text 2>/dev/null)
      [ "$allowed" = "False" ] && echo "NON CONFORME $id $ami"
    done

# Phase 2 : bloquer
aws ec2 enable-allowed-images-settings --allowed-images-settings-state enabled

Le réglage est par compte et par région. Pour le déployer d’un coup sur l’organisation, il passe par les declarative policies d’Organizations (type EC2), qui couvrent précisément ce paramètre. C’est plus lisible qu’une SCP et ça ne se contourne pas par un chemin d’API oublié.

3. AWS Config : détecter la dérive

La SCP bloque le futur. Config trouve le présent : les instances lancées avant la règle, ou dans un compte hors OU.

resource "aws_config_config_rule" "approved_amis" {
  name = "approved-amis-by-tag"
  source {
    owner             = "AWS"
    source_identifier = "APPROVED_AMIS_BY_TAG"
  }
  input_parameters = jsonencode({ amisByTagKeyAndValue = "GoldenAmi:true" })
}

resource "aws_config_config_rule" "imdsv2" {
  name = "ec2-imdsv2-check"
  source {
    owner             = "AWS"
    source_identifier = "EC2_IMDSV2_CHECK"
  }
}

Déployées en organization conformance pack depuis le compte d’audit, ces deux règles donnent un tableau de bord par compte : combien d’instances tournent sur autre chose qu’une golden AMI, combien acceptent encore IMDSv1. C’est le chiffre de départ de la mission, et celui qu’on fait tendre vers zéro.

La preuve pour l’auditeur

Ce pipeline produit, sans effort supplémentaire, les éléments qu’un auditeur SOC2, ISO 27001 ou un client grand compte demandent :

  • Provenance : logs Image Builder dans S3, recette et composants versionnés dans Git, Source et BuildDate dans les tags de chaque AMI.
  • Vulnérabilités : rapport Inspector par build, échec du pipeline sur finding critique, findings Inspector sur les instances en cours corrélés à la version d’AMI.
  • Durcissement : composant STIG ou CIS appliqué et testé, réglages vérifiés dans la phase test.
  • Enforcement : SCP versionnée, réglage Allowed AMIs en enabled, conformité Config à 100 % ou liste nominative des écarts.
  • Rotation : DeprecationTime sur chaque AMI, aucune instance avec une AMI de plus de 30 à 45 jours (requête Config Advanced Query sur imageId × creationDate).

Ce qui fait la différence en audit, ce n’est pas d’avoir une golden AMI. C’est de pouvoir montrer qu’il est impossible d’en lancer une autre, et de le prouver avec des données du jour plutôt qu’un document de politique.

Conclusion

La golden AMI est l’un des rares contrôles qui améliore à la fois la sécurité (surface réduite, patchs garantis, IMDSv2 partout), l’opérabilité (une seule image à connaître, agents toujours présents) et la conformité (preuves générées par le pipeline). Le coût est faible : une instance t3.medium quelques minutes par semaine, un compte partagé, trois ressources Terraform de plus. Le vrai travail est dans l’enforcement, parce que c’est lui qui expose les exceptions et oblige à les documenter.

Si vos instances tournent aujourd’hui sur des AMI dont personne ne connaît la provenance, ou si votre code contient encore un most_recent = true sans owners, c’est un des premiers points que nous regardons dans un Audit Sécurité Cloud : l’inventaire des images en production prend une heure et donne une idée très précise de la posture réelle.

Cet article vous a été utile ? Partagez-le.

Aller plus loin

Un sujet, une mission, une question ?

Distribuée accompagne des PME exigeantes sur la sécurité cloud, la supply chain CI/CD et la sécurité IA.

Réserver 15 min