diff --git a/infrastructure/modules/waf/context.tf b/infrastructure/modules/waf/context.tf new file mode 100644 index 0000000..90c23c2 --- /dev/null +++ b/infrastructure/modules/waf/context.tf @@ -0,0 +1,310 @@ +# +# 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 = "../tags" + + 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 + 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 + namespace = null + service = null + stage = null + project = null + tenant = 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 = {} + 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 "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 = "Terraform regular expression (regex) string. Characters matching the regex will be removed from the ID elements." +} + +variable "id_length_limit" { + type = number + default = null + description = "Limit `id` to this many characters (minimum 6). Set to `0` for unlimited length." +} + +variable "label_key_case" { + type = string + default = null + description = "Controls the letter case of the generated tag keys. Possible values: `lower`, `title`, `upper`." +} + +variable "label_value_case" { + type = string + default = null + description = "Controls the letter case of ID elements. Possible values: `lower`, `title`, `upper`, `none`." +} + +variable "descriptor_formats" { + type = any + default = {} + description = "Describe additional descriptors to be output in the `descriptors` output map." +} + +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 "owner" { + type = string + default = "None" + description = "The name and or NHS.net email address of the service owner" +} + +variable "tag_version" { + type = string + default = "1.0" + description = "Used to identify the tagging version in use" +} + +variable "data_classification" { + type = string + default = "n/a" + description = "Used to identify the data classification of the resource, e.g 1-5" +} + +variable "data_type" { + type = string + default = "None" + description = "The tag data_type" +} + +variable "public_facing" { + type = bool + default = false + description = "Whether this resource is public facing" +} + +variable "service_category" { + type = string + default = "n/a" + description = "The tag service_category" +} + +variable "on_off_pattern" { + type = string + default = "n/a" + description = "Used to turn resources on and off based on a time pattern" +} + +variable "application_role" { + type = string + default = "General" + description = "The role the application is performing" +} + +variable "tool" { + type = string + default = "Terraform" + description = "The tool used to deploy the resource" +} diff --git a/infrastructure/modules/waf/main.tf b/infrastructure/modules/waf/main.tf index 173a77a..a62ffb6 100644 --- a/infrastructure/modules/waf/main.tf +++ b/infrastructure/modules/waf/main.tf @@ -1,379 +1,345 @@ +locals { + context_id = trimspace(module.this.id) != "" ? module.this.id : null + + derived_name_prefix = coalesce(var.name_prefix, local.context_id, "waf") + derived_waf_name = coalesce(var.waf_name, local.derived_name_prefix) + derived_log_group_name = coalesce( + var.waf_log_group_name, + "aws-waf-logs-${local.derived_name_prefix}" + ) + + enable_legacy_bcss_mode = var.enable_legacy_bcss_mode != null ? var.enable_legacy_bcss_mode : anytrue([ + var.name_prefix != null, + var.waf_name != null, + var.waf_log_group_name != null, + var.exclude_ip_set_name != null, + var.web_services_ip_set_name != null + ]) + + enable_legacy_geo_rule = var.enable_legacy_geo_rule != null ? var.enable_legacy_geo_rule : local.enable_legacy_bcss_mode + create_waf_log_group = var.create_waf_log_group != null ? var.create_waf_log_group : local.enable_legacy_bcss_mode + enable_central_logging_subscription = var.enable_central_logging_subscription != null ? var.enable_central_logging_subscription : local.enable_legacy_bcss_mode + enable_splunk_logging_subscription = var.enable_splunk_logging_subscription != null ? var.enable_splunk_logging_subscription : local.enable_legacy_bcss_mode + enable_shield_ddos_alarming = var.enable_shield_ddos_alarming != null ? var.enable_shield_ddos_alarming : local.enable_legacy_bcss_mode + + waf_ips_secret_name = coalesce( + var.waf_ips_secret_name, + var.name_prefix != null ? "${var.name_prefix}-waf-ip-set" : null + ) + waf_bsis_ip_range_secret_name = coalesce( + var.waf_bsis_ip_range_secret_name, + var.name_prefix != null ? "${var.name_prefix}-waf-bsis-ip" : null + ) + cloudwatch_cross_account_secret_name = coalesce( + var.cloudwatch_cross_account_secret_name, + var.name_prefix != null ? "${var.name_prefix}-cloudwatch-cross-account-logging" : null + ) + alert_sns_topic_name = coalesce(var.alert_sns_topic_name, var.name_prefix) + + default_visibility_config = { + cloudwatch_metrics_enabled = true + metric_name = "${local.derived_name_prefix}-waf-acl-metric" + sampled_requests_enabled = true + } + visibility_config = var.visibility_config != null ? var.visibility_config : local.default_visibility_config + + default_managed_rule_group_statement_rules = [ + { + name = "${local.derived_name_prefix}-aws-common-rule-set" + priority = 10 + override_action = "count" + statement = { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-common-rule-set-metric" + } + }, + { + name = "${local.derived_name_prefix}-aws-bad-inputs-rule-set" + priority = 20 + override_action = "count" + statement = { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-bad-inputs-rule-set-metric" + } + }, + { + name = "${local.derived_name_prefix}-aws-ip-reputation-list" + priority = 30 + override_action = "count" + statement = { + name = "AWSManagedRulesAmazonIpReputationList" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-ip-reputation-list-metric" + } + }, + { + name = "${local.derived_name_prefix}-aws-sql-injection-rules" + priority = 40 + override_action = "count" + statement = { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-sql-injection-rules-metric" + } + }, + { + name = "${local.derived_name_prefix}-waf-aws-anonymous-ip-list-set" + priority = 50 + override_action = "none" + statement = { + name = "AWSManagedRulesAnonymousIpList" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-anonymous-ip-list-set-metric" + } + }, + { + name = "${local.derived_name_prefix}-waf-aws-linux-rule-set" + priority = 60 + override_action = "none" + statement = { + name = "AWSManagedRulesLinuxRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-aws-linux-rule-set-metric" + } + } + ] + + legacy_geo_match_statement_rules = local.enable_legacy_geo_rule ? [ + { + name = "${local.derived_name_prefix}-waf-non-GB-geo-match" + priority = var.legacy_geo_rule_priority + action = "count" + statement = { + country_codes = ["GB"] + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-waf-non-GB-geo-match-metric" + } + } + ] : [] + + managed_rule_group_statement_rules = length(var.managed_rule_group_statement_rules) > 0 ? var.managed_rule_group_statement_rules : local.default_managed_rule_group_statement_rules +} + data "aws_secretsmanager_secret_version" "waf_ips" { - secret_id = "${var.name_prefix}-waf-ip-set" + count = local.enable_legacy_bcss_mode && var.exclude_ip_set_addresses == null && local.waf_ips_secret_name != null ? 1 : 0 + secret_id = local.waf_ips_secret_name } + data "aws_secretsmanager_secret_version" "waf_bsis_ip_range" { - secret_id = "${var.name_prefix}-waf-bsis-ip" + count = local.enable_legacy_bcss_mode && var.webservices_ip_set_addresses == null && local.waf_bsis_ip_range_secret_name != null ? 1 : 0 + secret_id = local.waf_bsis_ip_range_secret_name +} + +data "aws_secretsmanager_secret" "cloudwatch_cross_accounts" { + count = local.create_waf_log_group && local.enable_central_logging_subscription && local.cloudwatch_cross_account_secret_name != null ? 1 : 0 + name = local.cloudwatch_cross_account_secret_name +} + +data "aws_secretsmanager_secret_version" "cloudwatch_cross_accounts" { + count = length(data.aws_secretsmanager_secret.cloudwatch_cross_accounts) > 0 ? 1 : 0 + secret_id = data.aws_secretsmanager_secret.cloudwatch_cross_accounts[0].id } data "aws_sns_topic" "alert" { - name = var.name_prefix + count = local.enable_shield_ddos_alarming && lower(coalesce(module.this.environment, var.environment, "")) == "prod" && local.alert_sns_topic_name != null ? 1 : 0 + name = local.alert_sns_topic_name } locals { - ip_list = jsondecode(data.aws_secretsmanager_secret_version.waf_ips.secret_string).ips - bsis_ips = jsondecode(data.aws_secretsmanager_secret_version.waf_bsis_ip_range.secret_string).bsis_ip + exclude_ip_addresses = var.exclude_ip_set_addresses != null ? var.exclude_ip_set_addresses : ( + length(data.aws_secretsmanager_secret_version.waf_ips) > 0 ? try( + jsondecode(data.aws_secretsmanager_secret_version.waf_ips[0].secret_string)[var.waf_ips_secret_key], + [] + ) : [] + ) + + webservices_ip_addresses = var.webservices_ip_set_addresses != null ? var.webservices_ip_set_addresses : ( + length(data.aws_secretsmanager_secret_version.waf_bsis_ip_range) > 0 ? try( + jsondecode(data.aws_secretsmanager_secret_version.waf_bsis_ip_range[0].secret_string)[var.waf_bsis_ip_range_secret_key], + [] + ) : [] + ) + + cross_account_id = length(data.aws_secretsmanager_secret_version.cloudwatch_cross_accounts) > 0 ? try( + jsondecode(data.aws_secretsmanager_secret_version.cloudwatch_cross_accounts[0].secret_string)[var.cloudwatch_cross_account_secret_key], + null + ) : null + + create_legacy_exclude_ip_set = local.enable_legacy_bcss_mode && var.exclude_ip_set_name != null && length(local.exclude_ip_addresses) > 0 + create_legacy_webservices_ip_set = local.enable_legacy_bcss_mode && var.web_services_ip_set_name != null && length(local.webservices_ip_addresses) > 0 + create_legacy_webservices_rule_group = local.create_legacy_webservices_ip_set && length(var.webservices_protected_paths) > 0 + + combined_geo_match_statement_rules = concat(var.geo_match_statement_rules, local.legacy_geo_match_statement_rules) + combined_rule_group_reference_statement_rules = concat( + var.rule_group_reference_statement_rules, + local.create_legacy_webservices_rule_group ? [ + { + name = "${local.derived_name_prefix}-legacy-webservices-rule-group" + priority = var.legacy_webservices_rule_priority + override_action = "none" + statement = { + arn = aws_wafv2_rule_group.legacy_webservices[0].arn + } + visibility_config = { + metric_name = "${local.derived_name_prefix}-legacy-webservices-rule-group" + } + } + ] : [] + ) + + combined_log_destination_configs = distinct(concat( + var.log_destination_configs, + local.create_waf_log_group ? [aws_cloudwatch_log_group.waf_logs[0].arn] : [] + )) + + cloudposse_context = merge(module.this.context, { + name = local.derived_waf_name + namespace = null + stage = null + tenant = null + tags = module.this.tags + }) } -####################### -# IP Sets ToDo: Check if the is relevant to our environment -####################### -#### Please note this resource creation might fail on the first run with error stating resource already exists (eventhough Terraform logs shows it is destroyrd) -# whenever there is change ticket raised to investigate this https://nhsd-jira.digital.nhs.uk/browse/SCM-726 -##### -resource "aws_wafv2_ip_set" "bs-select-exclude-ip-set" { +resource "aws_wafv2_ip_set" "legacy_exclude" { + count = local.create_legacy_exclude_ip_set ? 1 : 0 + name = var.exclude_ip_set_name - description = "This set of IPs are excluded from Anonymous and linux rule" - scope = "REGIONAL" + description = "Legacy BCSS excluded IP addresses" + scope = var.scope ip_address_version = "IPV4" - addresses = local.ip_list + addresses = local.exclude_ip_addresses + tags = module.this.tags } -#########For web Services add/remove on tfvars######### -resource "aws_wafv2_ip_set" "bs-select-webservices-ip-set" { +resource "aws_wafv2_ip_set" "legacy_webservices" { + count = local.create_legacy_webservices_ip_set ? 1 : 0 + name = var.web_services_ip_set_name - description = "This set of IPs are excluded from Anonymous and linux rule" - scope = "REGIONAL" + description = "Legacy BCSS webservices allowlist IP addresses" + scope = var.scope ip_address_version = "IPV4" - addresses = local.bsis_ips + addresses = local.webservices_ip_addresses + tags = module.this.tags } -###################### -# WAF -###################### +resource "aws_wafv2_rule_group" "legacy_webservices" { + count = local.create_legacy_webservices_rule_group ? 1 : 0 + name = "${local.derived_name_prefix}-legacy-webservices" + description = "Legacy BCSS rule that restricts selected paths to the webservices allowlist" + scope = var.scope + capacity = var.legacy_webservices_rule_capacity + tags = module.this.tags -resource "aws_wafv2_web_acl" "bss-waf-acl" { - name = var.waf_name - scope = "REGIONAL" - #checkov:skip=CKV_AWS_192:Even after adding required code to manage log4j still checkov failing ,New ticket- https://nhsd-jira.digital.nhs.uk/browse/SCM-695 raised to check this - - default_action { - allow {} - } - - # Primary Web ACL metric visibility_config { cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-acl-metric" + metric_name = "${local.derived_name_prefix}-legacy-webservices" sampled_requests_enabled = true } - # Custom rule for paths and IP set exclusion rule { - name = "bss-webservices-rule" - priority = 80 + name = "webservices-allowlist" + priority = 1 action { block {} } - # web service rules + statement { and_statement { - statement { or_statement { - statement { - byte_match_statement { - search_string = "/bss/dashboardExtracts" - field_to_match { - uri_path {} - } - text_transformation { - priority = 0 - type = "NONE" - } - positional_constraint = "CONTAINS" - } - } - # Statements not currently in live, ticket SCM-1826 created to investigate - # statement { - # byte_match_statement { - # search_string = "/bss/screeningbatchresults" - # field_to_match { - # uri_path {} - # } - # text_transformation { - # priority = 0 - # type = "NONE" - # } - # positional_constraint = "CONTAINS" - # } - # } - # statement { - # byte_match_statement { - # search_string = "/bss/nonbatchreferrals" - # field_to_match { - # uri_path {} - # } - # text_transformation { - # priority = 0 - # type = "NONE" - # } - # positional_constraint = "CONTAINS" - # } - # } - statement { - byte_match_statement { - search_string = "/bss/rawdatamigration" - field_to_match { - uri_path {} - } - text_transformation { - priority = 0 - type = "NONE" - } - positional_constraint = "CONTAINS" - } - } - } - } - - # Not statement to block requests that are not from the allowed IP set - statement { - not_statement { - statement { - ip_set_reference_statement { - arn = aws_wafv2_ip_set.bs-select-webservices-ip-set.arn - } - } - } - } - } - } + dynamic "statement" { + for_each = toset(var.webservices_protected_paths) - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "bss-webservices-rule" - sampled_requests_enabled = true - } - } + content { + byte_match_statement { + search_string = statement.value - # Base rules for all service teams - rule { - name = "${var.name_prefix}-aws-common-rule-set" - priority = 10 + field_to_match { + uri_path {} + } - override_action { - count {} - } + positional_constraint = "CONTAINS" - statement { - managed_rule_group_statement { - name = "AWSManagedRulesCommonRuleSet" - vendor_name = "AWS" - } - } - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-common-rule-set-metric" - sampled_requests_enabled = true - } - } - - rule { - name = "${var.name_prefix}-aws-bad-inputs-rule-set" - priority = 20 - - override_action { - count {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesKnownBadInputsRuleSet" - vendor_name = "AWS" - } - } - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-bad-inputs-rule-set-metric" - sampled_requests_enabled = true - } - } - - rule { - name = "${var.name_prefix}-aws-ip-reputation-list" - priority = 30 - - override_action { - count {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesAmazonIpReputationList" - vendor_name = "AWS" - } - } - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-ip-reputation-list-metric" - sampled_requests_enabled = true - } - } - - rule { - name = "${var.name_prefix}-aws-sql-injection-rules" - priority = 40 - - override_action { - count {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesSQLiRuleSet" - vendor_name = "AWS" - } - } - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-sql-injection-rules-metric" - sampled_requests_enabled = true - } - } - - # Service-team specfic rules - rule { - name = "${var.name_prefix}-waf-non-GB-geo-match" - priority = 100 - action { - count {} - } - statement { - not_statement { - statement { - geo_match_statement { - country_codes = ["GB"] - } - } - } - } - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-non-GB-geo-match-metric" - sampled_requests_enabled = true - } - } - - rule { - name = "${var.name_prefix}-waf-aws-anonymous-ip-list-set" - priority = 50 - - override_action { - none {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesAnonymousIpList" - vendor_name = "AWS" - scope_down_statement { - not_statement { - statement { - ip_set_reference_statement { - arn = aws_wafv2_ip_set.bs-select-exclude-ip-set.arn + text_transformation { + priority = 0 + type = "NONE" + } + } } } - } } - } - } - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-anonymous-ip-list-set-metric" - sampled_requests_enabled = true - } - } - - - rule { - name = "${var.name_prefix}-waf-aws-linux-rule-set" - priority = 60 - - override_action { - none {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesLinuxRuleSet" - vendor_name = "AWS" - - scope_down_statement { + statement { not_statement { statement { ip_set_reference_statement { - arn = aws_wafv2_ip_set.bs-select-exclude-ip-set.arn + arn = aws_wafv2_ip_set.legacy_webservices[0].arn } } - } } } } - visibility_config { cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-linux-rule-set-metric" + metric_name = "${local.derived_name_prefix}-legacy-webservices-rule" sampled_requests_enabled = true } } - rule { - name = "AWS-AWSManagedRulesKnownBadInputsRuleSet" - priority = 70 - - override_action { - none {} - } - - statement { - managed_rule_group_statement { - name = "AWSManagedRulesKnownBadInputsRuleSet" - vendor_name = "AWS" - } - } - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-known-bad-inputs-rules" - sampled_requests_enabled = true - } - - } - } resource "aws_cloudwatch_log_group" "waf_logs" { - // Note CW log group name should begin aws-waf-logs - name = var.waf_log_group_name - retention_in_days = 365 -} + count = local.create_waf_log_group ? 1 : 0 -resource "aws_wafv2_web_acl_logging_configuration" "waf_acl_lc" { - log_destination_configs = [aws_cloudwatch_log_group.waf_logs.arn] - resource_arn = aws_wafv2_web_acl.bss-waf-acl.arn + name = local.derived_log_group_name + retention_in_days = var.waf_log_retention_in_days + tags = module.this.tags } -# Create a CloudWatch Log Group with KMS Encryption -################################## -####### Forward logs to CSOC ##### -################################## - -# Create IAM role necessary for cross-account log subscriptions -resource "aws_iam_role" "cw_to_subscription_filter_role" { - name = "${var.name_prefix}_CWLtoSubscriptionFilterRole" - assume_role_policy = data.aws_iam_policy_document.central_logs_assume_role.json +module "waf" { + source = "cloudposse/waf/aws" + version = "1.17.0" + + scope = var.scope + description = var.description + default_action = var.default_action + visibility_config = local.visibility_config + association_resource_arns = var.association_resource_arns + log_destination_configs = local.combined_log_destination_configs + token_domains = var.token_domains + managed_rule_group_statement_rules = local.managed_rule_group_statement_rules + geo_match_statement_rules = local.combined_geo_match_statement_rules + ip_set_reference_statement_rules = var.ip_set_reference_statement_rules + rate_based_statement_rules = var.rate_based_statement_rules + rule_group_reference_statement_rules = local.combined_rule_group_reference_statement_rules + + context = local.cloudposse_context } data "aws_iam_policy_document" "central_logs_assume_role" { + count = local.create_waf_log_group && local.enable_central_logging_subscription && local.cross_account_id != null && var.aws_account_id != null ? 1 : 0 + statement { sid = "centralLogsAssumeRole" effect = "Allow" @@ -386,82 +352,81 @@ data "aws_iam_policy_document" "central_logs_assume_role" { } } +resource "aws_iam_role" "cw_to_subscription_filter_role" { + count = length(data.aws_iam_policy_document.central_logs_assume_role) > 0 ? 1 : 0 - -# Permissions policy to define actions cloudwatch logs can perform -resource "aws_iam_policy" "central_cw_subscription_iam_policy" { - name = "${var.name_prefix}_central_cw_subscription" - policy = data.aws_iam_policy_document.central_cw_subscription_doc_policy.json + name = "${local.derived_name_prefix}_CWLtoSubscriptionFilterRole" + assume_role_policy = data.aws_iam_policy_document.central_logs_assume_role[0].json + tags = module.this.tags } data "aws_iam_policy_document" "central_cw_subscription_doc_policy" { + count = length(aws_iam_role.cw_to_subscription_filter_role) > 0 ? 1 : 0 + statement { - actions = [ - "logs:PutLogEvents" - ] + actions = ["logs:PutLogEvents"] resources = [ - "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:aws-waf-logs-${var.name_prefix}:*" + "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:${local.derived_log_group_name}:*" ] } } -resource "aws_iam_role_policy_attachment" "central_logging_att" { - policy_arn = aws_iam_policy.central_cw_subscription_iam_policy.arn - role = aws_iam_role.cw_to_subscription_filter_role.id -} +resource "aws_iam_policy" "central_cw_subscription_iam_policy" { + count = length(data.aws_iam_policy_document.central_cw_subscription_doc_policy) > 0 ? 1 : 0 -data "aws_secretsmanager_secret" "cloudwatch-cross-accounts" { - name = "${var.name_prefix}-cloudwatch-cross-account-logging" + name = "${local.derived_name_prefix}_central_cw_subscription" + policy = data.aws_iam_policy_document.central_cw_subscription_doc_policy[0].json + tags = module.this.tags } -data "aws_secretsmanager_secret_version" "cloudwatch-cross-accounts" { - secret_id = data.aws_secretsmanager_secret.cloudwatch-cross-accounts.id -} +resource "aws_iam_role_policy_attachment" "central_logging_att" { + count = length(aws_iam_policy.central_cw_subscription_iam_policy) > 0 ? 1 : 0 -locals { - cross_account_id = jsondecode(data.aws_secretsmanager_secret_version.cloudwatch-cross-accounts.secret_string)["central-logging"] + policy_arn = aws_iam_policy.central_cw_subscription_iam_policy[0].arn + role = aws_iam_role.cw_to_subscription_filter_role[0].id } resource "time_sleep" "wait_30_seconds" { + count = length(aws_iam_role.cw_to_subscription_filter_role) > 0 ? 1 : 0 + depends_on = [aws_iam_role.cw_to_subscription_filter_role] create_duration = "30s" } -# The subscription filter to send to the central logging + resource "aws_cloudwatch_log_subscription_filter" "central_logging" { - name = "${var.name_prefix}_central_logging" - role_arn = aws_iam_role.cw_to_subscription_filter_role.arn - log_group_name = var.waf_log_group_name + count = local.create_waf_log_group && local.enable_central_logging_subscription && local.cross_account_id != null && length(aws_iam_role.cw_to_subscription_filter_role) > 0 ? 1 : 0 + + name = "${local.derived_name_prefix}_central_logging" + role_arn = aws_iam_role.cw_to_subscription_filter_role[0].arn + log_group_name = aws_cloudwatch_log_group.waf_logs[0].name filter_pattern = "" destination_arn = "arn:aws:logs:${var.aws_region}:${local.cross_account_id}:destination:waf_log_destination" distribution = "ByLogStream" depends_on = [ - aws_iam_role.cw_to_subscription_filter_role, aws_cloudwatch_log_group.waf_logs, time_sleep.wait_30_seconds ] } -# Send to splunk as well for our own logging/troubleshooting resource "aws_cloudwatch_log_subscription_filter" "splunk_subscr_filter" { - name = "${var.name_prefix}_splunk_subscr_filter" - role_arn = "arn:aws:iam::${var.aws_account_id}:role/${var.name_prefix}-CloudWatchToFirehoseRole" - log_group_name = var.waf_log_group_name + count = local.create_waf_log_group && local.enable_splunk_logging_subscription && var.aws_account_id != null ? 1 : 0 + + name = "${local.derived_name_prefix}_splunk_subscr_filter" + role_arn = "arn:aws:iam::${var.aws_account_id}:role/${local.derived_name_prefix}-CloudWatchToFirehoseRole" + log_group_name = aws_cloudwatch_log_group.waf_logs[0].name filter_pattern = "" - destination_arn = "arn:aws:firehose:${var.aws_region}:${var.aws_account_id}:deliverystream/${var.name_prefix}-cw-logs-firehose" + destination_arn = "arn:aws:firehose:${var.aws_region}:${var.aws_account_id}:deliverystream/${local.derived_name_prefix}-cw-logs-firehose" distribution = "ByLogStream" - depends_on = [ - aws_cloudwatch_log_group.waf_logs - ] + depends_on = [aws_cloudwatch_log_group.waf_logs] } -############################## -# DDoS Alarm logs forwarding to CSOC -############################## resource "aws_iam_role" "eventbridge_role" { - count = contains(["prod"], var.environment) ? 1 : 0 - name = "${var.name_prefix}-eventbridge-trust-role" + count = local.enable_shield_ddos_alarming && lower(coalesce(module.this.environment, var.environment, "")) == "prod" && local.cross_account_id != null && var.aws_account_id != null ? 1 : 0 + + name = "${local.derived_name_prefix}-eventbridge-trust-role" + tags = module.this.tags assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -475,17 +440,18 @@ resource "aws_iam_role" "eventbridge_role" { Action = "sts:AssumeRole" Condition = { StringEquals = { - "aws:SourceAccount" = "${var.aws_account_id}" + "aws:SourceAccount" = var.aws_account_id } } } ] }) } + resource "aws_iam_role_policy" "eventbridge_put_events" { - count = contains(["prod"], var.environment) ? 1 : 0 + count = length(aws_iam_role.eventbridge_role) > 0 ? 1 : 0 - name = "${var.name_prefix}-eventbridge-put-events" + name = "${local.derived_name_prefix}-eventbridge-put-events" role = aws_iam_role.eventbridge_role[0].id policy = jsonencode({ @@ -494,11 +460,9 @@ resource "aws_iam_role_policy" "eventbridge_put_events" { { Sid = "ActionsForResource" Effect = "Allow" - Action = [ - "events:PutEvents" - ] + Action = ["events:PutEvents"] Resource = [ - "arn:aws:events:eu-west-2:${local.cross_account_id}:event-bus/shield-eventbus" + "arn:aws:events:${var.shield_event_bus_region}:${local.cross_account_id}:event-bus/shield-eventbus" ] } ] @@ -506,8 +470,9 @@ resource "aws_iam_role_policy" "eventbridge_put_events" { } resource "aws_cloudwatch_metric_alarm" "shield_ddos_alarm" { - count = contains(["prod"], var.environment) ? 1 : 0 - alarm_name = "${var.name_prefix}_shield_ddos_WAF" + count = length(data.aws_sns_topic.alert) > 0 ? 1 : 0 + + alarm_name = "${local.derived_name_prefix}_shield_ddos_WAF" comparison_operator = "GreaterThanThreshold" evaluation_periods = 20 datapoints_to_alarm = 1 @@ -519,33 +484,34 @@ resource "aws_cloudwatch_metric_alarm" "shield_ddos_alarm" { treat_missing_data = "notBreaching" dimensions = { - ResourceArn = aws_wafv2_web_acl.bss-waf-acl.arn + ResourceArn = module.waf.arn } - alarm_actions = [data.aws_sns_topic.alert.arn] - ok_actions = [data.aws_sns_topic.alert.arn] + alarm_actions = [data.aws_sns_topic.alert[0].arn] + ok_actions = [data.aws_sns_topic.alert[0].arn] insufficient_data_actions = [] - - alarm_description = "Alarm triggers when Shield Advanced detects a DDoS attack on production WAF" + alarm_description = "Alarm triggers when Shield Advanced detects a DDoS attack on production WAF" + tags = module.this.tags } resource "aws_cloudwatch_event_rule" "shield_ddos_rule" { - count = contains(["prod"], var.environment) ? 1 : 0 - name = "${var.name_prefix}_shield_ddos_rules" - description = "Forward DDoS alarm state change events to cross-account EventBridge bus" + count = length(aws_cloudwatch_metric_alarm.shield_ddos_alarm) > 0 ? 1 : 0 + name = "${local.derived_name_prefix}_shield_ddos_rules" + description = "Forward DDoS alarm state change events to cross-account EventBridge bus" event_pattern = jsonencode({ source = ["aws.cloudwatch"] "detail-type" = ["CloudWatch Alarm State Change"] resources = [aws_cloudwatch_metric_alarm.shield_ddos_alarm[0].arn] }) + tags = module.this.tags } resource "aws_cloudwatch_event_target" "shield_ddos_target" { - count = contains(["prod"], var.environment) ? 1 : 0 + count = length(aws_cloudwatch_event_rule.shield_ddos_rule) > 0 ? 1 : 0 - rule = aws_cloudwatch_event_rule.shield_ddos_rule[count.index].name - target_id = "${var.name_prefix}-shield-ddos-target" - arn = "arn:aws:events:eu-west-2:${local.cross_account_id}:event-bus/shield-eventbus" - role_arn = aws_iam_role.eventbridge_role[count.index].arn + rule = aws_cloudwatch_event_rule.shield_ddos_rule[0].name + target_id = "${local.derived_name_prefix}-shield-ddos-target" + arn = "arn:aws:events:${var.shield_event_bus_region}:${local.cross_account_id}:event-bus/shield-eventbus" + role_arn = aws_iam_role.eventbridge_role[0].arn } diff --git a/infrastructure/modules/waf/outputs.tf b/infrastructure/modules/waf/outputs.tf index 148239a..e458a9c 100644 --- a/infrastructure/modules/waf/outputs.tf +++ b/infrastructure/modules/waf/outputs.tf @@ -1,3 +1,34 @@ output "web_acl_arn" { - value = aws_wafv2_web_acl.bss-waf-acl.arn + description = "ARN of the WAF web ACL." + value = module.waf.arn +} + +output "web_acl_id" { + description = "ID of the WAF web ACL." + value = module.waf.id +} + +output "web_acl_capacity" { + description = "Current WAF capacity usage in WCUs." + value = module.waf.capacity +} + +output "logging_config_id" { + description = "ARN of the WAF logging configuration when logging is enabled." + value = module.waf.logging_config_id +} + +output "legacy_exclude_ip_set_arn" { + description = "ARN of the legacy BCSS excluded IP set when created." + value = try(aws_wafv2_ip_set.legacy_exclude[0].arn, null) +} + +output "legacy_webservices_ip_set_arn" { + description = "ARN of the legacy BCSS webservices allowlist IP set when created." + value = try(aws_wafv2_ip_set.legacy_webservices[0].arn, null) +} + +output "waf_log_group_name" { + description = "Name of the WAF CloudWatch log group when created by this module." + value = try(aws_cloudwatch_log_group.waf_logs[0].name, null) } diff --git a/infrastructure/modules/waf/readme.md b/infrastructure/modules/waf/readme.md index f345691..4f1c555 100644 --- a/infrastructure/modules/waf/readme.md +++ b/infrastructure/modules/waf/readme.md @@ -1,5 +1,36 @@ # WAF +Screening wrapper around +[`cloudposse/waf/aws`](https://registry.terraform.io/modules/cloudposse/waf/aws/latest) +for the Web ACL itself, with the original BCSS-only behaviour retained as +optional legacy resources. + +## Implementation approach + +This module now splits responsibilities in a way that is safer for a shared +module repository: + +* The core web ACL, logging configuration, and optional associations are managed + through the upstream Cloud Posse module. +* The previous BCSS-specific pieces remain available behind legacy inputs: + secret-backed IP sets, the webservices allowlist rule group, cross-account log + forwarding, downstream log shipping, and production Shield alarming. +* Existing legacy variable names are still accepted so BCSS consumers have a + migration path without renaming everything immediately. + +## Important compatibility note + +The legacy module excluded a specific IP set from only two AWS managed rule +groups: `AWSManagedRulesAnonymousIpList` and `AWSManagedRulesLinuxRuleSet`. +The upstream Cloud Posse module does not currently expose that exact +scope-down-with-IP-set shape for managed rule groups. This wrapper therefore +preserves the IP-set resources and the BCSS path-based allowlist rule, but the +managed rule group exclusions themselves are not reproduced exactly. + +If BCSS still depends on that exact behaviour, keep using the legacy rule +inputs during migration and validate the resulting rule behaviour in a lower +environment before promoting. + diff --git a/infrastructure/modules/waf/variables.tf b/infrastructure/modules/waf/variables.tf index 3b3befa..fa0bc63 100644 --- a/infrastructure/modules/waf/variables.tf +++ b/infrastructure/modules/waf/variables.tf @@ -1,35 +1,260 @@ -variable "waf_log_group_name" { - description = "waf log group" +################################################################ +# WAF wrapper inputs. +# +# This module now wraps `cloudposse/waf/aws` for the Web ACL itself, +# while preserving the previous BCSS-specific resources as optional +# legacy add-ons so downstream consumers can migrate without an +# immediate breaking change. +################################################################ + +variable "name_prefix" { + description = "Legacy naming prefix used by the original BCSS WAF module. When not set, the module derives names from `context.tf` inputs." + type = string + default = null } variable "waf_name" { - description = "waf name" + description = "Explicit name for the WAF web ACL. Defaults to `name_prefix`, then to the shared tags context id." + type = string + default = null +} + +variable "description" { + description = "Description for the WAF web ACL." + type = string + default = "Managed by Terraform" } +variable "scope" { + description = "Whether this web ACL is regional or for CloudFront." + type = string + default = "REGIONAL" + + validation { + condition = contains(["CLOUDFRONT", "REGIONAL"], var.scope) + error_message = "scope must be one of CLOUDFRONT or REGIONAL." + } +} + +variable "default_action" { + description = "Default action applied by the web ACL when no rule matches. The legacy module allowed by default, so this wrapper preserves that default." + type = string + default = "allow" + + validation { + condition = contains(["allow", "block"], var.default_action) + error_message = "default_action must be either allow or block." + } +} + +variable "visibility_config" { + description = "Visibility configuration for the Web ACL. Leave null to use the module default metric derived from `name_prefix` or the shared context id." + type = object({ + cloudwatch_metrics_enabled = bool + metric_name = string + sampled_requests_enabled = bool + }) + default = null +} + +variable "association_resource_arns" { + description = "Resource ARNs to associate with the web ACL. Typical values are ALB, API Gateway stage, or AppSync ARNs." + type = list(string) + default = [] +} + +variable "managed_rule_group_statement_rules" { + description = "Managed rule group statements passed through to the upstream Cloud Posse WAF module. Leave empty to use the Screening default managed ruleset." + type = list(any) + default = [] +} + +variable "geo_match_statement_rules" { + description = "Optional geo match rules passed through to the upstream Cloud Posse WAF module." + type = list(any) + default = [] +} + +variable "ip_set_reference_statement_rules" { + description = "Optional IP set reference rules passed through to the upstream Cloud Posse WAF module." + type = list(any) + default = [] +} + +variable "rate_based_statement_rules" { + description = "Optional rate-based rules passed through to the upstream Cloud Posse WAF module." + type = list(any) + default = [] +} + +variable "rule_group_reference_statement_rules" { + description = "Optional rule-group reference rules passed through to the upstream Cloud Posse WAF module. The module appends the legacy BCSS webservices rule group here when enabled." + type = list(any) + default = [] +} + +variable "log_destination_configs" { + description = "Additional WAF logging destination ARNs to pass to the upstream module. The BCSS legacy log group ARN is appended automatically when that feature is enabled." + type = list(string) + default = [] +} + +variable "token_domains" { + description = "Optional token domains passed through to the upstream Cloud Posse WAF module." + type = list(string) + default = null +} + +################################################################ +# BCSS legacy compatibility inputs +################################################################ + +variable "enable_legacy_bcss_mode" { + description = "Whether to enable the BCSS-specific IP sets, webservices rule group, legacy log forwarding, and Shield alarming. Leave null to auto-enable when legacy naming variables are provided." + type = bool + default = null +} + +variable "enable_legacy_geo_rule" { + description = "Whether to retain the previous non-GB geo match count rule. Defaults to the same value as `enable_legacy_bcss_mode`." + type = bool + default = null +} variable "exclude_ip_set_name" { - description = "Service" + description = "Legacy BCSS IP set name used for excluded source addresses. Kept for compatibility; this wrapper creates the IP set when addresses are provided." + type = string + default = null +} + +variable "exclude_ip_set_addresses" { + description = "Explicit addresses for the legacy excluded IP set. When omitted in legacy mode, the module falls back to the `waf-ip-set` secret." + type = list(string) + default = null } + variable "web_services_ip_set_name" { + description = "Legacy BCSS IP set name used for the webservices allowlist." + type = string + default = null +} + +variable "webservices_ip_set_addresses" { + description = "List of webservices allowlist IP addresses. When omitted in legacy mode, the module falls back to the `waf-bsis-ip` secret." + type = list(string) + default = null +} + +variable "webservices_protected_paths" { + description = "URI path fragments protected by the legacy BCSS webservices rule. Requests to these paths are blocked unless they originate from the webservices IP set." + type = list(string) + default = ["/bss/dashboardExtracts", "/bss/rawdatamigration"] +} + +variable "legacy_webservices_rule_capacity" { + description = "Capacity assigned to the legacy BCSS webservices rule group." + type = number + default = 100 +} + +variable "legacy_webservices_rule_priority" { + description = "Priority used when attaching the legacy BCSS webservices rule group to the web ACL." + type = number + default = 80 +} + +variable "legacy_geo_rule_priority" { + description = "Priority used for the legacy non-GB geo count rule." + type = number + default = 100 +} + +variable "waf_ips_secret_name" { + description = "Optional override for the secret containing the legacy excluded IP set addresses." + type = string + default = null +} +variable "waf_ips_secret_key" { + description = "JSON key inside `waf_ips_secret_name` containing the excluded IP addresses." + type = string + default = "ips" +} + +variable "waf_bsis_ip_range_secret_name" { + description = "Optional override for the secret containing the legacy webservices allowlist addresses." + type = string + default = null +} + +variable "waf_bsis_ip_range_secret_key" { + description = "JSON key inside `waf_bsis_ip_range_secret_name` containing the webservices allowlist addresses." + type = string + default = "bsis_ip" +} + +variable "waf_log_group_name" { + description = "CloudWatch log group name used for WAF logging. Must start with `aws-waf-logs-` for AWS WAF logging. Defaults to an `aws-waf-logs-` prefix derived from either `name_prefix` or the shared context id." + type = string + default = null +} + +variable "create_waf_log_group" { + description = "Whether to create a dedicated CloudWatch log group and wire it into WAF logging. Defaults to the same value as `enable_legacy_bcss_mode`." + type = bool + default = null +} + +variable "waf_log_retention_in_days" { + description = "Retention period for the WAF CloudWatch log group." + type = number + default = 365 } variable "aws_account_id" { + description = "AWS account id used by the legacy cross-account log subscription, Splunk subscription, and Shield alarming features." + type = string + default = null +} +variable "alert_sns_topic_name" { + description = "SNS topic name used by the legacy Shield DDoS alarm. Defaults to `name_prefix`." + type = string + default = null } -variable "name_prefix" { +variable "enable_central_logging_subscription" { + description = "Whether to create the legacy cross-account CloudWatch Logs subscription for the WAF log group. Defaults to the same value as `enable_legacy_bcss_mode`." + type = bool + default = null +} +variable "cloudwatch_cross_account_secret_name" { + description = "Optional override for the secret used to resolve the central logging account id for the legacy cross-account log subscription." + type = string + default = null } -variable "aws_region" { +variable "cloudwatch_cross_account_secret_key" { + description = "JSON key inside `cloudwatch_cross_account_secret_name` used to read the destination account id." + type = string + default = "central-logging" +} +variable "enable_splunk_logging_subscription" { + description = "Whether to create the legacy Splunk Firehose CloudWatch Logs subscription for the WAF log group. Defaults to the same value as `enable_legacy_bcss_mode`." + type = bool + default = null } -variable "webservices_ip_set_addresses" { - description = "List of IP addresses for web services" - type = list(string) +variable "enable_shield_ddos_alarming" { + description = "Whether to create the legacy Shield DDoS alarm and EventBridge forwarding resources. Defaults to the same value as `enable_legacy_bcss_mode`, but only in prod." + type = bool + default = null } -variable "environment" { - description = "Environment i.e prod, nonprod" + +variable "shield_event_bus_region" { + description = "Region containing the target EventBridge bus used by the legacy Shield DDoS forwarding rule." + type = string + default = "eu-west-2" }