Threat Detection: GuardDuty + Security Hub + Automated Response
Scenario
Your security team is alerted to threats via email but by the time a human investigates, attackers have already exfiltrated data. You need an automated threat response system: GuardDuty detects threats, Security Hub aggregates findings across services, and an automated Lambda function immediately isolates any EC2 instance flagged with a high-severity finding (by adding it to an isolation security group) while SNS notifies the team.
- Enable GuardDuty (aws_guardduty_detector) and AWS Security Hub (aws_securityhub_account) with the AWS Foundational Security Best Practices standard enabled.
- Create a Lambda function that, when invoked with a GuardDuty finding, extracts the affected EC2 instance ID and modifies its security group to an isolation SG (allowing only SSH from your bastion).
- Create an EventBridge rule that matches GuardDuty findings with severity >= 7.0 (High) and invokes the Lambda function.
- Create a second EventBridge target on the same rule that publishes to an SNS topic (for human notification).
- Create the isolation security group in the VPC (allows inbound 22 only from a bastion CIDR).
- Output the GuardDuty detector ID, Security Hub ARN, isolation SG ID, and SNS topic ARN.
Additional Context
GuardDuty findings have a severity score from 0.1 to 8.9. The AWS definition: Low (0.1–3.9), Medium (4.0–6.9), High (7.0–8.9). The EventBridge event pattern must filter on detail.severity using a numeric comparison condition. EventBridge numeric filtering uses numeric conditions like [">=", 7].
The EC2 isolation pattern is a common incident response technique: instead of terminating the instance (which destroys forensic evidence), you replace its security group with one that blocks all inbound/outbound except for a forensics bastion. This allows your team to still connect and collect memory dumps while preventing further lateral movement.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "vpc_id" { type = string; description = "VPC where workloads run." }
variable "bastion_cidr" { type = string; default = "10.0.5.0/32"; description = "Bastion CIDR for forensic SSH access." }
variable "alert_email" { type = string; description = "Security team email for GuardDuty alerts." }
variable "environment" { type = string; default = "prod" }main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
# Enable GuardDuty.
resource "aws_guardduty_detector" "main" {
enable = true
datasources {
s3_logs { enable = true }
kubernetes { audit_logs { enable = true } }
malware_protection { scan_ec2_instance_with_findings { ebs_volumes { enable = true } } }
}
tags = local.tags
}
# Enable Security Hub.
resource "aws_securityhub_account" "main" {
enable_default_standards = false # we'll enable specific standards below
auto_enable_controls = true
}
# Enable AWS Foundational Security Best Practices standard.
resource "aws_securityhub_standards_subscription" "fsbp" {
standards_arn = "arn:aws:securityhub:${var.aws_region}::standards/aws-foundational-security-best-practices/v/1.0.0"
depends_on = [aws_securityhub_account.main]
}
# Isolation security group — only allows SSH from forensics bastion.
resource "aws_security_group" "isolation" {
name = "ec2-isolation-sg-${var.environment}"
description = "Applied to EC2s flagged by GuardDuty — blocks all traffic except bastion SSH"
vpc_id = var.vpc_id
ingress { description = "Forensic bastion only"; from_port = 22; to_port = 22; protocol = "tcp"; cidr_blocks = [var.bastion_cidr] }
# No egress — block all outbound to prevent further exfiltration.
tags = merge(local.tags, { Name = "isolation-sg", Purpose = "incident-response" })
}
# SNS topic for human alerts.
resource "aws_sns_topic" "security_alerts" {
name = "guardduty-high-severity-${var.environment}"
tags = local.tags
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.security_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
# Lambda IAM role — needs ec2:ModifyInstanceAttribute to change the SG.
data "aws_iam_policy_document" "lambda_assume" {
statement { effect = "Allow"; actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] } }
}
resource "aws_iam_role" "response_lambda" {
name = "guardduty-response-role-${var.environment}"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy" "response_lambda" {
name = "ec2-isolate-permissions"
role = aws_iam_role.response_lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"; Action = ["ec2:ModifyInstanceAttribute","ec2:DescribeInstances","ec2:DescribeSecurityGroups"]; Resource = "*" },
{ Effect = "Allow"; Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"]; Resource = "*" }
]
})
}
resource "aws_cloudwatch_log_group" "response" {
name = "/aws/lambda/guardduty-response-${var.environment}"
retention_in_days = 90 # keep security logs longer for forensics
}
resource "aws_lambda_function" "response" {
function_name = "guardduty-response-${var.environment}"
runtime = "python3.12"
handler = "response.lambda_handler"
role = aws_iam_role.response_lambda.arn
filename = "${path.module}/response.zip"
timeout = 30
environment { variables = {
ISOLATION_SG_ID = aws_security_group.isolation.id
ENVIRONMENT = var.environment
}}
depends_on = [aws_cloudwatch_log_group.response]
tags = local.tags
}
# EventBridge rule — matches GuardDuty High severity findings (>= 7.0).
resource "aws_cloudwatch_event_rule" "high_severity" {
name = "guardduty-high-severity-${var.environment}"
description = "Route GuardDuty findings with severity >= 7 for auto-response"
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
detail = { severity = [{ numeric = [">=", 7] }] }
})
tags = local.tags
}
# Target 1: Lambda isolation function.
resource "aws_cloudwatch_event_target" "isolate" {
rule = aws_cloudwatch_event_rule.high_severity.name
target_id = "isolate-ec2"
arn = aws_lambda_function.response.arn
}
resource "aws_lambda_permission" "eventbridge" {
statement_id = "AllowEventBridgeInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.response.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.high_severity.arn
}
# Target 2: SNS for human notification.
resource "aws_sns_topic_policy" "alerts" {
arn = aws_sns_topic.security_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "events.amazonaws.com" }
Action = "sns:Publish"; Resource = aws_sns_topic.security_alerts.arn }]
})
}
resource "aws_cloudwatch_event_target" "notify" {
rule = aws_cloudwatch_event_rule.high_severity.name
target_id = "notify-security-team"
arn = aws_sns_topic.security_alerts.arn
}outputs.tf
output "guardduty_detector_id" { value = aws_guardduty_detector.main.id }
output "isolation_sg_id" { value = aws_security_group.isolation.id }
output "sns_topic_arn" { value = aws_sns_topic.security_alerts.arn }
output "response_lambda_arn" { value = aws_lambda_function.response.arn }Workflow Commands
terraform init
terraform apply -var="vpc_id=vpc-xxxx" -var="[email protected]"
# Generate a sample finding for testing
aws guardduty create-sample-findings \
--detector-id $(terraform output -raw guardduty_detector_id) \
--finding-types UnauthorizedAccess:EC2/SSHBruteForce
# List high-severity findings
aws guardduty list-findings \
--detector-id $(terraform output -raw guardduty_detector_id) \
--finding-criteria '{"Criterion":{"severity":{"Gte":70}}}'
# Note: GuardDuty severity is stored as integer×10 in the API (7.0 = 70)
# Check Security Hub findings
aws securityhub get-findings \
--filters '{"SeverityLabel":[{"Value":"HIGH","Comparison":"EQUALS"}]}' \
--max-results 5
terraform destroy -var="vpc_id=..." -var="alert_email=..."Apply complete! Resources: 13 added, 0 changed, 0 destroyed. Outputs: guardduty_detector_id = "abc1234567890abc1234567890abc123" isolation_sg_id = "sg-0xxxxxx..." response_lambda_arn = "arn:aws:lambda:us-east-1:123456789012:function:guardduty-response-prod" sns_topic_arn = "arn:aws:sns:us-east-1:123456789012:guardduty-high-severity-prod"

