IaC AWS

Terraform Module: AWS Security Hub — one-block enablement with standards, cross-region aggregation, and curated controls

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

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 configlive/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 configlive/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

TerraformAWSSecurity HubModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading