Run workloads under platform identities (IAM roles, managed identities, attached service accounts), federate CI, and block new long-lived keys by policy. Require IMDSv2 in the account default and every launch template. Block public storage at account or organization level. Export audit logs: CloudTrail organization trail, Azure Activity Log diagnostic setting, Google aggregated sink, plus data-access logs for sensitive stores. Close inbound SSH and RDP. Keep secrets in Secrets Manager, Key Vault or Secret Manager.
- Give each workload its own platform identity and federate CI, then block new keys with an SCP, an Entra app management policy or
iam.managed.disableServiceAccountKeyCreation. - Launch settings beat the AWS account default, so require IMDSv2 in every launch template too and keep the hop limit at 1 unless a container needs IMDS.
- Azure and Google metadata endpoints rely on a fixed request header, which stops only SSRF bugs that control just the URL; fix the SSRF and keep the identity's permissions small.
- Block public storage at the highest level: S3 Block Public Access for the account or organization,
AllowBlobPublicAccessand Shared Key off,storage.publicAccessPreventionenforced. - Export audit logs before you need them: CloudTrail event history and the Azure Activity Log keep 90 days, and data-plane logs are off by default on all three clouds.
- Tools can disagree with the platform: azurerm before 5.0 defaulted
allow_nested_items_to_be_publictotrue, and Checkov 3.3.26 ignores azurerm's newerpublic_network_accessargument.
| Setting | Value | Note | Source |
|---|---|---|---|
| AWS IMDS (account default and every launch template) | HttpTokens required; hop limit 1 | Hop limit 2 only where containers must reach IMDS. Launch settings override the account default. Turn on HttpTokensEnforced once MetadataNoToken stays at zero. | https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html |
| AWS EBS encryption by default | Enabled in every Region you use | Per-Region account setting, off until you turn it on; existing volumes are not changed. | https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html |
| S3 Block Public Access (account or organization) | All four settings true | On for new buckets, with ACLs disabled, since April 2023. S3 applies the most restrictive of the organization, account and bucket settings. | https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html |
| AWS KMS customer managed key | Rotation on, every 365 days; deletion window 30 days | Rotation is off until enabled; the period can be 90 to 2,560 days. The deletion wait can be 7 to 30 days. | https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html |
| AWS CloudTrail | Organization trail, multi-Region, log file validation, KMS encryption | Event history alone keeps 90 days of management events. Data events are off by default and cost extra. | https://docs.aws.amazon.com/awscloudtrail/latest/userguide/best-practices-security.html |
| Azure Storage account | AllowBlobPublicAccess false; AllowSharedKeyAccess false; public network access Disabled | A null value on older accounts is permissive. Turn off Shared Key only after clients authenticate with Microsoft Entra ID. | https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent |
| Azure Key Vault | RBAC; purge protection on; soft-delete retention 90 days | Soft delete can't be turned off and its 7 to 90 day retention is fixed at creation. RBAC is the default for new vaults from API version 2026-02-01. | https://learn.microsoft.com/en-us/azure/key-vault/general/soft-delete-overview |
| Azure Activity Log | Subscription diagnostic setting to Log Analytics or storage | Azure keeps it 90 days. Export Entra sign-in and audit logs too: 7 days on Free, 30 days on P1 or P2. | https://learn.microsoft.com/en-us/azure/azure-monitor/fundamentals/activity-log#retention-period |
| Google Cloud organization policies | iam.managed.disableServiceAccountKeyCreation, storage.publicAccessPrevention and compute.skipDefaultNetworkCreation enforced | Organizations created on or after 3 May 2024 enforce key creation and uniform bucket-level access constraints by default, not public access prevention. | https://docs.cloud.google.com/resource-manager/docs/manage-baseline-constraints |
| Google Cloud Data Access audit logs | On for services that hold sensitive data | Off by default except BigQuery. Admin Activity logs are always on and stay 400 days in _Required; _Default keeps 30 days. | https://docs.cloud.google.com/logging/docs/audit#data-access |
| Google Compute Engine VM | Dedicated service account, cloud-platform scope, no external IP, OS Login | The Compute Engine default service account can hold Editor on the project. | https://docs.cloud.google.com/compute/docs/access/service-accounts |
- Run the read-only checks below with a read-only role in every account, subscription and project, and in every AWS Region you use; schedule them and alert on changes.
- On a test EC2 instance, call IMDS without a token and expect 401, then watch the
MetadataNoTokenRejectedmetric after requiring IMDSv2. - In a sandbox, try to create a service account key, an S3 bucket policy that grants
Principal "*", and an anonymous blob container; each must be refused. - Make a harmless IAM change and find it in the CloudTrail archive, the Log Analytics workspace and the Google aggregated sink, and run
aws cloudtrail validate-logson a recent range. - Run
terraform testwith mock providers and an IaC scanner on every change, and check each scanner rule against the current provider documentation before acting on it.
- Applies to
- Terraform 1.7 or later for mock-provider tests (tested 1.16.5)
- hashicorp/aws provider 6.0 or later for the per-resource region argument (tested 6.68.0)
- hashicorp/azurerm provider 5.5 or later for public_network_access (tested 5.9.0)
- hashicorp/google provider Tested 8.6.0
- Checkov 3.3.26
Workloads and accounts on AWS, Azure and Google Cloud. Provider defaults change over time, so check each control against the current account and policy state.
- Part of
- Workflows: Fix a finding, Review a design
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Choose the identity, metadata, storage, encryption, logging, network and secrets settings for each provider.
- Enforce them at organization, account and resource level with Terraform.
- Test the Terraform offline and check live accounts with read-only commands.
- Recognize provider, tool and scanner defaults that differ from the platform's.
Before you start
Developers, platform engineers and AppSec reviewers who create or review AWS accounts, Azure subscriptions and Google Cloud projects
- Read Terraform and basic IAM policies.
- Know how your workloads authenticate to their cloud provider.
- For the live checks, a read-only role in a sandbox account, subscription or project.
A cloud baseline is the short list of settings every account, subscription and project needs before an application lands in it. The failures it prevents need no clever exploit: a leaked long-lived key, a server-side request forgery (SSRF) bug that reads role credentials from the metadata endpoint, a bucket policy that grants access to everyone, or logs that were never exported.
Set each control at the highest level the provider offers, because resource settings drift and new accounts start empty: organization guardrails first (AWS service control and declarative policies, Azure Policy on management groups, Google Cloud organization policies), then account defaults, then resources in Terraform. For auditors, the CIS Foundations Benchmarks cover the same ground: AWS v7.1.0, Azure v6.0.0 and Google Cloud v5.0.0. The VM, node and container images you run in those accounts need their own benchmark: see /domains/runtime/containers/image-hardening-benchmarks/.
The names differ but the controls line up. Defaults are as of October 2026.
| Control | AWS | Azure | Google Cloud |
|---|---|---|---|
| Workload identity | IAM role: instance profile, task or execution role, EKS Pod Identity | Managed identity, user-assigned where lifecycles differ | Attached service account; Workload Identity Federation for GKE |
| CI and other clouds | OIDC provider and AssumeRoleWithWebIdentity | Federated identity credential | Workload Identity Federation |
| Stop new long-lived keys | SCP that denies iam:CreateUser and iam:CreateAccessKey | App management policy that blocks password addition | iam.managed.disableServiceAccountKeyCreation |
| Human access | IAM Identity Center; MFA required on every root user | MFA enforced for portal, CLI, PowerShell and IaC tools | Console 2-step verification being phased in; impersonate service accounts, no key files |
| Metadata endpoint | IMDSv2: PUT for a session token, IP TTL 1 | Metadata: true header; MSP signing is opt-in | Metadata-Flavor: Google header |
| Public storage | S3 Block Public Access for the account or organization | AllowBlobPublicAccess and AllowSharedKeyAccess false | storage.publicAccessPrevention; uniform bucket-level access |
| Encryption at rest | SSE-S3 on all new objects; EBS default is per-Region opt-in | Always on; temp disks need encryption at host | Always on, AES-256 |
| Control-plane audit log | Event history 90 days; add an organization trail | Activity Log 90 days; add a diagnostic setting | Admin Activity always on, 400 days |
| Data-plane audit log | CloudTrail data events, off by default | Resource logs, off until a diagnostic setting exists | Data Access logs, off except BigQuery |
| Inbound admin access | Session Manager, no open port; VPC Block Public Access | Azure Bastion or private access; no public IPs on VMs | IAP TCP forwarding; skip the default network |
| Secrets | Secrets Manager; RDS-managed master passwords | Key Vault with RBAC | Secret Manager |
Every compute service on the three clouds can run code under an identity whose credentials the platform issues and rotates: an IAM role, a managed identity or an attached service account. The SDKs' default credential chains find them, so the code holds no secret. Give each workload its own identity with only the actions and resources it uses; the cloud IAM guide at /domains/runtime/cloud-posture/cloud-iam-trust/ covers reviewing those grants.
CI systems and workloads elsewhere federate instead: the job exchanges an OIDC token for short-lived credentials, as the GitHub Actions guide at /domains/supply-chain/ci-cd/github-actions-hardening/ shows. Then close the old path. Google's iam.managed.disableServiceAccountKeyCreation constraint blocks new service account keys and Cloud Storage HMAC keys; an SCP that denies iam:CreateAccessKey does the same for AWS IAM users; a Microsoft Entra app management policy with the passwordAddition restriction blocks new client secrets.
Humans sign in through federation with MFA. AWS requires MFA on every root user, and centralized root access lets you delete root credentials in member accounts. On 1 October 2025 Azure began enforcing MFA for create, update and delete operations from the CLI, PowerShell, REST API and IaC tools. Workload identities are exempt, one more reason to move automation off user accounts. Google Cloud is phasing in 2-step verification for console sign-in.
A VM fetches its identity's tokens from a link-local HTTP endpoint at 169.254.169.254, so anything that can make it send a request, including an SSRF bug, can try to read them. AWS IMDSv2 opens a session with a PUT that returns a token, valid for up to six hours, which every read must carry. It refuses token requests that include X-Forwarded-For, and the token response has an IP TTL of 1, so it is dropped before it can leave an instance misconfigured as a router, NAT device or VPN.
Azure and Google require a fixed header (Metadata: true, Metadata-Flavor: Google) and reject requests carrying X-Forwarded-For. That stops SSRF bugs that control only the URL. AWS found that many SSRF bugs also let the attacker set headers, and that the PUT plus session token blocks the large majority of them. Azure's Metadata Security Protocol adds request signing by an in-guest agent, but it is off by default. None of this replaces fixing the SSRF, which the SSRF guide at /domains/appsec/secure-features/ssrf-egress-boundaries/ covers.
# AWS, IMDSv2: open a session with PUT, then send the token with every read.
TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 300")
curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# Azure: one static header. Microsoft also says to bypass any proxy.
curl -sS -H "Metadata: true" --noproxy "*" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F"
# Google Cloud: one static header.
curl -sS -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"Account IMDS defaults apply only to new instances, and anything set at launch overrides them: a launch template that says HttpTokens optional still produces IMDSv1 instances. Set both. Find stragglers with the MetadataNoToken metric, which counts IMDSv1 calls, and require tokens on existing instances once it stays at zero. The file also turns on EBS default encryption in each Region, through the per-resource region argument added in AWS provider 6.0, and S3 Block Public Access for the account.
Keep the hop limit at 1 unless a container must reach IMDS, since a container network adds a hop. On EKS nodes whose pods use EKS Pod Identity or IRSA, a limit of 1 also stops pods from borrowing the node role. Finally, enforce IMDSv2 for the account (HttpTokensEnforced) so IMDSv1 launches fail. Provider 6.68 cannot set it; use aws ec2 modify-instance-metadata-defaults --http-tokens-enforced enabled or an EC2 declarative policy.
# Account guardrails. IMDS and EBS defaults are per Region; S3 BPA is account-wide.
locals {
regions = toset(["eu-west-1", "us-east-1"]) # every Region you use
}
resource "aws_ec2_instance_metadata_defaults" "imds" {
for_each = local.regions
region = each.key
http_tokens = "required" # new instances launch IMDSv2-only
http_put_response_hop_limit = 1 # 2 only where containers must reach IMDS
}
resource "aws_ebs_encryption_by_default" "on" {
for_each = local.regions
region = each.key
enabled = true
}
resource "aws_s3_account_public_access_block" "all" {
block_public_acls = true
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
# Launch settings override account defaults, so require IMDSv2 here too.
resource "aws_launch_template" "app" {
name_prefix = "app-"
image_id = var.ami_id
instance_type = "m7g.large"
iam_instance_profile {
name = aws_iam_instance_profile.app.name # role scoped to this app only
}
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
}Turn on all four S3 Block Public Access settings for the account, or for the organization through an Organizations policy, which applies them as a set to member accounts. New buckets have had them on, with ACLs disabled, since April 2023, but a bucket owner can switch them off; an account-level setting wins because S3 applies the most restrictive combination. Since April 2026 new general purpose buckets also block SSE-C uploads.
On Azure, set AllowBlobPublicAccess to false and, once clients authenticate with Microsoft Entra ID, AllowSharedKeyAccess to false; that also stops account and service SAS tokens, leaving user delegation SAS. Older accounts may show null for either property, which Azure treats as permissive. On Google Cloud, enforce storage.publicAccessPrevention on the organization: unlike uniform bucket-level access, it is not in the set new organizations get by default.
None of these blocks stop presigned URLs, SAS tokens or signed URLs, which are bearer credentials signed by an identity you control. Issue them per object with a short expiry.
The 2023 Microsoft SAS token exposure shows what happens when one of these bearer URLs is too broad and too long-lived, and why Microsoft's own scanner finding it was not enough: /domains/runtime/cloud-posture/case-study-microsoft-sas-token/.
All three encrypt stored data by default: S3 has applied SSE-S3 to every new object since 5 January 2023, Azure Storage encryption cannot be disabled, and Google encrypts customer content at rest with AES-256. The gaps are narrow but real: EBS encryption by default is a per-Region setting you must turn on, and Azure VM temporary disks are not encrypted unless you enable encryption at host.
Customer-managed keys in AWS KMS, Key Vault or Cloud KMS add control rather than secrecy: a separate key policy, logs of key use and the option to make data unreadable by disabling the key. Use them for regulated or multi-tenant data, turn on rotation (off by default in KMS, then every 365 days unless you choose 90 to 2,560), and guard against deletion: KMS waits 7 to 30 days, Cloud KMS holds a version scheduled for destruction for 30 days by default, and Key Vault needs purge protection, which is off by default.
CloudTrail event history keeps 90 days of management events per account and Region. A multi-Region organization trail sends every account's events to one bucket, member accounts cannot change or stop it, and log file validation adds signed SHA-256 digests that expose edited or deleted files. The KMS key must be in the bucket's Region. The Azure Activity Log also keeps 90 days: export it with a subscription diagnostic setting, with Entra sign-in and audit logs (7 days on Free, 30 on P1 or P2). Google keeps Admin Activity logs 400 days in _Required; an aggregated sink copies them to a project you control.
Data-plane logs are off by default: CloudTrail data events, Azure resource logs and Google Data Access logs (except BigQuery). Turn them on for stores that hold sensitive data, because an investigation needs to know who read which object. Advanced event selectors replace basic ones, so the trail below keeps a selector for management events.
# Organization trail, created in the management account (or the CloudTrail delegated administrator).
# The key policy and bucket policy must allow cloudtrail.amazonaws.com; see the CloudTrail guide.
resource "aws_kms_key" "trail" {
description = "CloudTrail log files"
enable_key_rotation = true
rotation_period_in_days = 365
deletion_window_in_days = 30
policy = data.aws_iam_policy_document.trail_key.json
}
resource "aws_cloudtrail" "org" {
name = "org-trail"
s3_bucket_name = var.log_archive_bucket # bucket in the log-archive account
kms_key_id = aws_kms_key.trail.arn
is_organization_trail = true
is_multi_region_trail = true
enable_log_file_validation = true
# Advanced selectors replace basic ones, so management events need their own selector.
advanced_event_selector {
name = "All management events"
field_selector {
field = "eventCategory"
equals = ["Management"]
}
}
advanced_event_selector {
name = "Object reads and writes in the customer-data bucket"
field_selector {
field = "eventCategory"
equals = ["Data"]
}
field_selector {
field = "resources.type"
equals = ["AWS::S3::Object"]
}
field_selector {
field = "resources.ARN"
starts_with = ["arn:aws:s3:::acme-customer-data/"]
}
}
}The storage account accepts only Microsoft Entra ID requests and no public network traffic, and the app reaches it through a user-assigned managed identity with one data-plane role. Set storage_use_azuread in the provider, or Terraform's own data-plane calls fail once Shared Key is off. Write allow_nested_items_to_be_public = false even though azurerm 5.0 made it the default, because 4.x defaulted to true. Provider 5.5 added public_network_access. Private endpoints and DNS zones are omitted.
provider "azurerm" {
features {}
storage_use_azuread = true # Terraform itself must not need account keys
}
data "azurerm_client_config" "current" {}
resource "azurerm_user_assigned_identity" "app" {
name = "id-app-prod"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
}
resource "azurerm_storage_account" "data" {
name = "stappdataprod001"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
account_tier = "Standard"
account_replication_type = "ZRS"
allow_nested_items_to_be_public = false # the azurerm 4.x default was true
shared_access_key_enabled = false # Entra ID only: no account keys, no key-signed SAS
public_network_access = "Disabled" # reach it through a private endpoint
min_tls_version = "TLS1_2"
infrastructure_encryption_enabled = true
default_to_oauth_authentication = true
}
resource "azurerm_role_assignment" "app_blob" {
scope = azurerm_storage_account.data.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
resource "azurerm_key_vault" "app" {
name = "kv-app-prod-001"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
rbac_authorization_enabled = true
purge_protection_enabled = true
soft_delete_retention_days = 90
public_network_access_enabled = false
network_acls {
bypass = "None"
default_action = "Deny"
}
}
resource "azurerm_role_assignment" "app_secrets" {
scope = azurerm_key_vault.app.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
# Keep the subscription Activity Log beyond its 90 days.
resource "azurerm_monitor_diagnostic_setting" "activity_log" {
name = "activity-to-security-workspace"
target_resource_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}"
log_analytics_workspace_id = var.security_workspace_id
dynamic "enabled_log" {
for_each = ["Administrative", "Security", "Policy", "Alert"]
content {
category = enabled_log.value
}
}
}Remove SSH and RDP from the internet everywhere. Google's default network ships with rules that allow TCP 22, TCP 3389 and ICMP from 0.0.0.0/0; enforce compute.skipDefaultNetworkCreation, delete the network from existing projects, and use IAP TCP forwarding, which needs ingress only from 35.235.240.0/20. On AWS, Session Manager gives a shell with no inbound port, and VPC Block Public Access can block internet gateway traffic for a Region, with exclusions. On Azure, use Azure Bastion or a private path instead of public IPs on VMs.
Reach data services privately: private endpoints on Azure, VPC endpoints with endpoint policies on AWS, Private Service Connect on Google Cloud. With API versions released after 31 March 2026, subnets in new Azure virtual networks are private by default and need an explicit outbound method such as a NAT gateway.
Organizations created on or after 3 May 2024 already enforce the key creation, key upload, default service account and uniform bucket-level access constraints; older ones have none of them unless someone set them. Declare the policies anyway so the baseline is explicit, try them with a dry_run_spec first, and grant exceptions with tag-based conditions rather than lifting a constraint. The VM runs as its own service account with the cloud-platform scope, so IAM roles on that account set its permissions, and it has no external IP.
# Organization guardrails. Orgs created since 3 May 2024 already enforce some of these.
resource "google_org_policy_policy" "enforce" {
for_each = toset([
"iam.managed.disableServiceAccountKeyCreation",
"iam.managed.disableServiceAccountKeyUpload",
"iam.automaticIamGrantsForDefaultServiceAccounts",
"storage.publicAccessPrevention",
"storage.uniformBucketLevelAccess",
"compute.skipDefaultNetworkCreation",
"compute.managed.vmExternalIpAccess",
])
name = "organizations/${var.org_id}/policies/${each.key}"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
resource "google_storage_bucket" "data" {
project = var.project_id
name = "acme-prod-customer-data"
location = "EU"
uniform_bucket_level_access = true
public_access_prevention = "enforced"
}
# Data Access audit logs are off by default for everything except BigQuery.
resource "google_project_iam_audit_config" "storage" {
project = var.project_id
service = "storage.googleapis.com"
audit_log_config { log_type = "DATA_READ" }
audit_log_config { log_type = "DATA_WRITE" }
}
resource "google_service_account" "app" {
project = var.project_id
account_id = "vm-app-prod"
}
resource "google_compute_instance" "app" {
project = var.project_id
name = "app-prod-1"
zone = "europe-west1-b"
machine_type = "e2-standard-2"
boot_disk {
initialize_params { image = "debian-cloud/debian-12" }
}
network_interface {
subnetwork = var.subnet_id # no access_config block, so no external IP
}
service_account {
email = google_service_account.app.email # never the default compute SA
scopes = ["cloud-platform"] # IAM roles on the SA do the limiting
}
shielded_instance_config {
enable_secure_boot = true
}
metadata = {
enable-oslogin = "TRUE"
block-project-ssh-keys = "true"
}
}Some secrets survive all of this: third-party API keys, database passwords, signing keys. Keep them in AWS Secrets Manager, Azure Key Vault or Google Secret Manager, and let each workload's identity read only its own (Key Vault Secrets User on one vault, Secret Manager Secret Accessor on one secret). Prefer platform-managed credentials: with manage_master_user_password, RDS creates the master password in Secrets Manager and rotates it every seven days by default, so it never enters Terraform state.
Rotation differs. Secrets Manager rotates through managed rotation or a Lambda function, while Google Secret Manager only publishes a SECRET_ROTATE message to Pub/Sub on schedule and your code must add the new version. Google also advises reading secrets through the API rather than environment variables, and pinning version numbers instead of latest.
This configuration passes terraform validate and works on the first deploy. Every resource breaks the baseline: a permanent access key for CI, SSH open to the internet, an instance with a public IP and IMDSv1, and a bucket public access block that names two settings but turns off all four, because the omitted ones default to false. Checkov 3.3.26 flags each of them (CKV_AWS_273, CKV_AWS_24, CKV_AWS_88, CKV_AWS_79 and CKV_AWS_53 to CKV_AWS_56) among 20 findings.
# Passes terraform validate and works on day one. Every resource breaks the baseline.
resource "aws_iam_user" "ci" {
name = "ci-deployer"
}
resource "aws_iam_access_key" "ci" { # never expires; ends up in a CI secret store
user = aws_iam_user.ci.name
}
resource "aws_security_group" "app" {
name = "app"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # SSH from the whole internet
}
}
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "m7g.large"
associate_public_ip_address = true
vpc_security_group_ids = [aws_security_group.app.id]
# No metadata_options: IMDSv1 stays on unless an account default says otherwise
}
resource "aws_s3_bucket" "exports" {
bucket = "acme-exports"
}
# Omitted arguments default to false, so this turns off all four settings.
resource "aws_s3_bucket_public_access_block" "exports" {
bucket = aws_s3_bucket.exports.id
block_public_policy = false # turned off "temporarily" for a partner
restrict_public_buckets = false
}terraform test with mock providers (Terraform 1.7 and later) plans and applies a configuration with no credentials, so baseline assertions can run on every pull request. This test passes against the AWS files above and fails when a launch template sets HttpTokens to optional or the trail loses log file validation.
Run a scanner too, but read its rules. Checkov 3.3.26 flags the Azure account above as publicly reachable (CKV_AZURE_59) because the rule reads only the deprecated public_network_access_enabled argument.
# tests/baseline.tftest.hcl: `terraform test` runs it with no AWS credentials.
mock_provider "aws" {
# Mocked values must still pass provider validation, so give them real shapes.
mock_resource "aws_kms_key" {
defaults = { arn = "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" }
}
mock_data "aws_iam_policy_document" {
defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" }
}
}
run "baseline" {
command = apply
assert {
condition = alltrue([for r in aws_ec2_instance_metadata_defaults.imds : r.http_tokens == "required"])
error_message = "Every Region must default new instances to IMDSv2"
}
assert {
condition = aws_launch_template.app.metadata_options[0].http_tokens == "required"
error_message = "The launch template must not re-enable IMDSv1"
}
assert {
condition = aws_s3_account_public_access_block.all.restrict_public_buckets
error_message = "S3 Block Public Access must cover public bucket policies"
}
assert {
condition = aws_cloudtrail.org.is_multi_region_trail && aws_cloudtrail.org.enable_log_file_validation
error_message = "The trail must cover every Region and sign its digests"
}
}Most baseline failures are gaps between levels rather than wrong values.
- A launch template, Auto Scaling configuration or Terraform module that sets
HttpTokensoptional, which overrides the account default. - A hop limit of 2 on every host to fix one container, which also hands the node's credentials to every container on it.
- Turning off Shared Key before clients and Terraform use Microsoft Entra ID, which breaks them all at once.
- Trusting the 90-day defaults until an investigation needs day 91.
- VMs still running as the Compute Engine default service account, which may hold Editor.
Configuration in Git is not proof of what runs. Run these read-only checks with a read-only role on a schedule and treat any change as drift to investigate; the drift guide at /domains/runtime/cloud-posture/iac-policy-drift/ covers that workflow.
#!/usr/bin/env bash
# Read-only checks. Run with a read-only role in every account, subscription
# and project; repeat the AWS block for each Region you use.
REGION=eu-west-1 ACCOUNT_ID=111122223333 PROJECT=acme-app-prod
# AWS: HttpTokens "required", no instance IDs listed, true, four true flags
aws ec2 get-instance-metadata-defaults --region "$REGION"
aws ec2 describe-instances --region "$REGION" \
--filters Name=metadata-options.http-tokens,Values=optional \
--query 'Reservations[].Instances[].InstanceId' --output text
aws ec2 get-ebs-encryption-by-default --region "$REGION"
aws s3control get-public-access-block --account-id "$ACCOUNT_ID"
# Azure: False, False, Disabled (an empty value means the old permissive default)
az storage account list -o table \
--query "[].{name:name, anonymous:allowBlobPublicAccess, sharedKey:allowSharedKeyAccess, network:publicNetworkAccess}"
for kv in $(az keyvault list --resource-type vault --query "[].name" -o tsv); do
az keyvault show --name "$kv" -o tsv \
--query "{name:name, rbac:properties.enableRbacAuthorization, purge:properties.enablePurgeProtection}"
done
az monitor diagnostic-settings subscription list -o table
# Google Cloud: True for each policy, enforced/True buckets, no VM names, no rules
for c in iam.managed.disableServiceAccountKeyCreation storage.publicAccessPrevention \
compute.skipDefaultNetworkCreation; do
echo "$c: $(gcloud org-policies describe "$c" --project="$PROJECT" --effective \
--format='value(spec.rules[0].enforce)')"
done
gcloud storage buckets list --project="$PROJECT" \
--format="table(name, public_access_prevention, uniform_bucket_level_access)"
gcloud compute instances list --project="$PROJECT" \
--filter="networkInterfaces[].accessConfigs[].natIP:*" --format="value(name)"
gcloud compute firewall-rules list --project="$PROJECT" \
--filter="direction=INGRESS AND sourceRanges:0.0.0.0/0 AND disabled=false" \
--format="table(name, sourceRanges.list())"Guided practice
A platform team collected the settings below from three new environments with read-only roles on 2026-10-09. Decide what each finding allows an attacker or an incident responder to do, then write the fix and the level it belongs at.
Supplied scenario material
AWS account 111122223333, eu-west-1
A1 get-instance-metadata-defaults: HttpTokens=required, HttpPutResponseHopLimit=2
A2 launch template web-lt (used by the web Auto Scaling group): HttpTokens=optional
A3 EKS node launch template: HttpTokens=required, HttpPutResponseHopLimit=2;
every pod that calls AWS uses EKS Pod Identity
A4 s3control get-public-access-block: BlockPublicAcls=true, IgnorePublicAcls=true,
BlockPublicPolicy=false, RestrictPublicBuckets=false
A5 IAM user gha-deploy: one active access key, created 2024-03-02, stored as a GitHub Actions secret
Azure subscription prod
Z1 storage account stexports: allowBlobPublicAccess=null, allowSharedKeyAccess=null,
publicNetworkAccess=Enabled; managed by Terraform with azurerm 4.81, which does not
set allow_nested_items_to_be_public
Z2 no subscription diagnostic setting; an incident review must find who created a
role assignment 120 days ago
Google Cloud organization created in 2021, project app-prod
G1 default network present, including default-allow-ssh (0.0.0.0/0, tcp:22)
G2 service account app-sa has two user-managed keys; no key creation constraint is set
G3 bucket exports: public_access_prevention=inherited; storage.publicAccessPrevention not set anywhere1. Predict the next web instance
State which IMDS versions an instance launched from web-lt will accept, and fix A1 and A2.
Expected result and reasoning
It accepts IMDSv1, because launch settings override the account default. Set
HttpTokens=requiredand hop limit 1 in web-lt, set the account hop limit to 1 or no preference, wait forMetadataNoTokento reach zero, then turn onHttpTokensEnforcedso no launch can re-enable IMDSv1.2. Close the pod path to the node role
Explain what the hop limit of 2 on the EKS nodes allows, and change it.
Expected result and reasoning
Pods sit one network hop beyond the node, so with a limit of 2 any pod can fetch the node role's credentials. Every AWS-calling pod uses Pod Identity, so set the limit to 1.
3. Read the S3 settings
Decide what A4 permits and write the fix.
Expected result and reasoning
Any bucket whose own settings allow it can be made public with a bucket policy, because the account no longer blocks or restricts public policies. Set all four settings to true for the account or through an organization policy, and share with partners by naming their account in a non-public policy or by short-lived presigned URLs.
4. Retire the CI key
Replace A5 and stop the next one being created.
Expected result and reasoning
Configure GitHub Actions OIDC with a role whose trust policy names the repository and environment, deploy with it, deactivate and then delete the key, and attach an SCP that denies
iam:CreateAccessKeyandiam:CreateUseroutside a break-glass role.5. Fix the Azure gaps
Explain Z1 and Z2 and give the changes.
Expected result and reasoning
A
nullvalue is permissive for both properties. azurerm 4.81 treats the omittedallow_nested_items_to_be_publicastrueand reads thenullastrue, soterraform planshows no change and nothing closes it. Set it tofalse(or move to azurerm 5.x), turn off Shared Key once clients use Entra ID, and disable public network access behind a private endpoint. Z2 cannot be answered from Azure: the Activity Log keeps 90 days. Add the diagnostic setting now and record the gap.6. Bring the old Google organization up to the baseline
Fix G1 to G3.
Expected result and reasoning
A 2021 organization has none of the May 2024 default constraints. Enforce
compute.skipDefaultNetworkCreationand delete the default network or its open rules in app-prod, using IAP forwarding from35.235.240.0/20. Find what uses the keys in Cloud Audit Logs, move app-sa to an attached identity or federation, delete the keys and enforceiam.managed.disableServiceAccountKeyCreation. Set the bucket to enforced and enforcestorage.publicAccessPreventionon the organization.
Paper exercise: the fixture is complete and needs no cloud account. The Terraform and checks in this guide reproduce each fix in a sandbox.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- Amazon EC2 User Guide: configure instance metadata options for new instances (account defaults and IMDSv2 enforcement)
- AWS Security Blog: defense in depth against open firewalls, reverse proxies and SSRF with IMDSv2 (November 2019)
- Amazon S3 User Guide: blocking public access to your Amazon S3 storage
- AWS CloudTrail User Guide: security best practices
- Azure Instance Metadata Service: security and authentication
- Azure Monitor activity log: retention period
- Terraform AzureRM provider 5.0 upgrade guide (azurerm_storage_account and azurerm_key_vault changes)
- Google Cloud: organization policies enforced on new organizations (security baseline constraints)
- Google Cloud Storage: public access prevention and inheritance
- Google Cloud Audit Logs: Data Access audit logs
Reference information
- SDLC phase
- ReleaseMaintenance
- CAPEC attack patterns