This is a lab in the Security Specialty hands-on track. The reference posts argue
when automated response is a good idea; this stands one up. GuardDuty is the
detection half. You build the containment half: a rule that routes one kind of
GuardDuty finding to one Lambda, and a handler that strips the offending instance
down to a single quarantine security group. Nothing here prevents the compromise,
and part of the work is seeing what the containment leaves running.
The full lab is in lab-scs-02-auto-quarantine.zip.
Before your first lab, do the one-time, once-per-account setup: run the zip’s
preflight.sh to confirm your account is ready, then deploy the lab reaper,
a standing backstop that auto-deletes any lab you forget to tear down after 24
hours. Every lab tags its stack for the reaper on deploy.
The scenario
GuardDuty raises a finding when it detects trouble on an EC2 instance: a
crypto-miner beaconing to a command-and-control host, traffic to an address on a
threat list, a DNS query for a domain tied to malware. The finding lands on the
default EventBridge bus with source aws.guardduty, detail-type
GuardDuty Finding, and the instance id nested in the finding detail. You want
that one finding to trigger a Lambda that replaces the instance’s security groups
with a single quarantine group.
A group swap contains less than the word quarantine suggests. Security groups are
stateful, and AWS states the consequence plainly: existing tracked connections
are not terminated when you change an instance’s security groups, so only future
traffic is blocked. The attacker’s established session runs on until it times
out. Severing it now takes a network ACL on the subnet, which is stateless and
drops packets mid-flow. AWS’s isolation guidance is also not a group with no
rules at all. It is a group whose inbound and outbound rules reach specific
addresses only, with nothing allowing 0.0.0.0/0 across all ports, so a
responder can still reach the host to collect evidence.
The lazy design is a broad rule that catches everything from aws.guardduty and
sorts it out in the function. GuardDuty findings also arrive with a resource type
of AccessKey, S3Bucket, S3Object, Container, RDSDBInstance or Lambda,
and none of those has an instance to isolate. A broad rule invokes the function
on every one of them, bills an invocation each time, and hides the routing
decision in code. Narrow the rule and EventBridge drops the rest before the
function runs, which leaves the routing declarative and visible in the template.
One exclusion is worth naming before you write the pattern. GuardDuty’s IAM
findings always carry a resource type of AccessKey, and
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS is one of
them. If an attacker has lifted the instance role’s temporary credentials and is
calling APIs from their own host, the finding names an access key, not an
instance. This rule never matches it, and quarantining the instance does not
stop those credentials working. That half needs its own rule and a different
action.
What you’re given
CloudFormation builds the Lambda, an execution role that can describe, tag, and
modify EC2 instances and write logs, the EventBridge rule on the default bus, and
the permission that lets EventBridge invoke the function. The rule’s EventPattern
is a template parameter, fed from a file you control:
QuarantineRule:
Type: AWS::Events::Rule
Properties:
EventBusName: default
EventPattern: !Ref EventPattern
State: ENABLED
Targets:
- Id: QuarantineLambda
Arn: !GetAtt Function.Arn
There are two gaps. The first is src/event-pattern.json, which ships as
{"source": ["aws.guardduty"]}. That matches every GuardDuty finding, so the rule
fires on findings you did not mean to act on. The second is src/handler.py, a
skeleton that receives the finding but does not yet read the instance id, log the
isolation, or move anything.
Your first task: the pattern
Narrow the rule so it matches only an EC2 instance finding. An EventBridge
pattern matches a field when the event’s value for that field appears in the list
you give, you can require several fields at once, and content filters like
numeric match a range instead of an exact value. Comparison operators work on
leaf nodes, so nest down to the field you mean rather than matching an object.
Match on the source, the detail-type, the resource type, and a severity floor
together:
{
"source": ["aws.guardduty"],
"detail-type": ["GuardDuty Finding"],
"detail": {
"resource": {
"resourceType": ["Instance"]
},
"severity": [{ "numeric": [">=", 4] }]
}
}
With the source alone, an S3-bucket finding from GuardDuty would still reach the
Lambda. Adding the detail-type and the Instance resource type pins the rule to
findings that have an instance to isolate. The floor of 4 is not arbitrary.
GuardDuty severity runs from 1.0 to 10.0, Low is 1.0 to 3.9, and Medium starts at
4.0, so >= 4 drops the port scans and failed intrusion attempts and keeps
everything Medium and above. The rest is filtered at the bus.
Your second task: the handler
Fill in handler(). The rule delivers the whole EventBridge envelope, and a
GuardDuty finding nests the instance id deep in the detail, at
event["detail"]["resource"]["instanceDetails"]["instanceId"]. Read it, log the
isolation before you act so it is recorded even if the EC2 calls fail, then, when
a quarantine group is configured, replace the instance’s security groups with that
one group:
detail = event["detail"]
instance_id = detail["resource"]["instanceDetails"]["instanceId"]
print(f"quarantine: isolating instance {instance_id}")
ec2.modify_instance_attribute(
InstanceId=instance_id,
Groups=[quarantine_sg],
)
ModifyInstanceAttribute replaces the instance’s groups wholesale and you must
name at least one, so the single quarantine group is the containment. It carries
one trap. On an instance with more than one elastic network interface the call can
fail with InvalidInstanceID, and AWS points you at
ModifyNetworkInterfaceAttribute per interface instead. A production handler
enumerates the interfaces rather than assuming there is one, and it checks the
Auto Scaling group first. Isolation fails the health checks, the group replaces
the instance, and the evidence goes with it, so the real runbook detaches or sets
standby before it touches the groups.
instanceDetails can also be missing, when the instance has already stopped, so
read the id with .get() chains or catch the KeyError rather than indexing
blind. Wrap the EC2 calls in try/except ClientError as well. The test sends a
synthetic instance id so it can check the routing without a real instance running,
and the call raises InvalidInstanceID.NotFound on an id that does not exist.
Catching it keeps the invocation clean; the log line is what proves the handler
ran. In the lab the quarantine group id is usually left blank, and that is fine,
because the handler still logs the instance it would isolate.
Run it
./scripts/deploy.sh # deploys your pattern, uploads your handler
./scripts/test.sh # puts two findings and reads the function's logs
./scripts/teardown.sh # deletes the stack and the log group
test.sh puts two GuardDuty findings on the bus. One is an EC2 instance finding,
which should reach the Lambda; the other is an S3-bucket finding from the same
source, which the right pattern filters out. The instance finding carries a
well-formed id, 17 hexadecimal characters after the i-, because a malformed one
comes back as InvalidInstanceID.Malformed and tells you nothing about routing.
The script then reads the function’s CloudWatch logs and checks what the handler
did:
Handler isolated i-0a1b2c3d4e5f67890: true (expected true)
Lambda invoked for the S3-bucket finding: false (expected false)
PASS: the rule routed the EC2 instance finding to the Lambda, the handler
isolated it, and the S3-bucket finding was filtered out.
If it fails
- Nothing isolated. The handler is probably still the skeleton, or the pattern
does not match the source. Check
src/event-pattern.jsonnamesaws.guarddutyand thathandler()reads the instance id and logs the isolation. - The Lambda ran on the S3-bucket finding too. Your pattern is too broad. It
matches the source but not the detail-type and the
Instanceresource type, so the S3-bucket finding reaches the function. Add thedetail-typeanddetail.resource.resourceTypefilters and redeploy. - The handler ran but the group swap did not take. Expected against the
synthetic id the test sends; the EC2 call raises
InvalidInstanceID.NotFound. Against a real instance the swap applies. The test asserts routing and execution, not the swap.
Reveal the solution
Deploy both reference answers without editing anything:
SRC=solution ./scripts/deploy.sh && ./scripts/test.sh
What you just learned
- An EventBridge rule is a router, and the event pattern holds the routing decision. Narrowing the pattern bills fewer invocations than filtering in the Lambda, and it shrinks the blast radius of a buggy handler, because the function never receives the findings it should not touch.
- A GuardDuty finding is an ordinary EventBridge event, source
aws.guardduty, detail-typeGuardDuty Finding. You filter on the finding shape.detail.resource.resourceTypesays whether there is an instance to act on, and anumericfilter ondetail.severitywith a floor of 4 drops the Low band of 1.0 to 3.9. - The instance id sits at
detail.resource.instanceDetails.instanceId, and isolating an instance means replacing its groups with one that permits almost nothing, viaec2:ModifyInstanceAttribute. On an instance with several network interfaces that call fails, and you modify each interface instead. - Swapping security groups blocks future traffic only. AWS is explicit that existing tracked connections survive the change until they time out, so a stateless network ACL is what severs a live session.
- EventBridge invokes a Lambda only when the function’s resource policy allows
it, which is what the
AWS::Lambda::Permissionwith principalevents.amazonaws.comgrants, scoped bySourceArnto this one rule. - Quarantining the instance leaves stolen role credentials working. Those arrive
as
AccessKeyfindings, which this rule does not match, and they need their own rule and their own action.
Next
The rest of the Security Specialty lab track is in the track’s README.