Anti-Patterns de Sécurité Terraform : 10 Mauvaises Configurations Trouvées dans du Code de Production Réel
Chaque enquête sur une brèche cloud qui commence par un identifiant exposé ou un bucket S3 ouvert se termine de la même manière : quelqu'un trouve un fichier
.tf, ou unterraform.tfstatedans un bucket S3, ou un pipeline CI qui a exécutéterraform applyavec des clés admin intégrées dans une variable d'environnement. Terraform n'est pas intrinsèquement non sécurisé, mais les schémas qui le rendent rapide à utiliser sont précisément ceux qui créent la plus grande surface d'attaque. Les secrets en dur survivent dans l'historique Git après suppression. Les fichiers d'état stockent chaque attribut de ressource en clair, y compris les mots de passe, clés privées et chaînes de connexion, que vous les ayez marqués sensibles ou non. Ce ne sont pas des risques hypothétiques, ce sont les conclusions littérales de chaque engagement IR cloud majeur des cinq dernières années.
Cet article documente dix anti-patterns extraits de code Terraform de production réel, regroupés en cinq sections qui suivent le chemin d'attaque naturel à travers un environnement cloud compromis : exfiltration de secrets, accès réseau, exposition des magasins de données, élévation de privilèges et angles morts de visibilité persistants. Pour chaque anti-pattern, vous obtenez le code défaillant exact, le correctif à modification minimale, et une règle Checkov ou tfsec de politique-as-code fonctionnelle qui le bloque en CI avant même que terraform plan ne s'exécute. La section finale couvre l'intégration complète de tfsec, Checkov et Terrascan dans les pipelines GitHub Actions et GitLab CI avec des barrières d'application. À la fin, votre pipeline IaC rejettera les dix configurations Terraform les plus dangereuses avant qu'elles n'atteignent un compte cloud.
Section 1 : Secrets en Dur et Exposition du Fichier d'État Pourquoi terraform.tfstate Est le Fichier le Plus Dangereux que Produit Votre Pipeline
L'anti-pattern de secret Terraform le plus courant est aussi le plus évident : des identifiants saisis directement dans des blocs de ressources ou des valeurs par défaut de variables. Ce qui est moins bien compris, c'est que corriger le fichier .tf ne suffit pas. Les fichiers d'état Terraform stockent la valeur résolue de chaque attribut de ressource au moment de l'apply, y compris des attributs que vous n'avez jamais explicitement définis mots de passe maîtres RDS, clés d'API générées, matériel de clé privée des ressources tls_private_key, et chaînes de connexion assemblées par le fournisseur. Chaque terraform apply écrit ces valeurs dans terraform.tfstate en JSON clair, que vous ayez déclaré la variable comme sensitive = true ou non. Le drapeau sensitive ne contrôle que le masquage de la sortie console ; il n'a aucun effet sur ce qui est écrit dans l'état.
Le problème de l'historique Git aggrave cela. Un développeur code en dur aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" dans un provider.tf, réalise l'erreur, le supprime et committe le correctif. La clé a disparu du fichier actuel, mais elle existe en permanence dans l'historique Git au SHA de commit précédant la suppression. git log -p -- provider.tf la récupère en quelques secondes. Des outils comme truffleHog, gitleaks et git-secrets recherchent exactement ce motif, tout comme les scanners automatisés qui indexent en continu les dépôts GitHub publics. La brèche Uber de 2022 a commencé par un identifiant en dur dans un dépôt GitHub privé auquel un attaquant a accédé après avoir compromis le compte d'un sous-traitant.
Le second anti-pattern spécifique à l'état est le stockage de terraform.tfstate dans un dépôt Git ou dans un bucket S3 non protégé. Les backends d'état par défaut sur le stockage local terraform.tfstate dans le répertoire de travail qui est committé par les développeurs qui ne configurent pas de backend distant. Quand le backend d'état est S3, le bucket est fréquemment mal configuré sans chiffrement côté serveur, sans versioning, sans journalisation d'accès, et sans verrouillage d'état via DynamoDB. Un attaquant qui obtient un accès en lecture à ce bucket S3 obtient tous les secrets de votre infrastructure en un seul appel d'API.
Flux d'Attaque : De l'État Exposé à la Compromission Complète du Compte
Anti-Pattern 1 : Identifiants en Dur dans les Blocs Provider et Resource
# ❌ ANTI-PATTERN: Hardcoded AWS credentials in provider block
# Found in production code from a fintech startup in 2024 IR engagement
# These credentials survive in git history even after the commit is reverted
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
access_key = "AKIAIOSFODNN7EXAMPLE" # Hardcoded will be in git history forever
secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" # Same
}
resource "aws_db_instance" "production" {
identifier = "prod-postgres"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
# ❌ Password hardcoded also written to tfstate in plaintext
username = "dbadmin"
password = "SuperSecret123!" # Recoverable from tfstate AND git history
# ❌ Publicly accessible addressed in Section 2
publicly_accessible = true
}
resource "aws_instance" "bastion" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
# ❌ Private key stored as a variable default committed to .tfvars
# user_data installs this key for SSH access
user_data = <<-EOF
#!/bin/bash
echo "ssh-rsa AAAAB3NzaC1yc2EAAA...PRIVATE_KEY_MATERIAL" >> /root/.ssh/authorized_keys
EOF
}
# ✅ FIX: Use provider-level credential resolution chain + AWS Secrets Manager
# Credentials come from: instance profile, environment variables, or ~/.aws/credentials
# Never from .tf files
provider "aws" {
region = var.aws_region
# No access_key or secret_key uses default credential chain:
# 1. Environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
# 2. Shared credentials file (~/.aws/credentials)
# 3. EC2/ECS/Lambda instance profile (preferred in CI/CD)
# 4. EKS service account IRSA token
}
# RDS password: generate randomly and store in Secrets Manager never in .tf or tfstate
resource "random_password" "db_password" {
length = 32
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
# This value IS stored in tfstate use secrets manager reference to minimize blast radius
}
resource "aws_secretsmanager_secret" "db_password" {
name = "prod/rds/postgres/master-password"
recovery_window_in_days = 7
# KMS encryption for the secret itself
kms_key_id = aws_kms_key.secrets.arn
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({
username = "dbadmin"
password = random_password.db_password.result
})
}
resource "aws_db_instance" "production" {
identifier = "prod-postgres"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
username = "dbadmin"
# Reference the random password value is in tfstate but not in source code
# Rotate via Secrets Manager rotation Lambda, not terraform apply
password = random_password.db_password.result
# Mark as sensitive masks value in terraform plan/apply output
# NOTE: does NOT prevent writing to tfstate state encryption is the control
lifecycle {
ignore_changes = [password] # Prevents Terraform from resetting rotated passwords
}
}
Politique Checkov : Bloquer les Identifiants en Dur
# checkov/custom_checks/check_hardcoded_aws_credentials.py
# Custom Checkov check place in a directory passed to checkov via --external-checks-dir
# Runs against every .tf file in your repository
from checkov.common.models.enums import CheckCategories, CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
import re
class HardcodedAWSCredentials(BaseResourceCheck):
"""
Detects hardcoded AWS access keys and secret keys in provider blocks.
CKV_CUSTOM_1: No hardcoded AWS credentials in provider configuration.
"""
def __init__(self):
name = "Ensure no hardcoded AWS credentials in provider block"
id = "CKV_CUSTOM_1"
# Apply to the aws provider block
supported_resources = ["provider"]
categories = [CheckCategories.SECRETS]
super().__init__(name=name, id=id,
categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
# Check for access_key and secret_key attributes in the provider block
access_key = conf.get("access_key", [""])
secret_key = conf.get("secret_key", [""])
# AWS access key pattern: AKIA... (20 chars, uppercase alphanumeric)
aws_key_pattern = re.compile(r'AKIA[0-9A-Z]{16}')
# If either attribute is set and looks like a real AWS key fail
for key_value in [access_key, secret_key]:
if isinstance(key_value, list):
key_value = key_value[0] if key_value else ""
if key_value and key_value != "" and not key_value.startswith("var."):
if aws_key_pattern.search(str(key_value)):
return CheckResult.FAILED
# Also flag any non-variable, non-empty credential value
if str(key_value).strip() not in ("", "null"):
return CheckResult.FAILED
return CheckResult.PASSED
# Built-in Checkov checks for credential patterns (run these by default):
# CKV_AWS_41: Ensure no hard-coded credentials exist in aws provider
# CKV_SECRET_*: Checkov Secrets scanning (Bridgecrew Secrets module)
# tfsec rule equivalent .tfsec/config.toml for per-repo tfsec configuration
# tfsec ships with built-in checks for hardcoded secrets
# Run tfsec with secrets scanning enabled:
tfsec . \
--include-passed \ # Show passed checks too
--format json \ # Machine-readable output for CI
--out tfsec-results.json \
--minimum-severity HIGH \ # Only fail on HIGH and CRITICAL
--config-file .tfsec/config.toml
# .tfsec/config.toml configure which checks to enforce
[severity_overrides]
# Elevate these to CRITICAL (fail the pipeline)
"aws-iam-no-policy-wildcards" = "CRITICAL"
"aws-s3-no-public-access-with-acl" = "CRITICAL"
"general-secrets-sensitive-in-variable" = "CRITICAL"
[exclude]
# Exclude checks only with documented justification never for convenience
# "aws-vpc-no-public-ingress-sg" = "Bastion host exception tracked in ticket SEC-1234"
# Gitleaks: scan git history for committed secrets (run in CI on every PR)
# Catches secrets removed in subsequent commits but present in history
gitleaks detect \
--source . \ # Scan current directory
--verbose \
--report-format json \
--report-path gitleaks-report.json \
--log-opts "origin/main..HEAD" # Only scan commits in this PR, not full history
# For full history scan (run once on existing repos):
gitleaks detect --source . --log-opts "--all"
# .gitleaks.toml custom rules for Terraform-specific secrets
[[rules]]
description = "Terraform tfvars password"
id = "terraform-password-variable"
regex = '''(?i)(password|passwd|pwd|secret)\s*=\s*["'][^"']{8,}["']'''
path = '''.*\.tfvars$'''
tags = ["terraform", "secret"]
[[rules]]
description = "Terraform state file with sensitive data"
id = "terraform-state-secret"
regex = '''"(password|secret_key|private_key|token)"\s*:\s*"[^"]{8,}"'''
path = '''.*\.tfstate.*'''
tags = ["terraform", "state"]
KQL : Détecter l'Accès au Fichier d'État Terraform dans AWS
// KQL Detect access to Terraform state S3 buckets from unexpected principals
// Source: AWS CloudTrail forwarded to Sentinel
// Terraform state buckets should only be accessed by CI/CD service accounts and Terraform runners
AWSCloudTrail
| where TimeGenerated > ago(7d)
| where EventName in ("GetObject", "PutObject", "DeleteObject", "ListBucket")
// Filter to known state bucket names (maintain as a Watchlist)
| where RequestParameters has_any (toscalar(
_GetWatchlist('TerraformStateBuckets')
| summarize make_list(BucketName)
))
| extend
CallerPrincipal = tostring(parse_json(UserIdentity).arn),
CallerType = tostring(parse_json(UserIdentity).type),
SourceIP = tostring(SourceIPAddress)
// Flag access from anything that isn't your CI/CD service account or Terraform runner role
| where CallerPrincipal !contains "terraform-ci-role"
and CallerPrincipal !contains "github-actions"
and CallerPrincipal !contains "gitlab-runner"
and CallerType != "AWSService"
// Flag GetObject specifically reading state file = reading all secrets
| where EventName == "GetObject"
| project TimeGenerated, CallerPrincipal, CallerType, SourceIP,
EventName, tostring(RequestParameters), tostring(ResponseElements)
| order by TimeGenerated desc
Comparaison des Méthodes d'Injection de Secrets
| Méthode | Secrets dans la source .tf ? | Secrets dans tfstate ? | Support de rotation | Compatible CI/CD | Recommandée pour |
|---|---|---|---|---|---|
En dur dans .tf / .tfvars | Oui risque critique | Oui | Manuel uniquement | Non | Jamais |
Variable d'environnement TF_VAR_* | Non | Oui dans tfstate | Manuel | Oui (injection à l'exécution CI) | Variables peu sensibles uniquement |
Source de données aws_secretsmanager_secret | Non | Non (référence seulement) | Oui (rotation Lambda) | Oui | Tous les secrets de production |
| Fournisseur HashiCorp Vault | Non | Non (secret en bail) | Oui (TTL de bail Vault) | Oui (sidecar agent Vault) | Secrets nécessitant une piste d'audit |
random_password + sensitive = true | Non | Oui en clair dans tfstate | Via terraform taint | Oui | Seulement avec backend d'état chiffré |
| Source de données AWS SSM Parameter Store | Non | Non (référence seulement) | Oui (manuel ou Lambda) | Oui | Configuration de sensibilité moyenne |
Corriger la façon dont les secrets entrent dans Terraform élimine un vecteur d'attaque, mais ne fait rien contre l'exposition au niveau réseau configurée par les ressources que Terraform crée. La classe d'anti-pattern suivante est celle par laquelle la plupart des engagements IR cloud commencent encore : un groupe de sécurité avec le port 22 ouvert à 0.0.0.0/0 créé lors d'un incident il y a trois ans et jamais refermé.
Section 2 : Règles Réseau Trop Permissives Pourquoi 0.0.0.0/0 sur le Port 22 Reste la Conclusion N°1 Après une Décennie de Sécurité Cloud
La mauvaise configuration des groupes de sécurité est la plus ancienne conclusion de sécurité cloud et celle qui ne cesse de réapparaître dans le code Terraform de production parce que c'est le chemin de moindre résistance pendant le développement. Quand quelque chose ne se connecte pas, le réflexe de débogage est d'ouvrir le port plus largement. Quand cela se connecte après ouverture à 0.0.0.0/0, le changement de débogage est committé comme le « correctif ». Les opérateurs du malware SUNBURST de SolarWinds, une fois un point d'ancrage dans les réseaux des victimes, ciblaient spécifiquement les serveurs accessibles via RDP depuis Internet, car le RDP exposé à Internet sur le port 3389 est si courant qu'il se fond dans le bruit de fond. Shodan indexe en permanence les ports 22 et 3389, et les bots automatisés de credential-stuffing énumèrent tous les endpoints exposés en quelques minutes après l'apparition d'une nouvelle IP publique.
Les anti-patterns Terraform spécifiques sont : des règles ingress dans les ressources aws_security_group avec cidr_blocks = ["0.0.0.0/0"] sur des ports administratifs (22, 3389, 1433, 3306, 5432, 6379, 27017), des ressources aws_db_instance avec publicly_accessible = true, et aws_s3_bucket_public_access_block soit absent, soit avec l'un des quatre drapeaux de blocage à false. Chacun de ces éléments crée une surface d'attaque distincte, et chacun a un profil de détection différent dans CloudTrail.
Le drapeau publicly_accessible = true sur RDS est particulièrement dangereux car c'est la valeur par défaut dans les anciennes versions du fournisseur et dans de nombreuses configurations de tutoriels. Quand il est défini, AWS attribue à l'instance RDS un nom DNS publiquement résoluble et la place dans une route de sous-réseau qui autorise l'entrée depuis Internet même si le groupe de sécurité semble restreindre l'accès. Les attaquants qui obtiennent l'endpoint RDS (depuis tfstate, par énumération DNS, ou depuis un message d'erreur exposé) peuvent tenter des connexions directement, contournant toute exigence de VPN ou d'hôte bastion. La brèche Capital One de 2019 incluait des instances RDS dans un VPC avec des règles de groupe de sécurité trop permissives qui permettaient à l'exploit SSRF d'atteindre le service de métadonnées interne puis de pivoter vers des requêtes de base de données.
Arbre de Décision de l'Exposition Réseau Anti-Pattern
Anti-Pattern 3 et 4 : Groupes de Sécurité Ouverts et RDS Public
# ❌ ANTI-PATTERN 3: Security group open to internet on administrative ports
# Observed verbatim in startup production environments during 2023-2024 IR engagements
resource "aws_security_group" "web_server" {
name = "web-server-sg"
description = "Web server security group"
vpc_id = aws_vpc.main.id
# ❌ SSH open to entire internet indexed by Shodan within minutes
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Every IP on the internet
description = "SSH access" # No description of WHY this is open
}
# ❌ All outbound traffic standard but worth reviewing for data exfil risk
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "database" {
name = "db-sg"
vpc_id = aws_vpc.main.id
# ❌ Database port open to internet not just VPC internal
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # PostgreSQL accessible from anywhere
}
}
# ❌ ANTI-PATTERN 4: RDS publicly accessible
resource "aws_db_instance" "production" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
publicly_accessible = true # ❌ DNS name resolves to a public IP
db_subnet_group_name = aws_db_subnet_group.public.name # ❌ In public subnets
vpc_security_group_ids = [aws_security_group.database.id]
skip_final_snapshot = true # ❌ No backup on destroy
}
# ✅ FIX: Principle of least-network-access restrict every ingress to minimum required source
resource "aws_security_group" "web_server" {
name = "web-server-sg"
description = "Web server: HTTPS from ALB only, no direct SSH"
vpc_id = aws_vpc.main.id
# ✅ HTTPS from Application Load Balancer security group only not from internet directly
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # Reference SG, not CIDR
description = "HTTPS from ALB only"
}
# ✅ SSH removed entirely use AWS Systems Manager Session Manager instead
# SSM Session Manager: no open port 22, full session logging to CloudWatch/S3
# aws ssm start-session --target i-0123456789abcdef0
# No security group rule needed SSM agent connects outbound to SSM endpoint
# ✅ Egress: restrict to only what the application needs
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS outbound for AWS API calls and package updates"
}
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.database.id]
description = "PostgreSQL to database tier only"
}
}
resource "aws_security_group" "database" {
name = "db-sg"
description = "Database: accepts connections from app tier SG only"
vpc_id = aws_vpc.main.id
# ✅ Database accessible only from application server security group
# No CIDR block SG reference means only instances in the app SG can connect
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web_server.id]
description = "PostgreSQL from app tier only"
}
# ✅ No egress needed databases don't initiate outbound connections
# Explicit deny by omitting egress (or add explicit deny-all)
}
resource "aws_db_instance" "production" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
publicly_accessible = false # ✅ No public DNS name assigned
db_subnet_group_name = aws_db_subnet_group.private.name # ✅ Private subnets only
vpc_security_group_ids = [aws_security_group.database.id]
deletion_protection = true # ✅ Prevents accidental destroy
skip_final_snapshot = false # ✅ Backup on destroy
# ✅ Encryption at rest covered in Section 5
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
}
Règles tfsec et Checkov : Imposer la Sécurité Réseau
# Run tfsec specifically for network security findings
tfsec . \
--include-checks aws-ec2-no-public-ingress-sgr,aws-rds-no-public-db-access,aws-ec2-no-public-ip-subnet \
--format lovely \
--minimum-severity HIGH
# Checkov equivalent targeted network checks
checkov -d . \
--check CKV_AWS_24,CKV_AWS_25,CKV_AWS_23,CKV_AWS_17,CKV_AWS_88 \
--output cli \
--compact
# CKV_AWS_24: Ensure no security groups allow ingress from 0.0.0.0:0 to port 22
# CKV_AWS_25: Ensure no security groups allow ingress from 0.0.0.0:0 to port 3389
# CKV_AWS_23: Ensure every security group and rule has a description
# CKV_AWS_17: Ensure RDS is not publicly accessible
# CKV_AWS_88: Ensure RDS is not publicly accessible (duplicate with different check ID)
# Custom Checkov check: detect ANY 0.0.0.0/0 ingress on non-web ports
# Catches database ports, Redis, MongoDB, Elasticsearch, and others
from checkov.common.models.enums import CheckCategories, CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
# Ports that should NEVER be open to 0.0.0.0/0
SENSITIVE_PORTS = {
22, # SSH
23, # Telnet
25, # SMTP (outbound spam risk)
3389, # RDP
1433, # MSSQL
3306, # MySQL
5432, # PostgreSQL
6379, # Redis (no auth by default)
27017, # MongoDB (no auth by default)
9200, # Elasticsearch
9300, # Elasticsearch transport
2379, # etcd
2380, # etcd peer
}
class NoPublicIngressSensitivePorts(BaseResourceCheck):
"""
CKV_CUSTOM_2: No 0.0.0.0/0 ingress on sensitive ports.
Catches ports not covered by built-in CKV_AWS_24/25.
"""
def __init__(self):
name = "Ensure no security group allows ingress from internet on sensitive ports"
id = "CKV_CUSTOM_2"
supported_resources = ["aws_security_group", "aws_security_group_rule"]
categories = [CheckCategories.NETWORKING]
super().__init__(name=name, id=id,
categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
ingress_rules = conf.get("ingress", [])
if isinstance(ingress_rules, list):
for rule in ingress_rules:
if isinstance(rule, dict):
cidr_blocks = rule.get("cidr_blocks", [])
ipv6_blocks = rule.get("ipv6_cidr_blocks", [])
from_port = int(rule.get("from_port", 0) or 0)
to_port = int(rule.get("to_port", 0) or 0)
is_public = (
"0.0.0.0/0" in (cidr_blocks or []) or
"::/0" in (ipv6_blocks or [])
)
if is_public:
# Check if any sensitive port falls in the from_port:to_port range
for port in SENSITIVE_PORTS:
if from_port <= port <= to_port:
return CheckResult.FAILED
# Also flag protocol -1 (all traffic) with 0.0.0.0/0
if rule.get("protocol") in ("-1", "all"):
return CheckResult.FAILED
return CheckResult.PASSED
KQL : CloudTrail Détecter les Changements de Groupe de Sécurité Ouvrant l'Accès Internet
// KQL Detect security group rules being added with 0.0.0.0/0 source
// Source: AWS CloudTrail forwarded to Microsoft Sentinel
AWSCloudTrail
| where TimeGenerated > ago(1d)
| where EventName in ("AuthorizeSecurityGroupIngress", "CreateSecurityGroup",
"ModifyNetworkInterfaceAttribute")
| extend RequestParams = parse_json(RequestParameters)
| extend IpPermissions = RequestParams.ipPermissions
| mv-expand IpPermission = IpPermissions.items
| extend
FromPort = toint(IpPermission.fromPort),
ToPort = toint(IpPermission.toPort),
Protocol = tostring(IpPermission.ipProtocol),
CidrRange = tostring(IpPermission.ipRanges.items[0].cidrIp),
GroupId = tostring(RequestParams.groupId)
// Flag: internet-sourced ingress
| where CidrRange in ("0.0.0.0/0", "::/0")
// Further flag sensitive ports all traffic (-1) is automatic critical
| extend IsSensitivePort = case(
Protocol == "-1", true,
FromPort <= 22 and ToPort >= 22, true,
FromPort <= 3389 and ToPort >= 3389, true,
FromPort <= 3306 and ToPort >= 3306, true,
FromPort <= 5432 and ToPort >= 5432, true,
FromPort <= 6379 and ToPort >= 6379, true,
false
)
| extend Severity = iif(IsSensitivePort, "CRITICAL", "HIGH")
| extend CallerArn = tostring(parse_json(UserIdentity).arn)
| project TimeGenerated, CallerArn, EventName, GroupId,
Protocol, FromPort, ToPort, CidrRange, Severity
| order by Severity asc, TimeGenerated desc
Matrice des Risques de Mauvaise Configuration des Groupes de Sécurité
| Port / Protocole | Service | Risque si ouvert à 0.0.0.0/0 | Temps d'attaque automatisée | Observé dans des engagements IR |
|---|---|---|---|---|
| 22 (SSH) | Secure Shell | Critique brute force d'identifiants, vol de clés | Minutes (bots Shodan) | Presque chaque engagement IR cloud |
| 3389 (RDP) | Bureau à distance | Critique BlueKeep (CVE-2019-0708), contournement NLA, credential stuffing | Minutes | SolarWinds, brèches du secteur santé 2023 |
| 5432 / 3306 / 1433 | PostgreSQL / MySQL / MSSQL | Critique requête DB directe si identifiants obtenus | Heures (après collecte d'identifiants) | Capital One (2019), multiples incidents fintech 2024 |
| 6379 (Redis) | Cache Redis | Critique pas d'auth par défaut dans Redis < 6 ; RCE via chargement de module | Minutes | Nombreuses campagnes Redis exposé 2022-2024 |
| 9200 (Elasticsearch) | Elasticsearch | Critique pas d'auth par défaut dans la version OSS ; lecture complète des données | Minutes | L'exposition Elasticsearch est une source pérenne de fuite |
| 443 (HTTPS) | Trafic web | Faible si load balancer intentionnel ; Élevé si direct-vers-app | N/A attendu pour les endpoints publics | À signaler seulement si la cible n'est pas un load balancer |
| 0-65535 (protocole -1) | Tout le trafic | Critique exposition réseau complète | Immédiat | Changement de débogage « tout réparer » laissé en prod |
Les ports ouverts sont l'anti-pattern le plus détectable car ils apparaissent dans CloudTrail et peuvent être scannés depuis l'extérieur. La classe d'anti-pattern suivante est plus subtile : des mauvaises configurations de bucket S3 où l'exposition provient de l'intersection de trois paramètres indépendants ACL, politiques de bucket et blocage d'accès public qui peuvent se contredire de manières créant un accès public inattendu même lorsque chaque paramètre semble correct isolément.
Section 3 : Mauvaise Configuration S3 Pourquoi Trois Couches de Contrôle d'Accès se Chevauchant Produisent des Buckets Publics Inattendus Même Quand Chaque Couche Semble Correcte
Le contrôle d'accès S3 a trois couches indépendantes qui doivent toutes être configurées correctement, et elles interagissent de manières non évidentes. La première couche est l'ACL du bucket un mécanisme hérité qui précède les politiques de bucket et qui est toujours présent dans Terraform sous acl = "public-read" ou acl = "private". La seconde est la politique de bucket une politique IAM JSON attachée au bucket qui peut accorder s3:GetObject à Principal: "*", rendant tous les objets publics quels que soient les ACL d'objet. La troisième est le paramètre S3 Block Public Access quatre drapeaux booléens indépendants qui priment sur les ACL et politiques pour empêcher l'accès public, et qui doivent tous être à true pour réellement bloquer l'accès public. Le piège de la mauvaise configuration est que définir block_public_acls = true ne bloque pas une politique de bucket publique ; il vous faut aussi restrict_public_buckets = true. La plupart du code Terraform qui ajoute aws_s3_bucket_public_access_block ne configure correctement qu'un ou deux des quatre drapeaux.
AWS a introduit aws_s3_bucket_public_access_block en 2018 comme disjoncteur après une vague de brèches de buckets publics. Les quatre drapeaux fonctionnent ainsi : block_public_acls empêche la définition de nouvelles ACL publiques et ignore les ACL publiques existantes pour les décisions d'accès. ignore_public_acls ignore toutes les ACL publiques existantes sur les buckets et objets c'est distinct du blocage des nouvelles. block_public_policy empêche l'application de politiques de bucket accordant un accès public. restrict_public_buckets est celui qui applique réellement la restriction d'accès il prime sur toute politique de bucket publique existante et empêche l'accès public même si block_public_policy est à false. Si vous ne définissez que block_public_acls = true et block_public_policy = true mais laissez ignore_public_acls = false et restrict_public_buckets = false, une ACL publique existante accorde toujours l'accès public car elle n'est pas ignorée. Cette combinaison exacte se retrouve fréquemment dans le code Terraform porté depuis la console AWS, où l'interface fait paraître les drapeaux mutuellement exclusifs.
Les lacunes de chiffrement et de journalisation aggravent le problème d'ACL. Le chiffrement côté serveur S3 avec aws:kms n'a pas été appliqué rétroactivement aux objets existants avant que Terraform ne gère le bucket les objets écrits avant l'activation du SSE restent non chiffrés. La journalisation d'accès S3, quand elle est activée, pointe fréquemment vers le même bucket qu'elle surveille, ce qui signifie que les journaux sont supprimés quand les objets le sont et peuvent être publiquement lisibles si le bucket lui-même est mal configuré. Le bon schéma est un bucket de journalisation dédié et verrouillé avec une politique d'accès distincte.
Interaction des Couches de Contrôle d'Accès S3
Anti-Pattern 5 et 6 : Configuration de Bucket S3 Public
# ❌ ANTI-PATTERN 5 & 6: Multiple S3 misconfigurations in a single bucket resource
# This pattern was found in a healthcare data platform during a 2024 compliance audit
resource "aws_s3_bucket" "data_lake" {
bucket = "company-data-lake-prod"
# ❌ acl argument deprecated in provider v4+ but still valid and dangerous
acl = "public-read" # Makes ALL objects readable by anyone
}
# ❌ Public access block present but INCOMPLETE only blocks new ACLs
# Does NOT ignore existing public ACLs, does NOT restrict public bucket policies
resource "aws_s3_bucket_public_access_block" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
block_public_acls = true # ✅ Blocks new public ACLs
block_public_policy = false # ❌ Allows public bucket policies
ignore_public_acls = false # ❌ Existing public-read ACL still active
restrict_public_buckets = false # ❌ Public bucket policies still enforced
}
# ❌ No encryption configuration objects stored in plaintext
# ❌ No versioning objects can be deleted without recovery
# ❌ No access logging no record of who read what
# ❌ ANTI-PATTERN: Bucket policy that makes the bucket public
resource "aws_s3_bucket_policy" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*" # ❌ All principals any internet user
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.data_lake.arn}/*"
# No Condition block no IP restriction, no VPC endpoint restriction
}
]
})
}
# ✅ FIX: Complete S3 hardening all four public access block flags, encryption, logging
# Dedicated logging bucket separate from the data bucket it monitors
resource "aws_s3_bucket" "access_logs" {
bucket = "company-s3-access-logs"
force_destroy = false
}
resource "aws_s3_bucket_public_access_block" "access_logs" {
bucket = aws_s3_bucket.access_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "access_logs" {
bucket = aws_s3_bucket.access_logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3_logs.arn
}
bucket_key_enabled = true # Reduces KMS API call costs significantly
}
}
# Main data bucket fully hardened
resource "aws_s3_bucket" "data_lake" {
bucket = "company-data-lake-prod"
force_destroy = false # ✅ Prevents accidental destruction
}
# ✅ ALL FOUR flags set to true complete public access prevention
resource "aws_s3_bucket_public_access_block" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
block_public_acls = true # Block new public ACLs
block_public_policy = true # Block new public bucket policies
ignore_public_acls = true # Ignore existing public ACLs
restrict_public_buckets = true # Override existing public policies
}
# ✅ KMS encryption all objects encrypted on write
resource "aws_s3_bucket_server_side_encryption_configuration" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3_data.arn
}
bucket_key_enabled = true
}
}
# ✅ Versioning enables object recovery and MFA-Delete protection
resource "aws_s3_bucket_versioning" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
versioning_configuration {
status = "Enabled"
# MFA delete requires out-of-band MFA confirmation to delete object versions
# Prevents ransomware and insider deletion
mfa_delete = "Enabled"
}
}
# ✅ Access logging to separate logging bucket
resource "aws_s3_bucket_logging" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
target_bucket = aws_s3_bucket.access_logs.id
target_prefix = "data-lake/"
}
# ✅ Bucket policy: restrict to VPC endpoint only no public internet access
resource "aws_s3_bucket_policy" "data_lake" {
# This policy depends on the public access block being in place first
depends_on = [aws_s3_bucket_public_access_block.data_lake]
bucket = aws_s3_bucket.data_lake.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyNonVPCEndpointAccess"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.data_lake.arn,
"${aws_s3_bucket.data_lake.arn}/*"
]
Condition = {
StringNotEquals = {
# Only allow access through the VPC endpoint
"aws:SourceVpce" = aws_vpc_endpoint.s3.id
}
}
},
{
Sid = "EnforceSSLOnly"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.data_lake.arn,
"${aws_s3_bucket.data_lake.arn}/*"
]
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
}
]
})
}
Checkov : Contrôles de Sécurité S3
# Full S3 security scan maps all relevant Checkov checks to S3 resources
checkov -d . \
--check \
CKV_AWS_18,\ # S3 access logging enabled
CKV_AWS_19,\ # S3 encryption enabled
CKV_AWS_20,\ # S3 bucket not publicly readable via ACL
CKV_AWS_21,\ # S3 versioning enabled
CKV_AWS_52,\ # S3 MFA delete enabled
CKV_AWS_53,\ # S3 block public ACLs
CKV_AWS_54,\ # S3 block public policy
CKV_AWS_55,\ # S3 ignore public ACLs
CKV_AWS_56,\ # S3 restrict public buckets
CKV_AWS_145,\ # S3 encryption uses KMS (not AES256)
CKV2_AWS_6,\ # S3 public access block exists at account level
CKV2_AWS_62 # S3 event notifications configured
// KQL Detect S3 public access block being disabled or bucket policy granting public access
// Source: AWS CloudTrail
AWSCloudTrail
| where TimeGenerated > ago(1d)
| where EventName in (
"PutBucketPublicAccessBlock", // Public access block being modified
"DeleteBucketPublicAccessBlock", // Public access block being removed
"PutBucketPolicy", // Bucket policy being set/changed
"PutBucketAcl" // ACL being changed
)
| extend RequestParams = parse_json(RequestParameters)
| extend BucketName = tostring(RequestParams.bucketName)
// For PutBucketPublicAccessBlock: flag if any flag is being set to false
| extend PublicAccessConfig = RequestParams.PublicAccessBlockConfiguration
| extend IsWeakening = (
EventName == "PutBucketPublicAccessBlock" and (
PublicAccessConfig.BlockPublicAcls == "false" or
PublicAccessConfig.BlockPublicPolicy == "false" or
PublicAccessConfig.IgnorePublicAcls == "false" or
PublicAccessConfig.RestrictPublicBuckets == "false"
)
) or EventName == "DeleteBucketPublicAccessBlock"
// For PutBucketPolicy: flag if policy contains Principal: *
| extend PolicyDocument = tostring(RequestParams.bucketPolicy)
| extend HasPublicPrincipal = PolicyDocument contains '"Principal":"*"' or
PolicyDocument contains '"Principal": "*"'
| where IsWeakening or HasPublicPrincipal
| extend CallerArn = tostring(parse_json(UserIdentity).arn)
| project TimeGenerated, CallerArn, EventName, BucketName,
IsWeakening, HasPublicPrincipal, PolicyDocument
| order by TimeGenerated desc
Matrice de Couverture des Contrôles de Sécurité S3
| Contrôle | Ressource Terraform | Contrôle Checkov | Contrôle tfsec | Risque si absent |
|---|---|---|---|---|
| Bloquer les ACL publiques | aws_s3_bucket_public_access_block (block_public_acls) | CKV_AWS_53 | aws-s3-block-public-acls | Accès public via ACL possible |
| Bloquer la politique publique | aws_s3_bucket_public_access_block (block_public_policy) | CKV_AWS_54 | aws-s3-block-public-policy | Politique de bucket publique applicable |
| Ignorer les ACL publiques | aws_s3_bucket_public_access_block (ignore_public_acls) | CKV_AWS_55 | aws-s3-ignore-public-acls | ACL publiques existantes toujours appliquées |
| Restreindre les buckets publics | aws_s3_bucket_public_access_block (restrict_public_buckets) | CKV_AWS_56 | aws-s3-no-public-buckets | Politiques publiques appliquées malgré les autres drapeaux |
| Chiffrement KMS | aws_s3_bucket_server_side_encryption_configuration | CKV_AWS_145 | aws-s3-enable-bucket-encryption | Données au repos non chiffrées en clair si support volé |
| Journalisation d'accès | aws_s3_bucket_logging | CKV_AWS_18 | aws-s3-enable-bucket-logging | Aucune trace de qui a accédé à quoi aveugle à l'exfiltration |
| Versioning | aws_s3_bucket_versioning | CKV_AWS_21 | aws-s3-enable-versioning | Objets supprimés irrécupérables risque rançongiciel/interne |
| MFA Delete | aws_s3_bucket_versioning (mfa_delete = Enabled) | CKV_AWS_52 | N/A | Versions d'objets supprimables sans second facteur |
Les anti-patterns S3 ont une chose en commun avec les anti-patterns IAM de la section suivante : ils exigent de comprendre comment plusieurs contrôles indépendants interagissent, plutôt que d'appliquer un correctif unique. IAM va plus loin une politique IAM gérée par Terraform avec un seul joker * peut être la seule chose entre un attaquant ayant compromis une fonction Lambda et la prise de contrôle complète du compte.
Section 4 : Politiques IAM à Joker et Abus des Relations de Confiance Comment les Valeurs par Défaut Pratiques de Terraform Créent des Chemins d'Élévation de Privilèges vers Votre Compte
Les mauvaises configurations IAM dans Terraform tombent dans deux catégories souvent traitées comme le même problème mais ayant des surfaces d'attaque complètement différentes. La première catégorie est les jokers d'action/ressource trop permissifs dans les politiques IAM Action: "*" ou Resource: "*" dans une politique attachée à un rôle ou un utilisateur. La seconde est les relations de confiance trop permissives sur les rôles IAM qui est autorisé à sts:AssumeRole sur ce rôle. Les deux apparaissent constamment dans le code Terraform parce que les développeurs copient les exemples de la documentation AWS (qui utilisent * pour la clarté) et ne les resserrent jamais. Les conséquences diffèrent : les actions joker permettent à un principal compromis de tout faire dans votre compte ; les relations de confiance joker permettent à tout principal de votre compte (ou de tout compte) d'élever ses privilèges vers les permissions de ce rôle.
Le privilège iam:PassRole est le mécanisme spécifique qui rend l'IAM géré par Terraform particulièrement dangereux. iam:PassRole permet à un principal d'attacher un rôle IAM à un service Lambda, EC2, ECS, Glue. Si un attaquant compromet les identifiants AWS d'un développeur disposant de iam:PassRole et lambda:CreateFunction (tous deux courants dans les comptes développeurs), il peut créer une nouvelle fonction Lambda, lui passer n'importe quel rôle du compte (y compris un rôle admin), et invoquer la Lambda pour exécuter des appels d'API AWS arbitraires en tant que ce rôle admin. Le rôle développeur créé par Terraform avec Action: "lambda:*" et iam:PassRole sur Resource: "*" est un chemin complet d'élévation de privilèges vers admin. Ce vecteur spécifique est documenté dans la recherche sur l'élévation de privilèges AWS de Rhino Security Labs et observé dans de réelles intrusions cloud.
L'anti-pattern de la politique de confiance d'assume role est plus subtil. Une politique de confiance avec Principal: { AWS: "arn:aws:iam::123456789012:root" } qui semble restreindre l'accès à un compte spécifique accorde en réalité à tout principal de ce compte la capacité d'endosser le rôle, y compris les utilisateurs nouvellement créés, les clés d'API compromises et les rôles d'exécution de fonctions Lambda. Le principal root du compte signifie « quiconque dans le compte 123456789012 ayant les permissions sts:AssumeRole », pas « seulement l'utilisateur root ». C'est une incompréhension bien documentée du fonctionnement des politiques de confiance.
Chaîne d'Élévation de Privilèges IAM
Anti-Pattern 7 et 8 : Jokers IAM et Mauvaises Configurations de Politique de Confiance
# ❌ ANTI-PATTERN 7: Wildcard IAM policy observed in Terraform code for developer roles
# "Let's give them everything for now and restrict later" "later" never comes
resource "aws_iam_policy" "developer_policy" {
name = "developer-full-access"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DeveloperAccess"
Effect = "Allow"
Action = "*" # ❌ Every AWS API action admin-equivalent
Resource = "*" # ❌ Every resource in the account
}
]
})
}
# ❌ ANTI-PATTERN 8: Trust policy allowing entire account root
resource "aws_iam_role" "ci_deploy_role" {
name = "ci-deploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
# ❌ Account root = any principal in this account can assume this role
# Not just the root user every IAM user and role in the account
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "sts:AssumeRole"
# ❌ No Condition no IP restriction, no MFA requirement, no service restriction
}
]
})
}
# ❌ Overly permissive policy attached to the CI role
resource "aws_iam_role_policy" "ci_deploy" {
name = "ci-deploy-permissions"
role = aws_iam_role.ci_deploy_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["iam:*", "lambda:*", "s3:*", "ec2:*"] # ❌ Broad wildcards
Resource = "*" # ❌ On all resources
}
]
})
}
# ✅ FIX: Least privilege IAM policies with specific actions, resources, and conditions
# ✅ Developer policy: specific actions for specific resources
# Replace with actual services your developers need not a universal template
resource "aws_iam_policy" "developer_policy" {
name = "developer-limited-access"
description = "Developer access limited to sandbox account resources"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "LambdaDevelopment"
Effect = "Allow"
Action = [
"lambda:CreateFunction",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:InvokeFunction",
"lambda:GetFunction",
"lambda:ListFunctions",
"lambda:DeleteFunction"
]
# ✅ Resource scoped to functions with specific prefix not all functions
Resource = "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${var.team_prefix}-*"
},
{
Sid = "LambdaPassRoleScoped"
Effect = "Allow"
Action = ["iam:PassRole"]
# ✅ PassRole only to the specific execution roles for this team's functions
# Cannot pass an admin role only the designated Lambda execution role
Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.team_prefix}-lambda-execution-role"
Condition = {
StringEquals = {
"iam:PassedToService" = "lambda.amazonaws.com"
}
}
},
{
Sid = "DenyIAMAdminActions"
Effect = "Deny"
# ✅ Explicit deny on IAM privilege escalation actions overrides any Allow
Action = [
"iam:CreateUser",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:CreatePolicy",
"iam:CreateRole",
"iam:PutRolePolicy",
"iam:PutUserPolicy"
]
Resource = "*"
}
]
})
}
# ✅ CI deploy role: trust only the specific CI service, not the entire account
resource "aws_iam_role" "ci_deploy_role" {
name = "ci-deploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
# ✅ Specific IAM role that CI runner uses not account root
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/github-actions-runner"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
# ✅ External ID prevents confused deputy attacks from third-party services
"sts:ExternalId" = var.ci_external_id
}
# ✅ Also restrict to specific source IP range if CI runners have fixed IPs
# IpAddress = { "aws:SourceIp" = ["10.0.0.0/8"] }
}
}
]
})
}
# ✅ CI policy: deploy-only permissions, no IAM mutation
resource "aws_iam_role_policy" "ci_deploy" {
name = "ci-deploy-permissions"
role = aws_iam_role.ci_deploy_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECRPush"
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/${var.app_name}-*"
},
{
Sid = "ECSUpdateService"
Effect = "Allow"
Action = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:DescribeTaskDefinition"]
Resource = "arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:service/${var.cluster_name}/${var.app_name}-*"
},
{
Sid = "DenyAllIAM"
Effect = "Deny"
Action = "iam:*" # ✅ CI pipeline should never mutate IAM
Resource = "*"
}
]
})
}
Checkov et tfsec : Détection des Jokers IAM
# Checkov IAM checks run against all .tf files in your repository
checkov -d . --check \
CKV_AWS_40,\ # IAM policies attached directly to users (should use groups/roles)
CKV_AWS_1,\ # IAM policy documents contain wildcard actions
CKV_AWS_2,\ # Lambda function should use supported runtimes (not admin bypass)
CKV_AWS_274,\ # IAM policy should not have statements with both Action * and Resource *
CKV_AWS_275,\ # IAM policy should not allow * action
CKV2_AWS_40,\ # IAM managed policies are attached only to roles (not users directly)
CKV2_AWS_56 # Ensure IAM role policies are managed policies, not inline
# tfsec for IAM analysis note the check IDs for aws-iam
tfsec . --include-checks \
aws-iam-no-policy-wildcards,\
aws-iam-no-root-access-key,\
aws-iam-enforce-mfa,\
aws-iam-no-user-attached-policies
// KQL Detect IAM privilege escalation paths being executed
// Specifically: PassRole + CreateFunction (classic Lambda privesc) and other escalation combos
// Source: AWS CloudTrail
// Detect the Lambda privilege escalation sequence
let LambdaPrivEsc = AWSCloudTrail
| where TimeGenerated > ago(1h)
| where EventName in ("PassRole", "CreateFunction", "InvokeFunction",
"CreateUser", "AttachUserPolicy", "CreateRole", "AttachRolePolicy")
| extend CallerArn = tostring(parse_json(UserIdentity).arn)
| summarize
Actions = make_set(EventName, 10),
ActionCount = count(),
FirstAction = min(TimeGenerated),
LastAction = max(TimeGenerated)
by CallerArn, bin(TimeGenerated, 1h);
// Flag accounts performing privilege escalation sequences
LambdaPrivEsc
| where (Actions has "PassRole" and Actions has "CreateFunction") // Lambda privesc
or (Actions has "CreateUser" and Actions has "AttachUserPolicy") // Backdoor user creation
or (Actions has "CreateRole" and Actions has "AttachRolePolicy") // Backdoor role creation
| extend WindowMinutes = datetime_diff('minute', LastAction, FirstAction)
| project FirstAction, LastAction, CallerArn, Actions, ActionCount, WindowMinutes
| order by FirstAction desc
Élévation de Privilèges IAM via les Anti-Patterns Terraform
| Anti-Pattern | Code Terraform | Chemin d'attaque | Résultat de l'élévation | Contrôle Checkov |
|---|---|---|---|---|
Joker Action: "*" | Action = "*" dans le Statement | Tout principal compromis avec cette politique peut appeler toute API AWS | Admin complet du compte via API | CKV_AWS_275 |
Joker Resource: "*" | Resource = "*" avec actions sensibles | Les actions s'appliquent à toutes les ressources pas d'isolation | Accès inter-environnements | CKV_AWS_274 |
| Root du compte en politique de confiance | Principal: {AWS: "arn:aws:iam::ACCT:root"} | Toute entité IAM du compte peut endosser le rôle avec sts:AssumeRole | Mouvement latéral vers rôle privilégié | Aucun contrôle intégré utiliser un personnalisé |
iam:PassRole sur * | Action: ["iam:PassRole"], Resource: "*" | Peut passer n'importe quel rôle (y compris admin) à Lambda/EC2/ECS | Prise de contrôle complète via service | CKV_AWS_60 |
| Pas d'External ID en confiance inter-comptes | Confiance autorise un autre compte sans ExternalId | Attaque du confused deputy si un SaaS tiers est compromis | Accès à votre compte via partenaire compromis | CKV_AWS_156 |
| Politiques inline au lieu de gérées | aws_iam_role_policy (inline) | Les politiques inline n'apparaissent pas dans la liste IAM plus difficiles à auditer | Permissions cachées survivant aux revues | CKV2_AWS_56 |
Les anti-patterns IAM sont les plus difficiles à détecter de manière réactive car les politiques joker ne génèrent pas d'alertes elles génèrent une capacité. L'utilisation de cette capacité par l'attaquant génère des événements CloudTrail, mais à ce moment-là l'action a déjà été prise. La classe d'anti-pattern finale couvre les lacunes qui rendent la détection réactive plus difficile : chiffrement manquant, journaux d'audit manquants et backend d'état non protégé les trois conditions qui transforment un incident récupérable en catastrophe.
Section 5 : Lacunes de Chiffrement, Journaux d'Audit Manquants et Intégration CI/CD Les Anti-Patterns qui Survivent à la Revue Manuelle et Comment Automatiser leur Détection
La dernière classe d'anti-patterns Terraform est la plus dangereuse précisément parce qu'elle est invisible au moment du déploiement. Une instance RDS non chiffrée sert les requêtes de la même façon qu'une instance chiffrée. Une instance EC2 avec un volume racine non chiffré démarre et exécute les applications de manière identique à une instance avec chiffrement activé. Les VPC Flow Logs, lorsqu'ils sont absents, ne laissent aucun artefact indiquant leur absence il n'y a simplement aucune donnée de trafic. Ces lacunes ne causent pas d'échecs ; elles n'ont d'importance qu'après qu'un attaquant a déjà accédé à une base de données ou exfiltré des données, et même alors elles n'importent qu'à l'équipe IR tentant de déterminer ce qui s'est passé. L'enquête sur la brèche Capital One de 2019 a été considérablement compliquée par des lacunes dans la journalisation d'accès. Les enquêteurs de la brèche Uber de 2022 disposaient de données incomplètes sur les systèmes auxquels l'attaquant avait accédé parce que la journalisation d'accès au serveur S3 n'était pas activée sur tous les buckets. Ce ne sont pas des risques théoriques.
La lacune de chiffrement pour RDS, EBS et SQS spécifiquement : AWS ne chiffre par défaut ni l'un ni l'autre dans toutes les régions pour tous les types de ressources. Les instances RDS créées sans storage_encrypted = true stockent les fichiers de base de données en clair sur les volumes EBS sous-jacents. Si un attaquant obtient un instantané (via la technique rds:CreateDBSnapshot et rds:CopyDBSnapshot vers un autre compte), il obtient des données en clair. L'attaque nécessite les permissions IAM pour créer et partager des instantanés fréquemment présentes dans les rôles développeurs pas un accès direct à la base de données. Le chiffrement de volume EBS n'est de même pas par défaut dans les anciennes configurations du fournisseur Terraform, et les instantanés EBS non chiffrés partagés via aws_ebs_snapshot_copy sont un autre chemin d'exfiltration.
La ressource Terraform aws_cloudtrail est fréquemment absente ou mal configurée : include_global_service_events = false manque les événements IAM et STS (là où se produisent le vol d'identifiants et l'assomption de rôle), is_multi_region_trail = false manque l'activité dans les régions où l'attaquant pivote, et enable_log_file_validation = false signifie que les fichiers de journaux CloudTrail peuvent être falsifiés après coup. Ces trois éléments sont les valeurs par défaut dans de nombreux exemples Terraform car le fournisseur AWS ne les impose pas.
Architecture de Barrière de Politique CI/CD
Anti-Pattern 9 et 10 : Lacunes de Chiffrement et de Journalisation
# ❌ ANTI-PATTERN 9: Unencrypted storage resources
# Unencrypted RDS
resource "aws_db_instance" "production" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
storage_encrypted = false # ❌ Plaintext database files on EBS
# No kms_key_id using default AWS-managed key if encrypted (weaker audit trail)
}
# Unencrypted EBS volume
resource "aws_ebs_volume" "data" {
availability_zone = "us-east-1a"
size = 100
encrypted = false # ❌ Plaintext volume snapshots are also plaintext
}
# Unencrypted SQS queue (contains message data may include PII or credentials)
resource "aws_sqs_queue" "processing" {
name = "data-processing-queue"
# No kms_master_key_id messages stored in plaintext
}
# ❌ ANTI-PATTERN 10: Missing audit trail configuration
# CloudTrail with critical gaps
resource "aws_cloudtrail" "main" {
name = "main-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id
include_global_service_events = false # ❌ Misses IAM, STS, Route53 events
is_multi_region_trail = false # ❌ Only captures events in one region
enable_log_file_validation = false # ❌ Log files can be tampered post-incident
# No CloudWatch Logs integration logs only go to S3, not searchable in near-real-time
# No KMS encryption on CloudTrail log files
}
# No VPC Flow Logs no record of network connections to/from any resource
# No S3 access logging no record of who accessed which S3 objects
# ✅ FIX: Complete encryption and audit logging configuration
# ✅ Encrypted RDS with customer-managed KMS key
resource "aws_db_instance" "production" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
storage_encrypted = true # ✅ EBS encryption at rest
kms_key_id = aws_kms_key.rds.arn # ✅ Customer-managed key full audit trail in CloudTrail
deletion_protection = true
}
# ✅ KMS key for RDS with rotation enabled
resource "aws_kms_key" "rds" {
description = "KMS key for RDS encryption"
deletion_window_in_days = 30
enable_key_rotation = true # ✅ Annual automatic key rotation
policy = data.aws_iam_policy_document.kms_rds.json
}
# ✅ Encrypted EBS volumes
resource "aws_ebs_volume" "data" {
availability_zone = "us-east-1a"
size = 100
encrypted = true
kms_key_id = aws_kms_key.ebs.arn
}
# ✅ Encrypted SQS with KMS
resource "aws_sqs_queue" "processing" {
name = "data-processing-queue"
kms_master_key_id = aws_kms_key.sqs.arn
# Enforce HTTPS-only access via queue policy
}
# ✅ CloudTrail: comprehensive, multi-region, validated
resource "aws_cloudtrail" "main" {
name = "main-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id
include_global_service_events = true # ✅ Captures IAM, STS, Route53, CloudFront
is_multi_region_trail = true # ✅ Captures events in ALL regions attacker pivots included
enable_log_file_validation = true # ✅ SHA-256 hash of each log file detects tampering
is_organization_trail = var.is_organization_account # ✅ Covers all member accounts if using AWS Orgs
# ✅ CloudWatch Logs integration searchable in near-real-time, not just S3 batch
cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
cloud_watch_logs_role_arn = aws_iam_role.cloudtrail_cloudwatch.arn
# ✅ KMS encryption of log files even if S3 bucket policy is misconfigured, logs are encrypted
kms_key_id = aws_kms_key.cloudtrail.arn
# ✅ Log S3 data events who accessed which S3 objects (captures exfiltration)
event_selector {
read_write_type = "All"
include_management_events = true
data_resource {
type = "AWS::S3::Object"
values = ["arn:aws:s3:::"] # All S3 objects in all buckets
}
data_resource {
type = "AWS::Lambda::Function"
values = ["arn:aws:lambda"] # All Lambda invocations
}
}
}
# ✅ VPC Flow Logs record all network connections
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL" # ACCEPT, REJECT, and ALL traffic
iam_role_arn = aws_iam_role.flow_logs.arn
log_destination = aws_cloudwatch_log_group.vpc_flow_logs.arn
log_destination_type = "cloud-watch-logs"
# ✅ Extended format includes source/dest port, protocol, packets, bytes, direction
log_format = "$${version} $${account-id} $${interface-id} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${protocol} $${packets} $${bytes} $${windowstart} $${windowend} $${action} $${flowdirection} $${traffic-path}"
}
# ✅ Terraform remote state backend: encrypted, locked, versioned
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true # ✅ State file encrypted at rest
kms_key_id = "arn:aws:kms:us-east-1:ACCT:key/KEY-ID" # ✅ CMK encryption
dynamodb_table = "terraform-state-lock" # ✅ State locking prevents concurrent applies
# ✅ Access logging on this bucket via aws_s3_bucket_logging (separate resource)
}
}
Intégration CI/CD Complète : tfsec, Checkov, Terrascan dans GitHub Actions
# .github/workflows/terraform-security.yml
# Complete IaC security gate for pull requests blocks merge on CRITICAL findings
name: Terraform Security Analysis
on:
pull_request:
branches: [main, staging]
paths:
- '**.tf'
- '**.tfvars'
- '.github/workflows/terraform-security.yml'
permissions:
contents: read
pull-requests: write # Required to post findings as PR comments
security-events: write # Required to upload SARIF to GitHub Security tab
jobs:
secrets-scan:
name: "Secret Detection (gitleaks)"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git log scanning
- name: Run gitleaks on PR commits
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # Required for org repos
tfsec:
name: "tfsec Analysis"
runs-on: ubuntu-latest
needs: secrets-scan
steps:
- uses: actions/checkout@v4
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: .
minimum_severity: HIGH # HIGH and CRITICAL fail the build
format: sarif # Upload findings to GitHub Security tab
github_token: ${{ secrets.GITHUB_TOKEN }}
additional_args: >
--config-file .tfsec/config.toml
--out tfsec-results.sarif
- name: Upload tfsec SARIF
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even if tfsec found issues
with:
sarif_file: tfsec-results.sarif
checkov:
name: "Checkov Analysis"
runs-on: ubuntu-latest
needs: secrets-scan
steps:
- uses: actions/checkout@v4
- name: Run Checkov
id: checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
output_format: sarif
output_file_path: checkov-results.sarif
# Only hard-fail on CRITICAL findings HIGH creates annotation but doesn't block
soft_fail_on: HIGH
# External checks directory for custom checks (like the ones in this post)
external_checks_dir: ./checkov/custom_checks
# Suppress known accepted risks with documented justification
skip_check: "" # Add CKV IDs here only with ticket reference
- name: Upload Checkov SARIF
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov-results.sarif
terrascan:
name: "Terrascan OPA Policy Evaluation"
runs-on: ubuntu-latest
needs: [tfsec, checkov]
steps:
- uses: actions/checkout@v4
- name: Run Terrascan
id: terrascan
uses: tenable/terrascan-action@main
with:
iac_type: terraform
iac_version: v15
policy_type: aws
only_warn: false # Fail on policy violations
sarif_upload: true
# Custom OPA policies from your policies directory
policy_path: ./terrascan/policies/
- name: Upload Terrascan SARIF
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: terrascan.sarif
conftest-plan:
name: "OPA Conftest on Terraform Plan"
runs-on: ubuntu-latest
needs: [tfsec, checkov]
env:
AWS_ROLE_ARN: ${{ secrets.TF_PLAN_ROLE_ARN }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (read-only plan role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.0
- name: Terraform Init
run: terraform init -backend-config="key=pr-${{ github.event.pull_request.number }}/terraform.tfstate"
- name: Terraform Plan (JSON output for OPA)
run: |
terraform plan -out=tfplan.binary
# Convert binary plan to JSON for OPA evaluation
terraform show -json tfplan.binary > tfplan.json
- name: Install conftest
run: |
curl -Lo conftest.tar.gz https://github.com/open-policy-agent/conftest/releases/download/v0.53.0/conftest_0.53.0_Linux_x86_64.tar.gz
tar xzf conftest.tar.gz
chmod +x conftest
- name: Evaluate OPA policies against plan
run: |
# conftest evaluates the terraform plan JSON against OPA Rego policies
# Fails the pipeline if any policy returns a deny
./conftest test tfplan.json \
--policy ./opa/policies/ \
--namespace terraform \
--output github
# opa/policies/terraform_security.rego
# OPA Rego policies evaluated against terraform plan JSON
# These catch issues that static analysis misses like resource relationships
package terraform
import future.keywords.in
import future.keywords.if
# ---- Policy 1: No security group allows 0.0.0.0/0 ingress on sensitive ports ----
SENSITIVE_PORTS := {22, 3389, 1433, 3306, 5432, 6379, 27017}
deny[msg] if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_security_group"
ingress := resource.values.ingress[_]
cidr := ingress.cidr_blocks[_]
cidr in {"0.0.0.0/0", "::/0"}
port in SENSITIVE_PORTS
ingress.from_port <= port
ingress.to_port >= port
msg := sprintf("CRITICAL: Security group '%s' allows internet access (0.0.0.0/0) on sensitive port %d",
[resource.address, port])
}
# ---- Policy 2: RDS must not be publicly accessible ----
deny[msg] if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_db_instance"
resource.values.publicly_accessible == true
msg := sprintf("CRITICAL: RDS instance '%s' is publicly accessible must be false",
[resource.address])
}
# ---- Policy 3: All S3 buckets must have all four public access block flags true ----
deny[msg] if {
bucket := input.planned_values.root_module.resources[_]
bucket.type == "aws_s3_bucket"
bucket_name := bucket.values.bucket
# Check if a corresponding public_access_block resource exists
pab := input.planned_values.root_module.resources[_]
pab.type == "aws_s3_bucket_public_access_block"
pab.values.bucket == bucket_name
# Any flag set to false is a violation
flag_value in [
pab.values.block_public_acls,
pab.values.block_public_policy,
pab.values.ignore_public_acls,
pab.values.restrict_public_buckets
]
flag_value == false
msg := sprintf("CRITICAL: S3 bucket '%s' has incomplete public access block configuration",
[bucket_name])
}
# ---- Policy 4: RDS storage must be encrypted with a customer-managed KMS key ----
deny[msg] if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_db_instance"
# Either not encrypted, or using default AWS key (no kms_key_id)
not resource.values.storage_encrypted == true
msg := sprintf("CRITICAL: RDS instance '%s' does not have storage encryption enabled",
[resource.address])
}
# ---- Policy 5: CloudTrail must be multi-region and include global service events ----
deny[msg] if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudtrail"
not resource.values.is_multi_region_trail == true
msg := sprintf("HIGH: CloudTrail '%s' is not multi-region events in other regions will not be captured",
[resource.address])
}
deny[msg] if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudtrail"
not resource.values.include_global_service_events == true
msg := sprintf("HIGH: CloudTrail '%s' does not include global service events IAM/STS events will be missed",
[resource.address])
}
# .pre-commit-config.yaml local hooks that run before any git commit
# Install: pip install pre-commit && pre-commit install
# After install: runs automatically on git commit
repos:
# Secret detection catches hardcoded secrets before they reach git
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
name: Detect secrets in staged files
# Fails the commit if any secret pattern is found
# tfsec fast local security check
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.90.0
hooks:
- id: terraform_tfsec
args:
- --args=--minimum-severity=HIGH
- --args=--config-file=.tfsec/config.toml
# Blocks commit if tfsec finds HIGH or CRITICAL issues
- id: terraform_checkov
args:
- --args=--quiet
- --args=--compact
- --args=--framework=terraform
# Only CRITICAL findings block local commits
- --args=--soft-fail-on=HIGH,MEDIUM,LOW
# Also run: terraform fmt and terraform validate
- id: terraform_fmt
name: Terraform format check
- id: terraform_validate
name: Terraform validation
Comparaison des Outils de Sécurité IaC
| Outil | Types de contrôle | Évaluation du plan | Politiques personnalisées | Intégration CI | Taux de faux positifs | Idéal pour |
|---|---|---|---|---|---|---|
| tfsec | Analyse statique .tf, 150+ contrôles intégrés | Non (source uniquement) | Règles YAML/JSON | GitHub Actions, GitLab, Jenkins | Faible | Contrôles réseau + chiffrement rapides en PR |
| Checkov (Bridgecrew) | Statique .tf + plan JSON, 1000+ contrôles | Oui (via terraform show -json) | Classes Python | Toutes les grandes plateformes CI | Moyen | Couverture de politique complète ; s'intègre à Prisma |
| Terrascan | Statique .tf + politiques OPA Rego | Oui | OPA Rego | Toutes les grandes plateformes CI | Faible-Moyen | Politiques OPA personnalisées ; Kubernetes aussi |
| conftest + OPA | Plan JSON uniquement | Oui (cas d'usage principal) | OPA Rego (Turing-complet) | Tout (binaire) | Contrôlable | Politiques inter-ressources complexes ; relation bucket + public_access_block |
| Semgrep (règles IaC) | Correspondance de motifs .tf | Non | Règles YAML | GitHub Actions natif | Faible | Contrôles simples par motif ; rapide |
| AWS Config + Conformance Packs | Exécution (post-apply) | N/A continu | CDK/CloudFormation | CloudWatch Events | Faible | Détection de dérive après apply ; complète les contrôles pré-apply |
Action du RSSI Le Modèle de Barrière qui Arrête Ces Anti-Patterns Avant qu'Ils n'Atteignent un Compte Cloud
Les dix anti-patterns de cet article ont une chose en commun : ils sont tous plus rapides à créer qu'à corriger en production. Coder un secret en dur prend une ligne ; le supprimer de l'historique Git nécessite git filter-repo sur toutes les branches et l'invalidation de tous les clones locaux des membres de l'équipe. Ouvrir 0.0.0.0/0 sur le port 22 prend trente secondes ; auditer quels systèmes ont été accédés via cette exposition prend un engagement forensique. Désactiver le chiffrement rétroactivement nécessite des cycles snapshot-et-restore avec interruption. L'investissement dans les hooks pre-commit, les barrières CI et les politiques OPA se rembourse dès la première fois qu'il bloque une conclusion CRITICAL ce qui, d'après la fréquence observée de ces anti-patterns dans le code de production, surviendra dans la première semaine de déploiement.
Architecture de Sécurité du Pipeline DevSecOps
Tableau de Contrôles Priorisés
| Contrôle | Impact | Effort | Pointeur d'implémentation |
|---|---|---|---|
| Hooks pre-commit : tfsec + gitleaks | Critique | Faible (1 jour) | pip install pre-commit && pre-commit install avec le .pre-commit-config.yaml de la Section 5. Détecte les secrets et mauvaises configs réseau avant que le code ne quitte la machine du développeur. Coût CI nul s'exécute localement à chaque commit. |
| Barrière CI : les conclusions CRITICAL de Checkov bloquent la fusion | Critique | Faible-Moyen (2-3 jours) | Ajoutez bridgecrewio/checkov-action@v12 à chaque workflow de PR Terraform. Réglez soft_fail_on: HIGH seules les conclusions CRITICAL bloquent. Téléversez le SARIF vers l'onglet Security de GitHub. Ajoutez external_checks_dir pour les contrôles personnalisés des Sections 1 et 2. |
Évaluation OPA conftest contre le JSON terraform plan | Critique | Moyen (1 semaine) | Les cinq politiques OPA Rego de la Section 5 couvrent les relations inter-ressources les plus critiques que l'analyse statique manque. terraform show -json tfplan.binary > tfplan.json && conftest test tfplan.json --policy ./opa/policies/. |
| Backend d'état Terraform chiffré (S3 + DynamoDB + KMS) | Critique | Faible (heures) | Ajoutez un bloc backend "s3" à terraform {} avec encrypt = true, kms_key_id et dynamodb_table pour le verrouillage d'état. Utilisez une CMK, pas la clé AWS par défaut l'usage de CMK est journalisé dans CloudTrail à chaque déchiffrement. |
| CloudTrail multi-région avec événements globaux + validation des journaux | Élevé | Faible (heures) | Réglez is_multi_region_trail = true, include_global_service_events = true, enable_log_file_validation = true et kms_key_id dans aws_cloudtrail. Intégrez avec CloudWatch Logs pour l'alerte en temps réel. Un seul trail couvre toutes les régions. |
| Blocage d'accès public S3 les quatre drapeaux true au niveau du compte | Élevé | Faible (heures) | aws_s3_account_public_access_block avec les quatre drapeaux true définit le défaut au niveau du compte qui bloque tout accès public quelle que soit la config individuelle des buckets. Ressources aws_s3_bucket_public_access_block par bucket pour la défense en profondeur. |
| Règles AWS Config pour la détection de dérive à l'exécution | Élevé | Moyen (1 semaine) | Activez le Conformance Pack AWS Config Operational-Best-Practices-for-CIS-AWS-v1.4-Level2. Il évalue en continu toutes les ressources contre 100+ règles de sécurité et alimente Security Hub. Détecte la dérive après terraform apply et les changements manuels en console. |
| Chiffrement CMK KMS pour RDS, EBS, SQS avec rotation des clés | Élevé | Moyen (1 semaine) | Créez des ressources aws_kms_key avec enable_key_rotation = true et deletion_window_in_days = 30. Référencez dans l'attribut de chiffrement de chaque ressource. La CMK signifie que chaque déchiffrement est journalisé dans CloudTrail fournit une piste d'accès forensique que les clés gérées par AWS n'offrent pas. |
| VPC Flow Logs avec format étendu vers CloudWatch | Élevé | Faible (heures) | Ajoutez une ressource aws_flow_log à chaque VPC avec traffic_type = "ALL" et le format de journal étendu de la Section 5. Envoyez vers CloudWatch Logs pour les requêtes KQL/Sentinel. Sans Flow Logs, le mouvement latéral et l'exfiltration réseau ne laissent aucun artefact. |
| IAM Access Analyzer analyse continue des politiques IAM | Moyen-Élevé | Faible (heures) | Ressource aws_accessanalyzer_analyzer avec type = "ORGANIZATION". Signale automatiquement les rôles et politiques IAM accordant un accès externe, y compris les anti-patterns de politique de confiance de la Section 4. Les résultats apparaissent dans Security Hub. |
Les dix anti-patterns de cet article ne sont pas des cas limites trouvés dans des environnements mal gérés. Ils apparaissent dans du code écrit par des ingénieurs expérimentés sous pression de temps, dans des dépôts qui passent la revue de code, et dans une infrastructure qui fonctionne proprement pendant des années jusqu'à ce qu'elle fasse partie d'une enquête sur une brèche. Les outils d'analyse statique et les politiques OPA documentés ici prennent un sprint à déployer et ils détecteront chaque anti-pattern de cet article avant qu'il n'atteigne un compte cloud. Chaque conclusion que ces outils font remonter ne coûte rien à corriger au moment de la PR et potentiellement des millions à corriger après un incident.
Tags : terraform, infrastructure-as-code, cloud-security, devSecOps, checkov, tfsec, opa, aws-security, iam, s3, detection-engineering
Public : Ingénieurs en sécurité cloud · Ingénieurs DevSecOps · Ingénieurs de plateforme · RSSI
Contrôles mappés : CIS AWS Foundations Benchmark v2.0, AWS Well-Architected Security Pillar, NIST SP 800-190 (Container Security), SOC 2 CC6.1/CC6.6/CC7.1