Automate an AWS Security Baseline
Deploy an AWS security baseline that detects and remediates unsafe changes.
Introduction
30 Second Summary
A single unsafe account change can expose customer files or hide what happened next. Teams need a way to catch those changes before they become incidents.
In this project, you will deploy an automated AWS security baseline for a synthetic policyholder export account using CloudFormation. You will prove its controls with deliberate lab changes that leave no insurance or personal data in AWS.
What You'll Build
You will watch a protected Amazon S3 bucket lose its guardrail once, then see the same test recover automatically after your responder goes live.
By the end of this project, you'll have:
- A detective AWS security baseline you can verify through one completed CloudFormation stack. Amazon SNS alerts your inbox when AWS CloudTrail logging stops.
- An automatic S3 recovery demo where all four bucket protections return after you remove them. Amazon EventBridge triggers an AWS Lambda responder to perform the repair.
- A GitHub Actions security gate that turns green only after the repository passes all four checks. Its unattached CloudTrail-protection policy stays safe to review.
- Secret Mission: Extend the responder so a newly authorized 0.0.0.0/0 TCP port 22 rule disappears from the tagged lab security group.
Are there any prerequisites?
You need an AWS account with administrator-level lab permissions plus a clean AWS Region without conflicting customer-managed security services.
The local workflow requires Visual Studio Code, Python 3.12, Git, a GitHub account, and an email address for the Amazon SNS subscription.
Before We Start
Before the hands-on work begins, this checkpoint helps you commit to the lab's security goal and safe scope. You will use only synthetic resources with no insurance or personal data.
Build and Gate the Infrastructure Repository
A security baseline loses trust when malformed templates or weakened policies can reach AWS without repeatable checks. Your repository needs to reject those problems before deployment.
In this step, you will prepare a Git repository in Visual Studio Code. You will validate its infrastructure code locally before publishing the same checks through GitHub Actions.
In this step, get ready to:
- Create a Git repository with an activated Python virtual environment.
- Add the supplied infrastructure files and pass every local security check.
- Publish the repository and confirm that its GitHub Actions job passes.
Create the repository workspace
A dedicated workspace keeps the lab files separate from your other projects. Its virtual environment also isolates the pinned validation tools from other Python packages on your computer.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
- Click File in the top menu.
- Select Open Folder....
- Select your Desktop in the folder picker.
- Select New Folder.
- Name the folder policyholder-security-baseline.
- Select the policyholder-security-baseline folder.
- Click Open on macOS or Select Folder on Windows.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
- Select the Source Control view in the left Activity Bar.
- Click Initialize Repository.
You should now see the new files area in Source Control. This confirms that policyholder-security-baseline is a local Git repository.
- Click View in the top menu.
- Select Terminal to open the integrated terminal.
- Create the Python 3.12 virtual environment by running this command:
python3 -m venv .venv
What does this command do?
The command creates an isolated Python environment at .venv. Packages installed inside it stay scoped to this repository.
- Confirm that .venv appears in the Visual Studio Code Explorer.
Virtual environment missing?
Confirm that the integrated terminal is inside the policyholder-security-baseline folder. A Python error here usually means Python 3.12 is unavailable from this terminal.
Ask for help with the exact terminal output: Help me diagnose why python3 -m venv .venv failed in Visual Studio Code.
- Activate the virtual environment by running this command:
source .venv/bin/activate
What does activation change?
Activation points Python package commands at .venv. Your terminal prompt should now begin with (.venv).
- Confirm that your terminal prompt begins with (.venv).
- Install the pinned validators by running this command:
python -m pip install cfn-lint==1.58.0 checkov==3.3.30
Why pin both validators?
A version pin makes local checks reproducible. The workflow installs the same releases so your computer and GitHub Actions evaluate the repository with matching rules.
This installation can take a few minutes because Checkov has several dependencies. Continuous download output means the installation is still progressing.
- Check both installed versions by running these commands:
cfn-lint --version
checkov --version
What should these commands show?
The first command reports the installed cfn-lint version. The second command reports the installed Checkov version.
✔️ I see version 1.58.0 and 3.3.30
Your validators match the repository workflow. Keep the (.venv) environment active for the remaining local checks.
ⓧ I see an older version
The environment contains a different validator release. Reinstall the required versions so local results match CI.
- Install the pinned releases by running this command:
python -m pip install cfn-lint==1.58.0 checkov==3.3.30
What does this correct?
The exact requirement specifiers select cfn-lint 1.58.0 and Checkov 3.3.30 for this virtual environment.
- Run the version checks again and confirm that both pinned versions appear.
Still seeing an older version?
Confirm that your prompt begins with (.venv) before reinstalling. A missing prefix means the terminal is using packages from another Python environment.
Ask for help with the version output: Help me find why my activated virtual environment still reports the wrong validator versions.
ⓧ A command is not found
The validator executables are missing from the current shell. Activate .venv before reinstalling the packages.
- Activate the virtual environment by running this command:
source .venv/bin/activate
What does this restore?
Activation adds the virtual environment executables to your current shell. The prompt should begin with (.venv) afterwards.
- Install the pinned validators by running this command:
python -m pip install cfn-lint==1.58.0 checkov==3.3.30
What does this install?
The command installs both validator executables inside the active virtual environment. It also installs their required Python dependencies.
- Run both version checks again and confirm that the pinned versions appear.
Commands still unavailable?
Confirm that the installation completed without a network or package-resolution error. Keep the terminal output available for diagnosis.
Ask for help with the failed installation: Help me diagnose why cfn-lint or Checkov is unavailable inside my activated virtual environment.
Add and validate the project files
The supplied repository separates provisioning templates from runtime code. It also keeps the preventive service control policy as an unattached policy artifact.
- Select the Explorer view in the left Activity Bar.
- Copy the supplied project files into the matching paths inside policyholder-security-baseline.
- Save every copied file with Cmd+S (macOS) or Ctrl+S (Windows).
Your Explorer should show the following eight files. Each path has a distinct role in the validation and deployment flow.
- Confirm that .github/workflows/iac-security.yml exists.
- Confirm that .gitignore exists.
- Confirm that templates/baseline.yaml exists.
- Confirm that templates/remediation.yaml exists.
- Confirm that lambda/handler.py exists.
- Confirm that lambda/requirements.txt exists.
- Confirm that policies/cloudtrail-protection-scp.json exists.
- Confirm that scripts/validate_scp.py exists.
✔️ Awesome, I've got everything!
Great. Double-check that every file is saved before running the validation gate.
ⓧ I'd like to double check the full code
Compare each supplied file with the matching reference below. File paths and capitalization must match exactly.
name: IaC security gate
on:
push:
pull_request:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Python
uses: actions/setup-python@v7.1.0
with:
python-version: "3.12"
- name: Install pinned validators
run: python -m pip install cfn-lint==1.58.0 checkov==3.3.30
- name: Lint CloudFormation
run: cfn-lint templates/*.yaml
- name: Scan focused guardrails
run: >-
checkov --directory templates --framework cloudformation
--check CKV_AWS_24,CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56
--compact
- name: Compile Lambda handler
run: python -m py_compile lambda/handler.py
- name: Validate SCP artifact
run: python scripts/validate_scp.py
What does this workflow enforce?
The workflow runs on every push or pull request. It checks CloudFormation syntax, focused infrastructure guardrails, Python syntax, and the required SCP structure.
.venv/
build/
packaged-remediation.yaml
__pycache__/
*.pyc
What stays out of Git?
This file excludes the local virtual environment, generated build artifacts, packaged templates, and Python cache files. Git tracks the source files that another learner needs to reproduce the project.
AWSTemplateFormatVersion: "2010-09-09"
Description: Security baseline for a synthetic policyholder export account lab.
Parameters:
AlertEmail:
Type: String
Description: Email endpoint that confirms the SNS subscription.
Resources:
AuditBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
ProtectedBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
Tags:
- Key: Purpose
Value: policyholder-export-lab
AuditBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref AuditBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AWSCloudTrailAclCheck20150319
Effect: Allow
Principal:
Service: cloudtrail.amazonaws.com
Action: s3:GetBucketAcl
Resource: !GetAtt AuditBucket.Arn
Condition:
StringEquals:
aws:SourceArn: !Sub arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${AWS::StackName}-trail
- Sid: AWSCloudTrailWrite20150319
Effect: Allow
Principal:
Service: cloudtrail.amazonaws.com
Action: s3:PutObject
Resource: !Sub ${AuditBucket.Arn}/AWSLogs/${AWS::AccountId}/*
Condition:
StringEquals:
s3:x-amz-acl: bucket-owner-full-control
aws:SourceArn: !Sub arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${AWS::StackName}-trail
- Sid: AWSConfigBucketPermissionsCheck
Effect: Allow
Principal:
Service: config.amazonaws.com
Action: s3:GetBucketAcl
Resource: !GetAtt AuditBucket.Arn
Condition:
StringEquals:
AWS:SourceAccount: !Ref AWS::AccountId
- Sid: AWSConfigBucketExistenceCheck
Effect: Allow
Principal:
Service: config.amazonaws.com
Action: s3:ListBucket
Resource: !GetAtt AuditBucket.Arn
Condition:
StringEquals:
AWS:SourceAccount: !Ref AWS::AccountId
- Sid: AWSConfigBucketDelivery
Effect: Allow
Principal:
Service: config.amazonaws.com
Action: s3:PutObject
Resource: !Sub ${AuditBucket.Arn}/AWSLogs/${AWS::AccountId}/Config/*
Condition:
StringEquals:
s3:x-amz-acl: bucket-owner-full-control
AWS:SourceAccount: !Ref AWS::AccountId
Trail:
Type: AWS::CloudTrail::Trail
DependsOn: AuditBucketPolicy
Properties:
TrailName: !Sub ${AWS::StackName}-trail
S3BucketName: !Ref AuditBucket
IsLogging: true
EnableLogFileValidation: true
IncludeGlobalServiceEvents: true
IsMultiRegionTrail: true
EventSelectors:
- IncludeManagementEvents: true
ReadWriteType: All
AlertTopic:
Type: AWS::SNS::Topic
AlertSubscription:
Type: AWS::SNS::Subscription
Properties:
Protocol: email
Endpoint: !Ref AlertEmail
TopicArn: !Ref AlertTopic
ConfigRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: config.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWS_ConfigRole
Policies:
- PolicyName: ConfigAuditBucketDelivery
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- s3:GetBucketAcl
- s3:ListBucket
Resource: !GetAtt AuditBucket.Arn
- Effect: Allow
Action:
- s3:PutObject
- s3:PutObjectAcl
Resource: !Sub ${AuditBucket.Arn}/AWSLogs/${AWS::AccountId}/Config/*
Condition:
StringLike:
s3:x-amz-acl: bucket-owner-full-control
ConfigRecorder:
Type: AWS::Config::ConfigurationRecorder
Properties:
Name: default
RoleARN: !GetAtt ConfigRole.Arn
RecordingGroup:
AllSupported: false
IncludeGlobalResourceTypes: false
RecordingStrategy:
UseOnly: INCLUSION_BY_RESOURCE_TYPES
ResourceTypes:
- AWS::S3::Bucket
- AWS::EC2::SecurityGroup
- AWS::CloudTrail::Trail
- AWS::GuardDuty::Detector
- AWS::SecurityHub::Standard
ConfigDeliveryChannel:
Type: AWS::Config::DeliveryChannel
DependsOn:
- ConfigRecorder
- AuditBucketPolicy
Properties:
Name: default
S3BucketName: !Ref AuditBucket
ConfigSnapshotDeliveryProperties:
DeliveryFrequency: Six_Hours
S3PublicAccessRule:
Type: AWS::Config::ConfigRule
DependsOn: ConfigDeliveryChannel
Properties:
ConfigRuleName: policyholder-s3-public-access-prohibited
Source:
Owner: AWS
SourceIdentifier: S3_BUCKET_LEVEL_PUBLIC_ACCESS_PROHIBITED
RestrictedSshRule:
Type: AWS::Config::ConfigRule
DependsOn: ConfigDeliveryChannel
Properties:
ConfigRuleName: policyholder-restricted-ssh
Source:
Owner: AWS
SourceIdentifier: INCOMING_SSH_DISABLED
SecurityHub:
Type: AWS::SecurityHub::Hub
DependsOn: ConfigDeliveryChannel
Properties:
AutoEnableControls: true
ControlFindingGenerator: SECURITY_CONTROL
EnableDefaultStandards: false
CisStandard:
Type: AWS::SecurityHub::Standard
DependsOn: SecurityHub
Properties:
StandardsArn: !Sub arn:${AWS::Partition}:securityhub:${AWS::Region}::standards/cis-aws-foundations-benchmark/v/5.0.0
GuardDutyDetector:
Type: AWS::GuardDuty::Detector
Properties:
Enable: true
FindingPublishingFrequency: FIFTEEN_MINUTES
LabVpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.42.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: policyholder-security-lab
LabSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Isolated security group for remediation testing
VpcId: !Ref LabVpc
Tags:
- Key: Purpose
Value: policyholder-lab
Outputs:
AuditBucketName:
Value: !Ref AuditBucket
ProtectedBucketName:
Value: !Ref ProtectedBucket
TrailName:
Value: !Sub ${AWS::StackName}-trail
AlertTopicArn:
Value: !Ref AlertTopic
LabSecurityGroupId:
Value: !GetAtt LabSecurityGroup.GroupId
SecurityHubStandardSubscriptionArn:
Value: !Ref CisStandard
GuardDutyDetectorId:
Value: !Ref GuardDutyDetector
What does the baseline define?
This CloudFormation template defines the audit trail, targeted configuration recording, compliance rules, posture controls, notification topic, threat detector, protected bucket, and isolated network resources.
The audit bucket uses retention policies so stack deletion cannot remove stored audit data prematurely. Cleanup handles that bucket after dependent services stop writing to it.
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Event-driven S3 remediation and CloudTrail tamper alerting.
Parameters:
ProtectedBucketName:
Type: String
AlertTopicArn:
Type: String
Resources:
RemediationRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: RestoreProtectedBucketBlockPublicAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: s3:PutBucketPublicAccessBlock
Resource: !Sub arn:${AWS::Partition}:s3:::${ProtectedBucketName}
RemediationFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ../build/lambda
Handler: handler.lambda_handler
Runtime: python3.12
Role: !GetAtt RemediationRole.Arn
S3PublicAccessBlockDeletedRule:
Type: AWS::Events::Rule
Properties:
State: ENABLED
EventPattern:
source:
- aws.s3
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- s3.amazonaws.com
eventName:
- DeleteBucketPublicAccessBlock
requestParameters:
bucketName:
- !Ref ProtectedBucketName
Targets:
- Arn: !GetAtt RemediationFunction.Arn
Id: RestoreS3BlockPublicAccess
PermissionForS3RuleToInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref RemediationFunction
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt S3PublicAccessBlockDeletedRule.Arn
CloudTrailTamperRule:
Type: AWS::Events::Rule
Properties:
State: ENABLED
EventPattern:
source:
- aws.cloudtrail
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- cloudtrail.amazonaws.com
eventName:
- StopLogging
Targets:
- Arn: !Ref AlertTopicArn
Id: NotifyCloudTrailStopLogging
EventBridgeTopicPolicy:
Type: AWS::SNS::TopicPolicy
Properties:
Topics:
- !Ref AlertTopicArn
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowEventBridgeToPublishTamperAlert
Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sns:Publish
Resource: !Ref AlertTopicArn
Condition:
ArnEquals:
aws:SourceArn: !GetAtt CloudTrailTamperRule.Arn
Outputs:
RemediationFunctionName:
Value: !Ref RemediationFunction
S3RemediationRuleArn:
Value: !GetAtt S3PublicAccessBlockDeletedRule.Arn
CloudTrailTamperRuleArn:
Value: !GetAtt CloudTrailTamperRule.Arn
What does remediation add?
This template connects Amazon EventBridge to an AWS Lambda responder for S3 protection changes. A separate rule sends CloudTrail tamper events to the existing SNS topic.
import json
import boto3
s3 = boto3.client("s3")
def lambda_handler(event, context):
detail = event["detail"]
event_name = detail["eventName"]
if event_name != "DeleteBucketPublicAccessBlock":
raise ValueError(f"Unsupported event: {event_name}")
bucket_name = detail["requestParameters"]["bucketName"]
s3.put_public_access_block(
Bucket=bucket_name,
PublicAccessBlockConfiguration={
"BlockPublicAcls": True,
"IgnorePublicAcls": True,
"BlockPublicPolicy": True,
"RestrictPublicBuckets": True,
},
)
result = {"eventName": event_name, "remediatedBucket": bucket_name, "status": "RESTORED"}
print(json.dumps(result))
return result
How does the handler recover S3?
The handler accepts the expected S3 management event and reads its bucket name. It restores all four bucket-level Block Public Access settings before returning a RESTORED result.
boto3==1.43.118
Why package Boto3?
The dependency file pins Boto3 to 1.43.118. Packaging that release avoids relying on a runtime-provided SDK version that can vary by runtime or Region.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectCloudTrailConfiguration",
"Effect": "Deny",
"Action": [
"cloudtrail:AddTags",
"cloudtrail:CreateTrail",
"cloudtrail:DeleteTrail",
"cloudtrail:RemoveTags",
"cloudtrail:StartLogging",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail"
],
"Resource": "*"
}
]
}
What does the SCP express?
The document denies the seven CloudTrail configuration actions used by the official protection pattern. It remains unattached because this lab has no AWS Organizations member sandbox account for staged enforcement testing.
import json
from pathlib import Path
POLICY_PATH = Path("policies/cloudtrail-protection-scp.json")
REQUIRED_ACTIONS = {
"cloudtrail:AddTags", "cloudtrail:CreateTrail", "cloudtrail:DeleteTrail",
"cloudtrail:RemoveTags", "cloudtrail:StartLogging", "cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
}
def main():
document = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
if document.get("Version") != "2012-10-17":
raise SystemExit("Unexpected policy language version")
statements = document.get("Statement", [])
deny_statements = [s for s in statements if s.get("Effect") == "Deny"]
actions = {a for s in deny_statements for a in s.get("Action", [])}
resources = {
r for s in deny_statements
for r in (s.get("Resource") if isinstance(s.get("Resource"), list) else [s.get("Resource")])
}
missing_actions = REQUIRED_ACTIONS - actions
if missing_actions:
raise SystemExit(f"Missing required actions: {sorted(missing_actions)}")
if "*" not in resources:
raise SystemExit("The deny statement must cover resource *")
print("SCP validation passed")
if __name__ == "__main__":
main()
What does the validator prove?
The validator loads the policy artifact and checks its language version. It also requires every CloudTrail action, a deny effect, and the wildcard resource.
The local gate checks four different failure surfaces. CloudFormation linting catches template problems while the focused Checkov scan tests only the S3 and SSH guardrails taught in this project.
Before you run the gate, do you expect all four checks to accept the supplied repository?
- Lint both CloudFormation templates by running this command:
cfn-lint templates/*.yaml
What does this check?
cfn-lint evaluates both YAML templates against CloudFormation resource schemas and template rules. A successful run returns to the prompt without reporting a lint error.
You should return to the terminal prompt without a cfn-lint finding.
- Scan the focused infrastructure guardrails by running this command:
checkov --directory templates --framework cloudformation --check CKV_AWS_24,CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56 --compact
Why use focused checks?
The selected checks cover unrestricted SSH plus the four S3 Block Public Access settings. This keeps the gate aligned with the security controls demonstrated in the lab.
You should see the selected checks pass without a failed check.
- Compile the Lambda handler by running this command:
python -m py_compile lambda/handler.py
What does compilation catch?
Python compilation checks the handler for syntax errors. A successful run returns to the prompt without output.
You should return to the terminal prompt without a Python syntax error.
- Validate the unattached SCP artifact by running this command:
python scripts/validate_scp.py
What does this final check protect?
The custom validator prevents required CloudTrail actions from disappearing unnoticed. It also confirms that the deny statement covers the required resource scope.
You should see SCP validation passed. Your local repository now passes the same four gates used by CI.
A local check failed?
- Confirm that your prompt begins with (.venv) before rerunning cfn-lint or Checkov.
- Compare the failing file with the full-code reference and correct any indentation or capitalization mismatch.
- Run the failed command again after saving the corrected file.
Ask for help with the exact finding: Help me diagnose which supplied project file caused my local security gate to fail.
Publish and inspect the security gate
Local checks protect your current workspace. Publishing the repository makes the same gate run for every push or pull request.
- Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
- Enter Publish to GitHub in the Command Palette.
- Select Publish to GitHub.
- Complete the GitHub sign-in prompt if Visual Studio Code requests authorization.
- Enter policyholder-security-baseline as the repository name.
- Choose public visibility for the repository.
- Select every project file when Visual Studio Code asks which files to include.
- Confirm the publication to create the remote repository and push the files.
The workflow can take a few minutes while GitHub prepares a runner and installs the pinned validators. A queued or running status during that time is expected.
Before you inspect the run, do you expect the hosted gate to match the four checks that passed locally?
- Open the published repository from the confirmation link in Visual Studio Code.
- Select the Actions tab in the repository.
- Select the IaC security gate workflow.
- Open the latest workflow run.
- Select the validate job to inspect its result.
You should see a green result for the validate job. Its logs should include CloudFormation linting, the focused Checkov scan, Lambda compilation, and SCP validation.
Workflow job is not green?
- Open the failed workflow step and compare its command with .github/workflows/iac-security.yml.
- Confirm that all eight files were included in the published repository.
- Correct the affected file locally before publishing the new commit.
Ask for help with the workflow log: Help me diagnose why my IaC security gate failed on GitHub Actions after passing locally.
That is the repository gate working: unsafe template or policy changes now have to pass the same checks locally and on GitHub. Next, you will deploy the validated detective baseline into a clean AWS Region.
Deploy the Detective Security Baseline
Your green GitHub Actions gate proved that malformed infrastructure or a weakened policy cannot pass unnoticed.
The next move is to let AWS CloudFormation create the live detective layer. Automatic recovery needs visible security signals before it can repair an unsafe change.
In this step, get ready to:
- Prove that your chosen AWS Region is clean for this lab.
- Deploy the security baseline stack.
- Verify the live baseline.
Choose a clean AWS Region
AWS security services often allow only one customer-managed regional resource of each type. A clean Region prevents the lab from colliding with an existing recorder or security service.
Signing in with broad lab permissions can feel risky. This project limits those permissions to synthetic resources in a clean Region.
- Go to the AWS Management Console in your browser.
- Sign in using your administrator-level lab identity.
- Open the Select a Region menu in the console navigation bar.
- Choose a supported candidate Region for the lab.
- Choose the CloudShell icon in the console navigation bar.
- Wait for the $ prompt to confirm that AWS CloudShell is ready.
- Return to your public GitHub repository from earlier.
- Record its browser URL here: your public GitHub repository URL.
- Create a CloudShell working copy by running these commands:
git clone [[YOUR_REPO_URL="your public GitHub repository URL"]] policyholder-security-baseline
cd policyholder-security-baseline
What do these commands do?
- The git clone command downloads the public repository into a new policyholder-security-baseline directory.
- The cd command moves your shell into that directory.
You should see Git report its download progress before the prompt returns inside the cloned repository.
Repository not cloning?
- Confirm that the repository is public on GitHub.
- Check that your repository URL begins with a supported network protocol.
- Remove the incomplete policyholder-security-baseline directory before retrying the clone.
Help me fix the repository clone in AWS CloudShell.
The preflight checks look for services that would conflict with this stack. In a clean Region, the Security Hub check is expected to indicate that no hub is enabled.
- Check the candidate Region by running these commands:
aws configservice describe-configuration-recorders
aws configservice describe-delivery-channels
aws securityhub describe-hub
aws guardduty list-detectors
What do these checks prove?
- The first command checks AWS Config for an existing customer-managed configuration recorder.
- The second command checks AWS Config for an existing delivery channel.
- The third command checks whether AWS Security Hub CSPM already has a hub in this Region.
- The fourth command checks Amazon GuardDuty for an existing detector.
A clean result shows empty ConfigurationRecorders, empty DeliveryChannels, no existing Security Hub hub, plus empty DetectorIds.
That is your clean landing zone confirmed. The stack can create its regional security services without altering an existing setup.
- Select another supported Region if any preflight check finds an existing customer-managed resource.
- Repeat the four preflight checks after CloudShell reconnects to the new Region.
Preflight checks blocked?
- Confirm that CloudShell is using the administrator-level lab identity.
- Choose another clean Region instead of changing an existing security service.
- Check the Region name shown on the CloudShell terminal tab.
Help me interpret my AWS security service preflight results.
Deploy the baseline stack
The supplied templates/baseline.yaml file defines the complete detective baseline. CloudFormation follows the template dependencies while it creates each resource.
This deployment starts usage-priced AWS services. The same-day lab is estimated to stay under $5 when you clean it up at the end.
The deployment can take several minutes while the regional security services become ready. A quiet terminal during that wait does not mean the process has stalled.
- Record the email address for security alerts here: your-email@example.com.
- Deploy the policyholder-security-baseline stack by running this command:
aws cloudformation deploy --template-file templates/baseline.yaml --stack-name policyholder-security-baseline --capabilities CAPABILITY_IAM --parameter-overrides AlertEmail=[[ALERT_EMAIL="your-email@example.com"]]
What does this deployment create?
- Two encrypted Amazon S3 buckets provide audit storage plus an isolated protected resource.
- A multi-Region AWS CloudTrail trail records management events with log-file validation enabled.
- A targeted AWS Config recorder tracks only the five resource types needed by this lab.
- Two managed Config rules detect public S3 access plus unrestricted SSH.
- Security Hub CSPM enables CIS AWS Foundations Benchmark v5.0.0.
- GuardDuty enables regional threat detection.
- An Amazon SNS topic provides the email notification path.
- An isolated VPC contains the tagged lab security group.
The deployment command returns after CloudFormation finishes the stack operation.
Stack deployment failed?
- Confirm that every preflight check still indicates a clean Region.
- Confirm that CloudShell is inside the cloned policyholder-security-baseline directory.
- Confirm that your lab identity has permission to create IAM roles plus the listed security services.
Help me diagnose the CloudFormation deployment failure.
Verify the live baseline
The SNS email endpoint remains inactive until you approve its subscription. Confirmation completes the alert delivery path used later in the project.
- Switch to the inbox for your-email@example.com.
- Find the SNS subscription confirmation message.
- Follow the confirmation link in the message.
- Return to the CloudShell session from earlier.
You should reach a confirmation page showing that the email endpoint can receive topic notifications.
Confirmation email missing?
- Check the spam or junk folder for the subscription message.
- Confirm that the deployment command used the intended email address.
- Allow a short delivery delay before checking the inbox again.
Help me confirm my SNS email subscription safely.
Before you check the stack, do you think CloudFormation reached its completed state?
- Check the stack status by running this command:
aws cloudformation describe-stacks --stack-name policyholder-security-baseline --query 'Stacks[0].StackStatus' --output text
What does this status check do?
- The command requests the deployed policyholder-security-baseline stack.
- The query selects its current stack status.
- The output option returns the status as plain text.
You should see CREATE_COMPLETE. Great work. Your detective security baseline is now live in the chosen Region.
- Inspect the stack resources plus output values by running this command:
aws cloudformation describe-stacks --stack-name policyholder-security-baseline
What should the stack details show?
- The StackStatus value is CREATE_COMPLETE.
- The AuditBucketName output identifies the retained audit bucket.
- The ProtectedBucketName output identifies the protected test bucket.
- The TrailName output identifies the logging trail.
- The AlertTopicArn output identifies the confirmed notification topic.
- The LabSecurityGroupId output identifies the isolated lab security group.
- The SecurityHubStandardSubscriptionArn output confirms the CIS standard subscription.
- The GuardDutyDetectorId output confirms the enabled detector.
You should see every named output in the stack description. Together, they prove that the baseline created its audit, compliance, notification, posture, threat-detection, plus isolated test resources.
Your detective baseline is live. Next, you deliberately remove one S3 protection to reveal the gap that static infrastructure cannot repair by itself.
Expose the Missing S3 Guardrail
Your CloudFormation baseline is live. Its detective controls now give you a secure starting point.
The protected Amazon S3 bucket received Block Public Access from the template at deployment time. A direct API change creates configuration drift that the template cannot repair on its own.
In this step, get ready to:
- Capture the protected bucket name from the stack output.
- Demonstrate the configuration gap without adding data or a public bucket policy.
- Confirm the bucket finishes the step with all four protections enabled.
Capture the protected bucket name
Stack outputs provide the physical names that CloudFormation generated. Storing the protected bucket name in PROTECTED_BUCKET keeps every test focused on the synthetic lab resource.
- Return to the AWS CloudShell session from earlier.
- Store the protected bucket output in PROTECTED_BUCKET by running this command:
PROTECTED_BUCKET=$(aws cloudformation describe-stacks \
--stack-name policyholder-security-baseline \
--query "Stacks[0].Outputs[?OutputKey=='ProtectedBucketName'].OutputValue|[0]" \
--output text)
What does this command do?
The command reads the ProtectedBucketName stack output. Your shell stores that generated name in PROTECTED_BUCKET for the remaining checks.
Before you inspect the bucket, which protection values do you expect from the deployed template?
- Inspect the current bucket-level configuration by running this command:
aws s3api get-public-access-block --bucket "$PROTECTED_BUCKET"
What should I see?
- You should see BlockPublicAcls set to true.
- You should see IgnorePublicAcls set to true.
- You should see BlockPublicPolicy set to true.
- You should see RestrictPublicBuckets set to true.
Cannot read the bucket configuration?
- Confirm that your selected Region matches the Region containing policyholder-security-baseline.
- Check that PROTECTED_BUCKET came from the ProtectedBucketName output.
Help me diagnose why I cannot read the protected bucket configuration.
Remove the live protection
The template describes the bucket's intended state. This test changes the live resource directly so you can see whether that secure state returns automatically.
Keep the test synthetic
This controlled test removes four bucket-level protections from an empty lab bucket. No data or public bucket policy is involved.
- Keep the protected bucket empty throughout the test.
- Leave its bucket policy unchanged.
- Delete the bucket-level Block Public Access configuration by running this command:
aws s3api delete-public-access-block --bucket "$PROTECTED_BUCKET"
What does this command change?
The API call asks S3 to delete the bucket-level configuration from the live resource. The CloudFormation stack remains in CREATE_COMPLETE because no stack deployment occurred.
Before you check, do you expect the template-defined settings to return on their own?
- Inspect the live bucket configuration again by running this command:
aws s3api get-public-access-block --bucket "$PROTECTED_BUCKET"
The missing configuration is expected
The command reports that no bucket-level public access block configuration exists. You found the gap: the deployed template records secure intent without continuously enforcing the live setting.
Still seeing four true values?
- Confirm that the delete command targeted the bucket stored in PROTECTED_BUCKET.
- Check that your AWS CloudShell session still uses the selected Region.
Help me understand why the protected bucket still has its Block Public Access configuration.
Restore all four settings
The missing configuration proves that recovery currently depends on an operator. A manual put call returns the live bucket to the secure state declared by the template.
- Restore the complete bucket-level configuration by running this command:
aws s3api put-public-access-block --bucket "$PROTECTED_BUCKET" --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
What does this configuration restore?
- BlockPublicAcls rejects attempts to add new public access control lists.
- IgnorePublicAcls prevents existing public access control lists from granting access.
- BlockPublicPolicy rejects new public bucket policies.
- RestrictPublicBuckets limits access when a public bucket policy exists.
Before you check, do you expect the missing configuration to return with all four values enabled?
- Verify the restored bucket configuration by running this command:
aws s3api get-public-access-block --bucket "$PROTECTED_BUCKET"
What should I see now?
- You should see BlockPublicAcls set to true.
- You should see IgnorePublicAcls set to true.
- You should see BlockPublicPolicy set to true.
- You should see RestrictPublicBuckets set to true.
Configuration still missing?
- Check the restoration command for missing quotes around the public access block configuration.
- Confirm that all four setting names use the same capitalization shown above.
Help me restore the four bucket-level S3 Block Public Access settings safely.
That is the manual recovery loop complete. Next, you will turn the same unsafe API change into an automatic response with Amazon EventBridge and AWS Lambda.
Automate S3 Recovery with EventBridge and Lambda
You proved that the protected bucket can lose its bucket-level safeguards through an out-of-band API change. Now you will turn that same CloudTrail management event into an automatic recovery path.
Manual repair is slow. EventBridge will invoke a Python Lambda that restores all four settings without waiting for a person.
In this step, get ready to:
- Build a Lambda deployment package with the supplied handler and pinned dependency.
- Package the remediation template and deploy its CloudFormation stack.
- Delete the bucket-level setting and watch the responder restore it automatically.
Build the Lambda deployment package
A Lambda deployment package contains the handler plus every dependency that the function needs. Your package includes Boto3 version 1.43.118 so its behavior does not depend on the runtime copy available in the selected Region.
- Return to the repository directory from earlier in AWS CloudShell.
- Load the audit bucket and alert topic values from the baseline stack by running these commands:
AUDIT_BUCKET=$(aws cloudformation describe-stacks \
--stack-name policyholder-security-baseline \
--query "Stacks[0].Outputs[?OutputKey=='AuditBucketName'].OutputValue" \
--output text)
ALERT_TOPIC_ARN=$(aws cloudformation describe-stacks \
--stack-name policyholder-security-baseline \
--query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" \
--output text)
echo "$AUDIT_BUCKET"
echo "$ALERT_TOPIC_ARN"
What do these commands capture?
- The AUDIT_BUCKET variable holds the retained Amazon S3 bucket used to store the packaged Lambda artifact.
- The ALERT_TOPIC_ARN variable holds the Amazon SNS topic targeted by the CloudTrail tamper rule.
- The final two lines print both values so you can confirm that neither variable is empty.
You should see the audit bucket name on the first line. You should see the alert topic ARN on the second line.
Seeing an empty output?
- Confirm that the baseline stack is named policyholder-security-baseline.
- Confirm that CloudShell still uses the Region where you deployed the baseline.
Ask for help with the stack output query:
- Create a fresh build/lambda directory and copy the supplied handler into it by running these commands:
rm -rf build/lambda
mkdir -p build/lambda
cp lambda/handler.py build/lambda/handler.py
ls build/lambda
What does this build step do?
The commands remove any stale package before creating a clean deployment directory. They copy the unchanged lambda/handler.py file into that directory.
The final command lists the directory. You should see handler.py in the output.
- Install the dependencies from lambda/requirements.txt into the deployment directory by running this command:
python -m pip install -r lambda/requirements.txt --target build/lambda
ls build/lambda/boto3
Why install into the build directory?
The target option places Boto3 beside the handler instead of adding it to CloudShell's Python environment. CloudFormation can then upload one self-contained package.
The second command confirms that the installed package contains the boto3 directory. That directory comes from the boto3==1.43.118 pin in lambda/requirements.txt.
Dependency installation failed?
- Confirm that lambda/requirements.txt exists in the repository directory.
- Confirm that CloudShell can reach the Python package index.
- Remove build/lambda before repeating the build commands.
Get help diagnosing the package build:
The supplied source files remain unchanged in this step.
✔️ Awesome, I've got everything!
Your build directory now contains the supplied handler plus the pinned Boto3 dependency. Keep the source files unchanged before packaging.
ⓧ I'd like to double check the full code
Compare your remediation template with this complete reference.
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Event-driven S3 remediation and CloudTrail tamper alerting.
Parameters:
ProtectedBucketName:
Type: String
AlertTopicArn:
Type: String
Resources:
RemediationRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: RestoreProtectedBucketBlockPublicAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: s3:PutBucketPublicAccessBlock
Resource: !Sub arn:${AWS::Partition}:s3:::${ProtectedBucketName}
RemediationFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ../build/lambda
Handler: handler.lambda_handler
Runtime: python3.12
Role: !GetAtt RemediationRole.Arn
S3PublicAccessBlockDeletedRule:
Type: AWS::Events::Rule
Properties:
State: ENABLED
EventPattern:
source:
- aws.s3
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- s3.amazonaws.com
eventName:
- DeleteBucketPublicAccessBlock
requestParameters:
bucketName:
- !Ref ProtectedBucketName
Targets:
- Arn: !GetAtt RemediationFunction.Arn
Id: RestoreS3BlockPublicAccess
PermissionForS3RuleToInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref RemediationFunction
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt S3PublicAccessBlockDeletedRule.Arn
CloudTrailTamperRule:
Type: AWS::Events::Rule
Properties:
State: ENABLED
EventPattern:
source:
- aws.cloudtrail
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- cloudtrail.amazonaws.com
eventName:
- StopLogging
Targets:
- Arn: !Ref AlertTopicArn
Id: NotifyCloudTrailStopLogging
EventBridgeTopicPolicy:
Type: AWS::SNS::TopicPolicy
Properties:
Topics:
- !Ref AlertTopicArn
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowEventBridgeToPublishTamperAlert
Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sns:Publish
Resource: !Ref AlertTopicArn
Condition:
ArnEquals:
aws:SourceArn: !GetAtt CloudTrailTamperRule.Arn
Outputs:
RemediationFunctionName:
Value: !Ref RemediationFunction
S3RemediationRuleArn:
Value: !GetAtt S3PublicAccessBlockDeletedRule.Arn
CloudTrailTamperRuleArn:
Value: !GetAtt CloudTrailTamperRule.Arn
What should the template contain?
The template defines the least-privilege role and the Lambda function. It also defines the S3 recovery rule plus the CloudTrail tamper alert path.
Compare your Lambda handler with this complete reference.
import json
import boto3
s3 = boto3.client("s3")
def lambda_handler(event, context):
detail = event["detail"]
event_name = detail["eventName"]
if event_name != "DeleteBucketPublicAccessBlock":
raise ValueError(f"Unsupported event: {event_name}")
bucket_name = detail["requestParameters"]["bucketName"]
s3.put_public_access_block(
Bucket=bucket_name,
PublicAccessBlockConfiguration={
"BlockPublicAcls": True,
"IgnorePublicAcls": True,
"BlockPublicPolicy": True,
"RestrictPublicBuckets": True,
},
)
result = {"eventName": event_name, "remediatedBucket": bucket_name, "status": "RESTORED"}
print(json.dumps(result))
return result
What should the handler do?
The handler accepts only the expected S3 deletion event. It reads the affected bucket name before restoring all four bucket-level settings.
The returned result identifies the remediated bucket with a RESTORED status.
Compare your dependency file with this complete reference.
boto3==1.43.118
What should the dependency file contain?
The single pin gives the deployment package a known Boto3 version. The package therefore avoids relying on a Region-dependent runtime copy.
Package and deploy the remediation stack
The CloudFormation package operation uploads the local Lambda directory to the retained audit bucket. It writes a deployable template named packaged-remediation.yaml with the uploaded artifact location.
- Package the remediation template with the audit bucket by running this command:
aws cloudformation package --template-file templates/remediation.yaml --s3-bucket "$AUDIT_BUCKET" --output-template-file packaged-remediation.yaml
What does the package command do?
CloudFormation reads templates/remediation.yaml and finds the relative ../build/lambda artifact path. It compresses that directory before uploading the package to the audit bucket.
The generated packaged-remediation.yaml points the Lambda resource at the uploaded package. Your original template remains unchanged.
You should see packaging progress in CloudShell. The generated template is now ready for deployment.
Packaging did not complete?
- Confirm that AUDIT_BUCKET printed a bucket name earlier.
- Confirm that build/lambda/handler.py exists.
- Confirm that the audit bucket belongs to the same AWS account.
Get help with the packaging failure:
This deployment can take a few minutes while CloudFormation creates the function and its event rules.
- Deploy the packaged template as the remediation stack by running this command:
aws cloudformation deploy --template-file packaged-remediation.yaml --stack-name policyholder-security-remediation --capabilities CAPABILITY_IAM --parameter-overrides ProtectedBucketName="$PROTECTED_BUCKET" AlertTopicArn="$ALERT_TOPIC_ARN"
What does this stack deploy?
- The RemediationRole can call s3:PutBucketPublicAccessBlock only for the protected bucket.
- The S3PublicAccessBlockDeletedRule matches DeleteBucketPublicAccessBlock for PROTECTED_BUCKET.
- The Lambda permission allows the matching EventBridge rule to invoke RemediationFunction.
- The CloudTrailTamperRule sends matching StopLogging events to the existing alert topic.
Before you check the stack, do you expect the deployment to be complete or still in progress?
- Check the remediation stack status by running this command:
aws cloudformation describe-stacks --stack-name policyholder-security-remediation --query 'Stacks[0].StackStatus' --output text
What does this check prove?
The query returns the current status of the remediation stack. A completed status confirms that CloudFormation created the resources in the packaged template.
You should see CREATE_COMPLETE. The automatic recovery path is now deployed and ready for a real event.
Stack status is not CREATE_COMPLETE?
- Allow the deployment another minute if the status still shows an in-progress state.
- Review the CloudFormation failure details if the status shows a rollback state.
- Confirm that PROTECTED_BUCKET contains the protected bucket name.
Ask for help interpreting the stack status:
Test automatic S3 recovery
The deployed event path reacts to the same API call that exposed the gap earlier. This time the handler should repair the bucket without a manual put command.
This test touches a bucket safeguard, but the bucket remains empty with no public policy.
- Delete the protected bucket's Block Public Access configuration by running this command:
aws s3api delete-public-access-block --bucket "$PROTECTED_BUCKET"
What does this test change?
The command removes the bucket-level configuration from the synthetic protected bucket. It does not add a public bucket policy or upload any data.
Allow a short delivery delay while the CloudTrail event reaches EventBridge.
Before you check, do you expect the configuration to remain missing or show restored values?
- Check the effective bucket-level configuration by running this command:
aws s3api get-public-access-block --bucket "$PROTECTED_BUCKET"
What does this result prove?
The command reads the current bucket-level configuration after the event-driven path has had time to run. Restored values prove that lambda_handler processed the event and called put_public_access_block.
You should see BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, and RestrictPublicBuckets set to true. That is the recovery loop working from deletion event to repaired bucket.
Configuration still missing?
- Allow another short delivery delay because CloudTrail-delivered EventBridge events are best effort.
- Run the same bucket configuration check again.
- Confirm that the remediation stack still shows CREATE_COMPLETE.
Get help tracing the automatic recovery path:
Your protected bucket now repairs this unsafe change automatically. Next, you will validate the unattached SCP and trigger the deployed CloudTrail tamper alert.
Validate the SCP and Trigger the CloudTrail Tamper Alert
Your Amazon S3 responder now restores bucket protections after an unsafe API change.
A service control policy can prevent configuration changes across AWS Organizations. Attaching one safely requires a member sandbox account for staged testing.
This lab keeps the SCP unattached. You will pair CI/CD-backed policy validation with a live AWS CloudTrail tamper alert delivered through Amazon SNS.
In this step, get ready to:
- Validate the unattached CloudTrail-protection SCP against its required actions.
- Trigger a brief CloudTrail stop-start event.
- Inspect the tamper-alert email after confirming the trail status.
Inspect the SCP safety checks
The SCP is a policy as code artifact that protects CloudTrail configuration actions. A custom Python validator turns its safety requirements into a repeatable gate.
- Choose the tab that matches your review of policies/cloudtrail-protection-scp.json and scripts/validate_scp.py.
✔️ Awesome, I've got everything!
Your existing policy and validator match the supplied project files.
ⓧ I'd like to double check the full code
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectCloudTrailConfiguration",
"Effect": "Deny",
"Action": [
"cloudtrail:AddTags",
"cloudtrail:CreateTrail",
"cloudtrail:DeleteTrail",
"cloudtrail:RemoveTags",
"cloudtrail:StartLogging",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail"
],
"Resource": "*"
}
]
}
What does this policy protect?
- The Deny effect blocks the listed CloudTrail configuration actions when the policy is enforced.
- The Action list covers trail creation plus trail deletion.
- The same list covers logging changes plus trail updates.
- The Resource value of * satisfies the policy validator.
The document remains unattached throughout this lab. Its repository history demonstrates preventive policy design without claiming organization-wide enforcement.
import json
from pathlib import Path
POLICY_PATH = Path("policies/cloudtrail-protection-scp.json")
REQUIRED_ACTIONS = {
"cloudtrail:AddTags", "cloudtrail:CreateTrail", "cloudtrail:DeleteTrail",
"cloudtrail:RemoveTags", "cloudtrail:StartLogging", "cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
}
def main():
document = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
if document.get("Version") != "2012-10-17":
raise SystemExit("Unexpected policy language version")
statements = document.get("Statement", [])
deny_statements = [s for s in statements if s.get("Effect") == "Deny"]
actions = {a for s in deny_statements for a in s.get("Action", [])}
resources = {
r for s in deny_statements
for r in (s.get("Resource") if isinstance(s.get("Resource"), list) else [s.get("Resource")])
}
missing_actions = REQUIRED_ACTIONS - actions
if missing_actions:
raise SystemExit(f"Missing required actions: {sorted(missing_actions)}")
if "*" not in resources:
raise SystemExit("The deny statement must cover resource *")
print("SCP validation passed")
if __name__ == "__main__":
main()
What does this validator check?
- The validator loads the policy from policies/cloudtrail-protection-scp.json.
- It requires every official CloudTrail configuration action in REQUIRED_ACTIONS to appear inside a Deny statement.
- It requires the deny statement to cover Resource value *.
Before you run the validator, predict whether every safety check will pass.
- Run the custom SCP validator from the repository in CloudShell:
python scripts/validate_scp.py
What does this command prove?
The command checks the policy language version against the supplied requirement. It also checks the required deny actions plus wildcard resource coverage.
A passing result proves the repository policy still matches the safety gate.
You should see SCP validation passed.
Validator did not pass?
- Confirm CloudShell is inside the cloned repository that contains the policies and scripts folders.
- Compare both files with the full-code tab above.
- Restore any missing CloudTrail action before running the validator again.
Help me diagnose why the SCP validator or CI gate is failing.
The same validator runs through GitHub Actions on each matching repository push.
- Return to the public GitHub repository from earlier.
- Select the Actions tab.
- Open the latest IaC security gate workflow run.
- Confirm the Validate SCP artifact step is green.
The local pass plus the green workflow show that the unattached policy survives the same repeatable gate before every repository change.
Trigger the tamper event safely
The deployed remediation stack already watches for the CloudTrail StopLogging event. The rule forwards the matching event to the confirmed SNS topic.
- In AWS CloudShell from earlier, store the baseline trail name in TRAIL_NAME by running this command:
TRAIL_NAME=$(aws cloudformation describe-stacks --stack-name policyholder-security-baseline --query 'Stacks[0].Outputs[?OutputKey==`TrailName`].OutputValue' --output text)
What does this command do?
The command reads the TrailName output from the deployed baseline stack. It stores that value in the current CloudShell session as TRAIL_NAME.
For this stack, the variable contains policyholder-security-baseline-trail.
This test briefly suspends audit recording, which deserves care. The paired start command restores logging immediately after the test event.
Before you run the pair, predict whether the trail will remain stopped after the second command.
- Generate the tamper event plus its immediate recovery by running these commands:
aws cloudtrail stop-logging --name "$TRAIL_NAME"
aws cloudtrail start-logging --name "$TRAIL_NAME"
What happens during the test?
The first command suspends recording for the named trail. That API call creates the event matched by the deployed tamper rule.
The second command resumes recording immediately. This keeps the deliberate audit gap as short as possible.
Verify the tamper response
Before you check the trail, predict whether the immediate restart restored logging.
- Check the trail's current logging status by running this command:
aws cloudtrail get-trail-status --name "$TRAIL_NAME" --query IsLogging --output text
What does this status check prove?
The command asks CloudTrail for the current status of the trail stored in TRAIL_NAME. The query returns only the IsLogging value.
You should see True. That confirms the audit trail resumed recording after the tamper event.
Trail is not logging?
- Run the start command from the previous substep again if the status shows False.
- Rerun the trail-name assignment if CloudShell cannot resolve TRAIL_NAME.
- Confirm CloudShell still uses the selected Region where the baseline stack was deployed.
Help me restore logging for the CloudTrail trail.
Amazon EventBridge receives the management API event with type AWS API Call via CloudTrail. The deployed rule sends that event to SNS.
The alert may take a short while because CloudTrail-delivered EventBridge events use best-effort delivery. The True status remains your immediate recovery proof.
- Return to the inbox that confirmed the SNS subscription earlier.
- Refresh the inbox after allowing a short delivery delay.
- Open the newest SNS notification associated with this lab.
- Confirm the event type is AWS API Call via CloudTrail.
- Confirm eventName is StopLogging.
When the message arrives, it proves the detective control observed the tamper event. The trail status proves the audit service recovered immediately.
Alert email still pending?
- Allow a short delivery delay before refreshing the inbox again.
- Check the inbox's spam or junk folder for the SNS notification.
- Keep the True trail status as evidence that logging resumed while delivery remains pending.
Help me trace the delayed CloudTrail tamper alert.
Strong finish. Your repository now proves preventive policy design while the deployed alert proves live tamper detection.
Secret mission
Remediate World-Open SSH
Your responder already repairs a weakened S3 bucket. Extend the event-driven recovery pattern so it removes newly authorized world-open SSH from the isolated lab security group.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the lab active, pause part of it, or remove its AWS resources. AWS Config, AWS Security Hub CSPM, Amazon GuardDuty, Amazon S3, and Amazon SNS can continue generating usage charges while the baseline remains deployed.
Cost warning
An existing AWS CloudTrail trail can make this lab trail an additional paid management-event copy. Storage requests can also add charges.
Choose Pause or Delete once your demonstration is complete. Delete is the only option here that removes the lab's ongoing cloud usage.
Resources you used:
- The policyholder-security-baseline AWS CloudFormation stack. It owns the protected bucket, VPC, security group, multi-Region trail, targeted Config resources, managed Config rules, Security Hub CSPM resources, GuardDuty detector, SNS resources, and supporting IAM role.
- The policyholder-security-remediation stack. It owns the extended Lambda responder, its IAM role, the S3 recovery rule, the world-open SSH recovery rule, the CloudTrail tamper rule, invocation permissions, and the EventBridge topic policy.
- The retained S3 audit bucket. It contains CloudTrail logs, Config deliveries, and packaged Lambda artifacts.
Keep everything running
No action is needed. Choose this option while you are actively demonstrating the automated controls.
- Keep policyholder-security-baseline deployed.
- Keep policyholder-security-remediation deployed.
- Leave CloudTrail, AWS Config, Security Hub CSPM, GuardDuty, EventBridge, Lambda, S3, and SNS active.
- Expect these services to continue generating usage while the lab remains active.
Pause - I'll come back to this later
Pause the responder and audit trail when you need a short break. This option leaves most baseline services active.
- Reuse the TRAIL_NAME value from the tamper-alert test.
- Delete the remediation stack and stop audit logging by running these commands:
aws cloudformation delete-stack --stack-name policyholder-security-remediation
aws cloudformation wait stack-delete-complete --stack-name policyholder-security-remediation
aws cloudtrail stop-logging --name "$TRAIL_NAME"
What Does This Pause Do?
Deleting the remediation stack removes the Lambda responder and EventBridge rules. The waiter confirms that removal before the trail stops logging.
Stopping the trail creates CloudFormation drift in the baseline stack. AWS Config, Security Hub CSPM, GuardDuty, and S3 remain active with their associated usage.
- Confirm the stack waiter finishes without an error.
- Return later by rerunning the start-logging command from Step 5.
- Repeat the Step 4 deployment workflow to restore policyholder-security-remediation.
Delete - I don't want to use this again
Deleting these resources is irreversible, so the command order protects the retained audit bucket from fresh service writes. Your repository remains available after the cloud resources are gone.
- Remove the AWS resources in dependency order by running these commands in CloudShell:
AUDIT_BUCKET=$(aws cloudformation describe-stacks \
--stack-name policyholder-security-baseline \
--query "Stacks[0].Outputs[?OutputKey=='AuditBucketName'].OutputValue" \
--output text)
aws cloudformation delete-stack --stack-name policyholder-security-remediation
aws cloudformation wait stack-delete-complete --stack-name policyholder-security-remediation
aws cloudformation delete-stack --stack-name policyholder-security-baseline
aws cloudformation wait stack-delete-complete --stack-name policyholder-security-baseline
aws s3 rm "s3://$AUDIT_BUCKET" --recursive
aws s3 rb "s3://$AUDIT_BUCKET"
What Does This Cleanup Do?
- The first command saves the retained audit bucket name before its stack disappears.
- The next two commands delete the remediation stack and wait for its resources to disappear.
- The following two commands delete the baseline stack and wait for its resources to disappear.
- The final two commands empty the retained audit bucket and remove the bucket itself.
- Wait for each stack waiter to finish before judging cleanup complete.
- Confirm the final bucket removal command succeeds.
- Confirm in the AWS CloudFormation console for the selected Region that neither stack appears as active.
- Confirm in Amazon S3 that the retained audit bucket no longer appears.
Bucket Still Not Empty?
Delayed CloudTrail or AWS Config delivery can add a final object after stack deletion.
- Rerun the recursive removal command from the cleanup block after delayed delivery has stopped.
- Retry the final bucket removal command.
Help me diagnose why the retained audit bucket is still not empty.
That closes the loop: your usage-generating lab resources are gone in dependency order.
Nice Work!
Nice Work!
Mission accomplished. You've built a synthetic policyholder lab that demonstrates a complete AWS security automation workflow.
What you learned:
- Deployed a multi-service security baseline through AWS CloudFormation. Targeted AWS Config recording tracks the lab resources used by your controls. AWS Security Hub CSPM provides posture signals. Amazon GuardDuty provides threat-detection signals.
- Demonstrated an Amazon S3 guardrail failure before adding the automated fix. Connected Amazon EventBridge to AWS Lambda. The responder restores all four bucket-level Block Public Access settings after deletion.
- Built a GitHub Actions security gate for templates, Lambda syntax, and the policy artifact. Validated an unattached AWS Organizations service control policy for AWS CloudTrail configuration actions. Triggered StopLogging safely. Received the matching email through Amazon SNS after restoring logging.
- Secret Mission: Extended the Lambda responder to inspect the tagged lab security group. It now revokes the 0.0.0.0/0 TCP port 22 range after AuthorizeSecurityGroupIngress.
Ready to quiz yourself?