Auto-Quarantine Compromised Instances

Build a self-healing security system that auto-isolates compromised EC2 instances.

Introduction

30 Second Summary

When a burglar breaks into a building, the alarm goes off and the doors lock automatically. No one has to be standing at the security desk pressing buttons.

In this project, you will build a self-healing security system on AWS that detects compromised servers and automatically quarantines them. You will deploy the entire pipeline as code using Terraform, so your security infrastructure is version-controlled, repeatable, and interview-ready.

What You'll Build

A production-pattern security automation system that monitors your VPC for threats and automatically isolates compromised EC2 instances using an event-driven pipeline.

By the end of this project, you'll have:

  • A Terraform configuration defining a full security stack including a VPC, GuardDuty detector, EventBridge rule, Lambda function, and SNS topic.
  • A working auto-quarantine pipeline that replaces an instance's security groups with a deny-all quarantine SG, takes a forensic EBS snapshot, and sends you an email alert.
  • A D2 architecture diagram rendered as SVG and committed alongside your code as a self-documenting artifact.
  • Secret Mission: Add a second automated remediation that detects when someone opens a security group to 0.0.0.0/0 and automatically revokes the overly permissive rule.

Are there any prerequisites?

You need an AWS account, basic comfort with a Linux terminal, and familiarity with networking concepts like subnets, security groups, and route tables. You do not need prior Terraform experience.

Set Up Your Terraform Project

Your tools are installed and configured. Now it's time to create the Terraform project that will hold your entire security system as code.

Terraform uses configuration files to describe your infrastructure declaratively. In this step, you'll set up the project structure and pin the exact provider versions your stack depends on.

In this step, get ready to:
  • Create the project directory.
  • Define the Terraform provider configuration in main.tf.
  • Create the lambda/ subdirectory for the quarantine function.
Create the project directory

Every Terraform project lives in its own directory. Each .tf file inside the directory contributes to the overall infrastructure definition.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir auto-quarantine
cd auto-quarantine
  • Confirm the folder was created by running:
pwd

You should see /home/your-username/Desktop/auto-quarantine (or similar) in the output.

Define the Terraform provider configuration

The first file in any Terraform project is main.tf. This is where you declare which providers your project uses and what versions it requires.

  • Create a file called main.tf inside your auto-quarantine folder and open it in your text editor.
  • Add the following terraform block that pins your provider versions:
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.43"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
  }
}

What does this code do?

The terraform {} block sets project-wide constraints before any resources are created.

  • required_version = ">= 1.0" ensures anyone running this project has Terraform 1.0 or later.
  • hashicorp/aws ~> 6.43 pins the AWS provider to version 6.43.x. This prevents breaking changes from newer major versions.
  • hashicorp/archive ~> 2.0 provides the archive_file data source. You'll use this later to zip your Lambda function code for deployment.
  • Below the terraform {} block, add the provider configuration, input variable, and data sources:
provider "aws" {
  region = "us-east-1"
}

variable "notification_email" {
  description = "Email address for quarantine notifications"
  type        = string
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

What does this code do?

  • provider "aws" tells Terraform to deploy all resources into the us-east-1 region.
  • variable "notification_email" defines an input that Terraform will ask you for at deploy time. This is where quarantine alerts get sent.
  • The two data sources look up your AWS account ID and current region at runtime. Other files will reference these values without hardcoding them.
  • Save main.tf.

Seeing syntax highlighting issues?

Make sure your file is saved with the .tf extension. Some editors need a Terraform/HCL extension installed to recognize the syntax.

help me set up HCL syntax highlighting in my text editor

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf file. Make sure yours matches exactly:

terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.43"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

variable "notification_email" {
  description = "Email address for quarantine notifications"
  type        = string
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
Create the lambda/ subdirectory

Your quarantine logic will live in a Python file inside a dedicated lambda/ subdirectory. Terraform's archive_file data source will zip this directory for deployment later.

  • Create the subdirectory by running:
mkdir lambda
  • Confirm your project structure by running:
ls

You should see lambda and main.tf listed in the output.

Don't see both items?

Make sure you're inside the auto-quarantine directory. Run pwd to check your current location.

If you saved main.tf from your editor to a different location, move it into auto-quarantine/ now.

help me fix my project directory structure for Terraform

Your Terraform project skeleton is in place. Next up, you'll define the VPC, networking, and the EC2 instance that will serve as your quarantine test subject.

Define the VPC and Network Foundation

Your tools are installed and your AWS credentials are verified. Now it's time to build the network that your security system will protect.

Every auto-quarantine system needs something to quarantine. In this step, you'll define a VPC with a public subnet, an internet gateway, and a test EC2 instance. You'll also create the quarantine security group that your Lambda function will swap onto compromised instances later.

In this step, get ready to:
  • Define a VPC with a public subnet and internet gateway.
  • Create a quarantine security group that blocks all traffic.
  • Launch a test EC2 instance as the quarantine target.
Create the VPC and networking resources

A VPC is your own isolated network inside AWS. You need a public subnet so the test instance can reach the internet, and an internet gateway to provide that route.

  • Create a new file called vpc.tf inside your project directory (the same folder where main.tf lives).
  • Add the VPC, subnet, internet gateway, route table, and route table association by pasting this code into vpc.tf:
resource "aws_vpc" "security_vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "security-auto-quarantine-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.security_vpc.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "us-east-1a"

  tags = {
    Name = "security-public-subnet"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.security_vpc.id

  tags = {
    Name = "security-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.security_vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "security-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

  • aws_vpc.security_vpc creates an isolated network with the CIDR range 10.0.0.0/16, giving you 65,536 IP addresses to work with.
  • aws_subnet.public carves out a smaller /24 block (256 addresses) and assigns public IPs automatically on launch.
  • aws_internet_gateway.igw connects the VPC to the public internet.
  • aws_route_table.public routes all outbound traffic (0.0.0.0/0) to the internet gateway, and the association links this route table to the subnet.
  • Save vpc.tf.
  • Verify that Terraform can parse your new file by running:
terraform validate

You should see Success! The configuration is valid. in your terminal output.

Seeing a validation error?

  • Check that vpc.tf is in the same directory as main.tf. Terraform reads all .tf files in the current directory.
  • Make sure every opening brace { has a matching closing brace }. A missing brace is the most common HCL syntax error.
  • If you see "Could not satisfy plugin requirements", run terraform init first to download the AWS provider.

Still stuck? Get help here.

Define the security groups

Security groups act as virtual firewalls for your instances. You need two: a "normal" group that allows standard traffic, and a "quarantine" group with zero rules that blocks everything.

Why does an empty security group block all traffic?

AWS security groups are deny-by-default. If you don't add any ingress or egress rules, the group blocks all traffic in both directions. This makes an empty SG the perfect quarantine mechanism.

  • Add the normal and quarantine security groups below the route table association in vpc.tf:
resource "aws_security_group" "normal" {
  name        = "normal-instance-sg"
  description = "Normal security group for test instance"
  vpc_id      = aws_vpc.security_vpc.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "normal-instance-sg"
  }
}

resource "aws_security_group" "quarantine" {
  name        = "quarantine-no-traffic"
  description = "Quarantine SG - denies all inbound and outbound traffic"
  vpc_id      = aws_vpc.security_vpc.id

  tags = {
    Name        = "quarantine-no-traffic"
    Purpose     = "auto-quarantine"
    ManagedBy   = "terraform"
  }
}

What does this code do?

  • aws_security_group.normal allows SSH on port 22 inbound and all traffic outbound. This simulates a typical instance before compromise.
  • aws_security_group.quarantine has no ingress or egress rules. When your Lambda swaps an instance's SG to this one, the instance is completely cut off from the network.
  • The Purpose tag on the quarantine SG helps identify its role when you inspect resources in the AWS console.
  • Save vpc.tf.
  • Run terraform validate again. You should still see Success! The configuration is valid.

Seeing a reference error?

If you see an error about aws_vpc.security_vpc not being defined, make sure the VPC resource from the first chunk is still at the top of your vpc.tf file. Both security groups reference the VPC's ID.

Need help debugging? Ask here.

Add the target EC2 instance

You need a real EC2 instance to demonstrate the quarantine on. This instance will start with the normal security group, and your Lambda function will swap it to the quarantine SG when a threat is detected.

  • Add the AMI data source and EC2 instance below the quarantine security group in vpc.tf:
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "target" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.normal.id]

  tags = {
    Name = "quarantine-test-instance"
  }
}

What does this code do?

  • data.aws_ami.amazon_linux dynamically looks up the latest Amazon Linux 2023 AMI so you don't hardcode an ID that goes stale.
  • aws_instance.target launches a t3.micro instance in your public subnet with the normal security group attached. This is the instance your Lambda will quarantine.
  • vpc_security_group_ids accepts a list of SG IDs. During quarantine, the Lambda replaces this list with just the quarantine SG.
  • Save vpc.tf.
  • Run terraform validate one more time to confirm everything parses correctly. You should see Success! The configuration is valid.

Seeing an error about the AMI filter?

Make sure the filter value is exactly al2023-ami-*-x86_64 with the asterisk wildcard. The asterisk matches any version suffix in the AMI name.

Need help? Ask here.

✔️ Awesome, I've got everything!

Great. Double check you've saved vpc.tf and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete vpc.tf file. Compare it with yours to spot any differences.

resource "aws_vpc" "security_vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "security-auto-quarantine-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.security_vpc.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "us-east-1a"

  tags = {
    Name = "security-public-subnet"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.security_vpc.id

  tags = {
    Name = "security-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.security_vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "security-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "normal" {
  name        = "normal-instance-sg"
  description = "Normal security group for test instance"
  vpc_id      = aws_vpc.security_vpc.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "normal-instance-sg"
  }
}

resource "aws_security_group" "quarantine" {
  name        = "quarantine-no-traffic"
  description = "Quarantine SG - denies all inbound and outbound traffic"
  vpc_id      = aws_vpc.security_vpc.id

  tags = {
    Name        = "quarantine-no-traffic"
    Purpose     = "auto-quarantine"
    ManagedBy   = "terraform"
  }
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "target" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.normal.id]

  tags = {
    Name = "quarantine-test-instance"
  }
}

Your network foundation is in place. You have a VPC, a subnet, a test instance, and the quarantine SG ready to isolate compromised resources. Next up, you'll deploy GuardDuty and wire up the event pipeline that triggers the quarantine.

Deploy GuardDuty and the Event Pipeline

Your VPC is live with a target instance sitting behind its normal security group. Now you need a way to detect threats against that instance and route alerts to your quarantine logic.

In this step, you will wire together the detection and routing layer. GuardDuty watches your account for threats. EventBridge filters those findings by severity. SNS delivers email notifications so you know when something fires.

In this step, get ready to:
  • Enable GuardDuty as your threat detection layer.
  • Create an EventBridge rule that filters for high-severity findings.
  • Set up an SNS topic and email subscription for quarantine alerts.
Enable GuardDuty threat detection

GuardDuty is an AI-powered threat detection service that continuously monitors your AWS account for malicious activity. It analyzes VPC flow logs, DNS queries, and CloudTrail events to identify compromised instances.

  • Create a new file called guardduty.tf in your project directory.
  • Add the GuardDuty detector resource by pasting this code:
resource "aws_guardduty_detector" "main" {
  enable                       = true
  finding_publishing_frequency = "FIFTEEN_MINUTES"

  tags = {
    Name      = "security-detector"
    ManagedBy = "terraform"
  }
}

What does this code do?

This creates a GuardDuty detector in your account. The enable = true flag turns on monitoring immediately.

The finding_publishing_frequency controls how often GuardDuty sends updated findings to EventBridge. Setting it to FIFTEEN_MINUTES is the fastest interval available for standalone accounts.

  • Save guardduty.tf.

GuardDuty has a 30-day free trial

New accounts get 30 days of free GuardDuty monitoring. After that, charges are per-event analyzed. For a low-traffic personal account, expect less than $5/month. You will destroy this resource at the end of the project, so costs are negligible.

Create the EventBridge severity filter

GuardDuty publishes every finding to EventBridge automatically. You only want to quarantine instances for high-severity threats (severity 7 and above), so you need a rule that filters out lower-severity noise.

  • Create a new file called eventbridge.tf in your project directory.
  • Add the EventBridge rule resource by pasting this code:
resource "aws_cloudwatch_event_rule" "guardduty_high_severity" {
  name        = "guardduty-high-severity-findings"
  description = "Captures GuardDuty findings with severity >= 7 for auto-quarantine"

  event_pattern = jsonencode({
    source      = ["aws.guardduty"]
    detail-type = ["GuardDuty Finding"]
    detail = {
      severity = [{ numeric = [">=", 7] }]
    }
  })

  tags = {
    Name      = "guardduty-high-severity-rule"
    ManagedBy = "terraform"
  }
}

What does this event pattern do?

The event_pattern block tells EventBridge which events to match. It filters for events from aws.guardduty with the detail-type GuardDuty Finding.

The numeric filter on severity means only findings scored 7 or higher (HIGH severity) will trigger this rule. Lower-severity findings pass through without triggering quarantine.

  • Save eventbridge.tf.

Seeing JSON syntax errors?

The jsonencode() function converts the HCL map into JSON for the event pattern. Make sure your curly braces and square brackets are balanced. The numeric key uses nested brackets: [{ numeric = [">=", 7] }].

Help me fix a JSON syntax error in my EventBridge event_pattern block

Define the SNS notification topic

When your Lambda quarantines an instance, you want to know about it immediately. An SNS topic with an email subscription delivers that alert straight to your inbox.

  • Create a new file called sns.tf in your project directory.
  • Add the SNS topic and email subscription by pasting this code:
resource "aws_sns_topic" "quarantine_alerts" {
  name = "guardduty-quarantine-alerts"

  tags = {
    Name      = "guardduty-quarantine-alerts"
    ManagedBy = "terraform"
  }
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.quarantine_alerts.arn
  protocol  = "email"
  endpoint  = var.notification_email
}

What does this code do?

The aws_sns_topic creates a messaging channel that your Lambda function can publish alerts to.

The aws_sns_topic_subscription subscribes your email address to that topic. It uses var.notification_email which you defined in main.tf in the previous step. Terraform will prompt you for this value when you apply.

  • Save sns.tf.
  • Confirm your files exist by running:
ls *.tf

You should see eventbridge.tf, guardduty.tf, main.tf, sns.tf, and vpc.tf listed in the output.

Missing a file?

Make sure you are running ls from inside your project directory. If you see only main.tf and vpc.tf, double check that you saved the new files in the correct folder.

Help me figure out why my Terraform files are not showing up

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files before moving on.

ⓧ I'd like to double check the full code

Here is the complete content of each new file.

resource "aws_guardduty_detector" "main" {
  enable                       = true
  finding_publishing_frequency = "FIFTEEN_MINUTES"

  tags = {
    Name      = "security-detector"
    ManagedBy = "terraform"
  }
}
resource "aws_cloudwatch_event_rule" "guardduty_high_severity" {
  name        = "guardduty-high-severity-findings"
  description = "Captures GuardDuty findings with severity >= 7 for auto-quarantine"

  event_pattern = jsonencode({
    source      = ["aws.guardduty"]
    detail-type = ["GuardDuty Finding"]
    detail = {
      severity = [{ numeric = [">=", 7] }]
    }
  })

  tags = {
    Name      = "guardduty-high-severity-rule"
    ManagedBy = "terraform"
  }
}
resource "aws_sns_topic" "quarantine_alerts" {
  name = "guardduty-quarantine-alerts"

  tags = {
    Name      = "guardduty-quarantine-alerts"
    ManagedBy = "terraform"
  }
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.quarantine_alerts.arn
  protocol  = "email"
  endpoint  = var.notification_email
}

Your detection and routing layer is defined. The event pipeline will flow from GuardDuty through EventBridge to your Lambda function. Next up, you will write the Lambda function that actually performs the quarantine.

Build the Quarantine Lambda Function

You have a detection pipeline that spots threats and a notification channel ready to alert you. Now you need the brain that connects them: a Lambda function that receives the GuardDuty finding and automatically isolates the compromised instance.

This function performs three actions in sequence: swap the instance's security groups to the quarantine SG, take a forensic EBS snapshot for later investigation, and send an SNS alert to you. You will also define the least-privilege IAM role that gives the function only the permissions it needs.

In this step, get ready to:
  • Write a Python Lambda function that quarantines instances, takes forensic snapshots, and sends notifications.
  • Define a least-privilege IAM role and the Lambda resource in Terraform.
  • Wire the EventBridge rule to trigger the Lambda and create useful Terraform outputs.
Write the quarantine Lambda function

The Lambda function receives a GuardDuty finding event from EventBridge, extracts the compromised instance ID, then orchestrates the quarantine response.

  • Create the file lambda/quarantine.py in your project directory and add the following imports, client setup, and main handler:
import json
import os
import boto3
from datetime import datetime

ec2 = boto3.client("ec2")
sns = boto3.client("sns")

QUARANTINE_SG_ID = os.environ["QUARANTINE_SG_ID"]
SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]


def lambda_handler(event, context):
    """Auto-quarantine handler triggered by GuardDuty high-severity findings."""
    print(f"Received event: {json.dumps(event)}")

    # Extract finding details from the EventBridge event
    detail = event.get("detail", {})
    finding_type = detail.get("type", "Unknown")
    severity = detail.get("severity", 0)
    description = detail.get("description", "No description")

    # Dig into the resource block to find the affected instance
    resource = detail.get("resource", {})
    instance_details = resource.get("instanceDetails", {})
    instance_id = instance_details.get("instanceId")

    if not instance_id:
        print("No instance ID found in finding. Skipping quarantine.")
        return {"statusCode": 200, "body": "No instance to quarantine"}

    print(f"Quarantining instance {instance_id} due to: {finding_type} (severity: {severity})")

    # Execute the three-step quarantine response
    quarantine_instance(instance_id)
    snapshot_id = create_forensic_snapshot(instance_id)
    send_notification(instance_id, finding_type, severity, description, snapshot_id)

    return {
        "statusCode": 200,
        "body": f"Instance {instance_id} quarantined successfully"
    }

What does this code do?

The lambda_handler is the entry point that EventBridge calls. It navigates the nested JSON structure of a GuardDuty finding to extract the instance ID from detail.resource.instanceDetails.instanceId.

The environment variables QUARANTINE_SG_ID and SNS_TOPIC_ARN are injected by Terraform at deploy time. This keeps the function reusable across environments without hardcoding resource IDs.

  • Save the file.
  • Confirm the file was created by running:
ls lambda/quarantine.py
  • You should see lambda/quarantine.py in the output.

File not found?

Make sure you are running the command from your project root directory (the folder containing main.tf). The lambda/ subdirectory should already exist from the previous step.

help me find my quarantine.py file

  • Now add the three helper functions below lambda_handler in the same file. Start with quarantine_instance and create_forensic_snapshot:

def quarantine_instance(instance_id):
    """Replace instance security groups with quarantine SG."""
    ec2.modify_instance_attribute(
        InstanceId=instance_id,
        Groups=[QUARANTINE_SG_ID]
    )
    ec2.create_tags(
        Resources=[instance_id],
        Tags=[
            {"Key": "QuarantinedAt", "Value": datetime.utcnow().isoformat()},
            {"Key": "SecurityStatus", "Value": "QUARANTINED"},
        ]
    )
    print(f"Instance {instance_id} security groups replaced with quarantine SG {QUARANTINE_SG_ID}")


def create_forensic_snapshot(instance_id):
    """Create EBS snapshots of all volumes attached to the instance for forensics."""
    volumes = ec2.describe_volumes(
        Filters=[{"Name": "attachment.instance-id", "Values": [instance_id]}]
    )

    snapshot_ids = []
    for volume in volumes["Volumes"]:
        volume_id = volume["VolumeId"]
        snapshot = ec2.create_snapshot(
            VolumeId=volume_id,
            Description=f"Forensic snapshot - quarantined instance {instance_id}",
            TagSpecifications=[
                {
                    "ResourceType": "snapshot",
                    "Tags": [
                        {"Key": "Name", "Value": f"forensic-{instance_id}-{volume_id}"},
                        {"Key": "Purpose", "Value": "quarantine-forensics"},
                        {"Key": "SourceInstance", "Value": instance_id},
                    ]
                }
            ]
        )
        snapshot_ids.append(snapshot["SnapshotId"])
        print(f"Created forensic snapshot {snapshot['SnapshotId']} for volume {volume_id}")

    return ", ".join(snapshot_ids) if snapshot_ids else "No volumes found"

What do these functions do?

  • quarantine_instance calls modify_instance_attribute to swap the instance's security groups with the deny-all quarantine SG. It also tags the instance with the quarantine timestamp so you can trace when isolation happened.
  • create_forensic_snapshot finds all EBS volumes attached to the instance and takes a point-in-time snapshot of each. These snapshots preserve evidence for later investigation without keeping the compromised instance running.

You won't see a change yet. The next function completes the file.

  • Add the final send_notification function below create_forensic_snapshot:

def send_notification(instance_id, finding_type, severity, description, snapshot_id):
    """Send SNS notification about the quarantine action."""
    message = (
        f"SECURITY ALERT: Instance Auto-Quarantined\n"
        f"{'=' * 50}\n"
        f"Instance ID: {instance_id}\n"
        f"Finding Type: {finding_type}\n"
        f"Severity: {severity}\n"
        f"Description: {description}\n"
        f"Action Taken: Security groups replaced with quarantine SG\n"
        f"Forensic Snapshots: {snapshot_id}\n"
        f"Quarantine SG: {QUARANTINE_SG_ID}\n"
        f"Timestamp: {datetime.utcnow().isoformat()}Z\n"
        f"{'=' * 50}\n"
        f"Next Steps:\n"
        f"1. Review GuardDuty finding in AWS Console\n"
        f"2. Analyze forensic snapshots\n"
        f"3. Determine if instance should be terminated or restored\n"
    )

    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=f"[QUARANTINE] Instance {instance_id} isolated - {finding_type}",
        Message=message
    )
    print(f"Notification sent to {SNS_TOPIC_ARN}")

What does send_notification do?

This function formats a detailed alert message with the instance ID, finding type, severity, and forensic snapshot IDs. It publishes to SNS so you receive an email with all the context needed to start your investigation.

  • Save lambda/quarantine.py.
  • Verify the Python syntax is valid by running:
python3 -m py_compile lambda/quarantine.py
  • If the command returns with no output, your syntax is correct.

Seeing a SyntaxError?

Check that all f-strings use matching quotes and that every opening parenthesis or bracket has a closing match. Pay close attention to the nested dictionary structures in create_forensic_snapshot.

help me fix my Python syntax error

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import json
import os
import boto3
from datetime import datetime

ec2 = boto3.client("ec2")
sns = boto3.client("sns")

QUARANTINE_SG_ID = os.environ["QUARANTINE_SG_ID"]
SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]


def lambda_handler(event, context):
    """Auto-quarantine handler triggered by GuardDuty high-severity findings."""
    print(f"Received event: {json.dumps(event)}")

    detail = event.get("detail", {})
    finding_type = detail.get("type", "Unknown")
    severity = detail.get("severity", 0)
    description = detail.get("description", "No description")

    resource = detail.get("resource", {})
    instance_details = resource.get("instanceDetails", {})
    instance_id = instance_details.get("instanceId")

    if not instance_id:
        print("No instance ID found in finding. Skipping quarantine.")
        return {"statusCode": 200, "body": "No instance to quarantine"}

    print(f"Quarantining instance {instance_id} due to: {finding_type} (severity: {severity})")

    quarantine_instance(instance_id)
    snapshot_id = create_forensic_snapshot(instance_id)
    send_notification(instance_id, finding_type, severity, description, snapshot_id)

    return {
        "statusCode": 200,
        "body": f"Instance {instance_id} quarantined successfully"
    }


def quarantine_instance(instance_id):
    """Replace instance security groups with quarantine SG."""
    ec2.modify_instance_attribute(
        InstanceId=instance_id,
        Groups=[QUARANTINE_SG_ID]
    )
    ec2.create_tags(
        Resources=[instance_id],
        Tags=[
            {"Key": "QuarantinedAt", "Value": datetime.utcnow().isoformat()},
            {"Key": "SecurityStatus", "Value": "QUARANTINED"},
        ]
    )
    print(f"Instance {instance_id} security groups replaced with quarantine SG {QUARANTINE_SG_ID}")


def create_forensic_snapshot(instance_id):
    """Create EBS snapshots of all volumes attached to the instance for forensics."""
    volumes = ec2.describe_volumes(
        Filters=[{"Name": "attachment.instance-id", "Values": [instance_id]}]
    )

    snapshot_ids = []
    for volume in volumes["Volumes"]:
        volume_id = volume["VolumeId"]
        snapshot = ec2.create_snapshot(
            VolumeId=volume_id,
            Description=f"Forensic snapshot - quarantined instance {instance_id}",
            TagSpecifications=[
                {
                    "ResourceType": "snapshot",
                    "Tags": [
                        {"Key": "Name", "Value": f"forensic-{instance_id}-{volume_id}"},
                        {"Key": "Purpose", "Value": "quarantine-forensics"},
                        {"Key": "SourceInstance", "Value": instance_id},
                    ]
                }
            ]
        )
        snapshot_ids.append(snapshot["SnapshotId"])
        print(f"Created forensic snapshot {snapshot['SnapshotId']} for volume {volume_id}")

    return ", ".join(snapshot_ids) if snapshot_ids else "No volumes found"


def send_notification(instance_id, finding_type, severity, description, snapshot_id):
    """Send SNS notification about the quarantine action."""
    message = (
        f"SECURITY ALERT: Instance Auto-Quarantined\n"
        f"{'=' * 50}\n"
        f"Instance ID: {instance_id}\n"
        f"Finding Type: {finding_type}\n"
        f"Severity: {severity}\n"
        f"Description: {description}\n"
        f"Action Taken: Security groups replaced with quarantine SG\n"
        f"Forensic Snapshots: {snapshot_id}\n"
        f"Quarantine SG: {QUARANTINE_SG_ID}\n"
        f"Timestamp: {datetime.utcnow().isoformat()}Z\n"
        f"{'=' * 50}\n"
        f"Next Steps:\n"
        f"1. Review GuardDuty finding in AWS Console\n"
        f"2. Analyze forensic snapshots\n"
        f"3. Determine if instance should be terminated or restored\n"
    )

    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=f"[QUARANTINE] Instance {instance_id} isolated - {finding_type}",
        Message=message
    )
    print(f"Notification sent to {SNS_TOPIC_ARN}")
Define the IAM role and Lambda resource

Your Lambda function needs permissions to modify EC2 instances, create snapshots, and publish to SNS. Following the principle of least privilege, you will grant only the specific actions required.

Why least-privilege IAM?

A security automation tool with overly broad permissions becomes a liability if compromised. By scoping each permission to the exact API call needed, you limit the blast radius if something goes wrong.

  • Create a new file called iam.tf in your project directory and add the following IAM role and policy:
resource "aws_iam_role" "lambda_quarantine" {
  name = "lambda-quarantine-role"

  # Allow the Lambda service to assume this role
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name      = "lambda-quarantine-role"
    ManagedBy = "terraform"
  }
}

resource "aws_iam_role_policy" "quarantine_permissions" {
  name = "quarantine-permissions"
  role = aws_iam_role.lambda_quarantine.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EC2Quarantine"
        Effect = "Allow"
        Action = [
          "ec2:ModifyInstanceAttribute",
          "ec2:DescribeInstances",
          "ec2:DescribeVolumes",
          "ec2:CreateSnapshot",
          "ec2:CreateTags"
        ]
        Resource = "*"
      },
      {
        Sid    = "SNSNotify"
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = aws_sns_topic.quarantine_alerts.arn
      },
      {
        Sid    = "CloudWatchLogs"
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

What does this code do?

  • The assume_role_policy allows only the Lambda service to use this role. No other AWS service or user can assume it.
  • EC2Quarantine grants the five EC2 actions the function needs: modify security groups, describe instances and volumes, create snapshots, and apply tags.
  • SNSNotify scopes sns:Publish to only your quarantine alerts topic. The function cannot publish to any other SNS topic.
  • CloudWatchLogs allows the function to write its own execution logs. Without this, you would have no visibility into Lambda runs.
  • Save iam.tf.

You won't see a change yet. The Lambda resource in the next chunk uses this role.

  • Create a new file called lambda.tf and add the archive data source and Lambda function resource:
# Package the Python file into a zip for Lambda deployment
data "archive_file" "quarantine_lambda" {
  type        = "zip"
  source_file = "${path.module}/lambda/quarantine.py"
  output_path = "${path.module}/lambda/quarantine.zip"
}

resource "aws_lambda_function" "quarantine" {
  filename         = data.archive_file.quarantine_lambda.output_path
  function_name    = "guardduty-auto-quarantine"
  role             = aws_iam_role.lambda_quarantine.arn
  handler          = "quarantine.lambda_handler"
  source_code_hash = data.archive_file.quarantine_lambda.output_base64sha256
  runtime          = "python3.12"
  timeout          = 60

  # Pass resource IDs to the function as environment variables
  environment {
    variables = {
      QUARANTINE_SG_ID = aws_security_group.quarantine.id
      SNS_TOPIC_ARN    = aws_sns_topic.quarantine_alerts.arn
    }
  }

  tags = {
    Name      = "guardduty-auto-quarantine"
    ManagedBy = "terraform"
  }
}

What does this code do?

  • The archive_file data source zips your Python file automatically during terraform apply. You never need to manually zip the function.
  • source_code_hash tells Terraform to redeploy the function whenever the Python code changes. Without it, Terraform would not detect code updates.
  • The environment block injects the quarantine security group ID and SNS topic ARN at deploy time. These are the values your Python code reads with os.environ.
  • Save lambda.tf.
  • Verify your Terraform configuration is valid by running:
terraform validate
  • You should see Success! The configuration is valid. in the output.

Seeing a validation error?

If you see an error about an unsupported argument or missing resource, double check that iam.tf and lambda.tf are saved in the same directory as main.tf. Also confirm that lambda/quarantine.py exists, since the archive data source references it.

help me fix my terraform validate error

✔️ Awesome, I've got everything!

Great. Confirm both files are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete iam.tf:

resource "aws_iam_role" "lambda_quarantine" {
  name = "lambda-quarantine-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name      = "lambda-quarantine-role"
    ManagedBy = "terraform"
  }
}

resource "aws_iam_role_policy" "quarantine_permissions" {
  name = "quarantine-permissions"
  role = aws_iam_role.lambda_quarantine.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EC2Quarantine"
        Effect = "Allow"
        Action = [
          "ec2:ModifyInstanceAttribute",
          "ec2:DescribeInstances",
          "ec2:DescribeVolumes",
          "ec2:CreateSnapshot",
          "ec2:CreateTags"
        ]
        Resource = "*"
      },
      {
        Sid    = "SNSNotify"
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = aws_sns_topic.quarantine_alerts.arn
      },
      {
        Sid    = "CloudWatchLogs"
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

And the complete lambda.tf:

data "archive_file" "quarantine_lambda" {
  type        = "zip"
  source_file = "${path.module}/lambda/quarantine.py"
  output_path = "${path.module}/lambda/quarantine.zip"
}

resource "aws_lambda_function" "quarantine" {
  filename         = data.archive_file.quarantine_lambda.output_path
  function_name    = "guardduty-auto-quarantine"
  role             = aws_iam_role.lambda_quarantine.arn
  handler          = "quarantine.lambda_handler"
  source_code_hash = data.archive_file.quarantine_lambda.output_base64sha256
  runtime          = "python3.12"
  timeout          = 60

  environment {
    variables = {
      QUARANTINE_SG_ID = aws_security_group.quarantine.id
      SNS_TOPIC_ARN    = aws_sns_topic.quarantine_alerts.arn
    }
  }

  tags = {
    Name      = "guardduty-auto-quarantine"
    ManagedBy = "terraform"
  }
}
Wire EventBridge to the Lambda function

The EventBridge rule knows which findings to capture, and the Lambda function knows what to do with them. Now you need to connect the two by defining a target and granting EventBridge permission to invoke the function.

  • Open eventbridge.tf and add the following resources below the existing aws_cloudwatch_event_rule block:
resource "aws_cloudwatch_event_target" "quarantine_lambda" {
  rule      = aws_cloudwatch_event_rule.guardduty_high_severity.name
  target_id = "quarantine-lambda-target"
  arn       = aws_lambda_function.quarantine.arn
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.quarantine.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.guardduty_high_severity.arn
}

What does this code do?

  • The event_target tells EventBridge where to send matching events. It points to your Lambda function's ARN.
  • The lambda_permission is a resource-based policy on the Lambda function itself. Without it, EventBridge would be blocked from invoking the function even though the target is configured.
  • Save eventbridge.tf.

You won't see a change yet. The final file adds useful outputs for the deployment step.

  • Create a new file called outputs.tf and add the following output definitions:
output "vpc_id" {
  description = "ID of the security VPC"
  value       = aws_vpc.security_vpc.id
}

output "quarantine_sg_id" {
  description = "ID of the quarantine security group"
  value       = aws_security_group.quarantine.id
}

output "guardduty_detector_id" {
  description = "ID of the GuardDuty detector (needed for sample findings)"
  value       = aws_guardduty_detector.main.id
}

output "target_instance_id" {
  description = "ID of the test instance for quarantine demonstration"
  value       = aws_instance.target.id
}

output "lambda_function_name" {
  description = "Name of the quarantine Lambda function"
  value       = aws_lambda_function.quarantine.function_name
}

output "sns_topic_arn" {
  description = "ARN of the SNS notification topic"
  value       = aws_sns_topic.quarantine_alerts.arn
}

Why define outputs?

Terraform outputs print key resource IDs after deployment. You will use the guardduty_detector_id and target_instance_id directly in the testing step to generate sample findings and verify quarantine.

  • Save outputs.tf.
  • Run a final validation to confirm the entire configuration is consistent:
terraform validate
  • You should see Success! The configuration is valid. confirming all resource references resolve correctly across your files.

Validation failed on the event target or outputs?

If you see an error referencing aws_lambda_function.quarantine, confirm that lambda.tf is saved and uses the resource name "quarantine" (not a different name). All files must be in the same directory.

help me debug my terraform validate failure

✔️ Awesome, I've got everything!

Great. Make sure all files are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete eventbridge.tf (including the rule from the previous step):

resource "aws_cloudwatch_event_rule" "guardduty_high_severity" {
  name        = "guardduty-high-severity-findings"
  description = "Captures GuardDuty findings with severity >= 7 for auto-quarantine"

  event_pattern = jsonencode({
    source      = ["aws.guardduty"]
    detail-type = ["GuardDuty Finding"]
    detail = {
      severity = [{ numeric = [">=", 7] }]
    }
  })

  tags = {
    Name      = "guardduty-high-severity-rule"
    ManagedBy = "terraform"
  }
}

resource "aws_cloudwatch_event_target" "quarantine_lambda" {
  rule      = aws_cloudwatch_event_rule.guardduty_high_severity.name
  target_id = "quarantine-lambda-target"
  arn       = aws_lambda_function.quarantine.arn
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.quarantine.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.guardduty_high_severity.arn
}

And the complete outputs.tf:

output "vpc_id" {
  description = "ID of the security VPC"
  value       = aws_vpc.security_vpc.id
}

output "quarantine_sg_id" {
  description = "ID of the quarantine security group"
  value       = aws_security_group.quarantine.id
}

output "guardduty_detector_id" {
  description = "ID of the GuardDuty detector (needed for sample findings)"
  value       = aws_guardduty_detector.main.id
}

output "target_instance_id" {
  description = "ID of the test instance for quarantine demonstration"
  value       = aws_instance.target.id
}

output "lambda_function_name" {
  description = "Name of the quarantine Lambda function"
  value       = aws_lambda_function.quarantine.function_name
}

output "sns_topic_arn" {
  description = "ARN of the SNS notification topic"
  value       = aws_sns_topic.quarantine_alerts.arn
}

Your quarantine automation is fully defined in code. Next up, you will deploy the entire stack to AWS and test it with a simulated threat.

Deploy and Test the Auto-Quarantine

You have all the pieces of your security automation system defined as code. The VPC, GuardDuty detector, EventBridge rule, Lambda function, and SNS topic are all wired together in your Terraform configuration files.

Now it is time to bring the system to life. You will deploy the entire stack to AWS with a single command, then trigger a simulated threat to watch your quarantine pipeline respond in real time.

In this step, get ready to:
  • Deploy the full infrastructure stack with terraform apply.
  • Confirm your SNS email subscription for quarantine alerts.
  • Trigger a sample GuardDuty finding and observe the auto-quarantine in action.
Initialize and deploy the Terraform stack

Terraform needs to download the AWS provider plugin before it can create any resources. The terraform init command handles this, creating a .terraform directory and a lock file that pins your provider versions.

  • Initialize your Terraform project by running this command from your project directory:
terraform init
  • You should see output ending with Terraform has been successfully initialized! along with messages about downloading the hashicorp/aws and hashicorp/archive providers.

Seeing a provider error?

If you see Error: Failed to query available provider packages, check that your internet connection is active. Terraform downloads providers from the HashiCorp registry.

If you see a version constraint error, make sure your main.tf has version = "~> 6.43" for the AWS provider.

Help me troubleshoot my terraform init error

With providers downloaded, you can now deploy all your resources. Terraform will prompt you for your email address because main.tf defines a notification_email variable without a default value.

  • Deploy the full stack by running:
terraform apply
  • Terraform will prompt you with var.notification_email. Enter your real email address (you will need to confirm a subscription email shortly).
  • Terraform then shows a plan listing all resources it will create. Review the plan and type yes when prompted to confirm.

What happens during terraform apply?

Terraform reads all your .tf files, calculates the dependency graph between resources, and creates them in the correct order. For example, the VPC must exist before the subnet, and the IAM role must exist before the Lambda function.

The deployment takes 2-3 minutes because AWS needs time to provision the EC2 instance and configure GuardDuty.

  • Once complete, you should see Apply complete! Resources: X added, 0 changed, 0 destroyed. followed by your output values (VPC ID, quarantine SG ID, GuardDuty detector ID, instance ID, Lambda name, and SNS topic ARN).

Deployment failed with an error?

If you see Error: creating GuardDuty Detector, you may already have GuardDuty enabled in us-east-1. Disable the existing detector in the AWS Console first, or import it with terraform import.

If you see IAM permission errors, make sure the AWS credentials you configured with aws configure have administrator access.

Help me fix my terraform apply error

Confirm your SNS email subscription

AWS SNS requires you to confirm email subscriptions before it will deliver messages. This prevents someone from signing up an email address without the owner's consent.

  • Open your email inbox (the address you entered during terraform apply).
  • Find the email from AWS Notifications with the subject AWS Notification - Subscription Confirmation.
  • Click the Confirm subscription link in the email.

You should see a browser page confirming your subscription is active. This means quarantine alerts will now reach your inbox.

Don't see the confirmation email?

Check your spam or junk folder. The email comes from no-reply@sns.amazonaws.com and can get caught by filters.

If you still do not see it after a few minutes, double-check that you entered the correct email address. You can verify by running terraform output sns_topic_arn and checking the subscription in the AWS Console under the SNS service.

Generate a sample finding and observe the quarantine

GuardDuty provides a built-in way to generate sample threat findings for testing. These sample findings trigger EventBridge rules just like real findings do, making them perfect for end-to-end testing.

You will first grab the detector ID and instance ID from your Terraform outputs, then trigger a sample finding to watch the whole pipeline fire.

  • Retrieve your GuardDuty detector ID by running:
terraform output guardduty_detector_id
  • Copy the ID that is printed (it looks like a long alphanumeric string such as a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6).
  • Also retrieve your target instance ID by running:
terraform output target_instance_id
  • Copy this instance ID as well (it looks like i-0abc123def456789). You will need it shortly.
  • Generate a sample GuardDuty finding by running this command (replace the detector ID with yours):
aws guardduty create-sample-findings --detector-id [[DETECTOR_ID="your GuardDuty detector ID"]] --finding-types Backdoor:EC2/DenialOfService.Tcp

What does this command do?

This tells GuardDuty to create a simulated high-severity finding of type Backdoor:EC2/DenialOfService.Tcp. The finding is published to EventBridge, which triggers your quarantine Lambda function.

Sample findings use a placeholder instance ID that does not match your real EC2 instance. The Lambda will fire but may log an error when it tries to quarantine the non-existent instance. This is expected. You will manually invoke the Lambda with your real instance ID next to see the full quarantine in action.

To demonstrate the complete quarantine on your actual test instance, you will invoke the Lambda directly with a test event that includes your real instance ID.

  • Invoke the quarantine Lambda with your real instance ID by running this command:
aws lambda invoke \
  --function-name guardduty-auto-quarantine \
  --payload '{
    "detail": {
      "type": "Backdoor:EC2/DenialOfService.Tcp",
      "severity": 8,
      "description": "Manual test of quarantine automation",
      "resource": {
        "instanceDetails": {
          "instanceId": "[[INSTANCE_ID=\"your target instance ID\"]]"
        }
      }
    }
  }' \
  --cli-binary-format raw-in-base64-out \
  response.json

What does this invocation do?

This sends a simulated GuardDuty event directly to your Lambda function with your real instance ID. The Lambda will replace the instance's security groups with the quarantine SG, create an EBS forensic snapshot, and send you an SNS notification email.

  • Check the invocation response by running:
cat response.json
  • You should see {"statusCode": 200, "body": "Instance i-... quarantined successfully"} confirming the quarantine completed.

Now verify the quarantine took effect on the actual instance.

  • Confirm the instance's security group was swapped by running:
aws ec2 describe-instances \
  --instance-ids [[INSTANCE_ID="your target instance ID"]] \
  --query 'Reservations[0].Instances[0].SecurityGroups' \
  --output table
  • You should see quarantine-no-traffic listed as the only security group. The original normal-instance-sg has been removed.
  • Check your email inbox. You should have received a [QUARANTINE] email from SNS containing the instance ID, finding type, severity, and a list of forensic snapshots.

Lambda invocation returned an error?

If you see FunctionError in the response, check the CloudWatch Logs for your Lambda function. In the AWS Console, navigate to CloudWatch then Log groups and look for /aws/lambda/guardduty-auto-quarantine.

If the error mentions InvalidInstanceID.NotFound, double-check you pasted the correct instance ID from terraform output target_instance_id.

Help me debug my quarantine Lambda error

Your self-healing security system is live and proven. You just watched a threat detection trigger an automated response that isolated a compromised instance, preserved forensic evidence, and alerted the security team. Next up, you will document this architecture as code using D2.

Document the Architecture with D2

Your auto-quarantine system is deployed, tested, and proven. You have a working security pipeline that detects threats and isolates instances automatically.

Infrastructure without documentation becomes tribal knowledge. In this step, you will create a D2 architecture diagram that lives alongside your Terraform code as a self-documenting artifact. Because D2 is text-based, your diagram is version-controlled and diffable, just like the infrastructure it describes.

In this step, get ready to:
  • Write a D2 file describing the full architecture flow.
  • Render the diagram to SVG.
  • Commit the diagram alongside your Terraform code.
Write the D2 architecture file

D2 is a diagram-as-code tool. You define nodes and edges in a text file, and D2 renders them into a clean SVG.

Why D2 instead of draw.io?

Draw.io is excellent for GUI diagramming, but it produces XML files that are difficult to diff in version control. D2 files are plain text, meaning every change shows up clearly in a git diff.

This aligns with the IaC philosophy: everything as code, everything reviewable, everything versioned.

  • Create a file called architecture.d2 in your project directory by running this command:
touch architecture.d2
  • Open architecture.d2 in your text editor and add the node definitions:
title: |md
  # Auto-Quarantine Architecture
  Self-healing security system
|

direction: right

guardduty: AWS GuardDuty {
  shape: cloud
  style.fill: "#FF9900"
}

eventbridge: Amazon EventBridge {
  shape: hexagon
  style.fill: "#FF4F8B"
  rule: "Severity >= 7 Filter"
}

lambda: Quarantine Lambda {
  shape: rectangle
  style.fill: "#FF9900"
}

actions: Automated Actions {
  quarantine_sg: Replace SGs with\nDeny-All Quarantine
  snapshot: Create EBS\nForensic Snapshot
  notify: Send SNS\nAlert
}

instance: EC2 Instance {
  shape: rectangle
  style.fill: "#ED7100"
}

sns: SNS Topic {
  shape: queue
  style.fill: "#FF4F8B"
}

security_team: Security Engineer {
  shape: person
}

What does this code do?

Each block defines a node in your architecture diagram. The key properties are:

  • title adds a markdown heading to the rendered SVG.
  • direction: right flows the diagram left to right instead of top to bottom.
  • shape controls how each node renders (cloud, hexagon, rectangle, queue, person).
  • style.fill sets AWS-branded colors for each service.
  • actions is a container node. Its children (quarantine_sg, snapshot, notify) are grouped inside it.
  • Below the node definitions, add the edge definitions that connect your architecture components:
guardduty -> eventbridge: "High severity finding"
eventbridge -> lambda: "Triggers function"
lambda -> actions.quarantine_sg: "Isolate"
lambda -> actions.snapshot: "Preserve evidence"
lambda -> actions.notify: "Alert"
actions.quarantine_sg -> instance: "Swap SG"
actions.notify -> sns -> security_team: "Email notification"

What do the edges define?

Each -> line draws a labeled arrow between two nodes. The string after the colon becomes the edge label in the rendered diagram.

Notice that actions.notify -> sns -> security_team chains two connections on one line. D2 reads this as two separate edges sharing a single label.

  • Save architecture.d2.

Seeing syntax errors later when rendering?

Make sure the title block uses |md on the opening line and a lone | on the closing line. Missing either pipe character causes parse errors.

help me debug a D2 syntax error in my architecture file

✔️ Awesome, I've got everything!

Double check that you have saved architecture.d2.

ⓧ I'd like to double check the full code

title: |md
  # Auto-Quarantine Architecture
  Self-healing security system
|

direction: right

guardduty: AWS GuardDuty {
  shape: cloud
  style.fill: "#FF9900"
}

eventbridge: Amazon EventBridge {
  shape: hexagon
  style.fill: "#FF4F8B"
  rule: "Severity >= 7 Filter"
}

lambda: Quarantine Lambda {
  shape: rectangle
  style.fill: "#FF9900"
}

actions: Automated Actions {
  quarantine_sg: Replace SGs with\nDeny-All Quarantine
  snapshot: Create EBS\nForensic Snapshot
  notify: Send SNS\nAlert
}

instance: EC2 Instance {
  shape: rectangle
  style.fill: "#ED7100"
}

sns: SNS Topic {
  shape: queue
  style.fill: "#FF4F8B"
}

security_team: Security Engineer {
  shape: person
}

guardduty -> eventbridge: "High severity finding"
eventbridge -> lambda: "Triggers function"
lambda -> actions.quarantine_sg: "Isolate"
lambda -> actions.snapshot: "Preserve evidence"
lambda -> actions.notify: "Alert"
actions.quarantine_sg -> instance: "Swap SG"
actions.notify -> sns -> security_team: "Email notification"
Render the diagram to SVG

D2 compiles your text file into a vector graphic. The output is a clean SVG that you can open in any browser or embed in documentation.

  • Render your architecture diagram to SVG by running this command:
d2 architecture.d2 architecture.svg
  • Confirm the SVG was created by running:
ls architecture.svg
  • You should see architecture.svg listed in the output.
  • Open the SVG in your browser to verify the rendered diagram:
xdg-open architecture.svg

You should see a left-to-right flow diagram showing GuardDuty feeding into EventBridge, then Lambda, branching into three automated actions (quarantine SG swap, EBS snapshot, SNS notification).

xdg-open not working?

If you are on a headless server or WSL without a display, you can copy architecture.svg to a machine with a browser and open it there. The rendering step itself only needs the D2 CLI.

help me view my SVG file on a headless Linux system

Commit the diagram alongside your Terraform code

Your diagram is a documentation artifact that belongs in version control alongside the infrastructure it describes. When someone reviews your repository, they see both the code and the visual map of how it all connects.

  • Verify your project directory contains both diagram files by running:
ls architecture.*
  • You should see architecture.d2 and architecture.svg in the output.

Why commit both the source and the rendered SVG?

The .d2 file is your source of truth. Anyone with D2 installed can re-render it. The .svg is for people who want to view the diagram without installing D2.

Together, they make your repository self-documenting. A reviewer can understand your architecture at a glance from the SVG, or propose changes by editing the D2 source.

Your architecture is now documented as code. The D2 source file lives alongside your Terraform configuration, making your entire security system self-describing and reviewable.

Secret mission

Auto-Revoke Overly Permissive Security Groups

Your quarantine system catches threats after they happen. But what about preventing misconfigurations before they become threats? In this extension, you'll add a second automated remediation that detects when someone opens a security group to 0.0.0.0/0 and automatically revokes the overly permissive rule.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project creates AWS resources that may incur costs after the GuardDuty 30-day free trial ends.

Cost warning

GuardDuty is free for 30 days but charges per-event after the trial. The EC2 instance is free-tier eligible for the first 12 months of your AWS account. EBS snapshots cost $0.05/GB-month.

If you are done demonstrating, run terraform destroy to remove all resources and stop all charges.

Resources you used:

  • VPC with public subnet, internet gateway, and route table.
  • EC2 instance (quarantine test target).
  • Two security groups (normal and quarantine).
  • GuardDuty detector.
  • EventBridge rule.
  • Lambda function with IAM role and policy.
  • SNS topic and email subscription.
  • EBS snapshots created during quarantine testing.
  • Local Terraform project files and D2 diagram.

Keep everything running

No action needed. Choose this if you want to keep GuardDuty actively monitoring your account for threats.

  • GuardDuty will continue detecting threats in your account. After the 30-day free trial, expect less than $5/month for a low-traffic personal account.
  • The EC2 instance will keep running. It is free-tier eligible if your account is less than 12 months old.
  • Your Terraform state file tracks all resources, so you can always destroy later.

Pause - I'll come back to this later

Suspend monitoring without destroying your configuration. You can re-enable everything later without rebuilding from scratch.

  • Open guardduty.tf and change enable = true to enable = false. This suspends GuardDuty monitoring.
  • Stop the EC2 instance from the AWS Console or by adding instance_state = "stopped" logic. A stopped instance incurs no compute charges.
  • Run terraform apply to apply the paused state.
  • Your local Terraform files and D2 diagram stay on disk. No action needed for those.

Delete - I don't want to use this again

Remove all AWS resources and stop all charges. This is recommended if you are done demonstrating the system.

  • Navigate to your Terraform project directory in the terminal.
  • Run terraform destroy and type yes when prompted. This removes the VPC, EC2 instance, security groups, GuardDuty detector, EventBridge rule, Lambda function, IAM role, and SNS topic.
  • Delete any EBS snapshots manually from the AWS Console. Terraform does not manage snapshots created at runtime by the Lambda function.
  • Optionally delete your local project folder if you no longer need the Terraform files or D2 diagram.

Nice Work!

Nice Work!

You just built a self-healing security system entirely in code. This is the same event-driven quarantine pattern used in enterprise Security Operations Centers.

You learned how to:

  • Deploy a complete VPC, GuardDuty detector, and EventBridge pipeline using Terraform as your infrastructure-as-code tool.
  • Build a Lambda function that auto-quarantines compromised instances by swapping security groups and preserves forensic evidence with EBS snapshots.
  • Document your architecture as code using D2 diagrams, creating a self-documenting artifact committed alongside your Terraform configuration.
  • Secret Mission: Added auto-revocation of overly permissive security group rules that open traffic to 0.0.0.0/0.

Ready to quiz yourself?