In a multi-account AWS environment handling energy trading workloads, a single misconfigured S3 bucket or an overly permissive IAM role is not just a security finding. It is a compliance violation, a potential regulatory breach, and an audit risk. At RWE Supply & Trading, I faced this challenge at scale: dozens of accounts, hundreds of Terraform modules, and a continuous pressure to ship infrastructure quickly without compromising security posture.
This post documents the CSPM architecture I designed and implemented entirely on AWS-native services, deployed with Terraform. No third-party CSPM platforms, no external agents. It is a centralized, automated control plane that continuously monitors posture, enforces policy, and auto-remediates critical findings, built only from services that ship inside AWS and integrate natively through AWS Organizations.
Why AWS-Native?
Third-party CSPM platforms add real value, but they also add cost, a separate identity and data-egress surface, and another vendor in the audit scope. For a regulated workload, I wanted the control plane to live entirely inside the AWS trust boundary, with findings normalised in one format and no cloud data leaving the account perimeter.
AWS-native tooling delivers this through tight, low-latency integration: every detective service emits findings in the AWS Security Finding Format (ASFF), every finding lands in AWS Security Hub, and Security Hub becomes the single pane of glass and the single trigger source for automation. Enrolment is driven by AWS Organizations, so new accounts inherit the entire stack the moment they are created. Terraform remains the deployment tool: it provisions and versions every one of these native services as code.
The Problem with Point-in-Time Security Reviews
Traditional cloud security reviews are periodic. A team runs a checklist against a snapshot of the environment, flags findings, and assigns tickets. By the time those tickets are resolved, the environment has drifted further. In fast-moving cloud environments, this model breaks down within weeks.
The operational shift required is continuous posture management: every configuration change is evaluated against policy the moment it is applied, and deviations are either blocked before they land or remediated automatically within minutes.
Architecture Overview

The architecture has three layers, all built on AWS-native services and deployed with Terraform:
- Preventive layer: AWS CloudFormation Guard (
cfn-guard) runs in the CI/CD pipeline and blocks non-compliant Terraform before it is applied, evaluating theterraform planJSON against policy-as-code rules. AWS Config proactive rules evaluate resources against compliance rules before they are provisioned. AWS Organizations Service Control Policies (SCPs) enforce hard boundaries that no account-level policy can override. - Detective layer: Amazon GuardDuty, AWS Config rules and conformance packs, Amazon Inspector, Amazon Macie, and IAM Access Analyzer continuously monitor all accounts. AWS Security Hub aggregates every finding centrally in the Security/Audit account, scored against the CIS, AWS Foundational Security Best Practices, and NIST 800-53 standards.
- Responsive layer: Amazon EventBridge rules trigger AWS Lambda functions and AWS Systems Manager Automation runbooks that auto-remediate critical findings (for example public S3 buckets, disabled CloudTrail, overly permissive security groups) within minutes of detection.
Setting Up the Security Account as the Control Plane
All findings flow into a dedicated Security/Audit account. This account is not a workload account: it exists solely to aggregate, analyse, and act on security findings. AWS Security Hub and GuardDuty are delegated to this account as the organization administrator, and Security Hub central configuration pushes a single policy to every member account and Region.
# securityhub-control-plane.tf - applied in the Security/Audit account
# Aggregate findings from all Regions into the home Region
resource "aws_securityhub_finding_aggregator" "central" {
linking_mode = "ALL_REGIONS"
}
# Enable the security standards used for posture scoring
resource "aws_securityhub_standards_subscription" "cis" {
standards_arn = "arn:aws:securityhub:${var.region}::standards/cis-aws-foundations-benchmark/v/3.0.0"
}
resource "aws_securityhub_standards_subscription" "fsbp" {
standards_arn = "arn:aws:securityhub:::ruleset/finding-format/aws-foundational-security-best-practices/v/1.0.0"
}
# Push one Security Hub policy to all current and future org members
resource "aws_securityhub_organization_configuration" "central" {
auto_enable = true
auto_enable_standards = "DEFAULT"
organization_configuration {
configuration_type = "CENTRAL"
}
}GuardDuty is enabled organization-wide with the same delegated-admin model, so every member account is enrolled automatically and inherits the full detection stack on creation. No manual onboarding required.
# Designate the Security account as GuardDuty delegated admin
resource "aws_guardduty_organization_admin_account" "delegated" {
admin_account_id = var.security_account_id
}
resource "aws_guardduty_detector" "main" {
enable = true
}
# Auto-enable GuardDuty and its features for all org members
resource "aws_guardduty_organization_configuration" "auto_enable" {
auto_enable_organization_members = "ALL"
detector_id = aws_guardduty_detector.main.id
datasources {
s3_logs { auto_enable = true }
kubernetes {
audit_logs { enable = true }
}
malware_protection {
scan_ec2_instance_with_findings {
ebs_volumes { auto_enable = true }
}
}
}
}Preventive Controls: CloudFormation Guard on the Terraform Plan
The pipeline never reaches terraform apply unless the plan passes policy validation. AWS CloudFormation Guard (cfn-guard) is an AWS open-source policy-as-code engine. Despite the name, it evaluates any JSON or YAML, including the JSON output of terraform show, against declarative rules written in its own domain-specific language. It replaces third-party IaC scanners with a tool that AWS itself maintains and ships.
# .github/workflows/security-gate.yml
- name: Generate Terraform plan JSON
run: |
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
- name: Install cfn-guard
run: |
curl --proto '=https' --tlsv1.2 -sSf \
https://raw.githubusercontent.com/aws-cloudformation/cloudformation-guard/main/install-guard.sh | sh
- name: Validate plan against security ruleset
run: |
~/.guard/bin/cfn-guard validate \
--rules policies/aws-security.guard \
--data plan.json \
--show-summary failThe ruleset reads the resource_changes array from the Terraform plan and encodes the same posture controls we score against in Security Hub, but enforced before a resource is ever created:
# policies/aws-security.guard - evaluated against `terraform show -json`
# S3 buckets must block all public access
let public_access = resource_changes[ type == "aws_s3_bucket_public_access_block" ]
rule s3_block_public_access when %public_access !empty {
%public_access.change.after {
block_public_acls == true
block_public_policy == true
ignore_public_acls == true
restrict_public_buckets == true
}
}
# S3 buckets must declare server-side encryption
let s3_encryption = resource_changes[ type == "aws_s3_bucket_server_side_encryption_configuration" ]
rule s3_encryption_required when %s3_encryption !empty {
%s3_encryption.change.after.rule[*].apply_server_side_encryption_by_default.sse_algorithm
in ["aws:kms", "AES256"]
}
# EBS volumes must be encrypted
let volumes = resource_changes[ type == "aws_ebs_volume" ]
rule ebs_encryption when %volumes !empty {
%volumes.change.after.encrypted == true
}Any failed rule blocks the pipeline and the --show-summary fail output is posted directly to the PR as a review comment.
Proactive Config Rules: Blocking Before Provisioning
For controls that must be enforced regardless of how a resource is created (console, SDK, or another pipeline), I use AWS Config proactive rules. A proactive rule can be invoked from the pipeline through the Config StartResourceEvaluation API against the planned resource definition, and it fails the deployment if the resource would be non-compliant. This closes the gap that pipeline-only scanning leaves open and complements the cfn-guard gate with the same managed rules Config runs detectively.
Deploying AWS Config Rules at Scale with Terraform
AWS Config rules run continuously in every account, evaluating resources against compliance rules whenever a configuration change is detected. Rather than declaring rules one at a time, I deploy AWS-managed conformance packs organization-wide, bundling dozens of managed rules and remediation actions into a single Terraform-managed artifact.
# modules/config-rules/main.tf
# Org-wide conformance pack (bundles dozens of managed CIS rules)
resource "aws_config_organization_conformance_pack" "cis" {
name = "cis-aws-benchmark-level2"
template_s3_uri = "s3://my-conformance-packs/Operational-Best-Practices-for-CIS-v3.yaml"
}
# Individual high-value managed rules
resource "aws_config_config_rule" "s3_public_read_prohibited" {
name = "s3-bucket-public-read-prohibited"
description = "CIS 2.1.2 - S3 buckets must not allow public read"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED"
}
}
resource "aws_config_config_rule" "mfa_enabled_for_iam_console" {
name = "mfa-enabled-for-iam-console-access"
description = "CIS 1.2 - MFA required for console access"
source {
owner = "AWS"
source_identifier = "MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS"
}
}
resource "aws_config_config_rule" "cloudtrail_enabled" {
name = "cloudtrail-enabled"
description = "CIS 3.1 - CloudTrail must be enabled in all Regions"
source {
owner = "AWS"
source_identifier = "CLOUD_TRAIL_ENABLED"
}
}
resource "aws_config_config_rule" "encrypted_volumes" {
name = "encrypted-volumes"
description = "CIS 2.2.1 - EBS volumes must be encrypted"
source {
owner = "AWS"
source_identifier = "ENCRYPTED_VOLUMES"
}
}Findings from Config flow into Security Hub, which normalises them into the ASFF alongside GuardDuty, Inspector, Macie, and IAM Access Analyzer findings. One schema, one queue, one set of automation rules.
Workload Coverage: Inspector, Macie, and Access Analyzer
Three more native services round out detective coverage, each enabled org-wide via the delegated-admin model and deployed with Terraform:
- Amazon Inspector continuously scans EC2 instances, container images in Amazon ECR, and Lambda functions for CVEs and unintended network exposure, scoring findings with the Inspector risk score (exploitability and reachability), not just raw CVSS.
- Amazon Macie discovers and classifies sensitive data (PII, credentials, trading records) in S3 and raises a finding when sensitive data sits in a bucket that posture rules flag as exposed.
- IAM Access Analyzer identifies resources shared with external principals and surfaces unused access (roles, keys, permissions) so least-privilege can be enforced continuously.
All three publish to Security Hub. The combination means a single critical finding can carry full context: this internet-reachable instance (Inspector) has an over-permissioned role (Access Analyzer) that can read a bucket holding PII (Macie). That is the same attack-path context a third-party CSPM would surface, assembled from native signals.
Auto-Remediation with EventBridge, Lambda, and Systems Manager
Critical findings trigger immediate automated responses. The EventBridge rule pattern targets findings by severity and compliance status:
{
"source": ["aws.securityhub"],
"detail-type": ["Security Hub Findings - Imported"],
"detail": {
"findings": {
"Severity": {
"Label": ["CRITICAL", "HIGH"]
},
"Compliance": {
"Status": ["FAILED"]
},
"RecordState": ["ACTIVE"]
}
}
}For well-understood, parameterised fixes I use AWS Systems Manager Automation runbooks, the AWS-managed remediation documents such as AWS-DisableS3BucketPublicReadWrite and AWS-EnableCloudTrail, triggered directly from Security Hub automation rules or EventBridge. For anything that needs custom logic, an AWS Lambda function dispatches on finding type:
import boto3
def handler(event, context):
finding = event["detail"]["findings"][0]
finding_type = finding["Types"][0]
resource = finding["Resources"][0]
if "S3/BucketPubliclyAccessible" in finding_type:
remediate_s3_public_access(resource["Id"])
elif "IAM/RootAccountUsage" in finding_type:
notify_critical(finding)
elif "CloudTrail/CloudTrailStopped" in finding_type:
ssm_runbook("AWS-EnableCloudTrail", resource)
def remediate_s3_public_access(bucket_arn):
bucket_name = bucket_arn.split(":::")[-1]
s3 = boto3.client("s3")
s3.put_public_access_block(
Bucket=bucket_name,
PublicAccessBlockConfiguration={
"BlockPublicAcls": True,
"IgnorePublicAcls": True,
"BlockPublicPolicy": True,
"RestrictPublicBuckets": True,
},
)
print(f"[REMEDIATED] Blocked public access on S3 bucket: {bucket_name}")
def ssm_runbook(document, resource):
ssm = boto3.client("ssm")
ssm.start_automation_execution(
DocumentName=document,
Parameters={"AutomationAssumeRole": [REMEDIATION_ROLE_ARN]},
)For findings that cannot be auto-remediated safely (for example IAM policy changes), the Lambda publishes to an SNS topic and creates a ticket through an internal API with the finding detail, account ID, resource ARN, and a link to the relevant runbook. After acting, it writes the workflow status back to Security Hub (NOTIFIED or RESOLVED) so the finding lifecycle stays accurate.
Service Control Policies: The Non-Bypassable Layer
SCPs apply at the AWS Organizations level and cannot be overridden by any IAM policy within a member account, including root. This is the last-resort preventive control, deployed with the aws_organizations_policy Terraform resource:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyRootAccountActions",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringLike": {
"aws:PrincipalArn": "arn:aws:iam::*:root"
}
}
},
{
"Sid": "DenyIAMPrivilegeEscalation",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:PassRole",
"iam:CreateAccessKey"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityBreakGlassRole"
}
}
},
{
"Sid": "DenyDisablingSecurityServices",
"Effect": "Deny",
"Action": [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"securityhub:DisableSecurityHub",
"config:DeleteConfigurationRecorder",
"config:StopConfigurationRecorder",
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityBreakGlassRole"
}
}
},
{
"Sid": "DenyUnauthorisedRegions",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-central-1", "eu-west-1"]
}
}
}
]
}The DenyDisablingSecurityServices statement is critical: it stops a compromised or careless principal from turning off the very detective controls the CSPM relies on. The region restriction eliminates a large class of shadow-IT risk. If a developer accidentally provisions resources in us-east-1, the SCP blocks the API call before it lands.
Investigation and Evidence: Detective and Audit Manager
When a GuardDuty or Security Hub finding needs investigation rather than remediation, Amazon Detective automatically builds a behavioural graph from CloudTrail, VPC Flow Logs, and GuardDuty findings, letting an analyst pivot from a finding to the full activity history of the principal or resource in a couple of clicks. No manual log stitching.
For the compliance side, AWS Audit Manager continuously collects evidence mapped to frameworks (CIS, ISO 27001, the AWS-native NIST and GDPR packs), turning the same Config and Security Hub signals into audit-ready evidence packages. This replaces the spreadsheet-and-screenshot evidence gathering that audits usually demand.
Centralised Logging
A dedicated organization-wide CloudTrail writes every API call to a hardened S3 bucket in the Security account: encrypted with KMS, versioned, protected by S3 Object Lock (WORM), and replicated cross-region. CloudTrail log file validation is enabled so any tampering is detectable. This bucket is the immutable source of truth that Detective, Audit Manager, and incident response all draw from.
Operations and Alerting
Findings and remediation outcomes reach the team through native channels:
- AWS Chatbot delivers Security Hub and GuardDuty notifications to Slack
#security-alerts, including interactive runbook actions. - Amazon SNS fans out CRITICAL findings to on-call email and the paging integration.
- The Security Hub dashboard and summary insights provide the unified findings view and posture score trend.
- Amazon Q Developer is used in the console to summarise and triage finding clusters quickly.
Results After 6 Months
After deploying this architecture across the full AWS estate:
- CI/CD gate blocks:
cfn-guardcatches an average of 12 Terraform plan policy violations per sprint before they reach the AWS environment, with proactive Config rules catching out-of-band changes the pipeline never sees. - Mean time to remediate critical findings dropped from roughly 72 hours (manual ticket) to under 8 minutes for auto-remediable findings via SSM runbooks and Lambda.
- False-positive rate: GuardDuty tuning and Security Hub automation rules (auto-suppressing known-accepted findings) reduced noisy, low-value alerts by approximately 60%, so the on-call team focuses on signal.
- Compliance posture: CIS AWS Foundations Benchmark v3.0 score improved from 62% to 91% within the first quarter, tracked directly from the Security Hub security score.
Key Takeaways
- Shift left first: The cheapest fix is blocking a misconfiguration in the CI/CD pipeline before it reaches AWS.
cfn-guardrunning on every Terraform plan costs nothing compared to a breach or audit finding, and AWS maintains it for you. - Don’t build a SIEM, build automation: The goal of a CSPM control plane is not to show findings, it is to close them. Every HIGH or CRITICAL finding should have an automated response path through EventBridge, Lambda, or an SSM runbook.
- SCPs are your safety net, not your primary control: SCPs are powerful but blunt. Use them for hard organisational boundaries, especially to stop anyone disabling the detective stack, not fine-grained policy enforcement.
- AWS-native services compose into a full CSPM: GuardDuty, Inspector, Macie, Access Analyzer, and Config each cover one dimension; Security Hub stitches them into the attack-path context that justifies a third-party platform, without the extra vendor, cost, or data-egress surface.
- Measure posture, not findings: Report the Security Hub security score trend (CIS score over time), not raw finding counts. Leadership cares whether posture is improving, not how many findings were generated this week.
References
- CIS AWS Foundations Benchmark v3.0
- AWS Security Hub documentation
- Amazon GuardDuty documentation
- AWS CloudFormation Guard (cfn-guard)
- AWS Config conformance packs
- AWS Systems Manager Automation runbooks
- Amazon Inspector and Amazon Macie and IAM Access Analyzer
- Terraform AWS provider – Security Hub resources
- Code and Terraform modules: github.com/rohan-bhagat/security-guardrails