From 697459c062e5163e15d5890c5b7e433ab72ca444 Mon Sep 17 00:00:00 2001 From: piravinth Date: Wed, 3 Jun 2026 18:53:10 +0100 Subject: [PATCH 1/7] intro nwfw module --- .../modules/network-firewall/context.tf | 374 ++++++++++++++++++ .../modules/network-firewall/locals.tf | 40 ++ .../modules/network-firewall/main.tf | 77 ++++ .../modules/network-firewall/outputs.tf | 61 +++ .../modules/network-firewall/readme.md | 43 ++ .../modules/network-firewall/variables.tf | 196 +++++++++ .../modules/network-firewall/versions.tf | 10 + 7 files changed, 801 insertions(+) create mode 100644 infrastructure/modules/network-firewall/context.tf create mode 100644 infrastructure/modules/network-firewall/locals.tf create mode 100644 infrastructure/modules/network-firewall/main.tf create mode 100644 infrastructure/modules/network-firewall/outputs.tf create mode 100644 infrastructure/modules/network-firewall/readme.md create mode 100644 infrastructure/modules/network-firewall/variables.tf create mode 100644 infrastructure/modules/network-firewall/versions.tf diff --git a/infrastructure/modules/network-firewall/context.tf b/infrastructure/modules/network-firewall/context.tf new file mode 100644 index 00000000..4702770b --- /dev/null +++ b/infrastructure/modules/network-firewall/context.tf @@ -0,0 +1,374 @@ +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.6.0" + + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/network-firewall/locals.tf b/infrastructure/modules/network-firewall/locals.tf new file mode 100644 index 00000000..de661546 --- /dev/null +++ b/infrastructure/modules/network-firewall/locals.tf @@ -0,0 +1,40 @@ +locals { + # Build the subnet_mapping from the firewall subnet IDs provided + # by the VPC module. + subnet_mapping = { for idx, subnet_id in var.firewall_subnet_ids : + "subnet-${idx}" => { + subnet_id = subnet_id + ip_address_type = "IPV4" + } + } + + # KMS encryption configuration for the firewall and policy. + + encryption_configuration = var.kms_key_arn != null ? { + key_id = var.kms_key_arn + type = "CUSTOMER_KMS" + } : null + + # ---------------------------------------------------------------- + # Logging configuration + # ---------------------------------------------------------------- + alert_log_config = var.create_alert_log ? [{ + log_destination = { + logGroup = aws_cloudwatch_log_group.alert[0].name + } + log_destination_type = "CloudWatchLogs" + log_type = "ALERT" + }] : [] + + flow_log_config = var.flow_log_s3_bucket_name != null ? [{ + log_destination = { + bucketName = var.flow_log_s3_bucket_name + prefix = coalesce(var.flow_log_s3_prefix, module.this.id) + } + log_destination_type = "S3" + log_type = "FLOW" + }] : [] + + logging_config = concat(local.alert_log_config, local.flow_log_config) + create_logging = length(local.logging_config) > 0 +} diff --git a/infrastructure/modules/network-firewall/main.tf b/infrastructure/modules/network-firewall/main.tf new file mode 100644 index 00000000..be6b9c19 --- /dev/null +++ b/infrastructure/modules/network-firewall/main.tf @@ -0,0 +1,77 @@ +################################################################ +# Network Firewall Module +# +# Screening wrapper around the +# `terraform-aws-modules/network-firewall/aws` upstream module +# +# Deploys an AWS Network Firewall into the dedicated firewall +# subnets created by the VPC module + +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +module "network_firewall" { + source = "terraform-aws-modules/network-firewall/aws" + version = "2.1.0" + + create = module.this.enabled + + # ------------------------------------------------------------------ + # Firewall + # ------------------------------------------------------------------ + name = module.this.id + description = var.description + + vpc_id = var.vpc_id + subnet_mapping = local.subnet_mapping + + delete_protection = var.delete_protection + subnet_change_protection = var.subnet_change_protection + firewall_policy_change_protection = var.firewall_policy_change_protection + enabled_analysis_types = var.enabled_analysis_types + encryption_configuration = local.encryption_configuration + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + create_logging_configuration = local.create_logging + logging_configuration_destination_config = local.create_logging ? local.logging_config : null + + # ------------------------------------------------------------------ + # Policy + # ------------------------------------------------------------------ + create_policy = var.create_policy + firewall_policy_arn = var.firewall_policy_arn + + policy_name = module.this.id + policy_description = coalesce(var.description, "Firewall policy for ${module.this.id}") + policy_encryption_configuration = local.encryption_configuration + policy_variables = var.policy_variables + policy_stateful_default_actions = var.policy_stateful_default_actions + policy_stateful_engine_options = var.policy_stateful_engine_options + policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference + policy_stateless_default_actions = var.policy_stateless_default_actions + policy_stateless_fragment_default_actions = var.policy_stateless_fragment_default_actions + policy_stateless_rule_group_reference = var.policy_stateless_rule_group_reference + policy_stateless_custom_action = var.policy_stateless_custom_action + + tags = module.this.tags +} + +################################################################ +# CloudWatch Log Group for ALERT logs +# +# Created as a standalone resource so that the log group +# lifecycle (retention, encryption) is managed by this module +# rather than being implicit within the firewall. +################################################################ + +resource "aws_cloudwatch_log_group" "alert" { + count = module.this.enabled && var.create_alert_log ? 1 : 0 + + name = "/aws/network-firewall/${module.this.id}" + retention_in_days = var.alert_log_retention_in_days + kms_key_id = var.alert_log_kms_key_id + + tags = module.this.tags +} diff --git a/infrastructure/modules/network-firewall/outputs.tf b/infrastructure/modules/network-firewall/outputs.tf new file mode 100644 index 00000000..d84cf664 --- /dev/null +++ b/infrastructure/modules/network-firewall/outputs.tf @@ -0,0 +1,61 @@ +################################################################ +# Firewall +################################################################ + +output "firewall_arn" { + description = "The ARN of the Network Firewall." + value = try(module.network_firewall.arn, null) +} + +output "firewall_id" { + description = "The ARN that identifies the firewall (same as arn)." + value = try(module.network_firewall.id, null) +} + +output "firewall_status" { + description = "Nested list of information about the current status of the firewall." + value = try(module.network_firewall.status, null) +} + +output "firewall_update_token" { + description = "A string token used when updating the firewall." + value = try(module.network_firewall.update_token, null) +} + +################################################################ +# Logging +################################################################ + +output "logging_configuration_id" { + description = "The ARN of the associated firewall logging configuration." + value = try(module.network_firewall.logging_configuration_id, null) +} + +output "alert_log_group_arn" { + description = "The ARN of the CloudWatch Log Group for ALERT logs." + value = try(aws_cloudwatch_log_group.alert[0].arn, null) +} + +output "alert_log_group_name" { + description = "The name of the CloudWatch Log Group for ALERT logs." + value = try(aws_cloudwatch_log_group.alert[0].name, null) +} + +################################################################ +# Policy +################################################################ + +output "policy_arn" { + description = "The ARN of the firewall policy." + value = try(module.network_firewall.policy_arn, null) +} + +output "policy_id" { + description = "The ARN that identifies the firewall policy." + value = try(module.network_firewall.policy_id, null) +} + +output "policy_update_token" { + description = "A string token used when updating the firewall policy." + value = try(module.network_firewall.policy_update_token, null) +} diff --git a/infrastructure/modules/network-firewall/readme.md b/infrastructure/modules/network-firewall/readme.md new file mode 100644 index 00000000..b78a6067 --- /dev/null +++ b/infrastructure/modules/network-firewall/readme.md @@ -0,0 +1,43 @@ +# AWS Network Firewall Terraform module + +Terraform module to provision an [AWS Network Firewall](https://aws.amazon.com/network-firewall/) integrated with the VPC module's dedicated firewall subnets. + +## Usage + +```hcl + module "network_firewall" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/network-firewall" + + service = "bcss" + project = "bcss" + environment = "dev" + stack = "shared-resources" + workspace = terraform.workspace + name = "nwfw" + + vpc_id = module.vpc.vpc_id + firewall_subnet_ids = module.vpc.firewall_subnet_ids + + # Encryption (optional — from the kms module) + kms_key_arn = module.nwfw_kms.key_arn + alert_log_kms_key_id = module.nwfw_kms.key_arn + + # FLOW logs to S3 (optional — from the s3-bucket module) + flow_log_s3_bucket_name = module.logs_bucket.s3_bucket_id + + # Policy — rule groups are passed by arn + policy_stateful_rule_group_reference = { + deny_domains = { + priority = 1 + resource_arn = aws_networkfirewall_rule_group.deny_domains.arn + } + } + } +``` + + + + + + + diff --git a/infrastructure/modules/network-firewall/variables.tf b/infrastructure/modules/network-firewall/variables.tf new file mode 100644 index 00000000..214fbde6 --- /dev/null +++ b/infrastructure/modules/network-firewall/variables.tf @@ -0,0 +1,196 @@ +################################################################ +# Network Firewall-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# `context.tf` via `module.this`. +################################################################ + +################################################################ +# VPC / subnets +################################################################ + +variable "vpc_id" { + description = "The ID of the VPC where the Network Firewall will be deployed." + type = string +} + +variable "firewall_subnet_ids" { + description = "List of firewall subnet IDs (one per AZ) from the VPC module." + type = list(string) +} + +################################################################ +# Firewall settings +################################################################ + +variable "description" { + description = "A friendly description of the firewall." + type = string + default = "" +} + +variable "delete_protection" { + description = "Prevent accidental deletion of the firewall." + type = bool + default = true +} + +variable "subnet_change_protection" { + description = "Prevent changes to the associated subnets." + type = bool + default = true +} + +variable "firewall_policy_change_protection" { + description = "Prevent changes to the associated firewall policy." + type = bool + default = false +} + +variable "enabled_analysis_types" { + description = "Types for which to collect analysis metrics. Valid values: TLS_SNI, HTTP_HOST." + type = list(string) + default = null +} + +################################################################ +# Encryption (KMS) +################################################################ + +variable "kms_key_arn" { + description = "ARN of a KMS key to encrypt the firewall and its policy. Leave null for AWS-managed encryption." + type = string + default = null +} + +################################################################ +# Logging — ALERT (CloudWatch) +################################################################ + +variable "create_alert_log" { + description = "Create a CloudWatch Log Group for ALERT logs." + type = bool + default = true +} + +variable "alert_log_retention_in_days" { + description = "Number of days to retain alert logs in CloudWatch." + type = number + default = 365 +} + +variable "alert_log_kms_key_id" { + description = "ARN of a KMS key to encrypt the CloudWatch alert log group. Leave null for no encryption." + type = string + default = null +} + +################################################################ +# Logging — FLOW (S3) +################################################################ + +variable "flow_log_s3_bucket_name" { + description = "Name of the S3 bucket for FLOW logs. Leave null to disable S3 flow logging." + type = string + default = null +} + +variable "flow_log_s3_prefix" { + description = "S3 key prefix for flow logs. Defaults to the module ID." + type = string + default = null +} + +################################################################ +# Firewall policy +################################################################ + +variable "create_policy" { + description = "Create the firewall policy. Set to false and supply firewall_policy_arn to use an externally managed policy." + type = bool + default = true +} + +variable "firewall_policy_arn" { + description = "ARN of an externally managed firewall policy. Only used when create_policy is false." + type = string + default = "" +} + +variable "policy_stateless_default_actions" { + description = "Actions for packets that match no stateless rules. Default forwards all traffic to the stateful engine." + type = list(string) + default = ["aws:forward_to_sfe"] +} + +variable "policy_stateless_fragment_default_actions" { + description = "Actions for fragmented packets that match no stateless rules." + type = list(string) + default = ["aws:forward_to_sfe"] +} + +variable "policy_stateful_default_actions" { + description = "Actions for packets that match no stateful rules. Only valid with STRICT_ORDER rule order." + type = list(string) + default = null +} + +variable "policy_stateful_engine_options" { + description = "Stateful engine options (rule_order, stream_exception_policy, flow_timeouts)." + type = object({ + flow_timeouts = optional(object({ + tcp_idle_timeout_seconds = optional(number) + })) + rule_order = optional(string) + stream_exception_policy = optional(string) + }) + default = null +} + +variable "policy_stateful_rule_group_reference" { + description = "Map of stateful rule group references for the policy." + type = map(object({ + deep_threat_inspection = optional(bool) + override = optional(object({ + action = optional(string) + })) + priority = optional(number) + resource_arn = string + })) + default = null +} + +variable "policy_stateless_rule_group_reference" { + description = "Map of stateless rule group references for the policy." + type = map(object({ + priority = number + resource_arn = string + })) + default = null +} + +variable "policy_stateless_custom_action" { + description = "Custom action definitions for the firewall policy's stateless default actions." + type = map(object({ + action_definition = object({ + publish_metric_action = optional(object({ + dimension = optional(string) + })) + }) + action_name = string + })) + default = null +} + +variable "policy_variables" { + description = "Variables to override default Suricata settings in the firewall policy." + type = object({ + rule_variables = list(object({ + ip_set = optional(object({ + definition = list(string) + })) + key = string + })) + }) + default = null +} diff --git a/infrastructure/modules/network-firewall/versions.tf b/infrastructure/modules/network-firewall/versions.tf new file mode 100644 index 00000000..ad55bb5a --- /dev/null +++ b/infrastructure/modules/network-firewall/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28, < 7.0" + } + } +} From 9f95c7b521a0d4ff1901f17ea2cf95692b799ee3 Mon Sep 17 00:00:00 2001 From: piravinth Date: Wed, 3 Jun 2026 18:55:13 +0100 Subject: [PATCH 2/7] fmt terraform --- .../modules/network-firewall/locals.tf | 4 ++-- .../modules/network-firewall/main.tf | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/infrastructure/modules/network-firewall/locals.tf b/infrastructure/modules/network-firewall/locals.tf index de661546..18292b49 100644 --- a/infrastructure/modules/network-firewall/locals.tf +++ b/infrastructure/modules/network-firewall/locals.tf @@ -35,6 +35,6 @@ locals { log_type = "FLOW" }] : [] - logging_config = concat(local.alert_log_config, local.flow_log_config) - create_logging = length(local.logging_config) > 0 + logging_config = concat(local.alert_log_config, local.flow_log_config) + create_logging = length(local.logging_config) > 0 } diff --git a/infrastructure/modules/network-firewall/main.tf b/infrastructure/modules/network-firewall/main.tf index be6b9c19..09e92cef 100644 --- a/infrastructure/modules/network-firewall/main.tf +++ b/infrastructure/modules/network-firewall/main.tf @@ -40,20 +40,20 @@ module "network_firewall" { # ------------------------------------------------------------------ # Policy # ------------------------------------------------------------------ - create_policy = var.create_policy + create_policy = var.create_policy firewall_policy_arn = var.firewall_policy_arn - policy_name = module.this.id - policy_description = coalesce(var.description, "Firewall policy for ${module.this.id}") - policy_encryption_configuration = local.encryption_configuration - policy_variables = var.policy_variables - policy_stateful_default_actions = var.policy_stateful_default_actions - policy_stateful_engine_options = var.policy_stateful_engine_options - policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference - policy_stateless_default_actions = var.policy_stateless_default_actions + policy_name = module.this.id + policy_description = coalesce(var.description, "Firewall policy for ${module.this.id}") + policy_encryption_configuration = local.encryption_configuration + policy_variables = var.policy_variables + policy_stateful_default_actions = var.policy_stateful_default_actions + policy_stateful_engine_options = var.policy_stateful_engine_options + policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference + policy_stateless_default_actions = var.policy_stateless_default_actions policy_stateless_fragment_default_actions = var.policy_stateless_fragment_default_actions - policy_stateless_rule_group_reference = var.policy_stateless_rule_group_reference - policy_stateless_custom_action = var.policy_stateless_custom_action + policy_stateless_rule_group_reference = var.policy_stateless_rule_group_reference + policy_stateless_custom_action = var.policy_stateless_custom_action tags = module.this.tags } From 35ad86d2df70a5c3ac4b0ef6e05a6afd61ff6d5a Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 15:56:03 +0100 Subject: [PATCH 3/7] feat(network-firewall): refresh module from shared stack N.B. This is a copy taken from the 'shared' stack implementation, and has been pushed without validation checks --- .../modules/network-firewall/context.tf | 3 +- .../modules/network-firewall/locals.tf | 47 +++-- .../modules/network-firewall/main.tf | 49 +++++- .../modules/network-firewall/outputs.tf | 9 + .../modules/network-firewall/variables.tf | 160 +++++++++++++++--- 5 files changed, 221 insertions(+), 47 deletions(-) diff --git a/infrastructure/modules/network-firewall/context.tf b/infrastructure/modules/network-firewall/context.tf index 4702770b..c28646f8 100644 --- a/infrastructure/modules/network-firewall/context.tf +++ b/infrastructure/modules/network-firewall/context.tf @@ -21,8 +21,9 @@ # module "this" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.6.0" + source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/network-firewall/locals.tf b/infrastructure/modules/network-firewall/locals.tf index 18292b49..b0f12ea2 100644 --- a/infrastructure/modules/network-firewall/locals.tf +++ b/infrastructure/modules/network-firewall/locals.tf @@ -17,24 +17,37 @@ locals { # ---------------------------------------------------------------- # Logging configuration + # + # Build the upstream module's logging_configuration_destination_config + # from the flexible `var.logging` map, filtering out disabled entries. # ---------------------------------------------------------------- - alert_log_config = var.create_alert_log ? [{ - log_destination = { - logGroup = aws_cloudwatch_log_group.alert[0].name - } - log_destination_type = "CloudWatchLogs" - log_type = "ALERT" - }] : [] + logging_config = [ + for k, v in var.logging : { + log_destination = v.log_destination + log_destination_type = v.log_destination_type + log_type = v.log_type + } if v.enabled + ] - flow_log_config = var.flow_log_s3_bucket_name != null ? [{ - log_destination = { - bucketName = var.flow_log_s3_bucket_name - prefix = coalesce(var.flow_log_s3_prefix, module.this.id) - } - log_destination_type = "S3" - log_type = "FLOW" - }] : [] + create_logging = var.create_logging_configuration + + # ---------------------------------------------------------------- + # Rule group references + # + # Merge module-created rule groups (from var.rule_groups) with + # any externally supplied references (from + # var.policy_stateful_rule_group_reference). + # ---------------------------------------------------------------- + # TODO: below looks very complicated – can it be simplified with fewer merges and conditionals? + module_stateful_rule_group_references = { + for k, v in var.rule_groups : k => merge( + { resource_arn = module.rule_group[k].arn }, + v.priority != null ? { priority = v.priority } : {} + ) if v.type == "STATEFUL" + } - logging_config = concat(local.alert_log_config, local.flow_log_config) - create_logging = length(local.logging_config) > 0 + merged_stateful_rule_group_references = length(local.module_stateful_rule_group_references) > 0 || var.policy_stateful_rule_group_reference != null ? merge( + local.module_stateful_rule_group_references, + coalesce(var.policy_stateful_rule_group_reference, {}) + ) : null } diff --git a/infrastructure/modules/network-firewall/main.tf b/infrastructure/modules/network-firewall/main.tf index 09e92cef..17383073 100644 --- a/infrastructure/modules/network-firewall/main.tf +++ b/infrastructure/modules/network-firewall/main.tf @@ -39,6 +39,9 @@ module "network_firewall" { # ------------------------------------------------------------------ # Policy + # TODO: Do we want to use a separate policy submodule: terraform-aws-network-firewall/modules/policy at master · https://github.com/terraform-aws-modules/terraform-aws-network-firewall/tree/master/modules/policy + # TODO: Add support for external policy (create_policy = false + policy_arn) if users want to manage the policy separately (e.g. shared via RAM) + # TODO: The other way of doing it looks like this example: https://github.com/terraform-aws-modules/terraform-aws-network-firewall/tree/master/examples/separate – but it creates the policy first and then the firewall, which causes a dependency cycle if the policy references the firewall in its stateful rules. The upstream module's approach of creating the policy inline and then referencing it in the firewall seems cleaner to me. # ------------------------------------------------------------------ create_policy = var.create_policy firewall_policy_arn = var.firewall_policy_arn @@ -49,7 +52,9 @@ module "network_firewall" { policy_variables = var.policy_variables policy_stateful_default_actions = var.policy_stateful_default_actions policy_stateful_engine_options = var.policy_stateful_engine_options - policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference + # TODO: why was this changed? + # policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference + policy_stateful_rule_group_reference = local.merged_stateful_rule_group_references policy_stateless_default_actions = var.policy_stateless_default_actions policy_stateless_fragment_default_actions = var.policy_stateless_fragment_default_actions policy_stateless_rule_group_reference = var.policy_stateless_rule_group_reference @@ -59,19 +64,47 @@ module "network_firewall" { } ################################################################ -# CloudWatch Log Group for ALERT logs +# Rule Groups # -# Created as a standalone resource so that the log group -# lifecycle (retention, encryption) is managed by this module -# rather than being implicit within the firewall. +# Creates rule groups via the upstream rule-group submodule +# and automatically wires them into the firewall policy. +################################################################ + +module "rule_group" { + source = "terraform-aws-modules/network-firewall/aws//modules/rule-group" + version = "2.1.0" + + for_each = { for k, v in var.rule_groups : k => v if module.this.enabled } + + create = module.this.enabled + + name = "${module.this.id}${module.this.delimiter}${replace(each.key, "_", module.this.delimiter)}" + description = each.value.description + type = each.value.type + capacity = each.value.capacity + rules = each.value.rules + rule_group = each.value.rule_group + + encryption_configuration = local.encryption_configuration + + tags = module.this.tags +} + +################################################################ +# Managed CloudWatch Log Group for ALERT logs +# +# Optional convenience resource. When `create_alert_log_group` +# is true, the module creates and manages the log group +# lifecycle (retention, KMS encryption). Callers reference the +# log group name in their `logging` map. ################################################################ resource "aws_cloudwatch_log_group" "alert" { - count = module.this.enabled && var.create_alert_log ? 1 : 0 + count = module.this.enabled && var.create_alert_log_group ? 1 : 0 name = "/aws/network-firewall/${module.this.id}" - retention_in_days = var.alert_log_retention_in_days - kms_key_id = var.alert_log_kms_key_id + retention_in_days = var.alert_log_group_retention_in_days + kms_key_id = var.alert_log_group_kms_key_id tags = module.this.tags } diff --git a/infrastructure/modules/network-firewall/outputs.tf b/infrastructure/modules/network-firewall/outputs.tf index d84cf664..05686896 100644 --- a/infrastructure/modules/network-firewall/outputs.tf +++ b/infrastructure/modules/network-firewall/outputs.tf @@ -59,3 +59,12 @@ output "policy_update_token" { description = "A string token used when updating the firewall policy." value = try(module.network_firewall.policy_update_token, null) } + +################################################################ +# Rule Groups +################################################################ + +output "rule_group_arns" { + description = "Map of rule group keys to their ARNs." + value = { for k, v in module.rule_group : k => v.arn } +} diff --git a/infrastructure/modules/network-firewall/variables.tf b/infrastructure/modules/network-firewall/variables.tf index 214fbde6..deee353b 100644 --- a/infrastructure/modules/network-firewall/variables.tf +++ b/infrastructure/modules/network-firewall/variables.tf @@ -64,39 +64,103 @@ variable "kms_key_arn" { } ################################################################ -# Logging — ALERT (CloudWatch) +# Logging +# +# Flexible logging configuration supporting all combinations of: +# Log types: FLOW, ALERT, TLS +# Destinations: S3, CloudWatchLogs, KinesisDataFirehose +# +# Each entry in the map creates one log_destination_config block +# in the firewall logging configuration. AWS allows at most one +# config per log type (max 3 total). +# +# Destination-specific keys in `log_destination`: +# S3: { bucketName = "...", prefix = "..." } +# CloudWatchLogs: { logGroup = "..." } +# KinesisDataFirehose: { deliveryStream = "..." } +# +# If `enabled` is omitted it defaults to true. Use `enabled = false` +# to temporarily disable a destination without removing it. +# +# Example: +# logging = { +# flow_s3 = { +# log_type = "FLOW" +# log_destination_type = "S3" +# log_destination = { bucketName = "my-bucket", prefix = "nwfw" } +# } +# alert_cloudwatch = { +# log_type = "ALERT" +# log_destination_type = "CloudWatchLogs" +# log_destination = { logGroup = "/aws/network-firewall/alerts" } +# } +# } +# +# TODO(logging): Add support for managed CloudWatch log group per +# log type (similar to create_alert_log_group) so callers don't +# need to create log groups externally when using CloudWatchLogs. +# TODO(logging): Add support for managed Kinesis Firehose delivery +# stream if demand arises from consuming teams. ################################################################ -variable "create_alert_log" { - description = "Create a CloudWatch Log Group for ALERT logs." - type = bool - default = true -} +variable "logging" { + description = "Map of logging destinations. Each key creates one log_destination_config block. See variable comments for shape and examples." + type = map(object({ + enabled = optional(bool, true) + log_type = string # FLOW, ALERT, or TLS + log_destination_type = string # S3, CloudWatchLogs, or KinesisDataFirehose + log_destination = map(string) # destination-specific keys (see comments above) + })) + default = {} -variable "alert_log_retention_in_days" { - description = "Number of days to retain alert logs in CloudWatch." - type = number - default = 365 + validation { + condition = alltrue([ + for k, v in var.logging : contains(["FLOW", "ALERT", "TLS"], v.log_type) + ]) + error_message = "Each logging entry's log_type must be one of: FLOW, ALERT, TLS." + } + + validation { + condition = alltrue([ + for k, v in var.logging : contains(["S3", "CloudWatchLogs", "KinesisDataFirehose"], v.log_destination_type) + ]) + error_message = "Each logging entry's log_destination_type must be one of: S3, CloudWatchLogs, KinesisDataFirehose." + } } -variable "alert_log_kms_key_id" { - description = "ARN of a KMS key to encrypt the CloudWatch alert log group. Leave null for no encryption." - type = string - default = null +variable "create_logging_configuration" { + description = "Master toggle for logging configuration. Must be plan-time-known. When true, the `logging` map is used to build destination configs." + type = bool + default = false } ################################################################ -# Logging — FLOW (S3) +# Managed CloudWatch Log Group +# +# Convenience resource for callers who want this module to own +# the CloudWatch log group lifecycle (retention, KMS encryption) +# rather than creating it externally. +# +# When enabled, the log group name is automatically set to +# `/aws/network-firewall/` and can be referenced +# in the `logging` map via: +# log_destination = { logGroup = module.nwfw.alert_log_group_name } ################################################################ -variable "flow_log_s3_bucket_name" { - description = "Name of the S3 bucket for FLOW logs. Leave null to disable S3 flow logging." - type = string - default = null +variable "create_alert_log_group" { + description = "Create a managed CloudWatch Log Group for ALERT logs. The log group name is exposed via the alert_log_group_name output." + type = bool + default = false } -variable "flow_log_s3_prefix" { - description = "S3 key prefix for flow logs. Defaults to the module ID." +variable "alert_log_group_retention_in_days" { + description = "Number of days to retain logs in the managed alert log group." + type = number + default = 365 +} + +variable "alert_log_group_kms_key_id" { + description = "ARN of a KMS key to encrypt the managed CloudWatch alert log group. Leave null for no encryption." type = string default = null } @@ -194,3 +258,57 @@ variable "policy_variables" { }) default = null } + +################################################################ +# Rule groups +# +# Map of rule group definitions created alongside the firewall. +# Each entry creates a rule group via the upstream rule-group +# submodule and automatically wires it into the firewall policy. +# +# For Suricata-format rules (most common), use `rules` with a +# heredoc string. For structured rules, use `rule_group`. +# +# Example: +# rule_groups = { +# block_legacy_tls = { +# description = "Reject TLS 1.0/1.1" +# type = "STATEFUL" +# capacity = 10 +# priority = 100 +# rules = "reject tls any any -> any any (msg:\"Block TLS 1.0/1.1\"; ssl_version:tls1.0,tls1.1; sid:100001;)" +# rule_group = { +# stateful_rule_options = { rule_order = "STRICT_ORDER" } +# } +# } +# deny_domains = { +# description = "Block known-bad domains" +# type = "STATEFUL" +# capacity = 100 +# priority = 500 +# rule_group = { +# stateful_rule_options = { rule_order = "STRICT_ORDER" } +# rules_source = { +# rules_source_list = { +# generated_rules_type = "DENYLIST" +# target_types = ["TLS_SNI", "HTTP_HOST"] +# targets = ["evil.com", ".malware.net"] +# } +# } +# } +# } +# } +################################################################ + +variable "rule_groups" { + description = "Map of rule group definitions to create and attach to the firewall policy. See variable comments for shape and examples." + type = map(object({ + description = optional(string) + type = optional(string, "STATEFUL") + capacity = optional(number, 100) + priority = optional(number) + rules = optional(string) + rule_group = optional(any) + })) + default = {} +} From 80a0a9ea7fe4ebd069b5e5fd440195c9a04e5d76 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 16:12:08 +0100 Subject: [PATCH 4/7] fix(dependabot): include network-firewall module in dependency updates --- .github/dependabot.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 229165e4..7ccb7354 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -53,6 +53,7 @@ updates: - "infrastructure/modules/lambda-layer" - "infrastructure/modules/lambda" - "infrastructure/modules/license-manager" + - "infrastructure/modules/network-firewall" - "infrastructure/modules/parameter_store" - "infrastructure/modules/r53-healthcheck" - "infrastructure/modules/rds-database" From 827157845b70f1cc716108c20d25c651f3be0c0e Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 16:12:45 +0100 Subject: [PATCH 5/7] build: create README.md for network-firewall module with usage examples and compliance details --- .../modules/network-firewall/readme.md | 421 ++++++++++++++++-- 1 file changed, 394 insertions(+), 27 deletions(-) diff --git a/infrastructure/modules/network-firewall/readme.md b/infrastructure/modules/network-firewall/readme.md index b78a6067..811d9911 100644 --- a/infrastructure/modules/network-firewall/readme.md +++ b/infrastructure/modules/network-firewall/readme.md @@ -1,43 +1,410 @@ -# AWS Network Firewall Terraform module +# Network Firewall -Terraform module to provision an [AWS Network Firewall](https://aws.amazon.com/network-firewall/) integrated with the VPC module's dedicated firewall subnets. +NHS Screening wrapper around the community [`terraform-aws-modules/network-firewall/aws`](https://registry.terraform.io/modules/terraform-aws-modules/network-firewall/aws/latest) module that enforces the platform's baseline security controls. + +Deploys an AWS Network Firewall into dedicated firewall subnets created by the VPC module, with flexible logging, KMS encryption, and configurable stateful/stateless rule groups. + +## What this module enforces + +|Control|Status|Implementation| +|---|---|---| +|Deletion protection|✅ **Enforced**|Enabled by default (`delete_protection = true`); opt-out requires explicit override| +|Subnet change protection|✅ **Enforced**|Enabled by default (`subnet_change_protection = true`); guards against accidental subnet modifications| +|Logging disabled by default|✅ **Enforced**|Logging is opt-in via `create_logging_configuration = true`; prevents unintended log ingestion costs| +|Encryption at rest (KMS)|⚠️ **Optional**|AWS-managed encryption by default; customer-managed KMS via `kms_key_arn` parameter (production recommended)| +|Encryption in transit (TLS)|⚠️ **AWS default**|AWS enforces TLS for firewall API and logging destinations; module does not add validation| +|Least-privilege IAM|⚠️ **Upstream module**|The underlying `terraform-aws-modules/network-firewall/aws` follows AWS best practices; module does not restrict caller's rule definitions| +|Audit trail|⚠️ **Caller-configured**|Platform CloudTrail logs API calls; firewall logs (FLOW/ALERT/TLS) require explicit `logging` configuration| ## Usage +### Minimal + +```hcl +module "network_firewall" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/network-firewall?ref=v2.1.0" + + service = "bcss" + project = "bcss" + environment = "dev" + stack = "shared-resources" + name = "nwfw" + + vpc_id = module.vpc.vpc_id + firewall_subnet_ids = module.vpc.firewall_subnet_ids +} +``` + +### Production-style (with encryption and logging best practices) + +```hcl +# Production deployment with customer-managed KMS, comprehensive logging, +# and protection against accidental modifications. + +module "network_firewall" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/network-firewall?ref=v2.1.0" + + service = "bcss" + project = "bcss" + environment = "prod" + stack = "shared-resources" + name = "nwfw" + + vpc_id = module.vpc.vpc_id + firewall_subnet_ids = module.vpc.firewall_subnet_ids + + # ======================================================================== + # Encryption at rest — customer-managed KMS (recommended for prod) + # ======================================================================== + kms_key_arn = module.nwfw_kms.key_arn + alert_log_group_kms_key_id = module.nwfw_kms.key_arn + + # ======================================================================== + # Logging — comprehensive multi-destination setup + # ======================================================================== + # FLOW logs → S3 (long-term archive, analysis) + # ALERT logs → CloudWatch (real-time alerting, metric filters) + # TLS logs → S3 (compliance, SSL/TLS inspection audit trail) + create_logging_configuration = true + logging = { + flow_s3 = { + log_type = "FLOW" + log_destination_type = "S3" + log_destination = { bucketName = module.logs_bucket.id, prefix = "nwfw/flow-logs" } + enabled = true + } + alert_cloudwatch = { + log_type = "ALERT" + log_destination_type = "CloudWatchLogs" + log_destination = { logGroup = module.network_firewall.alert_log_group_name } + enabled = true + } + tls_s3 = { + log_type = "TLS" + log_destination_type = "S3" + log_destination = { bucketName = module.logs_bucket.id, prefix = "nwfw/tls-logs" } + enabled = true + } + } + + # Managed CloudWatch log group with retention and encryption + create_alert_log_group = true + alert_log_group_retention_in_days = 90 # Compliance requirement + + # ======================================================================== + # Protection against accidental changes + # ======================================================================== + delete_protection = true # Enforced by module default + subnet_change_protection = true # Enforced by module default + firewall_policy_change_protection = true # Recommended for prod + + # ======================================================================== + # Threat detection and metrics + # ======================================================================== + enabled_analysis_types = ["TLS_SNI", "HTTP_HOST"] + + tags = merge( + module.this.tags, + { + Compliance = "NHS-Baseline" + CostCenter = "Network-Operations" + Environment = "Production" + } + ) +} + +# KMS key for encrypting firewall and logs +module "nwfw_kms" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=v2.1.0" + + service = var.service + project = var.project + environment = var.environment + stack = var.stack + name = "nwfw" + + description = "KMS key for Network Firewall encryption (firewall, policy, CloudWatch logs)" + key_usage = "ENCRYPT_DECRYPT" + + # Allow firewall and logs services to use the key + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowNetworkFirewallEncryption" + Effect = "Allow" + Principal = { + Service = "network-firewall.amazonaws.com" + } + Action = ["kms:Decrypt", "kms:GenerateDataKey"] + Resource = "*" + }, + { + Sid = "AllowCloudWatchLogsEncryption" + Effect = "Allow" + Principal = { + Service = "logs.${data.aws_region.current.name}.amazonaws.com" + } + Action = ["kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:CreateGrant", "kms:DescribeKey"] + Resource = "*" + } + ] + }) + + tags = module.this.tags +} + +# S3 bucket for firewall logs with versioning and encryption +module "logs_bucket" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3-bucket?ref=v2.1.0" + + service = var.service + project = var.project + environment = var.environment + stack = var.stack + name = "nwfw-logs" + + versioning_enabled = true + server_side_encryption = "aws:kms" + kms_master_key_id = module.nwfw_kms.key_id + block_public_access = true + enforce_ssl = true + lifecycle_rule_id = "archive-old-logs" + lifecycle_rule_prefix = "nwfw/" + lifecycle_transition_days = 90 + + tags = module.this.tags +} + +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} +``` + +### Advanced (multi-AZ with custom rule groups) + ```hcl - module "network_firewall" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/network-firewall" - - service = "bcss" - project = "bcss" - environment = "dev" - stack = "shared-resources" - workspace = terraform.workspace - name = "nwfw" - - vpc_id = module.vpc.vpc_id - firewall_subnet_ids = module.vpc.firewall_subnet_ids - - # Encryption (optional — from the kms module) - kms_key_arn = module.nwfw_kms.key_arn - alert_log_kms_key_id = module.nwfw_kms.key_arn - - # FLOW logs to S3 (optional — from the s3-bucket module) - flow_log_s3_bucket_name = module.logs_bucket.s3_bucket_id - - # Policy — rule groups are passed by arn - policy_stateful_rule_group_reference = { - deny_domains = { - priority = 1 - resource_arn = aws_networkfirewall_rule_group.deny_domains.arn +module "network_firewall" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/network-firewall?ref=v2.1.0" + + service = "bcss" + project = "bcss" + environment = "prod" + stack = "shared-resources" + name = "nwfw" + + # Multi-AZ deployment — provide one subnet per AZ + vpc_id = module.vpc.vpc_id + firewall_subnet_ids = module.vpc.firewall_subnet_ids # e.g., [subnet-az1, subnet-az2, subnet-az3] + + # Encryption + kms_key_arn = module.nwfw_kms.key_arn + alert_log_group_kms_key_id = module.nwfw_kms.key_arn + + # Logging to all three destinations + create_logging_configuration = true + logging = { + flow_s3 = { + log_type = "FLOW" + log_destination_type = "S3" + log_destination = { bucketName = module.logs_bucket.id, prefix = "nwfw/flow" } + } + alert_s3 = { + log_type = "ALERT" + log_destination_type = "S3" + log_destination = { bucketName = module.logs_bucket.id, prefix = "nwfw/alerts" } + } + tls_firehose = { + log_type = "TLS" + log_destination_type = "KinesisDataFirehose" + log_destination = { deliveryStream = "nwfw-tls-stream" } + } + } + + create_alert_log_group = true + alert_log_group_retention_in_days = 90 + + # Custom rule groups for domain blocking and threat inspection + rule_groups = { + deny_known_malware_domains = { + description = "Block known-bad domains via TLS SNI and HTTP Host" + type = "STATEFUL" + capacity = 100 + priority = 1 + rule_group = { + stateful_rule_options = { rule_order = "STRICT_ORDER" } + rules_source = { + rules_source_list = { + generated_rules_type = "DENYLIST" + target_types = ["TLS_SNI", "HTTP_HOST"] + targets = ["badsite.example.com", ".malware.net"] + } } } } + inspect_ssl = { + description = "Enable SSL/TLS inspection on suspicious traffic" + type = "STATEFUL" + capacity = 50 + priority = 5 + rules = "alert tls any any -> any any (msg:\"SSL inspection enabled\"; ssl_version:!tls1.3; sid:100001;)" + rule_group = { + stateful_rule_options = { rule_order = "STRICT_ORDER" } + } + } + } + + policy_stateful_default_actions = ["aws:drop_strict"] + policy_stateful_engine_options = { + rule_order = "STRICT_ORDER" + stream_exception_policy = "DROP" + } + + # Stateless default actions: forward established connections + policy_stateless_default_actions = ["aws:forward_to_sfe"] + policy_stateless_fragment_default_actions = ["aws:forward_to_sfe"] + + delete_protection = true + subnet_change_protection = true + firewall_policy_change_protection = true + + enabled_analysis_types = ["TLS_SNI", "HTTP_HOST"] + + tags = merge( + module.this.tags, + { + Compliance = "NHS-Baseline" + Monitoring = "Advanced" + } + ) +} ``` +## Conventions + +**Naming and tagging:** All resources (firewall, policy, rule groups, log groups) are named and tagged via `module.this`. Callers should provide at least `service`, `project`, `environment`, `stack`, and `name` to get consistent, platform-compliant names. + +**Rule group priority:** Rule groups are processed in priority order (lowest first). When creating multiple rule groups via `rule_groups`, set explicit `priority` values to control evaluation order. The `policy_stateful_default_actions` (e.g. `["aws:drop_strict"]`) apply to packets that match no rules. + +**Logging configuration:** By default, logging is disabled. To enable, set `create_logging_configuration = true` and provide a `logging` map with destination configs. AWS allows at most one log destination per log type (FLOW, ALERT, TLS). Each destination must specify `log_type`, `log_destination_type` (S3, CloudWatchLogs, or KinesisDataFirehose), and log_destination-specific keys (see `variables.tf` for details). + +**CloudWatch log groups:** The module can optionally create and manage a CloudWatch log group for ALERT logs (set `create_alert_log_group = true`). The log group name is `/aws/network-firewall/{firewall_id}` and is exposed via `alert_log_group_name` output for use in the `logging` variable. + +**External policies:** By default, this module creates a firewall policy inline. To use an externally managed policy (e.g. shared via Resource Access Manager), set `create_policy = false` and provide `firewall_policy_arn`. + +## What this module does NOT do + +- **Does NOT create VPC or subnets.** Callers must provide firewall subnet IDs created by the VPC module. +- **Does NOT create S3 buckets, Kinesis Firehose streams, or KMS keys.** Callers must create and pass ARNs/names as needed. +- **Does NOT enforce a fixed set of firewall rules.** Rules are caller-supplied and must be appropriate for the platform. +- **Does NOT manage rule group lifecycle after creation.** Once created, rule groups are the caller's responsibility (no auto-rollback, no versioning). +- **Does NOT create or manage Route tables or routing to the firewall.** Callers handle VPC routing configuration. +- **Does NOT manage alerts or metrics beyond enabling optional logging.** CloudWatch monitoring/alarming is caller-configured. + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.5.7 | +| [aws](#requirement\_aws) | >= 6.28, < 7.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.51.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [network\_firewall](#module\_network\_firewall) | terraform-aws-modules/network-firewall/aws | 2.1.0 | +| [rule\_group](#module\_rule\_group) | terraform-aws-modules/network-firewall/aws//modules/rule-group | 2.1.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cloudwatch_log_group.alert](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [alert\_log\_group\_kms\_key\_id](#input\_alert\_log\_group\_kms\_key\_id) | ARN of a KMS key to encrypt the managed CloudWatch alert log group. Leave null for no encryption. | `string` | `null` | no | +| [alert\_log\_group\_retention\_in\_days](#input\_alert\_log\_group\_retention\_in\_days) | Number of days to retain logs in the managed alert log group. | `number` | `365` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [create\_alert\_log\_group](#input\_create\_alert\_log\_group) | Create a managed CloudWatch Log Group for ALERT logs. The log group name is exposed via the alert\_log\_group\_name output. | `bool` | `false` | no | +| [create\_logging\_configuration](#input\_create\_logging\_configuration) | Master toggle for logging configuration. Must be plan-time-known. When true, the `logging` map is used to build destination configs. | `bool` | `false` | no | +| [create\_policy](#input\_create\_policy) | Create the firewall policy. Set to false and supply firewall\_policy\_arn to use an externally managed policy. | `bool` | `true` | no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [delete\_protection](#input\_delete\_protection) | Prevent accidental deletion of the firewall. | `bool` | `true` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [description](#input\_description) | A friendly description of the firewall. | `string` | `""` | no | +| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [enabled\_analysis\_types](#input\_enabled\_analysis\_types) | Types for which to collect analysis metrics. Valid values: TLS\_SNI, HTTP\_HOST. | `list(string)` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [firewall\_policy\_arn](#input\_firewall\_policy\_arn) | ARN of an externally managed firewall policy. Only used when create\_policy is false. | `string` | `""` | no | +| [firewall\_policy\_change\_protection](#input\_firewall\_policy\_change\_protection) | Prevent changes to the associated firewall policy. | `bool` | `false` | no | +| [firewall\_subnet\_ids](#input\_firewall\_subnet\_ids) | List of firewall subnet IDs (one per AZ) from the VPC module. | `list(string)` | n/a | yes | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | ARN of a KMS key to encrypt the firewall and its policy. Leave null for AWS-managed encryption. | `string` | `null` | no | +| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [logging](#input\_logging) | Map of logging destinations. Each key creates one log\_destination\_config block. See variable comments for shape and examples. |
map(object({
enabled = optional(bool, true)
log_type = string # FLOW, ALERT, or TLS
log_destination_type = string # S3, CloudWatchLogs, or KinesisDataFirehose
log_destination = map(string) # destination-specific keys (see comments above)
}))
| `{}` | no | +| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [policy\_stateful\_default\_actions](#input\_policy\_stateful\_default\_actions) | Actions for packets that match no stateful rules. Only valid with STRICT\_ORDER rule order. | `list(string)` | `null` | no | +| [policy\_stateful\_engine\_options](#input\_policy\_stateful\_engine\_options) | Stateful engine options (rule\_order, stream\_exception\_policy, flow\_timeouts). |
object({
flow_timeouts = optional(object({
tcp_idle_timeout_seconds = optional(number)
}))
rule_order = optional(string)
stream_exception_policy = optional(string)
})
| `null` | no | +| [policy\_stateful\_rule\_group\_reference](#input\_policy\_stateful\_rule\_group\_reference) | Map of stateful rule group references for the policy. |
map(object({
deep_threat_inspection = optional(bool)
override = optional(object({
action = optional(string)
}))
priority = optional(number)
resource_arn = string
}))
| `null` | no | +| [policy\_stateless\_custom\_action](#input\_policy\_stateless\_custom\_action) | Custom action definitions for the firewall policy's stateless default actions. |
map(object({
action_definition = object({
publish_metric_action = optional(object({
dimension = optional(string)
}))
})
action_name = string
}))
| `null` | no | +| [policy\_stateless\_default\_actions](#input\_policy\_stateless\_default\_actions) | Actions for packets that match no stateless rules. Default forwards all traffic to the stateful engine. | `list(string)` |
[
"aws:forward_to_sfe"
]
| no | +| [policy\_stateless\_fragment\_default\_actions](#input\_policy\_stateless\_fragment\_default\_actions) | Actions for fragmented packets that match no stateless rules. | `list(string)` |
[
"aws:forward_to_sfe"
]
| no | +| [policy\_stateless\_rule\_group\_reference](#input\_policy\_stateless\_rule\_group\_reference) | Map of stateless rule group references for the policy. |
map(object({
priority = number
resource_arn = string
}))
| `null` | no | +| [policy\_variables](#input\_policy\_variables) | Variables to override default Suricata settings in the firewall policy. |
object({
rule_variables = list(object({
ip_set = optional(object({
definition = list(string)
}))
key = string
}))
})
| `null` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [rule\_groups](#input\_rule\_groups) | Map of rule group definitions to create and attach to the firewall policy. See variable comments for shape and examples. |
map(object({
description = optional(string)
type = optional(string, "STATEFUL")
capacity = optional(number, 100)
priority = optional(number)
rules = optional(string)
rule_group = optional(any)
}))
| `{}` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [subnet\_change\_protection](#input\_subnet\_change\_protection) | Prevent changes to the associated subnets. | `bool` | `true` | no | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [vpc\_id](#input\_vpc\_id) | The ID of the VPC where the Network Firewall will be deployed. | `string` | n/a | yes | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [alert\_log\_group\_arn](#output\_alert\_log\_group\_arn) | The ARN of the CloudWatch Log Group for ALERT logs. | +| [alert\_log\_group\_name](#output\_alert\_log\_group\_name) | The name of the CloudWatch Log Group for ALERT logs. | +| [firewall\_arn](#output\_firewall\_arn) | The ARN of the Network Firewall. | +| [firewall\_id](#output\_firewall\_id) | The ARN that identifies the firewall (same as arn). | +| [firewall\_status](#output\_firewall\_status) | Nested list of information about the current status of the firewall. | +| [firewall\_update\_token](#output\_firewall\_update\_token) | A string token used when updating the firewall. | +| [logging\_configuration\_id](#output\_logging\_configuration\_id) | The ARN of the associated firewall logging configuration. | +| [policy\_arn](#output\_policy\_arn) | The ARN of the firewall policy. | +| [policy\_id](#output\_policy\_id) | The ARN that identifies the firewall policy. | +| [policy\_update\_token](#output\_policy\_update\_token) | A string token used when updating the firewall policy. | +| [rule\_group\_arns](#output\_rule\_group\_arns) | Map of rule group keys to their ARNs. | From a8fc8b7efb407abaab324a8ee52bc3e1de291b22 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 16:13:16 +0100 Subject: [PATCH 6/7] feat(network-firewall): add new module for AWS Network Firewall with comprehensive logging and encryption options --- .../network-firewall/.terraform.lock.hcl | 30 +++++++++++++++++++ .../network-firewall/{readme.md => README.md} | 0 .../modules/network-firewall/context.tf | 3 +- .../modules/network-firewall/main.tf | 14 ++++----- 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 infrastructure/modules/network-firewall/.terraform.lock.hcl rename infrastructure/modules/network-firewall/{readme.md => README.md} (100%) diff --git a/infrastructure/modules/network-firewall/.terraform.lock.hcl b/infrastructure/modules/network-firewall/.terraform.lock.hcl new file mode 100644 index 00000000..ec361a5e --- /dev/null +++ b/infrastructure/modules/network-firewall/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.28.0, < 7.0.0" + hashes = [ + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/network-firewall/readme.md b/infrastructure/modules/network-firewall/README.md similarity index 100% rename from infrastructure/modules/network-firewall/readme.md rename to infrastructure/modules/network-firewall/README.md diff --git a/infrastructure/modules/network-firewall/context.tf b/infrastructure/modules/network-firewall/context.tf index c28646f8..62befcb0 100644 --- a/infrastructure/modules/network-firewall/context.tf +++ b/infrastructure/modules/network-firewall/context.tf @@ -1,3 +1,4 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations # # ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags # All other instances of this file should be a copy of that one @@ -112,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." + description = "Source location to record in the Terraform_source tag. Defaults to this module path." } variable "enabled" { diff --git a/infrastructure/modules/network-firewall/main.tf b/infrastructure/modules/network-firewall/main.tf index 17383073..cad99998 100644 --- a/infrastructure/modules/network-firewall/main.tf +++ b/infrastructure/modules/network-firewall/main.tf @@ -46,12 +46,12 @@ module "network_firewall" { create_policy = var.create_policy firewall_policy_arn = var.firewall_policy_arn - policy_name = module.this.id - policy_description = coalesce(var.description, "Firewall policy for ${module.this.id}") - policy_encryption_configuration = local.encryption_configuration - policy_variables = var.policy_variables - policy_stateful_default_actions = var.policy_stateful_default_actions - policy_stateful_engine_options = var.policy_stateful_engine_options + policy_name = module.this.id + policy_description = coalesce(var.description, "Firewall policy for ${module.this.id}") + policy_encryption_configuration = local.encryption_configuration + policy_variables = var.policy_variables + policy_stateful_default_actions = var.policy_stateful_default_actions + policy_stateful_engine_options = var.policy_stateful_engine_options # TODO: why was this changed? # policy_stateful_rule_group_reference = var.policy_stateful_rule_group_reference policy_stateful_rule_group_reference = local.merged_stateful_rule_group_references @@ -71,7 +71,7 @@ module "network_firewall" { ################################################################ module "rule_group" { - source = "terraform-aws-modules/network-firewall/aws//modules/rule-group" + source = "terraform-aws-modules/network-firewall/aws//modules/rule-group" version = "2.1.0" for_each = { for k, v in var.rule_groups : k => v if module.this.enabled } From c20e3da561416b0ebaee230d3e4e097987944999 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:55:05 +0100 Subject: [PATCH 7/7] feat(gitleaks): enhance gitleaks configuration for IPv4 and IPv6 rules --- .gitleaksignore | 4 ++++ scripts/config/gitleaks.toml | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index f97f5c8a..bf9d628a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -5,3 +5,7 @@ e876843351a025eb754ec61982c8b7d95deeb709:.pre-commit-config.yaml:ipv4:119 e364bc1869c67729653c7efb4d6169f2294e68de:.pre-commit-config.yaml:ipv4:110 62088509f98ce02ce379adef2168b867eecfb5da:.pre-commit-config.yaml:ipv4:110 a3fa25da4e8f9eaa2e28c29f6196f23bfe87a58d:.pre-commit-config.yaml:ipv4:119 +# Historical false positive: example ARN comment in tags/main.tf contained hex-like content +# which triggered the ipv6 rule. Comment updated in later commit; old commits suppressed here. +7b49758d98757e8f404cb2c540c1f146afd6e395:infrastructure/modules/tags/main.tf:ipv6:131 +091dcd76884ffd307aee6c6b306b015c065f4896:infrastructure/modules/tags/main.tf:ipv6:131 diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index af5f0bb7..8371dcbc 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -11,8 +11,31 @@ regex = '''[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}''' [rules.allowlist] regexTarget = "match" regexes = [ - # Exclude the private network IPv4 addresses as well as the DNS servers for Google and OpenDNS - '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|208\.67\.222\.222|208\.67\.220\.220)''', + # Exclude private/reserved IPv4 addresses and well-known DNS servers used in docs/examples. + # Includes RFC5737 TEST-NET ranges: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|192\.0\.2\.[0-9]{1,3}|198\.51\.100\.[0-9]{1,3}|203\.0\.113\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1|1\.0\.0\.1)''', +] + +[[rules]] +description = "IPv6" +id = "ipv6" +# Matches valid IPv6 forms requiring at least 2 groups on each side of :: to +# avoid false positives from AWS ARNs (which use :: between region and account). +# full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +# compressed: 2001:db8::1, fe80:db8::1 +# trailing :: fe80:db8:: (2+ groups required before ::) +# leading :: ::db8:1 (2+ groups required after ::) +# Note: RE2 does not support lookahead/lookbehind so boundary enforcement is +# achieved structurally via minimum repetition counts. +regex = '''(?i)(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){2,7}:|(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}|(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}|(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}|(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:(?::[0-9a-f]{1,4}){1,6}|:(?::[0-9a-f]{1,4}){2,7}''' + +[rules.allowlist] +regexTarget = "match" +regexes = [ + # Exclude IPv6 documentation prefixes used in examples. + # RFC3849: 2001:db8::/32 + # RFC9637: 3fff::/20 (3fff:0000:: to 3fff:0fff::) + '''(?i)(^|[^0-9a-f])(2001:db8:|3fff:0[0-9a-f]{0,3}:)''', ] [allowlist]