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.
Domains & Technologies
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.
[ 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 ]TipThe 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.
SecurityAutomated 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
aws guardduty create-detector \ --enable \ --finding-publishing-frequency FIFTEEN_MINUTES \ --region ap-south-1 \ --query 'DetectorId' \ --output textabc123def456abc123def456abc12345Save the Detector ID — you'll need it to generate test findings later.
aws guardduty list-detectors --region ap-south-1{ "DetectorIds": ["abc123def456abc123def456abc12345"]}Tip
FIFTEEN_MINUTESis 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
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 textsg-0abc123def456789aConfirm there are no inbound rules:
aws ec2 describe-security-groups \ --group-ids sg-0abc123def456789a \ --region ap-south-1 \ --query 'SecurityGroups[0].IpPermissions'[]New security groups have a default "allow all" outbound rule — remove it explicitly:
aws ec2 revoke-security-group-egress \ --group-id sg-0abc123def456789a \ --ip-permissions '[{"IpProtocol": "-1", "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]' \ --region ap-south-1Common MistakeForgetting 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
mkdir lambdacat > lambda/remediation.py << 'EOF'import jsonimport boto3import loggingimport osfrom 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 lambdazip function.zip remediation.pycd ..Common MistakeDeleting 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
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 textarn:aws:iam::123456789012:role/guardduty-remediation-roleAttach the required permissions:
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.jsonSecurityThese 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 unrestrictediam:UpdateAccessKeyis 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
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 functionsleep 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-1Common MistakeSkipping the
sleep 10between 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 aftercreate-rolereturns.
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
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-1LAMBDA_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-1Common MistakeGuardDuty 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
# 1. Generate a sample GuardDuty findingDETECTOR_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 Logsaws 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 streamaws 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 textProcessing finding: UnauthorizedAccess:EC2/TorIPCaller, severity: 8.0Isolating instance i-0abc123def456789aSuccessfully isolated instance i-0abc123def456789aTagged instance i-0abc123def456789a as QUARANTINEDSlack notification sent: 200Check your Slack channel — you should see the formatted security alert with finding details and the list of automated actions taken.
TipGuardDuty'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 |
Videos & Guides
Amazon GuardDuty Documentation
Official reference for GuardDuty finding types, severity scoring, and the data sources it analyzes.
Amazon EventBridge Event Patterns
Reference for writing the event pattern that filters GuardDuty findings by severity before routing to Lambda.
AWS Lambda Python Runtime Reference
Reference for the Lambda execution model, environment variables, and IAM execution role used by the function.