SCS Lab 02 - Auto-quarantine an instance on a GuardDuty finding
Scaffold: 3/5 (half-built). The Lambda, its execution role, the EventBridge rule, and the permission that lets events invoke the function are all wired. You write the two things that decide whether the right finding reaches the right code: the rule’s event pattern and the handler’s isolation body.
The scenario
GuardDuty watches your account and emits a finding when it spots trouble on an
EC2 instance: a crypto-miner beaconing out, a call from a known-bad IP, an
instance reaching for credentials it should not. The finding is an EventBridge
event: source aws.guardduty, detail-type GuardDuty Finding, with the offending
instance id buried in the finding detail. You want an EC2 instance finding, and
only that, to trigger a Lambda that quarantines the instance by moving it into an
isolation security group (and tagging it) so it can neither talk out nor be
reached.
The tempting shortcut is a broad rule (“anything from aws.guardduty”) with the
filtering done in the Lambda. 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 function on every one of them, pays for every invocation, and puts
the routing decision in code where it is harder to see. The version that earns its
place is a rule whose pattern matches the exact kind of finding, so EventBridge
drops everything else before the function is ever invoked.
What’s provided
template.yaml- a Lambda function (python3.12), an execution role allowed toec2:DescribeInstances,ec2:CreateTags, andec2:ModifyInstanceAttributeand to write logs, an EventBridge rule on the default bus that targets the function, and theAWS::Lambda::Permissionthat lets EventBridge invoke it. The rule’sEventPatternis a parameter, fed from yourevent-pattern.json. AQuarantineSecurityGroupIdparameter (env varQUARANTINE_SG_ID) is passed to the function; leave it blank in the lab and the handler logs the isolation it would perform.src/event-pattern.json- gap one. It ships as{"source": ["aws.guardduty"]}, which matches every GuardDuty finding, so the rule fires on S3 and IAM findings that have no instance to isolate.src/handler.py- gap two. A skeleton with aTODO: it receives the finding but does not yet read the instance id, log the isolation, or move the instance.scripts/- deploy, test, and teardown.solution/event-pattern.jsonandsolution/handler.py- the reference answers.
Your task
Gap one, src/event-pattern.json. Narrow the pattern so it matches only an
EC2 instance finding: source aws.guardduty, detail-type GuardDuty Finding,
and detail.resource.resourceType equal to Instance. EventBridge patterns
match a value when the event’s field appears in the list you give, and content
filters (like numeric) let you match a range. You can also require the finding
to clear a severity threshold:
{
"source": ["aws.guardduty"],
"detail-type": ["GuardDuty Finding"],
"detail": {
"resource": {
"resourceType": ["Instance"]
},
"severity": [{ "numeric": [">=", 4] }]
}
}
Gap two, src/handler.py. Fill in handler(): read the instance id from
event["detail"]["resource"]["instanceDetails"]["instanceId"], log the isolation
(log it before you touch EC2, so it is recorded even if the calls fail), then,
if QUARANTINE_SG_ID is set, call ec2.modify_instance_attribute to replace the
instance’s security groups with [QUARANTINE_SG_ID]. Wrap the EC2 calls in
try/except ClientError so the fake instance id the test sends does not crash the
invocation.
Run it
# Defaults: stack scs-lab-02, region ap-southeast-2.
./scripts/deploy.sh # deploys with your event-pattern.json, uploads your handler.py
./scripts/test.sh # puts two findings and reads the function's logs
./scripts/teardown.sh # deletes the stack and the Lambda log group
Before your first lab, do the one-time setup: run the zip’s preflight.sh, then
deploy the lab reaper so a forgotten stack cannot bill you. Every
deploy here tags its stack for the reaper.
What success looks like
./scripts/test.sh puts two GuardDuty findings on the bus, an EC2 instance
finding and an S3-bucket finding, waits, and reads the function’s CloudWatch logs:
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.
With the starter pattern the handler also acts on the S3 finding, so the test
reports it saw i-0lab02ignored00002 and tells you to narrow the pattern. With
the skeleton handler nothing is isolated at all, and the test tells you to fill in
handler.py.
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: the event pattern decides what reaches the target, before any of your code runs. Narrowing the pattern is cheaper and clearer than filtering in the Lambda, and it shrinks the blast radius of a buggy handler.
- A GuardDuty finding is an ordinary EventBridge event, source
aws.guardduty, detail-typeGuardDuty Finding, and you filter on the finding shape:detail.resource.resourceTypetells you whether there is an instance to act on, and anumericcontent filter ondetail.severitylets you ignore low-severity noise. - The instance id lives deep in the finding, at
detail.resource.instanceDetails.instanceId; isolation is replacing the instance’s security groups with a single group that permits nothing, viaec2:ModifyInstanceAttribute. - EventBridge can only invoke a Lambda if the function’s resource policy grants
it, which is what the
AWS::Lambda::Permissionwith principalevents.amazonaws.comdoes; theSourceArnpins the grant 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 with a condition rather than
Resource: '*'.
Next
The rest of the SCS Pro lab track is listed in labs/README-scs.md.