Exam Room · Security

Lab: Auto-Quarantine an Instance on a GuardDuty Finding

January 22, 2028 · 13 min read

Cloud Security · part of The Exam Room

This is a lab in the Security Specialty hands-on track. Detection and response is one of the domain’s recurring shapes: a service notices something bad, an event carries the detail, and a small piece of code contains the damage before a human is awake to see it. The reference posts argue when automated response is a good idea; this stands one up. You build the smallest honest slice, a rule that routes one kind of GuardDuty finding to one Lambda that isolates the offending instance. 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 watches your account and raises a finding when it spots trouble on an EC2 instance: a crypto-miner beaconing out, traffic from a known-bad IP, an instance reaching for credentials it has no business touching. The finding lands on the default EventBridge bus as an event with source aws.guardduty, detail-type GuardDuty Finding, and the offending instance id buried in the finding detail. You want that finding to trigger a Lambda that quarantines the instance by moving it into an isolation security group, one that permits no ingress and no egress, so the instance can neither talk to its operator nor reach anything else in your account.

The lazy design is a broad rule that catches everything from aws.guardduty and sorts it out in the function. GuardDuty also raises findings for S3 buckets, IAM principals, and access keys, none of which have an instance to isolate. A broad rule fires the Lambda on every one of them, costs an invocation each time, and buries the routing decision in code. The design that scores on a Specialty paper is a rule whose pattern matches the exact kind of finding, so EventBridge drops everything else before the function is invoked at all. The routing is declarative, visible in the template, and cheaper.

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 let you match a range instead of an exact value. 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 actually have an instance to isolate, and the numeric filter on severity drops the low-severity noise. Everything else 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],
)

Wrap the EC2 calls in try/except ClientError. The test sends a fake instance id so it can check the routing without a real instance running, and the call will raise 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: the handler still logs the instance it would isolate, which is all the test needs.

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 should filter out. It then reads the function’s CloudWatch logs and checks what the handler did:

Handler isolated i-0lab02quarantine1: true   (expected true)
Handler saw ignored i-0lab02ignored00002:  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 matches aws.guardduty and that handler() reads the instance id and logs the isolation.
  • It also acted on the ignored finding. 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 Lambda too. Add the detail-type and detail.resource.resourceType filters and redeploy.
  • The handler ran but the group swap did not take. That is expected against the fake id the test sends; the EC2 call raises NotFound. Against a real instance the swap would apply. The test asserts routing and execution, not the swap itself.

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 is where the routing decision lives. Narrowing the pattern is cheaper and clearer than filtering in the Lambda, and it shrinks the blast radius of a buggy handler because the function never sees 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 tells you whether there is an instance to act on, and a numeric content filter on detail.severity lets you ignore the low-severity noise.
  • The instance id lives deep in the finding, at detail.resource.instanceDetails.instanceId, and isolating an instance means replacing its security groups with one that permits nothing, via ec2:ModifyInstanceAttribute.
  • EventBridge can only invoke a Lambda 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.
  • Auto-quarantine is a routed finding plus a least-privilege handler. The role here can describe, tag, and modify instances and nothing else, and in production you would scope even those to a tag or an account boundary rather than every instance.

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.