Exam Room · Advanced Security Specialist

Lab: Auto-Quarantine an Instance on a GuardDuty Finding

· 17 min read

Cloud Security · part of The Exam Room

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.json names aws.guardduty and that handler() 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 Instance resource type, so the S3-bucket finding reaches the function. Add the detail-type and detail.resource.resourceType filters 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-type GuardDuty Finding. You filter on the finding shape. detail.resource.resourceType says whether there is an instance to act on, and a numeric filter on detail.severity with 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, via ec2: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::Permission with principal events.amazonaws.com grants, scoped by SourceArn to this one rule.
  • Quarantining the instance leaves stolen role credentials working. Those arrive as AccessKey findings, 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.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.