Cloud Security Scout with Terraform
Build a serverless AWS security scanner that detects S3 misconfigurations.
Introduction
30 Second Summary
Every cloud account accumulates storage buckets over time, and any one of them could be accidentally exposed to the public internet without anyone noticing. Without automated checks, a single misconfiguration can sit undetected for weeks.
In this project, you will build an automated security scanner that checks your AWS S3 buckets for public access risks, stores every finding in a database, and sends you an email alert the moment something looks wrong.
What You'll Build
You'll run a single terraform apply command and watch an entire security monitoring pipeline spring to life, scanning your buckets daily and pinging your inbox when it finds trouble.
By the end of this project, you'll have:
- A Lambda function that scans every S3 bucket in your account and flags public access misconfigurations, with results you can trigger and inspect on demand.
- A DynamoDB table that records each finding with a timestamp, and SNS email alerts that hit your inbox within seconds of a critical discovery.
- A complete Terraform configuration that deploys and tears down the entire pipeline with a single command, so you can version, review, and replicate the system anywhere.
- Secret Mission: extend your scout to detect overly permissive security groups with ports open to the entire internet.
Are there any prerequisites?
You'll need an AWS account with the AWS CLI configured, Python installed locally, the Terraform CLI installed, and a code editor. Step 1 walks you through confirming each tool is ready.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
You're creating a fully automated security scanner that detects S3 buckets left open to the public. The entire infrastructure will be defined as code with Terraform, so you can deploy, version, and tear down the whole system with a single command.
Scaffold the Terraform Project and Write the Scanner
Before you can deploy anything to AWS, you need two things: a project structure that Terraform understands, and the Python code that actually performs the security checks.
Writing the scanner first means you can deploy it immediately in the next step and see results right away. In this step you will create your Terraform configuration files and write the Lambda function that inspects every S3 bucket in your account for public access misconfigurations.
In this step, get ready to:
- Create a Terraform project with provider configuration and input variables.
- Write the Python security scanner that checks S3 buckets for public access risks.
- Initialize Terraform and download the AWS provider.
Create the project directory and Terraform configuration
Terraform expects a directory with .tf files that declare your infrastructure. You will create two files: one for the provider setup and one for input variables.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cloud-security-scout
cd cloud-security-scout
- Open the cloud-security-scout folder in your code editor.
- Create a new file called main.tf and paste the following code:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- The required_providers block tells Terraform which plugins to download. You are pinning the AWS provider to version ~> 6.0 and the archive provider to ~> 2.0 (used later to zip your Lambda code).
- The provider "aws" block configures which AWS region to deploy into, using a variable you will define next.
- Create a second file called variables.tf and paste the following code:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "alert_email" {
description = "Email address to receive security alerts"
type = string
}
What does this code do?
- aws_region defaults to us-east-1 so you do not need to specify it every time you run Terraform.
- alert_email has no default, which means Terraform will prompt you for it at deploy time. This is the address that receives security alerts.
- Save both files.
- Confirm the files exist by running:
ls
You should see main.tf and variables.tf in the output.
Don't see both files?
- Make sure you saved both files inside the cloud-security-scout folder, not one level above it.
- Check that your terminal's working directory is cloud-security-scout by running pwd.
Help me figure out why my Terraform files are not showing up.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
}
provider "aws" {
region = var.aws_region
}
And here is the full variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "alert_email" {
description = "Email address to receive security alerts"
type = string
}
Write the security scanner
This is the Python code that will run inside AWS Lambda to scan your S3 buckets. It uses boto3 (the AWS SDK for Python, pre-installed in the Lambda runtime) to list buckets, check their public access settings, store findings in DynamoDB, and send alerts via SNS.
- Create a new file called lambda_function.py in your cloud-security-scout folder.
- Paste the following imports and client setup at the top of the file:
import json
import boto3
import uuid
import os
from datetime import datetime, timezone
# Initialize clients
s3_client = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
sns_client = boto3.client('sns')
# Configuration from environment variables
TABLE_NAME = os.environ['TABLE_NAME']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
table = dynamodb.Table(TABLE_NAME)
What does this code do?
- Three boto3 clients are initialized: one for S3 (to read bucket configurations), one for DynamoDB (to store findings), and one for SNS (to send alerts).
- TABLE_NAME and SNS_TOPIC_ARN are read from environment variables that Terraform will inject when it creates the Lambda function.
- Add the main handler function below the configuration code:
def lambda_handler(event, context):
findings = check_s3_public_access()
for finding in findings:
store_finding(finding)
if finding['severity'] == 'CRITICAL':
send_alert(finding)
return {
'statusCode': 200,
'body': json.dumps({
'scan_time': datetime.now(timezone.utc).isoformat(),
'total_findings': len(findings),
'critical_findings': len([f for f in findings if f['severity'] == 'CRITICAL']),
'findings': findings
})
}
What does this code do?
- lambda_handler is the entry point AWS Lambda calls. It runs the S3 public access check, stores every finding, and sends an alert for any CRITICAL result.
- The return value is a JSON summary of the scan including timestamps, counts, and the full findings list.
- Save lambda_function.py and verify the syntax is valid so far by running:
python3 -c "import ast; ast.parse(open('lambda_function.py').read()); print('Syntax valid')"
You should see Syntax valid printed in your terminal.
Seeing a SyntaxError?
- Check for missing colons, unmatched parentheses, or incorrect indentation. Python requires consistent 4-space indentation.
- Make sure you pasted the handler code below the imports, not inside them.
Help me fix a SyntaxError in my lambda_function.py file.
- Add the check_s3_public_access function below lambda_handler. Start with the function definition and the bucket-checking loop setup:
def check_s3_public_access():
findings = []
buckets = s3_client.list_buckets()['Buckets']
for bucket in buckets:
bucket_name = bucket['Name']
finding = {
'finding_id': str(uuid.uuid4()),
'timestamp': datetime.now(timezone.utc).isoformat(),
'bucket_name': bucket_name,
'check_type': 's3_public_access',
'status': 'PASS',
'severity': 'INFO'
}
try:
response = s3_client.get_public_access_block(Bucket=bucket_name)
config = response['PublicAccessBlockConfiguration']
all_blocked = (
config.get('BlockPublicAcls', False)
and config.get('IgnorePublicAcls', False)
and config.get('BlockPublicPolicy', False)
and config.get('RestrictPublicBuckets', False)
)
What does this code do?
- The function lists every S3 bucket in your account and loops through each one.
- For each bucket, it starts with an optimistic finding (status PASS, severity INFO) and calls get_public_access_block to retrieve the bucket's public access configuration.
- all_blocked is only True when all four public access block settings are enabled.
- Immediately below the code you just pasted (still inside the for loop), add the conditional logic and error handling:
if not all_blocked:
finding['status'] = 'FAIL'
finding['severity'] = 'CRITICAL'
finding['detail'] = (
f"Bucket {bucket_name} does not have all public access "
f"block settings enabled. Config: {json.dumps(config)}"
)
else:
finding['detail'] = (
f"Bucket {bucket_name} has all public access blocks enabled."
)
except s3_client.exceptions.NoSuchPublicAccessBlockConfiguration:
finding['status'] = 'FAIL'
finding['severity'] = 'CRITICAL'
finding['detail'] = (
f"Bucket {bucket_name} has no Public Access Block "
f"configuration at all."
)
except Exception as e:
finding['status'] = 'ERROR'
finding['severity'] = 'LOW'
finding['detail'] = (
f"Could not check bucket {bucket_name}: {str(e)}"
)
findings.append(finding)
return findings
What does this code do?
- If any of the four Block Public Access settings is disabled, the finding is marked CRITICAL.
- The NoSuchPublicAccessBlockConfiguration exception fires when a bucket has never had any public access block set at all. This is also CRITICAL.
- Any other error (permissions, throttling) is caught and logged at LOW severity so the scan continues through all buckets.
- Save the file and verify the syntax is still valid:
python3 -c "import ast; ast.parse(open('lambda_function.py').read()); print('Syntax valid')"
You should see Syntax valid again.
Seeing an IndentationError?
- The if not all_blocked line must be indented to the same level as the all_blocked = ( line above it (12 spaces, inside both the for loop and the try block).
- The except lines must align with the try keyword (8 spaces of indentation).
Help me fix an IndentationError in check_s3_public_access.
- Finally, add the two helper functions below check_s3_public_access:
def store_finding(finding):
table.put_item(Item=finding)
def send_alert(finding):
message = (
f"CRITICAL Security Finding Detected\n\n"
f"Check Type: {finding['check_type']}\n"
f"Bucket: {finding['bucket_name']}\n"
f"Status: {finding['status']}\n"
f"Severity: {finding['severity']}\n"
f"Detail: {finding['detail']}\n"
f"Time: {finding['timestamp']}\n"
)
sns_client.publish(
TopicArn=SNS_TOPIC_ARN,
Subject='CRITICAL: Cloud Security Scout Alert',
Message=message
)
What does this code do?
- store_finding writes the finding dict to DynamoDB as a new item. The table's partition key (finding_id) and sort key (timestamp) are already in the finding dict.
- send_alert publishes a formatted message to Amazon SNS, which delivers it to your email address.
- Save lambda_function.py and run the final syntax check:
python3 -c "import ast; ast.parse(open('lambda_function.py').read()); print('Syntax valid')"
You should see Syntax valid one last time, confirming the complete file is well-formed.
✔️ Awesome, I've got everything!
Great. Confirm you have saved lambda_function.py with all five sections: imports, lambda_handler, check_s3_public_access, store_finding, and send_alert.
ⓧ I'd like to double check the full code
Your lambda_function.py should contain these sections in order. Scroll up and confirm each is present:
- Imports and client initialization (import json through table = dynamodb.Table(TABLE_NAME)).
- The lambda_handler function that calls check_s3_public_access() and returns a JSON summary.
- The check_s3_public_access function with the bucket loop, try block, and two except clauses.
- store_finding (one-liner calling table.put_item).
- send_alert building a message string and calling sns_client.publish.
Initialize Terraform
Terraform needs to download the AWS provider plugin before it can plan or apply any infrastructure. The terraform init command reads your required_providers block and fetches the matching plugins.
Before you run the next command, what do you think will happen? Will Terraform report success or an error?
- Initialize Terraform by running:
terraform init
The output shows "Terraform has been successfully initialized!" and lists the installed providers including hashicorp/aws.
What does this command do?
terraform init downloads the provider plugins specified in your main.tf and stores them in a hidden .terraform directory. It also creates a .terraform.lock.hcl file that locks the exact provider versions for reproducibility.
Seeing a provider error?
- Make sure you are running the command from inside the cloud-security-scout directory where main.tf lives.
- Check that your main.tf has the required_providers block with source = "hashicorp/aws" spelled exactly right.
- Confirm you have internet connectivity. Terraform needs to reach registry.terraform.io to download providers.
Help me troubleshoot a terraform init failure.
- Confirm your project now has 5 items by running:
ls -a
You should see main.tf, variables.tf, lambda_function.py, .terraform, and .terraform.lock.hcl in the output.
Your project is scaffolded and the scanner code is ready to deploy. Next up, you will define the full infrastructure (IAM role, DynamoDB table, SNS topic, and more) so a single terraform apply brings everything to life.
Define the Infrastructure and Deploy
In Step 1, you wrote the scanner code and initialized Terraform. The code exists, but it has no infrastructure to run on.
In this step, you will define every AWS resource the scanner needs as Terraform code: an IAM role, a DynamoDB table, an SNS topic, a Lambda function, and an EventBridge schedule. A single terraform apply will provision everything at once.
In this step, get ready to:
- Define all the AWS resources your scanner depends on in main.tf.
- Run terraform plan to preview what Terraform will create.
- Deploy the full infrastructure with terraform apply.
Define the IAM role and permissions
Every Lambda function needs an execution role that tells AWS what the function is allowed to do. You will create the role, then attach a policy granting exactly the permissions your scanner needs.
- In your code editor, open main.tf inside your project folder.
- Add the archive data source and IAM role below the existing provider block by pasting this code:
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda_function.py"
output_path = "${path.module}/lambda_function.zip"
}
resource "aws_iam_role" "lambda_role" {
name = "security-scout-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
What does this code do?
- data "archive_file" tells Terraform to zip your Python file into lambda_function.zip automatically every time you run plan or apply. This is how the code gets packaged for upload to Lambda.
- aws_iam_role creates an execution role that only the Lambda service can assume. The assume_role_policy acts like a door lock that only opens for lambda.amazonaws.com.
- Now add the IAM policy that grants your scanner its permissions. Paste this directly below the IAM role:
resource "aws_iam_role_policy" "lambda_policy" {
name = "security-scout-lambda-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:ListAllMyBuckets",
"s3:GetBucketPublicAccessBlock"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"dynamodb:PutItem"
]
Resource = aws_dynamodb_table.findings.arn
},
- Continue adding the remaining permission statements directly after the code above. Paste this immediately below:
{
Effect = "Allow"
Action = [
"sns:Publish"
]
Resource = aws_sns_topic.alerts.arn
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
What do these permissions grant?
The policy has four permission groups, each scoped to the minimum the scanner needs:
- S3 read to list buckets and check their public access settings.
- DynamoDB write to store findings in the SecurityFindings table.
- SNS publish to send alert emails when something is critical.
- CloudWatch Logs so the function can write execution logs for debugging.
- Save main.tf.
Seeing a syntax error in your editor?
- Check that the closing braces }) and } at the end of the policy resource are correct. The resource needs three closing characters: ] for the Statement array, }) for the jsonencode call, and } for the resource block.
- Make sure you pasted both policy code blocks in sequence with no gap or duplicate lines between them.
- Confirm the archive provider is listed in your required_providers block from Step 1. The data "archive_file" resource depends on it.
Help me debug a Terraform syntax error in my IAM policy
Define the data stores and Lambda function
With permissions in place, you need the actual resources the scanner interacts with: a DynamoDB table to store findings, an SNS topic to send alerts, and the Lambda function itself.
- Add the DynamoDB table and SNS resources below the IAM policy in main.tf:
resource "aws_dynamodb_table" "findings" {
name = "SecurityFindings"
billing_mode = "PAY_PER_REQUEST"
hash_key = "finding_id"
range_key = "timestamp"
attribute {
name = "finding_id"
type = "S"
}
attribute {
name = "timestamp"
type = "S"
}
}
resource "aws_sns_topic" "alerts" {
name = "SecurityAlerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
What does this code do?
- aws_dynamodb_table creates a table called SecurityFindings with on-demand billing. Each finding gets a unique finding_id (partition key) and a timestamp (sort key) so you can query findings over time.
- aws_sns_topic creates a notification channel. aws_sns_topic_subscription subscribes your email address to it, so you receive alerts when something critical is found.
- Add the Lambda function resource directly below the SNS subscription:
resource "aws_lambda_function" "security_scout" {
filename = data.archive_file.lambda_zip.output_path
function_name = "security-scout"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.13"
timeout = 30
code_sha256 = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.findings.name
SNS_TOPIC_ARN = aws_sns_topic.alerts.arn
}
}
}
What does this code do?
- filename points to the zip archive that Terraform creates from your Python file.
- runtime sets python3.13, the latest managed Lambda runtime.
- The environment block passes the DynamoDB table name and SNS topic ARN to the function at runtime. Your Python code reads these with os.environ.
- code_sha256 tells Terraform to redeploy the function whenever the zip contents change.
- Save main.tf.
- Preview what Terraform will create by running:
terraform plan
Terraform will prompt you for your alert email address. Enter the email where you want to receive security alerts.
You should see a plan summary ending with a line like Plan: X to add, 0 to change, 0 to destroy. confirming the resources it will create.
Seeing errors in the plan?
- If you see a reference error for aws_dynamodb_table.findings or aws_sns_topic.alerts, make sure those resource blocks are defined somewhere in main.tf. Terraform resolves references across the file regardless of order.
- If the plan shows 0 resources, confirm you saved main.tf after adding the new code.
Help me fix my terraform plan errors
Add the EventBridge schedule and deploy
The Lambda function is defined, but nothing triggers it automatically. An EventBridge rule with a schedule expression will invoke your scanner on a daily cadence.
- Add the EventBridge resources and Lambda permission below the Lambda function in main.tf:
resource "aws_cloudwatch_event_rule" "daily_scan" {
name = "security-scout-daily"
description = "Triggers the security scout Lambda daily"
schedule_expression = "rate(1 day)"
}
resource "aws_cloudwatch_event_target" "lambda_target" {
rule = aws_cloudwatch_event_rule.daily_scan.name
target_id = "SecurityScoutLambda"
arn = aws_lambda_function.security_scout.arn
}
resource "aws_lambda_permission" "allow_eventbridge" {
statement_id = "AllowEventBridgeInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.security_scout.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.daily_scan.arn
}
What does this code do?
- aws_cloudwatch_event_rule creates a schedule that fires once per day using rate(1 day).
- aws_cloudwatch_event_target tells EventBridge to invoke your Lambda function when the rule fires.
- aws_lambda_permission grants EventBridge explicit permission to call the function. Without this, the invocation would be rejected even though the target is configured.
- Add three outputs at the bottom of main.tf so Terraform prints key resource identifiers after deployment:
output "lambda_function_name" {
value = aws_lambda_function.security_scout.function_name
}
output "sns_topic_arn" {
value = aws_sns_topic.alerts.arn
}
output "dynamodb_table_name" {
value = aws_dynamodb_table.findings.name
}
What are outputs?
Terraform outputs print values after a successful apply. They act like a receipt showing the names and ARNs of what was created. You will use these values in later steps when invoking the function or checking resources.
- Save main.tf.
- Before you deploy, predict: how many resources do you think Terraform will create in total?
- Deploy the full infrastructure by running:
terraform apply
Terraform will prompt you for your alert email, then show the execution plan. Type yes when prompted to confirm the deployment.
After a minute or so, you should see output confirming all resources were created, followed by your three outputs displaying the Lambda function name, SNS topic ARN, and DynamoDB table name.
- Confirm the Lambda function exists in AWS by running:
aws lambda get-function --function-name security-scout
You should see a JSON response showing your function with Runtime set to python3.13 and Timeout set to 30.
Terraform apply failed?
- If you see an error about insufficient permissions, confirm your AWS CLI credentials have admin access or at minimum permissions to create IAM roles, Lambda functions, DynamoDB tables, SNS topics, and EventBridge rules.
- If you see InvalidParameterValueException for the Lambda function, make sure lambda_function.py exists in the same directory as main.tf. The archive data source needs to find it.
- If you see a region error, check that your variables.tf has us-east-1 as the default region and your AWS CLI is configured for the same region.
Help me troubleshoot my failed terraform apply
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf and that your terraform apply completed with all resources created.
ⓧ I'd like to double check the full code
Your main.tf should contain all of the following blocks in this order. Scroll up and confirm each one is present:
- terraform block with required_providers (aws and archive).
- provider "aws" block.
- data "archive_file" "lambda_zip" block.
- resource "aws_iam_role" "lambda_role" block.
- resource "aws_iam_role_policy" "lambda_policy" block with four Statement entries (S3, DynamoDB, SNS, Logs).
- resource "aws_dynamodb_table" "findings" block.
- resource "aws_sns_topic" "alerts" block.
- resource "aws_sns_topic_subscription" "email" block.
- resource "aws_lambda_function" "security_scout" block.
- resource "aws_cloudwatch_event_rule" "daily_scan" block.
- resource "aws_cloudwatch_event_target" "lambda_target" block.
- resource "aws_lambda_permission" "allow_eventbridge" block.
- Three output blocks (lambda_function_name, sns_topic_arn, dynamodb_table_name).
Your entire security pipeline now exists in AWS, deployed from a single Terraform configuration. Next up, you will confirm your email subscription and trigger the scanner to see it work end-to-end.
Subscribe to Alerts and Test the Scanner
Your infrastructure is live, but there is a gap in the alert pipeline. The SNS email subscription you created during terraform apply is still pending confirmation.
Until you click the confirmation link AWS sent to your inbox, the topic cannot deliver alerts to you. In this step, you will activate the subscription and then invoke the scanner manually to prove the full pipeline works end-to-end.
In this step, get ready to:
- Confirm your SNS email subscription so alerts can reach your inbox.
- Invoke the Lambda function manually and inspect the scan results.
Confirm your SNS email subscription
When Terraform created the SNS subscription, AWS sent a confirmation email to the address you provided in alert_email. The subscription stays in a PendingConfirmation state until you explicitly accept it.
- Open the email inbox for the address you used as your alert_email variable.
- Find the confirmation email from AWS Notifications. It may take a minute or two to arrive.
- Click the Confirm subscription link inside the email.
- Your browser opens a page showing a subscription confirmation message with your subscription ID.
Why do I need to confirm?
AWS requires email subscription confirmation to prevent someone from signing up an email address they do not own. Without this step, anyone could subscribe arbitrary addresses to a topic and flood their inbox.
Don't see the confirmation email?
Check your spam or junk folder. The sender is AWS Notifications and the email comes from no-reply@sns.amazonaws.com.
If it still has not arrived after 5 minutes, confirm you entered the correct email address in your variables.tf or terraform.tfvars file. If the address was wrong, fix it and run terraform apply again to trigger a new confirmation.
Help me troubleshoot my missing SNS confirmation email
Invoke the scanner and inspect the results
With the subscription confirmed, your alert pipeline is active. Now you need to prove the scanner actually runs and returns useful results.
Before you invoke the function, what do you think the scan results will show? Will your buckets pass or fail the public access check?
- Invoke the Lambda function manually by running this command:
aws lambda invoke --function-name security-scout --payload '{}' output.json
What does this command do?
- aws lambda invoke calls your Lambda function directly, bypassing the EventBridge schedule.
- --function-name security-scout targets the function you deployed with Terraform.
- --payload '{}' sends an empty JSON event body. The scanner does not need any input data.
- output.json is the local file where the CLI writes the Lambda response.
You should see a short response in your terminal confirming the invocation succeeded with a 200 status code.
- Inspect the scan results by running:
cat output.json
The output is a JSON object containing your scan results. You should see a body field with scan_time, total_findings, critical_findings (which should be 0), and a findings array listing each of your S3 buckets.
Every bucket should show status: PASS and severity: INFO. Since April 2023, all new S3 buckets have Block Public Access enabled by default, so your existing buckets should all pass.
What is happening behind the scenes?
When you invoke the function, the scanner loops through every S3 bucket in your account. For each bucket, it calls get_public_access_block to check whether all four public access settings are enabled.
Each finding is written to the SecurityFindings DynamoDB table. If any bucket had a CRITICAL severity, the function would publish an alert to your SecurityAlerts SNS topic. Since all buckets pass, no alert email is sent this time.
Seeing an error in output.json?
If output.json contains an errorMessage mentioning TABLE_NAME or SNS_TOPIC_ARN, the Lambda environment variables were not set correctly. Run terraform apply again and confirm the outputs show the correct table name and topic ARN.
If you see an AccessDenied error, the IAM policy may not have propagated yet. Wait 30 seconds and invoke the function again.
Help me debug my Lambda invocation error
Your scanner is working. It scanned every bucket, stored findings in DynamoDB, and confirmed there are no public access issues right now. But how do you know the alert path actually works? In the next step, you will intentionally create a vulnerable bucket and trigger a real CRITICAL alert.
Trigger a Real Alert
In the previous step, you invoked the scanner and every bucket came back as PASS. That proves the function runs, but it does not prove the alerting path actually works.
To close that gap, you need a bucket that genuinely fails the security check. In this step, you will create a deliberately insecure bucket, trigger the scanner, and confirm the full flow: a CRITICAL finding stored in DynamoDB and an alert email landing in your inbox.
In this step, get ready to:
- Create an S3 bucket with Block Public Access deliberately disabled.
- Invoke the Lambda and confirm a CRITICAL finding is detected.
- Verify the alert email arrives and the finding is stored in DynamoDB.
Create an insecure test bucket
Since April 2023, AWS enables Block Public Access by default on every new bucket. Your scanner will never flag anything unless you deliberately remove that protection from a test bucket.
- Create a new S3 bucket for testing by running this command:
aws s3api create-bucket --bucket [[TESTBUCKET="security-scout-test-yourname"]] --region us-east-1
You should see a JSON response with a Location field showing your new bucket name.
- Now remove the Block Public Access protection from that bucket by running:
aws s3api delete-public-access-block --bucket [[TESTBUCKET="security-scout-test-yourname"]]
This command produces no output on success. The bucket now has no public access protection at all.
Why remove Block Public Access?
Your scanner's check_s3_public_access function calls get_public_access_block on each bucket. When the configuration is missing entirely, boto3 raises a NoSuchPublicAccessBlockConfiguration exception, which your code catches and marks as CRITICAL.
This simulates a real misconfiguration: a bucket that was created before April 2023 or one where someone explicitly removed the protection.
Seeing a BucketAlreadyExists error?
S3 bucket names are globally unique across all AWS accounts. If the name you chose is already taken, pick a different name by adding random characters or numbers to the end.
help me fix a BucketAlreadyExists error when creating my test bucket
Run the scanner and verify the alert flow
Your insecure bucket is ready. Before you invoke the scanner, think about what you expect to happen differently this time.
- Predict: do you think the scanner will now report all PASS results like last time, or something different? What about the email?
- Invoke the Lambda again by running:
aws lambda invoke --function-name security-scout --payload "{}" output.json
- View the scan results by running:
cat output.json
The response now shows critical_findings with a value of 1 or higher. In the findings array, your test bucket appears with status set to FAIL and severity set to CRITICAL.
What just happened end-to-end?
The scanner listed all your buckets, called get_public_access_block on the test bucket, caught the NoSuchPublicAccessBlockConfiguration exception, marked it CRITICAL, wrote the finding to DynamoDB, and published an SNS alert to your email.
That is the full detection-to-notification pipeline running in a single invocation.
- Check your email inbox for a message with subject CRITICAL: Cloud Security Scout Alert. It contains your test bucket name and the finding details.
No email in your inbox?
Check your spam or junk folder. AWS SNS emails sometimes get filtered. If you still do not see it, confirm your SNS subscription is active by checking the Subscriptions page in the SNS console. The status should show Confirmed.
help me troubleshoot why I'm not receiving SNS email alerts
- Verify the finding was stored in DynamoDB by running:
aws dynamodb scan --table-name SecurityFindings --filter-expression "severity = :s" --expression-attribute-values "{\":s\":{\"S\":\"CRITICAL\"}}"
The output shows at least one item with severity set to CRITICAL and check_type set to s3_public_access for your test bucket.
Clean up the test bucket
The test bucket served its purpose. Leaving an unprotected bucket in your account is the exact risk your scanner is built to detect.
- Delete the test bucket by running:
aws s3api delete-bucket --bucket [[TESTBUCKET="security-scout-test-yourname"]]
This command produces no output on success. The bucket is gone.
Seeing a BucketNotEmpty error?
If you accidentally uploaded any objects to the test bucket, you need to empty it first. Run aws s3 rm s3://your-bucket-name --recursive to remove all objects, then retry the delete command.
help me delete an S3 bucket that is not empty
You have now proven the entire detection-to-alert pipeline. The scanner finds a misconfiguration, records it in DynamoDB, and fires an email within seconds. Next up, you will verify that this pipeline runs automatically on a schedule without any manual invocation.
Verify the Automated Schedule
So far, every scan has happened because you typed aws lambda invoke by hand. The EventBridge rule you deployed in Step 2 is supposed to trigger the scanner on a daily schedule. But how do you know it actually fires?
In this step, you will temporarily shorten the schedule to 2 minutes, wait for it to fire, and confirm the invocation shows up in CloudWatch Logs. Once confirmed, you will restore the daily cadence.
In this step, get ready to:
- Temporarily speed up the EventBridge schedule to rate(2 minutes).
- Confirm a scheduled invocation appears in CloudWatch Logs.
- Restore the production rate(1 day) schedule.
Speed up the EventBridge schedule
A daily schedule means you would need to wait 24 hours to see a trigger. Temporarily changing it to 2 minutes lets you observe a scheduled invocation without the wait.
- In your code editor, open main.tf and scroll to the aws_cloudwatch_event_rule resource block.
- Find this line:
schedule_expression = "rate(1 day)"
- Replace rate(1 day) with rate(2 minutes), so the line now looks like this:
schedule_expression = "rate(2 minutes)"
- Save main.tf.
- Deploy the change by running:
terraform apply -auto-approve
Terraform updates the EventBridge rule in place. You should see 1 resource changed in the output.
Why -auto-approve?
You already reviewed the plan in Step 2. Since this is a single-field change you initiated, -auto-approve skips the confirmation prompt so you can move quickly.
Confirm the scheduled invocation in CloudWatch Logs
EventBridge will now fire the Lambda every 2 minutes. You need to wait for at least one trigger, then check the logs.
- Note your current Unix timestamp in milliseconds by running:
echo $(date +%s)000
- Copy the number that prints (e.g. 1723456789000). You will use it shortly.
- Wait 2-3 minutes for EventBridge to fire. Set a timer on your phone or grab a drink.
How long until the first trigger?
EventBridge fires on a rolling interval from when the rule was last updated. It may take up to 3 minutes for the first trigger after your change.
Before you run the next command, do you think the logs will show a new invocation, or will they be empty? What would prove it was triggered by the schedule rather than your manual invoke?
- Check CloudWatch Logs for a recent scheduled invocation by running:
aws logs filter-log-events --log-group-name /aws/lambda/security-scout --start-time [[STARTTIME="your Unix millisecond timestamp from earlier, e.g. 1723456789000"]] --limit 5
The output shows log events from the Lambda execution. You should see entries with a recent timestamp containing scan results, proving that EventBridge triggered the function without any manual invoke.
How do I know this was a scheduled trigger?
The --start-time filter only returns events AFTER the timestamp you noted. Since you did not run aws lambda invoke during the wait, any new invocation in the logs was triggered by EventBridge.
Look for log lines containing scan_time or total_findings. These confirm the Lambda ran its full scan logic.
Logs are empty?
If you see an empty events array, wait another minute and run the command again. EventBridge may not have fired yet.
If logs still do not appear after 5 minutes, confirm the rule is enabled by running aws events describe-rule --name security-scout-daily and checking that State is ENABLED.
Help me debug why my schedule is not firing
Restore the daily schedule
The 2-minute schedule served its purpose. Time to set it back to the production cadence so your scout runs once per day.
- Return to main.tf in your code editor.
- Find this line in the aws_cloudwatch_event_rule resource:
schedule_expression = "rate(2 minutes)"
- Change it back to rate(1 day), so the line reads:
schedule_expression = "rate(1 day)"
- Save main.tf.
- Deploy the restored schedule by running:
terraform apply -auto-approve
Terraform updates the rule back to a daily cadence. You should see 1 resource changed in the output confirming the restore.
What does rate(1 day) mean in practice?
EventBridge fires the Lambda once every 24 hours, starting from when the rule was last enabled or updated. Your security scout now runs a full S3 public access scan every day without you lifting a finger.
Seeing more than 1 resource changed?
If Terraform reports additional changes, you may have accidentally edited another line in main.tf. Run terraform plan to review what differs. The only change should be schedule_expression reverting to rate(1 day).
Help me figure out what changed unexpectedly
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf with the restored rate(1 day) schedule and that your last terraform apply succeeded.
ⓧ I'd like to double check the full code
Your main.tf should have the aws_cloudwatch_event_rule resource looking like this:
resource "aws_cloudwatch_event_rule" "daily_scan" {
name = "security-scout-daily"
description = "Triggers the security scout Lambda daily"
schedule_expression = "rate(1 day)"
}
Everything else in main.tf remains unchanged from Step 2. Scroll through and confirm no accidental edits elsewhere.
Your automated security pipeline is now proven end-to-end. EventBridge triggers the scan, Lambda checks your buckets, findings land in DynamoDB, and critical alerts hit your inbox. Next up, a bonus challenge to extend your scout's detection capabilities.
Secret mission
Detect Overly Permissive Security Groups
S3 public access is only one attack surface. Security groups with ports open to the entire internet (0.0.0.0/0) are another top misconfiguration that attackers scan for constantly. Can you extend your Cloud Security Scout to detect security groups with SSH (port 22) or RDP (port 3389) open to the world?
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. All services used in this project fall within the AWS Free Tier, so the daily scan costs effectively $0.
Resources you used:
- AWS Lambda function (security-scout).
- Amazon DynamoDB table (SecurityFindings).
- Amazon SNS topic (SecurityAlerts) and email subscription.
- Amazon EventBridge rule (security-scout-daily).
- IAM role and policy (security-scout-lambda-role).
- CloudWatch Log Group (/aws/lambda/security-scout).
- Local project files (main.tf, variables.tf, lambda_function.py).
Keep everything running
No action needed. Choose this if you are still actively building or want the daily security scan to keep running. The Lambda, DynamoDB, SNS, and EventBridge resources all stay within the AWS Free Tier for normal use.
- Your Lambda function will continue scanning once per day.
- Findings will accumulate in your DynamoDB table.
- You will receive email alerts if any CRITICAL findings are detected.
Pause - I'll come back to this later
Shut down the automated schedule to stop daily scans while keeping all resources in place. You can re-enable later with a single Terraform apply.
- Open main.tf in your code editor.
- Find the aws_cloudwatch_event_rule resource named daily_scan.
- Add state = "DISABLED" inside the resource block so it looks like this:
resource "aws_cloudwatch_event_rule" "daily_scan" {
name = "security-scout-daily"
description = "Triggers the security scout Lambda daily"
schedule_expression = "rate(1 day)"
state = "DISABLED"
}
- Save the file, then apply the change by running:
terraform apply
- Confirm with yes when prompted.
- To re-enable later, remove the state = "DISABLED" line and run terraform apply again.
Delete - I don't want to use this again
Remove all project resources from AWS and delete local files. This is irreversible.
- From your project directory (the folder containing main.tf), destroy all Terraform-managed resources by running:
terraform destroy
- Confirm with yes when prompted. This removes the Lambda function, DynamoDB table, SNS topic, EventBridge rule, and IAM role.
- Delete the CloudWatch Log Group (not managed by Terraform) by running:
aws logs delete-log-group --log-group-name /aws/lambda/security-scout
- Delete your local project folder by running:
macOS
cd ~/Desktop
rm -rf cloud-security-scout
Windows
cd ~\Desktop
Remove-Item -Recurse -Force cloud-security-scout
Nice Work!
Nice Work!
Nice work! You just built and deployed a fully automated cloud security monitoring pipeline using Terraform and AWS Lambda.
You've learned how to:
- Write a complete Terraform configuration that provisions an IAM role, DynamoDB table, SNS topic, Lambda function, and EventBridge schedule in a single terraform apply.
- Build a Python Lambda function that scans S3 buckets for public access misconfigurations using boto3, persists findings to DynamoDB with timestamps, and fires email alerts through SNS for critical issues.
- Practice the full Infrastructure as Code workflow from terraform init through terraform destroy, including updating live resources by modifying config and re-applying.
- (Secret Mission) Extended the scanner to detect overly permissive security groups with ports open to 0.0.0.0/0 on SSH and RDP.
Ready to quiz yourself?