Build Automated Security Incident Response with GuardDuty, EventBridge, and Lambda

Build automated incident response - GuardDuty detects threats, EventBridge routes findings, Lambda isolates compromised resources and alerts Slack.

Related Concepts & TermsGuardDuty

Domains & Technologies

Domains
GUARDDUTYLAMBDASECURITY
Technologies
AWS

Blueprint Walkthrough

Architecture Overview & Problem Statement
Architecture Overview

A security alert with no automation is just noise. When GuardDuty detects an EC2 instance communicating with a known cryptocurrency-mining command-and-control server at 3am, nobody is awake to respond. By the time someone at a company like Hotstar sees the alert in the morning, the instance may have been compromised for hours during peak streaming traffic.

This project builds an automated security response system. GuardDuty detects the threat. EventBridge routes the finding to a Lambda function within seconds. Lambda isolates the compromised instance by replacing its security groups with a deny-all group, revokes any compromised IAM credentials, and sends a formatted Slack alert — all without a human needing to be awake or online.

SMALI
[ EC2 instance shows suspicious behavior ]
|
v
[ GuardDuty detects it - CryptoMining, UnusualTraffic, etc. ]
|
v
[ GuardDuty publishes finding to EventBridge ]
|
v
[ EventBridge rule matches HIGH/CRITICAL severity ]
|
v
[ EventBridge invokes Lambda with finding details ]
|
v
[ Lambda isolates instance, revokes creds, tags, alerts Slack ]
|
v
[ Security team receives Slack alert with full context ]
Tip

The entire response — detection to isolation to Slack notification — completes in under 60 seconds, automatically, at any hour of the day, in any AWS region including ap-south-1 (Mumbai).

Problem Solved

Manual incident response has a fundamental timing problem: the gap between "a threat occurs" and "a human notices and reacts" can be hours, especially overnight or on weekends. During that gap, a compromised instance can exfiltrate data, mine cryptocurrency on your bill, or pivot to attack other resources in the account — a real risk for any company running production workloads around the clock, like Zerodha during market hours.

This project closes that gap by making the first response step fully automated. GuardDuty continuously analyzes CloudTrail API activity, VPC Flow Logs, and DNS logs using machine learning and threat intelligence feeds, without you configuring any of the underlying detection logic. EventBridge routes qualifying findings instantly. Lambda performs the actual isolation — and critically, isolation is not termination. The standard incident response pattern is to cut a compromised instance off from the network while preserving its disk and memory for forensic analysis, not to destroy the evidence by shutting it down.

Security

Automated remediation buys time and limits blast radius — it does not replace a human security review. Every automated action here also produces a Slack alert and a set of tags, so a person can pick up the investigation with full context.


Milestone 1: Enable GuardDuty
Concept

Enabling GuardDuty is a single API call — there's no agent to install and no infrastructure to manage. Once enabled, it immediately starts analyzing your account's CloudTrail, VPC Flow Log, and DNS data in the background.

Steps
Bash
aws guardduty create-detector \
--enable \
--finding-publishing-frequency FIFTEEN_MINUTES \
--region ap-south-1 \
--query 'DetectorId' \
--output text
TEXT
abc123def456abc123def456abc12345

Save the Detector ID — you'll need it to generate test findings later.

Bash
aws guardduty list-detectors --region ap-south-1
JSON
{
"DetectorIds": ["abc123def456abc123def456abc12345"]
}
Tip

FIFTEEN_MINUTES is the finding publishing frequency, not the detection frequency — GuardDuty detects threats continuously. This setting only controls how often updates to an existing finding get republished to EventBridge.


Milestone 2: Create the Deny-All Quarantine Security Group
Concept

The isolation strategy for this project relies on one specific security group: a group with zero inbound rules and zero outbound rules. Attaching this to a compromised instance instantly blocks all network traffic in both directions, without touching the instance's disk or memory.

Steps
Bash
VPC_ID=$(aws ec2 describe-vpcs \
--filters Name=isDefault,Values=true \
--query 'Vpcs[0].VpcId' \
--output text \
--region ap-south-1)
aws ec2 create-security-group \
--group-name "QUARANTINE-deny-all" \
--description "Quarantine security group - no inbound or outbound traffic" \
--vpc-id $VPC_ID \
--region ap-south-1 \
--query 'GroupId' \
--output text
TEXT
sg-0abc123def456789a

Confirm there are no inbound rules:

Bash
aws ec2 describe-security-groups \
--group-ids sg-0abc123def456789a \
--region ap-south-1 \
--query 'SecurityGroups[0].IpPermissions'
JSON
[]

New security groups have a default "allow all" outbound rule — remove it explicitly:

Bash
aws ec2 revoke-security-group-egress \
--group-id sg-0abc123def456789a \
--ip-permissions '[{"IpProtocol": "-1", "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]' \
--region ap-south-1
Common Mistake

Forgetting to revoke the default outbound rule. A brand-new security group blocks all inbound traffic by default, but still allows all outbound traffic — a compromised instance could still exfiltrate data even after you think you've isolated it, unless you explicitly close the egress side too.


Milestone 3: Write the Lambda Remediation Function
Concept

This function does three things when invoked: it isolates the affected resource (EC2 instance or IAM user), it tags the resource with what happened and why, and it sends a Slack alert. Note that IAM Access Key findings get access keys deactivated, not deleted — deactivation is reversible and preserves the audit trail, which matters if the finding turns out to be a false positive.

Steps
Bash
mkdir lambda
cat > lambda/remediation.py << 'EOF'
import json
import boto3
import logging
import os
from datetime import datetime
logger = logging.getLogger()
logger.setLevel(logging.INFO)
QUARANTINE_SG_ID = os.environ['QUARANTINE_SG_ID']
SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
AWS_REGION = os.environ.get('AWS_DEFAULT_REGION', 'ap-south-1')
def lambda_handler(event, context):
"""
Handle GuardDuty findings delivered via EventBridge.
EventBridge wraps the finding as event['detail'].
"""
logger.info(f"Received event: {json.dumps(event)}")
finding = event.get('detail', {})
finding_type = finding.get('type', 'Unknown')
severity = finding.get('severity', 0)
finding_id = finding.get('id', 'unknown')
account_id = finding.get('accountId', 'unknown')
region = finding.get('region', AWS_REGION)
logger.info(f"Processing finding: {finding_type}, severity: {severity}")
if severity < 7.0:
logger.info(f"Severity {severity} below threshold - skipping")
return {'status': 'skipped', 'reason': 'below_severity_threshold'}
resource = finding.get('resource', {})
resource_type = resource.get('resourceType', 'Unknown')
response_actions = []
if resource_type == 'Instance':
instance_id = resource.get('instanceDetails', {}).get('instanceId')
if instance_id:
response_actions.extend(
isolate_ec2_instance(instance_id, finding_id, finding_type, region)
)
elif resource_type == 'AccessKey':
user_name = resource.get('accessKeyDetails', {}).get('userName')
if user_name:
response_actions.extend(
disable_iam_user(user_name, finding_id, finding_type)
)
send_slack_alert(
finding_type=finding_type,
severity=severity,
finding_id=finding_id,
account_id=account_id,
region=region,
resource_type=resource_type,
response_actions=response_actions
)
return {
'status': 'remediated',
'finding_id': finding_id,
'actions_taken': response_actions
}
def isolate_ec2_instance(instance_id, finding_id, finding_type, region):
ec2 = boto3.client('ec2', region_name=region)
actions = []
try:
response = ec2.describe_instances(InstanceIds=[instance_id])
instance = response['Reservations'][0]['Instances'][0]
current_sgs = [sg['GroupId'] for sg in instance['SecurityGroups']]
ec2.modify_instance_attribute(
InstanceId=instance_id,
Groups=[QUARANTINE_SG_ID]
)
actions.append(f"Isolated instance {instance_id} - replaced SGs with deny-all")
ec2.create_tags(
Resources=[instance_id],
Tags=[
{'Key': 'SECURITY_STATUS', 'Value': 'QUARANTINED'},
{'Key': 'QUARANTINE_TIME', 'Value': datetime.utcnow().isoformat()},
{'Key': 'FINDING_ID', 'Value': finding_id},
{'Key': 'FINDING_TYPE', 'Value': finding_type},
{'Key': 'ORIGINAL_SGS', 'Value': ','.join(current_sgs)}
]
)
actions.append(f"Tagged instance {instance_id} as QUARANTINED")
except Exception as e:
logger.error(f"Failed to isolate instance {instance_id}: {str(e)}")
actions.append(f"ERROR isolating {instance_id}: {str(e)}")
return actions
def disable_iam_user(user_name, finding_id, finding_type):
iam = boto3.client('iam')
actions = []
try:
response = iam.list_access_keys(UserName=user_name)
for key in response['AccessKeyMetadata']:
key_id = key['AccessKeyId']
if key['Status'] == 'Active':
iam.update_access_key(
UserName=user_name,
AccessKeyId=key_id,
Status='Inactive'
)
actions.append(f"Deactivated access key {key_id} for user {user_name}")
iam.tag_user(
UserName=user_name,
Tags=[
{'Key': 'SECURITY_STATUS', 'Value': 'CREDENTIALS_REVOKED'},
{'Key': 'REVOCATION_TIME', 'Value': datetime.utcnow().isoformat()},
{'Key': 'FINDING_ID', 'Value': finding_id},
{'Key': 'FINDING_TYPE', 'Value': finding_type}
]
)
except Exception as e:
logger.error(f"Failed to disable user {user_name}: {str(e)}")
actions.append(f"ERROR disabling {user_name}: {str(e)}")
return actions
def send_slack_alert(finding_type, severity, finding_id, account_id,
region, resource_type, response_actions):
import urllib.request
if severity >= 9.0:
emoji, color, severity_text = ":rotating_light:", "#FF0000", "CRITICAL"
elif severity >= 7.0:
emoji, color, severity_text = ":warning:", "#FF8C00", "HIGH"
else:
emoji, color, severity_text = ":information_source:", "#FFA500", "MEDIUM"
actions_text = "\n".join([f"- {action}" for action in response_actions])
message = {
"text": f"{emoji} GuardDuty {severity_text} Finding Detected",
"attachments": [{
"color": color,
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": f"Security Alert - {severity_text}"}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Finding Type:*\n{finding_type}"},
{"type": "mrkdwn", "text": f"*Severity:*\n{severity}/10"},
{"type": "mrkdwn", "text": f"*Account:*\n{account_id}"},
{"type": "mrkdwn", "text": f"*Region:*\n{region}"},
{"type": "mrkdwn", "text": f"*Resource Type:*\n{resource_type}"},
{"type": "mrkdwn", "text": f"*Finding ID:*\n{finding_id[:16]}..."}
]
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*Actions Taken:*\n{actions_text}"}
}
]
}]
}
data = json.dumps(message).encode('utf-8')
req = urllib.request.Request(
SLACK_WEBHOOK_URL,
data=data,
headers={'Content-Type': 'application/json'}
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
logger.info(f"Slack notification sent: {response.status}")
except Exception as e:
logger.error(f"Failed to send Slack notification: {str(e)}")
EOF
cd lambda
zip function.zip remediation.py
cd ..
Common Mistake

Deleting compromised access keys instead of deactivating them. Deletion is irreversible and destroys evidence — if a finding turns out to be a false positive, or you need to trace exactly what the credential accessed, a deactivated key is far more useful than a deleted one.


Milestone 4: Create the Lambda IAM Role and Permissions
Concept

The Lambda needs just enough IAM permission to describe instances, modify their security groups, tag resources, and manage IAM access keys — nothing more. This is the principle of least privilege applied to the responder itself, since the responder is now a high-value target in your account too.

Steps
Bash
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name guardduty-remediation-role \
--assume-role-policy-document file://trust-policy.json \
--query 'Role.Arn' \
--output text
Bash
arn:aws:iam::123456789012:role/guardduty-remediation-role

Attach the required permissions:

Bash
aws iam attach-role-policy \
--role-name guardduty-remediation-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
cat > ec2-remediation-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:ModifyInstanceAttribute",
"ec2:CreateTags",
"ec2:DescribeSecurityGroups"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"iam:ListAccessKeys",
"iam:UpdateAccessKey",
"iam:TagUser"
],
"Resource": "*"
}
]
}
EOF
aws iam put-role-policy \
--role-name guardduty-remediation-role \
--policy-name remediation-permissions \
--policy-document file://ec2-remediation-policy.json
Security

These permissions use "Resource": "*" for simplicity in this learning project. In a real environment, scope EC2 and IAM actions down to specific resource ARNs or tag-based conditions wherever the API supports it — a Lambda with unrestricted iam:UpdateAccessKey is itself a high-value target if compromised.


Milestone 5: Deploy the Lambda Function
Concept

IAM changes are eventually consistent — the role you just created may not be immediately visible to the Lambda service. This is a common source of confusing, intermittent errors for anyone new to scripting AWS resource creation, so we account for it explicitly below.

Steps
Bash
ROLE_ARN="arn:aws:iam::123456789012:role/guardduty-remediation-role"
QUARANTINE_SG="sg-0abc123def456789a"
SLACK_WEBHOOK="https://hooks.slack.com/services/T.../B.../xxx"
## Wait for IAM role propagation before creating the function
sleep 10
aws lambda create-function \
--function-name guardduty-remediation \
--runtime python3.12 \
--role $ROLE_ARN \
--handler remediation.lambda_handler \
--zip-file fileb://lambda/function.zip \
--timeout 30 \
--environment "Variables={QUARANTINE_SG_ID=$QUARANTINE_SG,SLACK_WEBHOOK_URL=$SLACK_WEBHOOK}" \
--region ap-south-1
Common Mistake

Skipping the sleep 10 between creating the IAM role and creating the Lambda function often produces a "role not found" error, since IAM changes are eventually consistent and the role may not be visible to the Lambda service the instant after create-role returns.


Milestone 6: Create the EventBridge Rule
Concept

The event pattern below is the actual filter deciding which findings reach your Lambda at all. Getting the severity comparison right matters — GuardDuty severity is a number (0.1 to 10.0), not a category string, so the pattern has to use a numeric comparison operator, not a string match.

Steps
Bash
aws events put-rule \
--name guardduty-high-severity-findings \
--event-pattern '{
"source": ["aws.guardduty"],
"detail-type": ["GuardDuty Finding"],
"detail": {
"severity": [{"numeric": [">=", 7.0]}]
}
}' \
--state ENABLED \
--description "Route HIGH and CRITICAL GuardDuty findings to remediation Lambda" \
--region ap-south-1
Bash
LAMBDA_ARN="arn:aws:lambda:ap-south-1:123456789012:function:guardduty-remediation"
aws events put-targets \
--rule guardduty-high-severity-findings \
--targets "Id=RemediationLambda,Arn=$LAMBDA_ARN" \
--region ap-south-1
aws lambda add-permission \
--function-name guardduty-remediation \
--statement-id eventbridge-invoke \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn "arn:aws:events:ap-south-1:123456789012:rule/guardduty-high-severity-findings" \
--region ap-south-1
Common Mistake

GuardDuty severity is a float from 0.1 to 10.0, not a string. {"numeric": [">=", 7.0]} correctly catches both HIGH and CRITICAL findings; writing {"severity": ["HIGH"]} will never match anything, since severity is never sent as that literal string value.


Validation & Testing
Verification Steps
Bash
# 1. Generate a sample GuardDuty finding
DETECTOR_ID="abc123def456abc123def456abc12345"
aws guardduty create-sample-findings \
--detector-id $DETECTOR_ID \
--finding-types "UnauthorizedAccess:EC2/TorIPCaller" \
--region ap-south-1
# 2. Confirm Lambda was invoked by checking CloudWatch Logs
aws logs describe-log-streams \
--log-group-name /aws/lambda/guardduty-remediation \
--order-by LastEventTime \
--descending \
--region ap-south-1 \
--query 'logStreams[0].logStreamName' \
--output text
# 3. Read the log events from that stream
aws logs get-log-events \
--log-group-name /aws/lambda/guardduty-remediation \
--log-stream-name "PASTE_STREAM_NAME_HERE" \
--region ap-south-1 \
--query 'events[*].message' \
--output text
TEXT
Processing finding: UnauthorizedAccess:EC2/TorIPCaller, severity: 8.0
Isolating instance i-0abc123def456789a
Successfully isolated instance i-0abc123def456789a
Tagged instance i-0abc123def456789a as QUARANTINED
Slack notification sent: 200

Check your Slack channel — you should see the formatted security alert with finding details and the list of automated actions taken.

Tip

GuardDuty's sample findings use synthetic instance IDs that don't exist in your account, so the EC2 isolation step will log an error for those specific test runs — that's expected. To fully validate the isolation logic, launch a real disposable test EC2 instance and manually invoke the Lambda with a finding payload referencing that instance's ID.

Common Mistakes Recap
Mistake Why It Breaks Fix
No sleep after IAM role creation "Role not found" error on Lambda create Add a short delay before deploying
String match on severity Rule never fires Use a numeric comparison operator
Missing lambda add-permission Rule fires but Lambda never runs Grant EventBridge invoke permission explicitly
Terminating an isolated instance Destroys forensic evidence Isolate first, terminate only after investigation