Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module that enables AWS Security Hub on an account, subscribes to security standards (FSBP/CIS/PCI), aggregates findings across regions, and disables noisy controls — for consistent CSPM posture from one module block. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "security_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-hub?ref=v1.0.0"
# (no required inputs — all have sensible defaults)
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Security Hub is a cloud security posture management (CSPM) service that runs automated, control-based checks against your account and rolls findings from Security Hub itself, GuardDuty, Inspector, Macie, IAM Access Analyzer, and dozens of partner products into a single, normalized stream of ASFF (AWS Security Finding Format) findings. It is how you answer “are we compliant with the AWS Foundational Security Best Practices / CIS / PCI-DSS controls in this account, in this region?” without wiring up each check by hand.
The pivotal resource is aws_securityhub_account — a per-account, per-region toggle that turns the service on. By itself it just enables the hub; the value comes from what you attach to it. In production that means three companions: one or more aws_securityhub_standards_subscription resources (which standard frameworks Security Hub evaluates), an aws_securityhub_finding_aggregator (so findings from every region surface in one home region instead of forcing analysts to region-hop), and targeted aws_securityhub_standards_control resources to suppress controls that are intentionally not applicable (so your score reflects real risk, not noise).
Wrapping this in a module matters because Security Hub is regional. A 4-region, 30-account estate means 120 independent enablement decisions, and getting them inconsistent — CIS on in one region but not the next, aggregation pointed at the wrong home region, enable_default_standards left true so it silently re-subscribes frameworks you deliberately removed — is exactly how compliance drift creeps in. This module gives every account the same opinionated posture from a single module block: hub on, the right standards subscribed, findings aggregated, and known false-positive controls disabled with a documented reason.
When to use it
- You are rolling out Security Hub across many accounts and regions and need each one enabled identically, with the same standards and the same home region for aggregation.
- You want explicit, version-controlled control of which standards run (FSBP, CIS 1.4/3.0, PCI-DSS, NIST 800-53) rather than relying on whatever
enable_default_standardshappens to switch on this provider release. - You need cross-region finding aggregation so SecOps reviews one consolidated queue in a designated home region instead of opening the console in
ap-south-1,eu-west-1, andus-east-1separately. - You have controls that are legitimately not applicable (e.g. an account with no public ALBs, or a control superseded by a compensating control) and want to suppress them as code, with the reason captured in the diff — not clicked away in the console where it is invisible to the next engineer.
- You are wiring Security Hub findings into EventBridge → ticketing/Slack/SOAR and want the hub guaranteed-enabled before those rules deploy.
If you only manage a single account in a single region and never touch standards, the raw aws_securityhub_account resource is enough. The moment a second region or a “turn that control off” request appears, reach for the module.
Module structure
terraform-module-aws-security-hub/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
locals {
# Map a short standard key -> the ARN Security Hub expects in a subscription.
# CIS/PCI/NIST ARNs are global ("::"); FSBP is region-scoped.
region = data.aws_region.current.name
standard_arns = {
fsbp = "arn:${var.partition}:securityhub:${local.region}::standards/aws-foundational-security-best-practices/v/1.0.0"
cis_120 = "arn:${var.partition}:securityhub:::ruleset/cis-aws-foundations-benchmark/v/1.2.0"
cis_140 = "arn:${var.partition}:securityhub:${local.region}::standards/cis-aws-foundations-benchmark/v/1.4.0"
cis_300 = "arn:${var.partition}:securityhub:${local.region}::standards/cis-aws-foundations-benchmark/v/3.0.0"
pci_321 = "arn:${var.partition}:securityhub:${local.region}::standards/pci-dss/v/3.2.1"
nist_800_53 = "arn:${var.partition}:securityhub:${local.region}::standards/nist-800-53/v/5.0.0"
}
# Resolve the requested standard keys to ARNs (ignoring unknown keys defensively).
subscribed_standard_arns = {
for key in var.enabled_standards :
key => local.standard_arns[key]
if contains(keys(local.standard_arns), key)
}
}
# ---------------------------------------------------------------------------
# Enable Security Hub for this account in this region.
# ---------------------------------------------------------------------------
resource "aws_securityhub_account" "this" {
enable_default_standards = var.enable_default_standards
control_finding_generator = var.control_finding_generator
auto_enable_controls = var.auto_enable_controls
}
# ---------------------------------------------------------------------------
# Standards subscriptions (FSBP / CIS / PCI / NIST).
# Depends on the hub being enabled first.
# ---------------------------------------------------------------------------
resource "aws_securityhub_standards_subscription" "this" {
for_each = local.subscribed_standard_arns
standards_arn = each.value
depends_on = [aws_securityhub_account.this]
}
# ---------------------------------------------------------------------------
# Cross-region finding aggregation. Created ONLY in the home region.
# linking_mode = ALL_REGIONS aggregates every current and future region;
# SPECIFIED_REGIONS / ALL_REGIONS_EXCEPT_SPECIFIED use specified_regions.
# ---------------------------------------------------------------------------
resource "aws_securityhub_finding_aggregator" "this" {
count = var.enable_finding_aggregator ? 1 : 0
linking_mode = var.aggregator_linking_mode
specified_regions = var.aggregator_linking_mode == "ALL_REGIONS" ? null : var.aggregator_specified_regions
depends_on = [aws_securityhub_account.this]
}
# ---------------------------------------------------------------------------
# Disable individually named controls (false positives / not-applicable).
# Key = control ID (e.g. "EC2.10"); value = the disabled reason string.
# Each control must belong to a standard that is actually subscribed.
# ---------------------------------------------------------------------------
resource "aws_securityhub_standards_control" "disabled" {
for_each = var.disabled_controls
# ARN form: <subscription-arn>/security-control/<control-id>
standards_control_arn = "arn:${var.partition}:securityhub:${local.region}:${data.aws_caller_identity.current.account_id}:security-control/${each.key}/finding/00000000-0000-0000-0000-000000000000"
control_status = "DISABLED"
disabled_reason = each.value
depends_on = [aws_securityhub_standards_subscription.this]
}
variables.tf
variable "partition" {
description = "AWS partition for ARN construction (aws, aws-us-gov, or aws-cn)."
type = string
default = "aws"
validation {
condition = contains(["aws", "aws-us-gov", "aws-cn"], var.partition)
error_message = "partition must be one of: aws, aws-us-gov, aws-cn."
}
}
variable "enable_default_standards" {
description = <<-EOT
Whether Security Hub auto-subscribes its default standards on enablement.
Set false to take full control of standards via enabled_standards — otherwise
AWS may silently re-add frameworks you intentionally removed.
EOT
type = bool
default = false
}
variable "control_finding_generator" {
description = <<-EOT
How findings are generated when a control maps to multiple standards.
"SECURITY_CONTROL" emits a single consolidated finding per control (recommended,
less noise); "STANDARD_CONTROL" emits one finding per standard per control.
EOT
type = string
default = "SECURITY_CONTROL"
validation {
condition = contains(["SECURITY_CONTROL", "STANDARD_CONTROL"], var.control_finding_generator)
error_message = "control_finding_generator must be SECURITY_CONTROL or STANDARD_CONTROL."
}
}
variable "auto_enable_controls" {
description = "Automatically enable new controls AWS adds to subscribed standards. Keep true to stay current; set false to vet each new control first."
type = bool
default = true
}
variable "enabled_standards" {
description = <<-EOT
Short keys of the standards to subscribe. Valid keys:
fsbp - AWS Foundational Security Best Practices v1.0.0
cis_120 - CIS AWS Foundations Benchmark v1.2.0
cis_140 - CIS AWS Foundations Benchmark v1.4.0
cis_300 - CIS AWS Foundations Benchmark v3.0.0
pci_321 - PCI DSS v3.2.1
nist_800_53 - NIST SP 800-53 Rev. 5
EOT
type = list(string)
default = ["fsbp"]
validation {
condition = alltrue([
for s in var.enabled_standards :
contains(["fsbp", "cis_120", "cis_140", "cis_300", "pci_321", "nist_800_53"], s)
])
error_message = "enabled_standards may only contain: fsbp, cis_120, cis_140, cis_300, pci_321, nist_800_53."
}
}
variable "enable_finding_aggregator" {
description = "Create a cross-region finding aggregator. Set true ONLY in the designated home/aggregation region, false everywhere else."
type = bool
default = false
}
variable "aggregator_linking_mode" {
description = "Aggregator linking mode: ALL_REGIONS, ALL_REGIONS_EXCEPT_SPECIFIED, or SPECIFIED_REGIONS."
type = string
default = "ALL_REGIONS"
validation {
condition = contains(["ALL_REGIONS", "ALL_REGIONS_EXCEPT_SPECIFIED", "SPECIFIED_REGIONS"], var.aggregator_linking_mode)
error_message = "aggregator_linking_mode must be ALL_REGIONS, ALL_REGIONS_EXCEPT_SPECIFIED, or SPECIFIED_REGIONS."
}
}
variable "aggregator_specified_regions" {
description = "Region codes to include/exclude when aggregator_linking_mode is not ALL_REGIONS (e.g. [\"eu-west-1\", \"ap-south-1\"])."
type = list(string)
default = []
validation {
condition = (
var.aggregator_linking_mode == "ALL_REGIONS" || length(var.aggregator_specified_regions) > 0
)
error_message = "aggregator_specified_regions must be non-empty when linking mode is SPECIFIED_REGIONS or ALL_REGIONS_EXCEPT_SPECIFIED."
}
}
variable "disabled_controls" {
description = <<-EOT
Map of control ID => disabled reason for controls to suppress
(e.g. { "EC2.10" = "No public-facing EC2 in this account" }).
Each control must belong to a subscribed standard. The reason is required
by AWS and surfaces in the console audit trail.
EOT
type = map(string)
default = {}
validation {
condition = alltrue([
for reason in values(var.disabled_controls) :
length(trimspace(reason)) >= 5
])
error_message = "Every disabled control must have a disabled reason of at least 5 characters."
}
}
outputs.tf
output "securityhub_account_id" {
description = "AWS account ID for which Security Hub was enabled (resource ID)."
value = aws_securityhub_account.this.id
}
output "arn" {
description = "ARN of the Security Hub hub resource in this region."
value = aws_securityhub_account.this.arn
}
output "control_finding_generator" {
description = "Effective finding-generation mode (SECURITY_CONTROL or STANDARD_CONTROL)."
value = aws_securityhub_account.this.control_finding_generator
}
output "subscribed_standard_arns" {
description = "Map of standard key => subscribed standards ARN."
value = { for k, s in aws_securityhub_standards_subscription.this : k => s.standards_arn }
}
output "finding_aggregator_arn" {
description = "ARN of the cross-region finding aggregator (null when not enabled in this region)."
value = try(aws_securityhub_finding_aggregator.this[0].arn, null)
}
output "finding_aggregator_linking_mode" {
description = "Linking mode of the finding aggregator, if created."
value = try(aws_securityhub_finding_aggregator.this[0].linking_mode, null)
}
output "disabled_control_ids" {
description = "List of control IDs explicitly disabled by this module."
value = keys(var.disabled_controls)
}
How to use it
# Home/aggregation region (ap-south-1): enable the hub, subscribe FSBP + CIS 3.0,
# and aggregate findings from every region into here.
module "security_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-hub?ref=v1.0.0"
# Own the standards explicitly instead of letting AWS pick defaults.
enable_default_standards = false
control_finding_generator = "SECURITY_CONTROL"
auto_enable_controls = true
enabled_standards = ["fsbp", "cis_300"]
# This IS the home region, so create the aggregator here.
enable_finding_aggregator = true
aggregator_linking_mode = "ALL_REGIONS"
# Suppress controls that are genuinely not applicable to this account.
disabled_controls = {
"EC2.10" = "No public-facing EC2; egress handled via NAT only"
"S3.20" = "MFA delete superseded by SCP-enforced deny on s3:DeleteObject"
"Lambda.1" = "No resource policies in use; access via SCP-restricted roles"
}
providers = {
aws = aws.home # ap-south-1
}
}
# Secondary region (eu-west-1): enable the hub + same standards, but DO NOT
# create another aggregator — findings flow to the home region above.
module "security_hub_eu" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-hub?ref=v1.0.0"
enable_default_standards = false
enabled_standards = ["fsbp", "cis_300"]
enable_finding_aggregator = false
providers = {
aws = aws.eu # eu-west-1
}
}
# Downstream reference: route Security Hub findings to a remediation pipeline.
# Using the module output guarantees the hub is enabled before the rule exists.
resource "aws_cloudwatch_event_rule" "securityhub_high" {
name = "securityhub-high-critical-findings"
description = "Forward HIGH/CRITICAL Security Hub findings to SecOps"
event_pattern = jsonencode({
source = ["aws.securityhub"]
"detail-type" = ["Security Hub Findings - Imported"]
detail = {
findings = {
Severity = { Label = ["HIGH", "CRITICAL"] }
Workflow = { Status = ["NEW"] }
RecordState = ["ACTIVE"]
ProductArn = [module.security_hub.arn] # ensures dependency on the enabled hub
}
}
})
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/security_hub/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-hub?ref=v1.0.0"
}
inputs = {
# (no required inputs)
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/security_hub && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| partition | string | “aws” | no | AWS partition for ARN construction (aws, aws-us-gov, aws-cn). |
| enable_default_standards | bool | false | no | Let AWS auto-subscribe default standards on enablement. Keep false to own standards explicitly. |
| control_finding_generator | string | “SECURITY_CONTROL” | no | Single consolidated finding per control vs. one per standard. |
| auto_enable_controls | bool | true | no | Auto-enable new controls AWS adds to subscribed standards. |
| enabled_standards | list(string) | [“fsbp”] | no | Standard keys to subscribe: fsbp, cis_120, cis_140, cis_300, pci_321, nist_800_53. |
| enable_finding_aggregator | bool | false | no | Create a cross-region aggregator. True only in the home region. |
| aggregator_linking_mode | string | “ALL_REGIONS” | no | ALL_REGIONS, ALL_REGIONS_EXCEPT_SPECIFIED, or SPECIFIED_REGIONS. |
| aggregator_specified_regions | list(string) | [] | no | Regions to include/exclude when linking mode is not ALL_REGIONS. |
| disabled_controls | map(string) | {} | no | Control ID => disabled reason for controls to suppress. |
Outputs
| Name | Description |
|---|---|
| securityhub_account_id | AWS account ID for which Security Hub was enabled (resource ID). |
| arn | ARN of the Security Hub hub resource in this region. |
| control_finding_generator | Effective finding-generation mode. |
| subscribed_standard_arns | Map of standard key => subscribed standards ARN. |
| finding_aggregator_arn | ARN of the cross-region finding aggregator (null when not enabled here). |
| finding_aggregator_linking_mode | Linking mode of the finding aggregator, if created. |
| disabled_control_ids | List of control IDs explicitly disabled by this module. |
Enterprise scenario
A fintech running 28 workload accounts under AWS Organizations operates in ap-south-1 (primary) and eu-west-1 (DR). The platform team delegates a Security account as the Security Hub administrator, then calls this module twice per workload account — once per region — pinned to v1.0.0. Every account gets FSBP plus CIS 3.0, control_finding_generator = "SECURITY_CONTROL" to halve duplicate findings, and the aggregator is created only in ap-south-1 so the SOC reviews a single consolidated queue. Three controls that the company addresses with org-wide SCPs (e.g. S3.20 MFA-delete) are disabled in code with the SCP cited as the reason, so the next auditor sees why the score excludes them instead of finding an unexplained suppression clicked in the console.
Best practices
- Pin standards explicitly; set
enable_default_standards = false. AWS changes its default standard set over provider releases. Owningenabled_standardsin code means aterraform plan— not an AWS announcement — decides what your account is evaluated against, and removed frameworks stay removed. - Create exactly one finding aggregator, in your home region.
aws_securityhub_finding_aggregatoris regional but consolidates globally; creating it in two regions causes conflicting aggregation. Gate it behindenable_finding_aggregatorand set ittruein a single region per account. - Prefer
SECURITY_CONTROLfinding generation to cut cost and noise. Security Hub bills per finding ingested and per control check; consolidated findings mean one finding for a control shared across FSBP/CIS/PCI instead of three, which directly lowers ingestion volume and analyst fatigue. - Never disable a control without a real
disabled_reason. The module enforces a minimum-length reason because a suppressed control with no rationale is indistinguishable from an oversight; the reason becomes your audit evidence that the exclusion was deliberate and compensated. - Keep
auto_enable_controls = truein regulated accounts so newly published controls are evaluated immediately rather than silently skipped — unless your change-control process requires vetting each control, in which case set itfalseand review additions on a cadence. - Use a consistent enablement pattern across the estate (same module version, same
enabled_standards, same home region) and drive it from a per-account/per-region loop so a 28-account fleet cannot drift into mismatched posture between regions.