diff --git a/infrastructure/modules/waf/.terraform.lock.hcl b/infrastructure/modules/waf/.terraform.lock.hcl index 6e35c198..217e2f54 100644 --- a/infrastructure/modules/waf/.terraform.lock.hcl +++ b/infrastructure/modules/waf/.terraform.lock.hcl @@ -2,54 +2,29 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" - constraints = ">= 6.42.0" + version = "6.52.0" + constraints = ">= 6.2.0, >= 6.14.0, >= 6.42.0" hashes = [ - "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", - "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", - "h1:Uf2LlEibaBdksEUkOoiQbzEbkIgOR6tUE/0tCd36Xzk=", - "h1:gnyVeH3L2erQ/di0a4x5i0AlsIcdLjyK5+Vmbf3qyck=", - "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", - "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", - "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", - "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", - "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", - "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", - "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", - "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", - "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", - "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", - "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", - "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", - "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.14.0" - constraints = ">= 0.14.0" - hashes = [ - "h1:+nUGGWxp4gQCusc6Sa+FLSQfHUPWh7YaQV6qrWV9exo=", - "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", - "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", - "h1:Gra0WVbFgIWSbXrZNd+E4zlXsMooCSKeUTrkxqT7//c=", - "h1:dk9Ywmokq1TYodG7B7QVespzwwJeUOBujLaSKgd7M4k=", - "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", - "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", - "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", - "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", - "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", - "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", - "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", - "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", - "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", - "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", - "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", ] } diff --git a/infrastructure/modules/waf/README.md b/infrastructure/modules/waf/README.md new file mode 100644 index 00000000..b578e255 --- /dev/null +++ b/infrastructure/modules/waf/README.md @@ -0,0 +1,368 @@ +# WAF + +NHS Screening wrapper around the community +[`cloudposse/waf/aws`](https://registry.terraform.io/modules/cloudposse/waf/aws/latest) +module that consumes shared `context.tf` naming and tagging and leaves WAF rule +composition to the consumer. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Shared naming and tagging | Uses `context.tf` via `module.this` and forwards that context to the upstream module | +| No embedded platform rules | The module defines no managed rules, custom rules, or rule groups internally | +| Consumer-owned priorities | Consumers pass rule lists directly, avoiding collisions with module-defined priorities | +| External logging resources | Logging destinations are passed in as ARNs instead of being created in this module | +| Standard visibility defaults | `visibility_config` defaults to an enabled CloudWatch metric and sampling configuration | + +## Usage + +### Minimal WAF with consumer-defined managed rules + +```hcl +module "waf" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/waf?ref=" + + service = "bcss" + project = "portal" + environment = "prod" + name = "frontend" + + managed_rule_group_statement_rules = [ + { + name = "aws-common-rules" + priority = 10 + override_action = "none" + statement = { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "frontend-common-rules" + } + } + ] +} +``` + +### WAF with external log destination and custom byte-match rule + +```hcl +resource "aws_cloudwatch_log_group" "waf" { + name = "aws-waf-logs-frontend" + retention_in_days = 30 +} + +module "waf" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/waf?ref=" + + service = "bcss" + project = "portal" + environment = "prod" + name = "frontend" + + log_destination_configs = [aws_cloudwatch_log_group.waf.arn] + + byte_match_statement_rules = [ + { + name = "block-admin-path" + priority = 20 + action = "block" + statement = { + search_string = "/admin" + positional_constraint = "STARTS_WITH" + field_to_match = { + uri_path = true + } + text_transformation = [ + { + priority = 0 + type = "NONE" + } + ] + } + visibility_config = { + metric_name = "frontend-block-admin-path" + } + } + ] +} +``` + +### WAF associated to an existing load balancer + +```hcl +module "waf" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/waf?ref=" + + service = "bcss" + project = "portal" + environment = "prod" + name = "frontend" + + association_resource_arns = [module.alb.lb_arn] + + managed_rule_group_statement_rules = [ + { + name = "aws-common-rules" + priority = 10 + override_action = "none" + statement = { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + visibility_config = { + metric_name = "frontend-common-rules" + } + } + ] +} +``` + +### Path restriction via a consumer-managed rule group + +```hcl +resource "aws_wafv2_ip_set" "webservices_allowlist" { + name = "bcss-webservices-allowlist" + description = "Source addresses allowed to reach selected BCSS paths" + scope = "REGIONAL" + ip_address_version = "IPV4" + addresses = ["10.0.0.0/24"] +} + +resource "aws_wafv2_rule_group" "webservices_paths" { + name = "bcss-webservices-paths" + scope = "REGIONAL" + capacity = 100 + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "bcss-webservices-paths" + sampled_requests_enabled = true + } + + rule { + name = "limit-selected-paths" + priority = 1 + + action { + block {} + } + + statement { + and_statement { + statement { + or_statement { + statement { + byte_match_statement { + search_string = "/bss/dashboardExtracts" + + field_to_match { + uri_path {} + } + + positional_constraint = "CONTAINS" + + text_transformation { + priority = 0 + type = "NONE" + } + } + } + + statement { + byte_match_statement { + search_string = "/bss/rawdatamigration" + + field_to_match { + uri_path {} + } + + positional_constraint = "CONTAINS" + + text_transformation { + priority = 0 + type = "NONE" + } + } + } + } + } + + statement { + not_statement { + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.webservices_allowlist.arn + } + } + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "bcss-webservices-path-guard" + sampled_requests_enabled = true + } + } +} + +module "waf" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/waf?ref=" + + service = "bcss" + project = "portal" + environment = "prod" + name = "frontend" + + rule_group_reference_statement_rules = [ + { + name = "bcss-webservices-paths" + priority = 80 + override_action = "none" + statement = { + arn = aws_wafv2_rule_group.webservices_paths.arn + } + visibility_config = { + metric_name = "bcss-webservices-paths" + } + } + ] +} +``` + +## Conventions + +* This wrapper does not define any rules internally. Consumers are responsible + for supplying all WAF rules and ensuring that priorities are unique across all + rule lists they pass in. +* `visibility_config` defaults to enabled metrics and sampled requests using a + metric name derived from `module.this.id`. +* Logging destinations must be created outside this module and provided as ARNs + through `log_destination_configs`. +* The wrapper stays aligned with upstream input names so consumers can move + between this module and the upstream Cloud Posse module without learning a + second naming model. + +## What this module does NOT do + +* Create CloudWatch log groups, Firehose streams, S3 buckets, SNS topics, or + any other external logging or alerting resources. +* Inject Screening-wide default rules or rule groups. +* Automatically resolve consumer-provided rule priority conflicts. + +## Validation + +To catch configuration errors before deployment, be aware of these constraints: + +* **Rule priority uniqueness**: All rules across all rule lists must have unique + priority values. Terraform will not validate this—violations will only fail at + AWS API time. Use local validation or pre-deployment checks to ensure + priorities are unique. +* **Metric name format**: `visibility_config.metric_name` must comply with + CloudWatch metrics naming (alphanumeric, _, -, .). +* **Rule naming**: All rule names must be unique within the web ACL. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [this](#module\_this) | ../tags | n/a | +| [waf](#module\_waf) | cloudposse/waf/aws | 1.17.0 | + +## Resources + +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [association\_resource\_arns](#input\_association\_resource\_arns) | List of resource ARNs to associate with this web ACL, such as an ALB, API Gateway stage, or AppSync resource. | `list(string)` | `[]` | 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 | +| [byte\_match\_statement\_rules](#input\_byte\_match\_statement\_rules) | Byte match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | 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 | +| [custom\_response\_body](#input\_custom\_response\_body) | Custom response bodies that can be referenced by block actions. |
map(object({
content = string
content_type = string
}))
| `{}` | 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 | +| [default\_action](#input\_default\_action) | Default action for requests that do not match any rule. Valid values are allow or block. | `string` | `"allow"` | no | +| [default\_block\_custom\_response\_body\_key](#input\_default\_block\_custom\_response\_body\_key) | Custom response body key to use when default\_action is block. | `string` | `null` | no | +| [default\_block\_response](#input\_default\_block\_response) | HTTP status code to return when default\_action is block. | `string` | `null` | 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) | Friendly description of the WAF web ACL. | `string` | `"Managed by Terraform"` | 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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [geo\_allowlist\_statement\_rules](#input\_geo\_allowlist\_statement\_rules) | Geo allowlist rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [geo\_match\_statement\_rules](#input\_geo\_match\_statement\_rules) | Geo match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [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 | +| [ip\_set\_reference\_statement\_rules](#input\_ip\_set\_reference\_statement\_rules) | IP set reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), 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 | +| [log\_destination\_configs](#input\_log\_destination\_configs) | Destination ARNs for WAF logging. Create log groups, Firehose streams, or S3 buckets outside this module and pass their ARNs here. | `list(string)` | `[]` | no | +| [logging\_filter](#input\_logging\_filter) | Optional WAF logging filter configuration passed directly to the upstream module. |
object({
default_behavior = string
filter = list(object({
behavior = string
requirement = string
condition = list(object({
action_condition = optional(object({
action = string
}), null)
label_name_condition = optional(object({
label_name = string
}), null)
}))
}))
})
| `null` | no | +| [managed\_rule\_group\_statement\_rules](#input\_managed\_rule\_group\_statement\_rules) | Managed rule group rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
override_action = optional(string)
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = object({
name = string
vendor_name = string
scope_down_not_statement_enabled = optional(bool, false)
scope_down_statement = optional(object({
byte_match_statement = object({
positional_constraint = string
search_string = string
field_to_match = object({
all_query_arguments = optional(bool)
body = optional(bool)
method = optional(bool)
query_string = optional(bool)
single_header = optional(object({ name = string }))
single_query_argument = optional(object({ name = string }))
uri_path = optional(bool)
})
text_transformation = list(object({
priority = number
type = string
}))
})
}), null)
version = optional(string)
rule_action_override = optional(map(object({
action = string
custom_request_handling = optional(object({
insert_header = object({
name = string
value = string
})
}), null)
custom_response = optional(object({
response_code = string
response_header = optional(object({
name = string
value = string
}), null)
}), null)
})), null)
managed_rule_group_configs = optional(list(object({
aws_managed_rules_anti_ddos_rule_set = optional(object({
sensitivity_to_block = optional(string)
client_side_action_config = optional(object({
challenge = object({
usage_of_action = string
sensitivity = optional(string)
exempt_uri_regular_expression = optional(list(object({
regex_string = string
})))
})
}))
}))
aws_managed_rules_bot_control_rule_set = optional(object({
inspection_level = string
enable_machine_learning = optional(bool, true)
}), null)
aws_managed_rules_atp_rule_set = optional(object({
enable_regex_in_path = optional(bool)
login_path = string
request_inspection = optional(object({
payload_type = string
password_field = object({
identifier = string
})
username_field = object({
identifier = string
})
}), null)
response_inspection = optional(object({
body_contains = optional(object({
success_strings = list(string)
failure_strings = list(string)
}), null)
header = optional(object({
name = string
success_values = list(string)
failure_values = list(string)
}), null)
json = optional(object({
identifier = string
success_strings = list(string)
failure_strings = list(string)
}), null)
status_code = optional(object({
success_codes = list(string)
failure_codes = list(string)
}), null)
}), null)
}), null)
aws_managed_rules_acfp_rule_set = optional(object({
creation_path = string
enable_regex_in_path = optional(bool)
registration_page_path = string
request_inspection = optional(object({
payload_type = string
password_field = optional(object({
identifier = string
}), null)
username_field = optional(object({
identifier = string
}), null)
email_field = optional(object({
identifier = string
}), null)
address_fields = optional(object({
identifiers = list(string)
}), null)
phone_number_fields = optional(object({
identifiers = list(string)
}), null)
}), null)
response_inspection = optional(object({
body_contains = optional(object({
success_strings = list(string)
failure_strings = list(string)
}), null)
header = optional(object({
name = string
success_values = list(string)
failure_values = list(string)
}), null)
json = optional(object({
identifier = string
success_values = list(string)
failure_values = list(string)
}), null)
status_code = optional(object({
success_codes = list(string)
failure_codes = list(string)
}), null)
}), null)
}))
})), null)
})
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | 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 | +| [nested\_statement\_rules](#input\_nested\_statement\_rules) | Nested statement rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = object({
and_statement = object({
statements = list(object({
type = string
statement = string
}))
})
})
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), 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 | +| [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 | +| [rate\_based\_statement\_rules](#input\_rate\_based\_statement\_rules) | Rate-based rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = object({
limit = number
aggregate_key_type = string
evaluation_window_sec = optional(number)
forwarded_ip_config = optional(object({
fallback_behavior = string
header_name = string
}), null)
custom_key = optional(list(object({
ip = optional(object({}), null)
header = optional(object({
name = string
text_transformation = list(object({
priority = number
type = string
}))
}), null)
})), null)
scope_down_statement = optional(object({
byte_match_statement = object({
positional_constraint = string
search_string = string
field_to_match = object({
all_query_arguments = optional(bool)
body = optional(bool)
method = optional(bool)
query_string = optional(bool)
single_header = optional(object({ name = string }))
single_query_argument = optional(object({ name = string }))
uri_path = optional(bool)
})
text_transformation = list(object({
priority = number
type = string
}))
})
}), null)
})
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [redacted\_fields](#input\_redacted\_fields) | Optional log redaction settings passed directly to the upstream module. |
map(object({
method = optional(bool, false)
uri_path = optional(bool, false)
query_string = optional(bool, false)
single_header = optional(list(string), null)
}))
| `{}` | no | +| [regex\_match\_statement\_rules](#input\_regex\_match\_statement\_rules) | Regex match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [regex\_pattern\_set\_reference\_statement\_rules](#input\_regex\_pattern\_set\_reference\_statement\_rules) | Regex pattern set reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | 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\_group\_reference\_statement\_rules](#input\_rule\_group\_reference\_statement\_rules) | Rule group reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
override_action = optional(string)
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = object({
arn = string
rule_action_override = optional(map(object({
action = string
custom_request_handling = optional(object({
insert_header = object({
name = string
value = string
})
}), null)
custom_response = optional(object({
response_code = string
response_header = optional(object({
name = string
value = string
}), null)
}), null)
})), null)
})
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [scope](#input\_scope) | Whether the web ACL is regional or for CloudFront. | `string` | `"REGIONAL"` | 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 | +| [size\_constraint\_statement\_rules](#input\_size\_constraint\_statement\_rules) | Size constraint rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
custom_response = optional(object({
response_code = string
custom_response_body_key = optional(string, null)
response_header = optional(object({
name = string
value = string
}), null)
}), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [sqli\_match\_statement\_rules](#input\_sqli\_match\_statement\_rules) | SQL injection match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | 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 the caller module path when not set. | `string` | `null` | no | +| [token\_domains](#input\_token\_domains) | Optional list of token domains accepted by AWS WAF for cross-domain token usage. | `list(string)` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [visibility\_config](#input\_visibility\_config) | Visibility configuration for the web ACL. Leave null to use the module default metric name and sampling settings. |
object({
cloudwatch_metrics_enabled = bool
metric_name = string
sampled_requests_enabled = bool
})
| `null` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | +| [xss\_match\_statement\_rules](#input\_xss\_match\_statement\_rules) | Cross-site scripting match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists. |
list(object({
name = string
priority = number
action = string
captcha_config = optional(object({
immunity_time_property = object({
immunity_time = number
})
}), null)
rule_label = optional(list(string), null)
statement = any
visibility_config = optional(object({
cloudwatch_metrics_enabled = optional(bool)
metric_name = string
sampled_requests_enabled = optional(bool)
}), null)
}))
| `[]` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [logging\_config\_id](#output\_logging\_config\_id) | ARN of the WAF logging configuration when logging is enabled. | +| [web\_acl\_arn](#output\_web\_acl\_arn) | ARN of the WAF web ACL. | +| [web\_acl\_capacity](#output\_web\_acl\_capacity) | Current WAF capacity usage in WCUs. | +| [web\_acl\_id](#output\_web\_acl\_id) | ID of the WAF web ACL. | + + + diff --git a/infrastructure/modules/waf/context.tf b/infrastructure/modules/waf/context.tf new file mode 100644 index 00000000..dd1e2a1f --- /dev/null +++ b/infrastructure/modules/waf/context.tf @@ -0,0 +1,376 @@ +# 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 +# +# +# 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" + + enabled = var.enabled + 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 = {} + 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/waf/locals.tf b/infrastructure/modules/waf/locals.tf new file mode 100644 index 00000000..8994ee61 --- /dev/null +++ b/infrastructure/modules/waf/locals.tf @@ -0,0 +1,14 @@ +locals { + visibility_config = var.visibility_config != null ? var.visibility_config : { + cloudwatch_metrics_enabled = true + metric_name = module.this.id + sampled_requests_enabled = true + } + + # Cloud Posse modules expect namespace/stage/tenant context fields. + cloudposse_context = merge(module.this.context, { + namespace = module.this.service + stage = module.this.environment + tenant = module.this.project + }) +} diff --git a/infrastructure/modules/waf/main.tf b/infrastructure/modules/waf/main.tf index b098ac2a..c8701c5d 100644 --- a/infrastructure/modules/waf/main.tf +++ b/infrastructure/modules/waf/main.tf @@ -1,558 +1,41 @@ - -# tflint-ignore: terraform_naming_convention -data "aws_secretsmanager_secret_version" "waf_ips" { - secret_id = "${var.name_prefix}-waf-ip-set" -} -# tflint-ignore: terraform_naming_convention -data "aws_secretsmanager_secret_version" "waf_bsis_ip_range" { - secret_id = "${var.name_prefix}-waf-bsis-ip" -} - -data "aws_sns_topic" "alert" { - name = var.name_prefix -} - -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 -} - -####################### -# 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 -##### -# tflint-ignore: terraform_naming_convention -resource "aws_wafv2_ip_set" "bs-select-exclude-ip-set" { - name = var.exclude_ip_set_name - description = "This set of IPs are excluded from Anonymous and linux rule" - scope = "REGIONAL" - ip_address_version = "IPV4" - addresses = local.ip_list -} - -#########For web Services add/remove on tfvars######### -# tflint-ignore: terraform_naming_convention -resource "aws_wafv2_ip_set" "bs-select-webservices-ip-set" { - name = var.web_services_ip_set_name - description = "This set of IPs are excluded from Anonymous and linux rule" - scope = "REGIONAL" - ip_address_version = "IPV4" - addresses = local.bsis_ips -} - -###################### -# WAF -###################### - - -# tflint-ignore: terraform_naming_convention -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" - sampled_requests_enabled = true - } - - # Custom rule for paths and IP set exclusion - rule { - name = "bss-webservices-rule" - priority = 80 - - 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 - } - } - } - } - } - } - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "bss-webservices-rule" - sampled_requests_enabled = true - } - } - - # Base rules for all service teams - rule { - name = "${var.name_prefix}-aws-common-rule-set" - priority = 10 - - override_action { - count {} - } - - 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 - } - } - - } - } - } - } - - 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 { - not_statement { - statement { - ip_set_reference_statement { - arn = aws_wafv2_ip_set.bs-select-exclude-ip-set.arn - } - } - - } - } - } - } - - - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "${var.name_prefix}-waf-aws-linux-rule-set-metric" - 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 -} - -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 -} -# 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 -} - -data "aws_iam_policy_document" "central_logs_assume_role" { - statement { - sid = "centralLogsAssumeRole" - effect = "Allow" - actions = ["sts:AssumeRole"] - - principals { - type = "Service" - identifiers = ["logs.${var.aws_region}.amazonaws.com"] - } - } -} - - - -# 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 -} - -data "aws_iam_policy_document" "central_cw_subscription_doc_policy" { - statement { - actions = [ - "logs:PutLogEvents" - ] - resources = [ - "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:aws-waf-logs-${var.name_prefix}:*" - ] - } -} - -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 -} - -# tflint-ignore: terraform_naming_convention -data "aws_secretsmanager_secret" "cloudwatch-cross-accounts" { - name = "${var.name_prefix}-cloudwatch-cross-account-logging" -} - -# tflint-ignore: terraform_naming_convention -data "aws_secretsmanager_secret_version" "cloudwatch-cross-accounts" { - secret_id = data.aws_secretsmanager_secret.cloudwatch-cross-accounts.id -} - -locals { - cross_account_id = jsondecode(data.aws_secretsmanager_secret_version.cloudwatch-cross-accounts.secret_string)["central-logging"] -} - -resource "time_sleep" "wait_30_seconds" { - 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 - 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 - filter_pattern = "" - destination_arn = "arn:aws:firehose:${var.aws_region}:${var.aws_account_id}:deliverystream/${var.name_prefix}-cw-logs-firehose" - distribution = "ByLogStream" - - 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" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "TrustEventBridgeService" - Effect = "Allow" - Principal = { - Service = "events.amazonaws.com" - } - Action = "sts:AssumeRole" - Condition = { - StringEquals = { - "aws:SourceAccount" = var.aws_account_id - } - } - } - ] - }) -} -resource "aws_iam_role_policy" "eventbridge_put_events" { - count = contains(["prod"], var.environment) ? 1 : 0 - - name = "${var.name_prefix}-eventbridge-put-events" - role = aws_iam_role.eventbridge_role[0].id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "ActionsForResource" - Effect = "Allow" - Action = [ - "events:PutEvents" - ] - Resource = [ - "arn:aws:events:eu-west-2:${local.cross_account_id}:event-bus/shield-eventbus" - ] - } - ] - }) -} - -resource "aws_cloudwatch_metric_alarm" "shield_ddos_alarm" { - count = contains(["prod"], var.environment) ? 1 : 0 - alarm_name = "${var.name_prefix}_shield_ddos_WAF" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 20 - datapoints_to_alarm = 1 - metric_name = "DDoSDetected" - namespace = "AWS/DDoSProtection" - period = 60 - statistic = "Maximum" - threshold = 0 - treat_missing_data = "notBreaching" - - dimensions = { - ResourceArn = aws_wafv2_web_acl.bss-waf-acl.arn - } - - alarm_actions = [data.aws_sns_topic.alert.arn] - ok_actions = [data.aws_sns_topic.alert.arn] - insufficient_data_actions = [] - - alarm_description = "Alarm triggers when Shield Advanced detects a DDoS attack on production WAF" -} - -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" - - event_pattern = jsonencode({ - source = ["aws.cloudwatch"] - "detail-type" = ["CloudWatch Alarm State Change"] - resources = [aws_cloudwatch_metric_alarm.shield_ddos_alarm[0].arn] - }) -} - -resource "aws_cloudwatch_event_target" "shield_ddos_target" { - count = contains(["prod"], var.environment) ? 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 +################################################################ +# WAF +# +# Thin NHS wrapper around `cloudposse/waf/aws` that keeps naming and +# tagging aligned with the shared `context.tf` pattern while leaving +# rules, rule groups, associations, and logging destinations to the +# consumer. +################################################################ + +module "waf" { + source = "cloudposse/waf/aws" + version = "1.17.0" + + association_resource_arns = var.association_resource_arns + byte_match_statement_rules = var.byte_match_statement_rules + custom_response_body = var.custom_response_body + default_action = var.default_action + default_block_custom_response_body_key = var.default_block_custom_response_body_key + default_block_response = var.default_block_response + description = var.description + geo_allowlist_statement_rules = var.geo_allowlist_statement_rules + geo_match_statement_rules = var.geo_match_statement_rules + ip_set_reference_statement_rules = var.ip_set_reference_statement_rules + log_destination_configs = var.log_destination_configs + logging_filter = var.logging_filter + managed_rule_group_statement_rules = var.managed_rule_group_statement_rules + nested_statement_rules = var.nested_statement_rules + rate_based_statement_rules = var.rate_based_statement_rules + redacted_fields = var.redacted_fields + regex_match_statement_rules = var.regex_match_statement_rules + regex_pattern_set_reference_statement_rules = var.regex_pattern_set_reference_statement_rules + rule_group_reference_statement_rules = var.rule_group_reference_statement_rules + scope = var.scope + size_constraint_statement_rules = var.size_constraint_statement_rules + sqli_match_statement_rules = var.sqli_match_statement_rules + token_domains = var.token_domains + visibility_config = local.visibility_config + xss_match_statement_rules = var.xss_match_statement_rules + + context = local.cloudposse_context } diff --git a/infrastructure/modules/waf/outputs.tf b/infrastructure/modules/waf/outputs.tf index a9c43803..af09ad71 100644 --- a/infrastructure/modules/waf/outputs.tf +++ b/infrastructure/modules/waf/outputs.tf @@ -1,4 +1,19 @@ output "web_acl_arn" { - description = "ARN of the WAFv2 web ACL." - 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 } diff --git a/infrastructure/modules/waf/readme.md b/infrastructure/modules/waf/readme.md deleted file mode 100644 index ad5be66e..00000000 --- a/infrastructure/modules/waf/readme.md +++ /dev/null @@ -1,74 +0,0 @@ -# WAF - - - - -## Requirements - -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.13 | -| [aws](#requirement\_aws) | >= 6.42 | -| [time](#requirement\_time) | >= 0.14 | - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | -| [time](#provider\_time) | 0.14.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---- | ---- | -| [aws_cloudwatch_event_rule.shield_ddos_rule](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | -| [aws_cloudwatch_event_target.shield_ddos_target](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | -| [aws_cloudwatch_log_group.waf_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_cloudwatch_log_subscription_filter.central_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_subscription_filter) | resource | -| [aws_cloudwatch_log_subscription_filter.splunk_subscr_filter](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_subscription_filter) | resource | -| [aws_cloudwatch_metric_alarm.shield_ddos_alarm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm) | resource | -| [aws_iam_policy.central_cw_subscription_iam_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | -| [aws_iam_role.cw_to_subscription_filter_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role.eventbridge_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.eventbridge_put_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.central_logging_att](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_wafv2_ip_set.bs-select-exclude-ip-set](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_ip_set) | resource | -| [aws_wafv2_ip_set.bs-select-webservices-ip-set](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_ip_set) | resource | -| [aws_wafv2_web_acl.bss-waf-acl](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_web_acl) | resource | -| [aws_wafv2_web_acl_logging_configuration.waf_acl_lc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_web_acl_logging_configuration) | resource | -| [time_sleep.wait_30_seconds](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | -| [aws_iam_policy_document.central_cw_subscription_doc_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.central_logs_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_secretsmanager_secret.cloudwatch-cross-accounts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/secretsmanager_secret) | data source | -| [aws_secretsmanager_secret_version.cloudwatch-cross-accounts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/secretsmanager_secret_version) | data source | -| [aws_secretsmanager_secret_version.waf_bsis_ip_range](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/secretsmanager_secret_version) | data source | -| [aws_secretsmanager_secret_version.waf_ips](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/secretsmanager_secret_version) | data source | -| [aws_sns_topic.alert](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/sns_topic) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [aws\_account\_id](#input\_aws\_account\_id) | AWS account ID used in IAM and logging integrations. | `string` | n/a | yes | -| [aws\_region](#input\_aws\_region) | AWS region where WAF resources are deployed. | `string` | n/a | yes | -| [environment](#input\_environment) | Environment i.e prod, nonprod | `string` | n/a | yes | -| [exclude\_ip\_set\_name](#input\_exclude\_ip\_set\_name) | Service | `string` | n/a | yes | -| [name\_prefix](#input\_name\_prefix) | Prefix used for naming related resources. | `string` | n/a | yes | -| [waf\_log\_group\_name](#input\_waf\_log\_group\_name) | waf log group | `string` | n/a | yes | -| [waf\_name](#input\_waf\_name) | waf name | `string` | n/a | yes | -| [web\_services\_ip\_set\_name](#input\_web\_services\_ip\_set\_name) | Name of the IP set for web service source addresses. | `string` | n/a | yes | -| [webservices\_ip\_set\_addresses](#input\_webservices\_ip\_set\_addresses) | List of IP addresses for web services | `list(string)` | n/a | yes | - -## Outputs - -| Name | Description | -| ---- | ----------- | -| [web\_acl\_arn](#output\_web\_acl\_arn) | ARN of the WAFv2 web ACL. | - - - diff --git a/infrastructure/modules/waf/validations.tf b/infrastructure/modules/waf/validations.tf new file mode 100644 index 00000000..69ff1198 --- /dev/null +++ b/infrastructure/modules/waf/validations.tf @@ -0,0 +1,81 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * Rule priority uniqueness across all rule lists +# * Rule name uniqueness within the web ACL +# * CloudWatch metric name format for all visibility configs +################################################################ + +locals { + all_rule_priorities = concat( + [for r in var.byte_match_statement_rules : r.priority], + [for r in var.geo_allowlist_statement_rules : r.priority], + [for r in var.geo_match_statement_rules : r.priority], + [for r in var.ip_set_reference_statement_rules : r.priority], + [for r in var.managed_rule_group_statement_rules : r.priority], + [for r in var.nested_statement_rules : r.priority], + [for r in var.rate_based_statement_rules : r.priority], + [for r in var.regex_match_statement_rules : r.priority], + [for r in var.regex_pattern_set_reference_statement_rules : r.priority], + [for r in var.rule_group_reference_statement_rules : r.priority], + [for r in var.size_constraint_statement_rules : r.priority], + [for r in var.sqli_match_statement_rules : r.priority], + [for r in var.xss_match_statement_rules : r.priority], + ) + + all_rule_names = concat( + [for r in var.byte_match_statement_rules : r.name], + [for r in var.geo_allowlist_statement_rules : r.name], + [for r in var.geo_match_statement_rules : r.name], + [for r in var.ip_set_reference_statement_rules : r.name], + [for r in var.managed_rule_group_statement_rules : r.name], + [for r in var.nested_statement_rules : r.name], + [for r in var.rate_based_statement_rules : r.name], + [for r in var.regex_match_statement_rules : r.name], + [for r in var.regex_pattern_set_reference_statement_rules : r.name], + [for r in var.rule_group_reference_statement_rules : r.name], + [for r in var.size_constraint_statement_rules : r.name], + [for r in var.sqli_match_statement_rules : r.name], + [for r in var.xss_match_statement_rules : r.name], + ) + + # Explicit metric names from per-rule visibility_config blocks, plus the web ACL-level metric name. + all_metric_names = concat( + [for r in var.byte_match_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.geo_allowlist_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.geo_match_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.ip_set_reference_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.managed_rule_group_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.nested_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.rate_based_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.regex_match_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.regex_pattern_set_reference_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.rule_group_reference_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.size_constraint_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.sqli_match_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [for r in var.xss_match_statement_rules : r.visibility_config.metric_name if r.visibility_config != null], + [local.visibility_config.metric_name], + ) +} + +resource "terraform_data" "validations" { + lifecycle { + precondition { + condition = length(local.all_rule_priorities) == length(toset(local.all_rule_priorities)) + error_message = "All WAF rule priorities must be unique across all rule lists. Check for duplicate priority values." + } + + precondition { + condition = length(local.all_rule_names) == length(toset(local.all_rule_names)) + error_message = "All WAF rule names must be unique within the web ACL. Check for duplicate rule names." + } + + precondition { + condition = alltrue([for m in local.all_metric_names : can(regex("^[-a-zA-Z0-9_.]+$", m))]) + error_message = "All visibility_config.metric_name values must contain only alphanumeric characters, underscores (_), hyphens (-), or periods (.)." + } + } +} diff --git a/infrastructure/modules/waf/variables.tf b/infrastructure/modules/waf/variables.tf index 2fa09c1b..9e8b9a4a 100644 --- a/infrastructure/modules/waf/variables.tf +++ b/infrastructure/modules/waf/variables.tf @@ -1,46 +1,647 @@ -variable "waf_log_group_name" { - description = "waf log group" +################################################################ +# Web ACL +################################################################ + +variable "description" { + description = "Friendly description of the WAF web ACL." type = string + default = "Managed by Terraform" } -variable "waf_name" { - description = "waf name" +variable "default_action" { + description = "Default action for requests that do not match any rule. Valid values are allow or block." type = string -} + default = "allow" + validation { + condition = contains(["allow", "block"], var.default_action) + error_message = "default_action must be either allow or block." + } +} -variable "exclude_ip_set_name" { - description = "Service" +variable "scope" { + description = "Whether the 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 "web_services_ip_set_name" { - description = "Name of the IP set for web service source addresses." - type = string +variable "visibility_config" { + description = "Visibility configuration for the web ACL. Leave null to use the module default metric name and sampling settings." + type = object({ + cloudwatch_metrics_enabled = bool + metric_name = string + sampled_requests_enabled = bool + }) + default = null } -variable "aws_account_id" { - description = "AWS account ID used in IAM and logging integrations." - type = string +variable "association_resource_arns" { + description = "List of resource ARNs to associate with this web ACL, such as an ALB, API Gateway stage, or AppSync resource." + type = list(string) + default = [] +} + +variable "token_domains" { + description = "Optional list of token domains accepted by AWS WAF for cross-domain token usage." + type = list(string) + default = null } -variable "name_prefix" { - description = "Prefix used for naming related resources." +################################################################ +# Logging +################################################################ + +variable "log_destination_configs" { + description = "Destination ARNs for WAF logging. Create log groups, Firehose streams, or S3 buckets outside this module and pass their ARNs here." + type = list(string) + default = [] +} + +variable "logging_filter" { + description = "Optional WAF logging filter configuration passed directly to the upstream module." + type = object({ + default_behavior = string + filter = list(object({ + behavior = string + requirement = string + condition = list(object({ + action_condition = optional(object({ + action = string + }), null) + label_name_condition = optional(object({ + label_name = string + }), null) + })) + })) + }) + default = null +} + +variable "redacted_fields" { + description = "Optional log redaction settings passed directly to the upstream module." + type = map(object({ + method = optional(bool, false) + uri_path = optional(bool, false) + query_string = optional(bool, false) + single_header = optional(list(string), null) + })) + default = {} +} + +################################################################ +# Custom responses +################################################################ + +variable "custom_response_body" { + description = "Custom response bodies that can be referenced by block actions." + type = map(object({ + content = string + content_type = string + })) + default = {} +} + +variable "default_block_response" { + description = "HTTP status code to return when default_action is block." type = string + default = null } -variable "aws_region" { - description = "AWS region where WAF resources are deployed." +variable "default_block_custom_response_body_key" { + description = "Custom response body key to use when default_action is block." type = string + default = null } -# tflint-ignore: terraform_unused_declarations -variable "webservices_ip_set_addresses" { - description = "List of IP addresses for web services" - type = list(string) +################################################################ +# Rule inputs +################################################################ + +variable "byte_match_statement_rules" { + description = "Byte match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] } -variable "environment" { - description = "Environment i.e prod, nonprod" - type = string +variable "geo_allowlist_statement_rules" { + description = "Geo allowlist rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "geo_match_statement_rules" { + description = "Geo match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "ip_set_reference_statement_rules" { + description = "IP set reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "managed_rule_group_statement_rules" { + description = "Managed rule group rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + override_action = optional(string) + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = object({ + name = string + vendor_name = string + scope_down_not_statement_enabled = optional(bool, false) + scope_down_statement = optional(object({ + byte_match_statement = object({ + positional_constraint = string + search_string = string + field_to_match = object({ + all_query_arguments = optional(bool) + body = optional(bool) + method = optional(bool) + query_string = optional(bool) + single_header = optional(object({ name = string })) + single_query_argument = optional(object({ name = string })) + uri_path = optional(bool) + }) + text_transformation = list(object({ + priority = number + type = string + })) + }) + }), null) + version = optional(string) + rule_action_override = optional(map(object({ + action = string + custom_request_handling = optional(object({ + insert_header = object({ + name = string + value = string + }) + }), null) + custom_response = optional(object({ + response_code = string + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + })), null) + managed_rule_group_configs = optional(list(object({ + aws_managed_rules_anti_ddos_rule_set = optional(object({ + sensitivity_to_block = optional(string) + client_side_action_config = optional(object({ + challenge = object({ + usage_of_action = string + sensitivity = optional(string) + exempt_uri_regular_expression = optional(list(object({ + regex_string = string + }))) + }) + })) + })) + aws_managed_rules_bot_control_rule_set = optional(object({ + inspection_level = string + enable_machine_learning = optional(bool, true) + }), null) + aws_managed_rules_atp_rule_set = optional(object({ + enable_regex_in_path = optional(bool) + login_path = string + request_inspection = optional(object({ + payload_type = string + password_field = object({ + identifier = string + }) + username_field = object({ + identifier = string + }) + }), null) + response_inspection = optional(object({ + body_contains = optional(object({ + success_strings = list(string) + failure_strings = list(string) + }), null) + header = optional(object({ + name = string + success_values = list(string) + failure_values = list(string) + }), null) + json = optional(object({ + identifier = string + success_strings = list(string) + failure_strings = list(string) + }), null) + status_code = optional(object({ + success_codes = list(string) + failure_codes = list(string) + }), null) + }), null) + }), null) + aws_managed_rules_acfp_rule_set = optional(object({ + creation_path = string + enable_regex_in_path = optional(bool) + registration_page_path = string + request_inspection = optional(object({ + payload_type = string + password_field = optional(object({ + identifier = string + }), null) + username_field = optional(object({ + identifier = string + }), null) + email_field = optional(object({ + identifier = string + }), null) + address_fields = optional(object({ + identifiers = list(string) + }), null) + phone_number_fields = optional(object({ + identifiers = list(string) + }), null) + }), null) + response_inspection = optional(object({ + body_contains = optional(object({ + success_strings = list(string) + failure_strings = list(string) + }), null) + header = optional(object({ + name = string + success_values = list(string) + failure_values = list(string) + }), null) + json = optional(object({ + identifier = string + success_values = list(string) + failure_values = list(string) + }), null) + status_code = optional(object({ + success_codes = list(string) + failure_codes = list(string) + }), null) + }), null) + })) + })), null) + }) + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "nested_statement_rules" { + description = "Nested statement rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = object({ + and_statement = object({ + statements = list(object({ + type = string + statement = string + })) + }) + }) + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "rate_based_statement_rules" { + description = "Rate-based rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = object({ + limit = number + aggregate_key_type = string + evaluation_window_sec = optional(number) + forwarded_ip_config = optional(object({ + fallback_behavior = string + header_name = string + }), null) + custom_key = optional(list(object({ + ip = optional(object({}), null) + header = optional(object({ + name = string + text_transformation = list(object({ + priority = number + type = string + })) + }), null) + })), null) + scope_down_statement = optional(object({ + byte_match_statement = object({ + positional_constraint = string + search_string = string + field_to_match = object({ + all_query_arguments = optional(bool) + body = optional(bool) + method = optional(bool) + query_string = optional(bool) + single_header = optional(object({ name = string })) + single_query_argument = optional(object({ name = string })) + uri_path = optional(bool) + }) + text_transformation = list(object({ + priority = number + type = string + })) + }) + }), null) + }) + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "regex_match_statement_rules" { + description = "Regex match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "regex_pattern_set_reference_statement_rules" { + description = "Regex pattern set reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "rule_group_reference_statement_rules" { + description = "Rule group reference rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + override_action = optional(string) + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = object({ + arn = string + rule_action_override = optional(map(object({ + action = string + custom_request_handling = optional(object({ + insert_header = object({ + name = string + value = string + }) + }), null) + custom_response = optional(object({ + response_code = string + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + })), null) + }) + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "size_constraint_statement_rules" { + description = "Size constraint rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + custom_response = optional(object({ + response_code = string + custom_response_body_key = optional(string, null) + response_header = optional(object({ + name = string + value = string + }), null) + }), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "sqli_match_statement_rules" { + description = "SQL injection match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] +} + +variable "xss_match_statement_rules" { + description = "Cross-site scripting match rules passed directly to the upstream module. Consumers must ensure rule priorities are unique across all rule lists." + type = list(object({ + name = string + priority = number + action = string + captcha_config = optional(object({ + immunity_time_property = object({ + immunity_time = number + }) + }), null) + rule_label = optional(list(string), null) + statement = any + visibility_config = optional(object({ + cloudwatch_metrics_enabled = optional(bool) + metric_name = string + sampled_requests_enabled = optional(bool) + }), null) + })) + default = [] } diff --git a/infrastructure/modules/waf/versions.tf b/infrastructure/modules/waf/versions.tf index 68585825..cb30fe5c 100644 --- a/infrastructure/modules/waf/versions.tf +++ b/infrastructure/modules/waf/versions.tf @@ -6,9 +6,5 @@ terraform { source = "hashicorp/aws" version = ">= 6.42" } - time = { - source = "hashicorp/time" - version = ">= 0.14" - } } }