Compliance Automation: AWS Config Rules + Lambda Auto-Remediation
Scenario
Your security team's compliance scan revealed that S3 buckets with public access enabled and EC2 instances without required tags slip through undetected until quarterly audits. You need a continuous compliance system: AWS Config watches all resource changes, flags non-compliant resources immediately, automatically remediates the S3 public access issue, and sends a notification to the security team's SNS topic.
- Enable an AWS Config Recorder and Delivery Channel with an S3 bucket for configuration history.
- Create two managed Config rules: s3-bucket-public-read-prohibited and required-tags (checking for Environment and ManagedBy tags on EC2 instances).
- Create a Lambda auto-remediation function that blocks S3 public access on non-compliant buckets. Wire it up via aws_config_remediation_configuration with automatic execution.
- Create an EventBridge rule that triggers on AWS Config ConfigRuleComplianceChangeNotification events and routes to an SNS topic to alert the security team.
- Output the Config recorder name, delivery channel name, and SNS topic ARN.
Additional Context
AWS Config charges per configuration item recorded and per active Config rule evaluation. Use recording_group with all_supported = true to cover all resource types, or use resource_types to scope recording to specific types and reduce costs.
aws_config_remediation_configuration uses AWS Systems Manager Automation documents for remediation. For simple cases (disabling S3 public access), you can use the AWS-managed SSM document AWS-DisableS3BucketPublicReadWrite if available, or call a Lambda directly with a Lambda remediation action.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "security_email" { type = string; description = "Email address for compliance alerts." }
variable "environment" { type = string; default = "prod" }main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
data "aws_caller_identity" "current" {}
# S3 bucket for AWS Config delivery.
resource "aws_s3_bucket" "config" {
bucket = "aws-config-delivery-${data.aws_caller_identity.current.account_id}"
tags = local.tags
}
resource "aws_s3_bucket_policy" "config" {
bucket = aws_s3_bucket.config.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Sid = "AWSConfigBucketPermissionsCheck"; Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:GetBucketAcl"; Resource = aws_s3_bucket.config.arn },
{ Sid = "AWSConfigBucketDelivery"; Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.config.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/Config/*"
Condition = { StringEquals = { "s3:x-amz-acl" = "bucket-owner-full-control" } } }
]
})
}
# IAM role for AWS Config service.
resource "aws_iam_role" "config" {
name = "aws-config-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "config.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy_attachment" "config" {
role = aws_iam_role.config.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}
# Config Recorder — records all supported resources.
resource "aws_config_configuration_recorder" "main" {
name = "default"
role_arn = aws_iam_role.config.arn
recording_group {
all_supported = true
include_global_resource_types = true
}
}
# Delivery Channel — sends config data to S3.
resource "aws_config_delivery_channel" "main" {
name = "default"
s3_bucket_name = aws_s3_bucket.config.bucket
depends_on = [aws_config_configuration_recorder.main]
}
# Enable recording.
resource "aws_config_configuration_recorder_status" "main" {
name = aws_config_configuration_recorder.main.name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
# Managed Config Rule 1: S3 public read prohibited.
resource "aws_config_config_rule" "s3_public_read" {
name = "s3-bucket-public-read-prohibited"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED"
}
depends_on = [aws_config_configuration_recorder_status.main]
}
# Managed Config Rule 2: Required tags on EC2 instances.
resource "aws_config_config_rule" "required_tags" {
name = "ec2-required-tags-${var.environment}"
source {
owner = "AWS"
source_identifier = "REQUIRED_TAGS"
}
input_parameters = jsonencode({
tag1Key = "Environment"
tag2Key = "ManagedBy"
})
scope {
compliance_resource_types = ["AWS::EC2::Instance"]
}
depends_on = [aws_config_configuration_recorder_status.main]
}
# Lambda remediation function — blocks S3 public access on non-compliant buckets.
data "aws_iam_policy_document" "remediation_assume" {
statement { effect = "Allow"; actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["lambda.amazonaws.com","ssm.amazonaws.com"] } }
}
resource "aws_iam_role" "remediation" {
name = "config-remediation-role-${var.environment}"
assume_role_policy = data.aws_iam_policy_document.remediation_assume.json
}
resource "aws_iam_role_policy" "remediation" {
name = "remediation-s3"
role = aws_iam_role.remediation.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"; Action = ["s3:PutBucketPublicAccessBlock","s3:GetBucketPublicAccessBlock"]; Resource = "*" },
{ Effect = "Allow"; Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"]; Resource = "*" }
]
})
}
resource "aws_lambda_function" "remediate_s3" {
function_name = "config-remediate-s3-public-${var.environment}"
runtime = "python3.12"
handler = "handler.lambda_handler"
role = aws_iam_role.remediation.arn
filename = "${path.module}/remediation.zip"
description = "Automatically blocks S3 public access on non-compliant buckets"
tags = local.tags
}
# Config Remediation — auto-executes Lambda when S3 public read rule fires.
resource "aws_config_remediation_configuration" "s3_public" {
config_rule_name = aws_config_config_rule.s3_public_read.name
resource_type = "AWS::S3::Bucket"
target_type = "SSM_DOCUMENT"
target_id = "AWS-InvokeFunction"
parameter {
name = "FunctionName"
static_value = aws_lambda_function.remediate_s3.function_name
}
parameter {
name = "Payload"
resource_value = "RESOURCE_ID"
}
automatic = true
maximum_automatic_attempts = 3
retry_attempt_seconds = 60
execution_controls {
ssm_controls { concurrent_execution_rate_percentage = 25; error_percentage = 20 }
}
}
# SNS topic for compliance alerts.
resource "aws_sns_topic" "compliance" {
name = "config-compliance-alerts-${var.environment}"
tags = local.tags
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.compliance.arn
protocol = "email"
endpoint = var.security_email
}
# EventBridge rule — routes Config compliance change events to SNS.
resource "aws_cloudwatch_event_rule" "compliance_change" {
name = "config-compliance-change-${var.environment}"
description = "Fires when any Config rule compliance status changes"
event_pattern = jsonencode({
source = ["aws.config"]
detail-type = ["Config Rules Compliance Change"]
})
}
resource "aws_sns_topic_policy" "compliance" {
arn = aws_sns_topic.compliance.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "events.amazonaws.com" }
Action = "sns:Publish"; Resource = aws_sns_topic.compliance.arn }]
})
}
resource "aws_cloudwatch_event_target" "sns" {
rule = aws_cloudwatch_event_rule.compliance_change.name
target_id = "compliance-sns"
arn = aws_sns_topic.compliance.arn
}outputs.tf
output "config_recorder_name" { value = aws_config_configuration_recorder.main.name }
output "delivery_channel_name" { value = aws_config_delivery_channel.main.name }
output "sns_topic_arn" { value = aws_sns_topic.compliance.arn }
output "config_s3_bucket" { value = aws_s3_bucket.config.bucket }Workflow Commands
terraform init
terraform apply -var="[email protected]"
# Check compliance status for all rules
aws configservice describe-compliance-by-config-rule \
--config-rule-names s3-bucket-public-read-prohibited ec2-required-tags-prod
# Trigger a compliance evaluation
aws configservice start-config-rules-evaluation \
--config-rule-names s3-bucket-public-read-prohibited
# List non-compliant resources
aws configservice get-compliance-details-by-config-rule \
--config-rule-name s3-bucket-public-read-prohibited \
--compliance-types NON_COMPLIANT
terraform destroy -var="[email protected]"Apply complete! Resources: 16 added, 0 changed, 0 destroyed. Outputs: config_recorder_name = "default" config_s3_bucket = "aws-config-delivery-123456789012" delivery_channel_name = "default" sns_topic_arn = "arn:aws:sns:us-east-1:123456789012:config-compliance-alerts-prod"

