A new AWS account comes with almost no security monitoring turned on. There is no multi-region trail, no threat detection, no configuration history, and nothing tells you when the root user signs in. Every account I have worked on needed the same set of controls, so I packaged them as a Terraform module: terraform-aws-security-baseline.
This post walks through what the module sets up and the decisions behind it.
What the module turns on
| Area | What it does |
|---|---|
| Audit logging | CloudTrail in every region, with log file validation, also streamed to CloudWatch Logs |
| Log storage | A versioned S3 bucket, encrypted with KMS, that only accepts TLS and blocks public access |
| Threat detection | GuardDuty with S3, EKS audit log, EBS malware, RDS login, and Lambda network protection |
| Posture | Security Hub with the AWS Foundational Security Best Practices and CIS v3.0 standards |
| Configuration | AWS Config recording everything, plus 9 managed rules |
| Account defaults | S3 public access block, EBS encryption by default, no public snapshot or AMI sharing, strict password policy |
| Access review | IAM Access Analyzer for anything shared outside the account |
| Alerting | EventBridge rules that send serious findings to an encrypted SNS topic |
Using it takes a few lines:
module "security_baseline" {
source = "github.com/22taran/terraform-aws-security-baseline"
name_prefix = "acme"
alert_email = "security@acme.example"
}
One KMS key for everything
CloudTrail logs, AWS Config snapshots, the CloudWatch log group, and the SNS alert topic are all encrypted with one customer-managed key that rotates automatically. One key keeps the setup easy to reason about: if you want to know who can read the security logs, you read one key policy.
The key policy only lets each AWS service use the key for its own job. CloudTrail, for example, can only generate data keys when the request comes from this specific trail:
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
That aws:SourceArn condition matters. Without it, a trail in another account could be pointed at your key or your bucket. This is the "confused deputy" problem, and the same condition is used in the S3 bucket policy.
A log bucket that is hard to misuse
The log bucket has the protections you would expect: versioning, public access blocked, bucket-owner-enforced object ownership, and KMS encryption. Two details are easy to miss:
- It denies any request that is not over TLS. A single
Denystatement onaws:SecureTransport = falsecovers every action. - It is not force-destroyed. Running
terraform destroywill not delete your audit logs. Audit logs should outlive the infrastructure they describe.
Objects move to Glacier after 90 days and expire after a configurable retention period, 365 days by default.
Security Hub with control-level findings
Security Hub is enabled with enable_default_standards = false, and the module subscribes to the two standards explicitly. That way the enabled standards are visible in code instead of depending on whatever AWS enables by default at the time.
It also uses control_finding_generator = "SECURITY_CONTROL". With this setting, a control that appears in both the AWS standard and CIS produces one finding instead of two, which cuts down on duplicate alerts.
Alerts only for what needs a human
Sending every finding to email is the fastest way to get alerts ignored. The module creates three EventBridge rules:
- GuardDuty findings with severity 7 or higher (High).
- New, active Security Hub findings labelled CRITICAL or HIGH.
- Any console sign-in by the root user.
The GuardDuty rule uses a numeric match on severity, so the threshold is a variable:
detail = {
severity = [{ numeric = [">=", var.guardduty_alert_min_severity] }]
}
One gotcha: root console sign-in events are delivered in us-east-1. If you deploy the module in another region, that rule will not fire. Deploy it in us-east-1 too, or add a second provider for that region.
Scanning the scanner
The repo runs its own CI on every push: terraform fmt, terraform validate, TFLint, and Checkov. The module passes all 115 Checkov checks that apply to it.
A few checks are skipped on purpose, and each skip has a written reason next to it. For example, Checkov flags KMS key policies that use Resource "*". Inside a key policy, "*" means "this key", and it is the only valid value, so the check does not apply:
# checkov:skip=CKV_AWS_356:Key policy - "*" is the only valid resource value inside a KMS key policy.
I think this is the right way to handle scanner findings. Every exception is visible in code review, with its reason, instead of being hidden in a global ignore list.
What it does not do
- It is single-account. For AWS Organizations, you would add delegated administrators for GuardDuty, Security Hub, and Config and run it once per account.
- It does not remediate. It detects and alerts. Automatic remediation is a separate decision that depends on how much you trust the automation.
- It costs money. GuardDuty, Security Hub, and AWS Config are billed by usage, so the cost grows with account activity. Check the pricing pages before enabling it across many accounts.
Try it
The code, a runnable example, and the full list of inputs are on GitHub: 22taran/terraform-aws-security-baseline.
If you need help securing your AWS accounts, I'm open to full-time, contract, and freelance work across Canada. You can reach me at itaran.singh@outlook.com.