Securing Infrastructure as Code: CloudFormation Guardrails and Drift Detection
Infrastructure as Code should be your single source of truth for AWS resource configuration. But IaC only provides security guarantees if the deployment pipeline itself is secure. Unrestricted CloudFormation permissions, missing stack policies, and undetected drift can undermine even the most carefully written templates.
Stack Policies
Stack policies prevent accidental modification or deletion of critical resources during updates. Without a stack policy, any aws cloudformation update-stack call can replace your production database.
Protecting Stateful Resources
{
"Statement": [
{
"Effect": "Deny",
"Action": ["Update:Replace", "Update:Delete"],
"Principal": "*",
"Resource": "*",
"Condition": {
"StringEquals": {
"ResourceType": [
"AWS::RDS::DBInstance",
"AWS::RDS::DBCluster",
"AWS::DynamoDB::Table",
"AWS::S3::Bucket",
"AWS::KMS::Key",
"AWS::Cognito::UserPool"
]
}
}
},
{
"Effect": "Allow",
"Action": "Update:*",
"Principal": "*",
"Resource": "*"
}
]
}
Apply the policy when creating the stack:
aws cloudformation create-stack \
--stack-name production-stack \
--template-body file://template.yaml \
--stack-policy-body file://stack-policy.json \
--role-arn arn:aws:iam::123456789012:role/CloudFormationDeployRole
If you need to update a protected resource, temporarily override the policy for that specific update — the override expires automatically after the update completes.
CloudFormation Service Roles
By default, CloudFormation uses the credentials of the user who runs the deployment. This means developers need permissions to create every resource type in the template. A CloudFormation service role breaks this coupling.
import boto3
import json
def create_cloudformation_deploy_role(stack_prefix, allowed_services):
"""Create a least-privilege CloudFormation service role"""
iam = boto3.client('iam')
account_id = boto3.client('sts').get_caller_identity()['Account']
# Trust policy allowing only CloudFormation to assume this role
trust_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "cloudformation.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": account_id
}
}
}
]
}
role = iam.create_role(
RoleName=f"cfn-deploy-{stack_prefix}",
AssumeRolePolicyDocument=json.dumps(trust_policy),
Description=f"CloudFormation service role for {stack_prefix} stacks"
)
# Build permissions from allowed services
actions = []
for service in allowed_services:
actions.append(f"{service}:*")
# Always need these for CloudFormation operations
actions.extend([
"cloudformation:CreateChangeSet",
"cloudformation:DescribeChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudformation:DescribeStacks",
"cloudformation:DescribeStackEvents"
])
permissions_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": actions,
"Resource": "*",
"Condition": {
"StringLike": {
"aws:ResourceTag/aws:cloudformation:stack-name": f"{stack_prefix}-*"
}
}
}
]
}
iam.put_role_policy(
RoleName=role['Role']['RoleName'],
PolicyName='deploy-permissions',
PolicyDocument=json.dumps(permissions_policy)
)
return role['Role']['Arn']
# Example: role for a serverless API stack
api_role = create_cloudformation_deploy_role('api', [
'lambda', 'apigateway', 'dynamodb', 'sqs', 'logs', 'iam'
])
The developer only needs cloudformation:* and iam:PassRole on the service role — they never need direct permissions to create Lambda functions or DynamoDB tables.
Policy-as-Code with CloudFormation Guard
CloudFormation Guard (cfn-guard) validates templates against rules before deployment. Integrate it into CI/CD to catch security violations early.
Security Rules
# security-rules.guard
# All S3 buckets must have encryption enabled
AWS::S3::Bucket {
Properties.BucketEncryption.ServerSideEncryptionConfiguration[*] {
ServerSideEncryptionByDefault.SSEAlgorithm in ["aws:kms", "AES256"]
}
}
# All S3 buckets must block public access
AWS::S3::Bucket {
Properties.PublicAccessBlockConfiguration exists
Properties.PublicAccessBlockConfiguration {
BlockPublicAcls == true
BlockPublicPolicy == true
IgnorePublicAcls == true
RestrictPublicBuckets == true
}
}
# All RDS instances must have encryption at rest
AWS::RDS::DBInstance {
Properties.StorageEncrypted == true
}
# All RDS instances must not be publicly accessible
AWS::RDS::DBInstance {
Properties.PubliclyAccessible == false
}
# Lambda functions must not use admin policies
AWS::IAM::Role when Properties.AssumeRolePolicyDocument.Statement[*].Principal.Service[*] == "lambda.amazonaws.com" {
Properties.ManagedPolicyArns[*] != "arn:aws:iam::aws:policy/AdministratorAccess"
}
# All DynamoDB tables must have encryption
AWS::DynamoDB::Table {
Properties.SSESpecification exists
Properties.SSESpecification.SSEEnabled == true
}
Run in CI/CD:
# Validate a template against security rules
cfn-guard validate \
--data template.yaml \
--rules security-rules.guard \
--show-summary fail
# Exit code 0 = all rules pass, non-zero = violations found
Drift Detection
Drift occurs when someone modifies a CloudFormation-managed resource directly through the console or CLI. This creates a gap between the template (your intended state) and the actual state.
# Initiate drift detection
DETECTION_ID=$(aws cloudformation detect-stack-drift \
--stack-name production-stack \
--query 'StackDriftDetectionId' \
--output text)
# Wait for detection to complete
aws cloudformation describe-stack-drift-detection-status \
--stack-drift-detection-id $DETECTION_ID
# Get drifted resources
aws cloudformation describe-stack-resource-drifts \
--stack-name production-stack \
--stack-resource-drift-status-filters MODIFIED DELETED \
--query 'StackResourceDrifts[].{Resource: LogicalResourceId, Type: ResourceType, Status: StackResourceDriftStatus}'
Schedule drift detection daily for production stacks. Drift in security groups, IAM roles, or bucket policies is especially dangerous because it may indicate unauthorized access or misconfiguration that your templates did not intend.
Securing StackSets for Multi-Account Deployment
StackSets deploy CloudFormation templates across multiple accounts and regions. Use service-managed permissions with Organizations:
aws cloudformation create-stack-set \
--stack-set-name security-baseline \
--template-body file://security-baseline.yaml \
--permission-model SERVICE_MANAGED \
--auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \
--capabilities CAPABILITY_NAMED_IAM
With SERVICE_MANAGED, StackSets automatically creates execution roles in target accounts. Enable auto-deployment so new accounts added to your Organization automatically receive the security baseline.
Securing IaC Deployments with AccessLens
CloudFormation service roles, StackSet execution roles, and CI/CD pipeline roles all need IAM permissions to create and modify AWS resources. Overpermissive deployment roles are a common escalation path — a compromised CI/CD pipeline with AdministratorAccess can take over the entire account.
AccessLens helps secure your IaC pipeline by providing:
- Deployment role analysis that identifies overpermissive CloudFormation and CI/CD roles
- Cross-account StackSet visibility that shows which accounts and roles have deployment access
- Permission usage tracking that reveals which deployment permissions are actually used vs. granted
- Drift correlation that connects resource drift to IAM principals who made out-of-band changes
Secure infrastructure starts with secure deployment. AccessLens ensures your IaC pipeline roles follow least privilege.
Secure your IaC pipeline with AccessLens and prevent deployment roles from becoming your biggest attack surface.