CoursesAWS security engineeringDetection as code & response

Detection as code & response

EventBridge to SIEM/SOAR and auto-containment.

Expert35 min · lesson 6 of 15

A stolen AWS access key gets used within minutes of leaking. An access key is just a long secret string that lets a program sign in to Amazon's cloud (AWS) and do anything its owner could, with nobody at the keyboard. The attacker uses it to spin up a fleet of servers to mine cryptocurrency, or to start copying your data out to a storage bucket they own. Meanwhile the alert that caught them, a finding from GuardDuty (Amazon's built-in threat-detection service that watches your account for signs of attack), sits unread in a regional console nobody will open until the morning standup. By then it's an incident with a real blast radius and a real bill, not a tidy little alert. The mistake underneath the whole story is treating detection and response as two different jobs, handed to two different people who happen to be awake at different hours.

A smoke alarm that only beeps is worth something when someone's home and awake to hear it. Wire that same alarm straight to the sprinklers and the fire gets dealt with whether anyone's listening or not. Detection as code is that wiring. You write the rule that recognises a serious problem, then connect it directly to a response that runs on its own, in seconds, the same way every time. The console stops being a thing a human has to sit and stare at.

Wire the alarm to the sprinklers

AWS runs a single internal message bus called EventBridge. A message bus is one central conveyor belt that every service drops notes onto. GuardDuty drops a note when it spots a threat. AWS Config drops one when a resource drifts away from the settings you said it should have. CloudTrail, the running log of every action taken in your account, drops one for the API calls themselves (the individual commands programs send to AWS). EventBridge works like a mailroom that reads the sender and the subject line on each envelope and routes it to the right desk, so you're not standing there sorting mail by hand. You don't watch the mailroom. You write one rule that says anything from GuardDuty marked serious goes straight to the response desk, and EventBridge checks every event that flows past against that rule for you.

GuardDuty scores every finding from 1.0 to 10.0, and the number is the whole point. Low findings (1.0 to 3.9) are background noise, someone rattling the doorknob. The two bands you actually care about sit at the top: High (7.0 to 8.9) means a resource is already compromised and being used against you, and Critical (9.0 to 10.0) means GuardDuty thinks a full attack sequence is unfolding right now. The finding itself is a big structured record, JSON, which is just labelled fields of text. It names the finding type (say, one of your servers talking to an IP address on a known-bad list), the exact resource involved, the attacker's source address, and which account it happened in. Your rule ignores almost all of that. It filters on the two fields that decide whether to act, the source service and the severity number, and lets everything else ride along untouched.

connect a serious finding to the runbook
# Match ONLY high-severity (>= 7.0) GuardDuty findings and forward them.
aws events put-rule \
--name gd-highsev-autorespond \
--event-pattern '{"source":["aws.guardduty"],"detail-type":["GuardDuty Finding"],"detail":{"severity":[{"numeric":[">=",7]}]}}'
{
"RuleArn": "arn:aws:events:eu-west-1:333333333333:rule/gd-highsev-autorespond"
}
# Point the rule at the response runbook. EventBridge needs a role to start it.
aws events put-targets \
--rule gd-highsev-autorespond \
--targets 'Id=ir-runbook,Arn=arn:aws:states:eu-west-1:333333333333:stateMachine:ir-quarantine,RoleArn=arn:aws:iam::333333333333:role/EventBridgeInvokeSfn'
{
"FailedEntryCount": 0,
"FailedEntries": []
}
# FailedEntryCount 0 means the wire is connected; anything else is a broken rule.

The runbook is a program, not a wiki page

The response desk here is code, not a document. It's a checklist where each step does exactly one thing, waits for it to finish, then chooses the next step based on the result. AWS calls that a Step Functions state machine. Compare it to a wiki page that reads 'first isolate the box, then snapshot the disk, then page on-call': that page is only ever as good as the tired engineer trying to follow it at three in the morning. The state machine runs itself, identically on every finding, and it records exactly what it did and when.

The order of the steps is where the real thinking lives. Quarantine first. You swap the instance's security group for one that allows no traffic in or out. A security group is the virtual firewall wrapped around a machine, the guest list that says who's allowed to talk to it, so swapping in an empty list cuts the attacker's connection right away. Snapshot the disk second, so a frozen copy of the evidence exists before anyone reboots or terminates the host and wipes it. Revoke the stolen credentials third, so the keys the attacker pulled off the machine stop working even if they're already using them somewhere else. Then, and only then, open a ticket and page a human. That person walks into a scene that's already contained instead of a live fire they have to fight half-awake.

ir-quarantine.asl.json + deploy
{
"Comment": "Auto-contain a high-severity GuardDuty finding",
"StartAt": "Quarantine",
"States": {
"Quarantine": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:modifyInstanceAttribute",
"Parameters": {
"InstanceId.$": "$.detail.resource.instanceDetails.instanceId",
"Groups": ["sg-0a1b2c3d4e5f60718"]
},
"ResultPath": null,
"Next": "SnapshotForensics"
},
"SnapshotForensics": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "ir-snapshot-volumes",
"Payload": { "instanceId.$": "$.detail.resource.instanceDetails.instanceId" }
},
"ResultPath": "$.forensics",
"Next": "RevokeSessions"
},
"RevokeSessions": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "ir-revoke-role-sessions",
"Payload": { "instanceId.$": "$.detail.resource.instanceDetails.instanceId" }
},
"ResultPath": "$.revoke",
"Next": "PageOnCall"
},
"PageOnCall": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "arn:aws:sns:eu-west-1:333333333333:ir-page",
"Subject": "CONTAINED: high-sev GuardDuty finding",
"Message.$": "$.detail.type"
},
"End": true
}
}
}
aws stepfunctions create-state-machine \
--name ir-quarantine \
--definition file://ir-quarantine.asl.json \
--role-arn arn:aws:iam::333333333333:role/StepFunctionsIRRole \
--type STANDARD
{
"stateMachineArn": "arn:aws:states:eu-west-1:333333333333:stateMachine:ir-quarantine",
"creationDate": "2026-07-16T09:14:22.187000+01:00"
}

Detections you write, in git

GuardDuty catches the broadly known bad. But your own account also has actions that are perfectly legal and still alarming the moment someone actually does them. Turning off CloudTrail logging. Disabling an encryption key so data can't be read back. Deleting the very bucket that stores your audit logs. A permission check never flags any of these, because your admins are supposed to hold those permissions. What gives the game away is the pattern: a normally-rare, allowed action suddenly being used. You catch it with a CloudWatch metric filter, a standing search over the CloudTrail logs that ticks a counter up every time a matching action shows up, wired to an alarm. Keep the filter, the EventBridge rule, and the state machine together in git, reviewed and version-controlled like any other code, so nobody can quietly weaken a detection without leaving a trace in the change history that someone has to sign off on.

a custom detection on legal-but-alarming calls
# A metric that ticks up whenever someone tries to blind the audit trail.
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/org-trail \
--filter-name blind-audit-trail \
--filter-pattern '{ ($.eventName = "StopLogging") || ($.eventName = "DisableKey") || ($.eventName = "DeleteTrail") }' \
--metric-transformations metricName=BlindAuditTrail,metricNamespace=Detections,metricValue=1,defaultValue=0
# Alarm on a single occurrence and route it to the same paging topic.
aws cloudwatch put-metric-alarm \
--alarm-name blind-audit-trail \
--namespace Detections --metric-name BlindAuditTrail \
--statistic Sum --period 300 --evaluation-periods 1 --threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--alarm-actions arn:aws:sns:eu-west-1:333333333333:ir-page
# Verify the alarm exists and check its current state.
aws cloudwatch describe-alarms --alarm-names blind-audit-trail \
--query 'MetricAlarms[0].{Name:AlarmName,State:StateValue,Threshold:Threshold}'
{
"Name": "blind-audit-trail",
"State": "OK",
"Threshold": 1.0
}

You don't wait for a real attacker to find out whether the wiring holds. GuardDuty can generate sample findings on demand, and you can also drop a synthetic finding onto the bus yourself, shaped exactly like the real thing and set to whatever severity you want to test. Fire one and then check two things: that the runbook actually started and finished, and that the instance really landed in the quarantine group. If the test finding doesn't move the machine, far better to learn that now than in the middle of a real break-in.

test the pipeline end to end
# Fire a synthetic high-sev finding at the bus, matching the rule's pattern.
aws events put-events --entries '[{
"Source": "aws.guardduty",
"DetailType": "GuardDuty Finding",
"Detail": "{\"severity\":8.0,\"type\":\"UnauthorizedAccess:EC2/MaliciousIPCaller.Custom\",\"resource\":{\"instanceDetails\":{\"instanceId\":\"i-0abc123def4567890\"}}}"
}]'
{
"FailedEntryCount": 0,
"Entries": [
{ "EventId": "8f9a1c02-7b3e-4d61-9f2a-1c0de5f6a7b8" }
]
}
# Did the runbook fire and finish?
aws stepfunctions list-executions \
--state-machine-arn arn:aws:states:eu-west-1:333333333333:stateMachine:ir-quarantine \
--max-results 1 --query 'executions[0].{name:name,status:status}'
{
"name": "ir-quarantine-9c3b17e2",
"status": "SUCCEEDED"
}
# Is the instance actually quarantined now?
aws ec2 describe-instances --instance-ids i-0abc123def4567890 \
--query 'Reservations[0].Instances[0].SecurityGroups[].GroupId'
[
"sg-0a1b2c3d4e5f60718"
]
Your automation can become the outage
A runbook that isolates any instance a high-severity finding names will, on the day GuardDuty false-positives against your busiest production host, take that host offline all by itself. Now your automation is the outage. So before you let a runbook act on its own, run it in notify-only mode until you trust its judgement. Keep an allowlist of assets it may never quarantine without a human saying yes, and make sure every destructive step is one you can undo.
The detection-and-response pipeline
signals
GuardDuty finding (sev 8)
EC2 talking to a bad IP
CloudWatch alarm
StopLogging / DisableKey
routing as code (in git)
EventBridge rule
pattern: severity >= 7
metric filter + alarm
legal-but-alarming calls
response runbook (Step Functions)
quarantine SG
cut the attacker off
snapshot EBS
freeze the evidence
revoke sessions
kill stolen keys
page on-call
hand off contained
human trail
SNS + ticket
who, what, when
Security Hub
one finding view
Everything left of the runbook lives in version control and is tested. The runbook acts in seconds and writes down every step it took.
Quick check
01A teammate wants to remove the severity filter from the EventBridge rule so the auto-containment runbook responds to every GuardDuty finding, not only those at severity 7 and above. What's the main risk?
Correct — Auto-containment belongs on a small stream of high-confidence findings; opening the gate to everything turns normal background noise into self-inflicted outages.
Incorrect — The numeric filter is entirely your choice; EventBridge will match any severity you write a pattern for.
Incorrect — Cost does rise, but that's minor next to auto-isolating production on benign findings.
Incorrect — GuardDuty's emissions don't change based on what your EventBridge rule happens to match.
02GuardDuty (Amazon's built-in threat-detection service) already watches the account, yet the lesson still builds a separate CloudWatch metric filter over the CloudTrail logs to catch actions like StopLogging, DisableKey, and DeleteTrail. Why can't a normal permission check or GuardDuty alone be relied on to flag those actions?
Incorrect — CloudTrail is one of the sources GuardDuty analyzes; the real issue is that these actions are legitimate, not that GuardDuty can't see them.
Incorrect — AWS does not auto-allow these for everyone; they are permitted for your admins by design, which is a different thing from being universally allowed.
Correct — the lesson notes a permission check never flags these because admins are supposed to hold those rights, so you detect the suspicious pattern with a metric filter over CloudTrail.
Incorrect — speed is not the reason (the alarm here evaluates over a 5-minute period); the point is that these legal actions never trip a permission denial at all.
03An engineer rewrites the containment runbook so its first step terminates the compromised EC2 virtual server to cut the attacker off fast, and only then tries to snapshot its disk. Following the lesson's reasoning about step order, what goes wrong?
Incorrect — terminating first wipes the host, so you lose the forensic evidence the snapshot was meant to preserve.
Correct — the lesson orders quarantine first and snapshot second precisely so a frozen copy of the evidence exists before anyone reboots or terminates the host and wipes it.
Incorrect — Step Functions can terminate an instance through its SDK integrations; the problem is the destroyed evidence, not an inability to run the step.
Incorrect — that constraint is invented, and the real consequence is lost evidence rather than a redundant step.

One thing the runbook quietly depends on: the forensic snapshot it takes and the audit log it writes are only worth anything if they're encrypted with keys the attacker can't reach. A locked box is only safe if the thief doesn't also hold the key. In AWS, those keys live in KMS, the Key Management Service, and a compromised identity that can call kms:DisableKey can lock you out of the very evidence you just captured. That's exactly why disabling a key sits on the alarm list above. How those keys actually protect data, and why envelope encryption lets you hand someone a snapshot that reads fine for you and is useless to them, is the next thing to pin down.

Try this

Work through “Detections you write, in git” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: your automation can become the outage. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related