Detection as code & response
EventBridge to SIEM/SOAR and auto-containment.
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.
# 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.
{"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 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.
# 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"]
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.