Skip to content

Commit 1c0113a

Browse files
authored
Merge pull request #464 from NHSDigital/feature/eja-eli-384-adding-WAF-for-API-gateway
Feature/eja eli 384 adding waf for api gateway
2 parents 0f1328e + 49591da commit 1c0113a

File tree

6 files changed

+561
-2
lines changed

6 files changed

+561
-2
lines changed

infrastructure/stacks/api-layer/api_gateway.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ resource "aws_api_gateway_deployment" "eligibility_signposting_api" {
4646
resource "aws_api_gateway_stage" "eligibility-signposting-api" {
4747
#checkov:skip=CKV2_AWS_51: mTLS is enforced at the custom domain, not at the stage level
4848
#checkov:skip=CKV_AWS_120: We're not enabling caching for this API Gateway, yet
49+
#checkov:skip=CKV2_AWS_77: WAF with AWSManagedRulesKnownBadInputsRuleSet (Log4j protection) is attached via aws_wafv2_web_acl_association in waf.tf
4950
deployment_id = aws_api_gateway_deployment.eligibility_signposting_api.id
5051
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
5152
stage_name = "${local.workspace}-eligibility-signposting-api-live"

infrastructure/stacks/api-layer/locals.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ locals {
99
data.aws_ssm_parameter.mtls_api_client_cert.value,
1010
data.aws_ssm_parameter.mtls_api_ca_cert.value
1111
])
12+
13+
# Toggle for deploying WAF resources in the current environment
14+
# True when var.environment is contained in var.waf_enabled_environments
15+
waf_enabled = contains(var.waf_enabled_environments, var.environment)
1216
}

infrastructure/stacks/api-layer/variables.tf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ variable "SPLUNK_HEC_ENDPOINT" {
88
description = "The HEC endpoint url for ITOC splunk"
99
sensitive = true
1010
}
11+
12+
# WAF deployment environments (list of environment names where WAF should be deployed)
13+
variable "waf_enabled_environments" {
14+
type = list(string)
15+
description = "Environments in which WAF resources are deployed. Adjust to disable in test after evaluation."
16+
default = ["dev", "preprod"]
17+
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
# WAF Web ACL for API Gateway
2+
# Only deployed in production environment for cost optimization
3+
# Initially all rules are in COUNT mode to monitor traffic patterns
4+
5+
resource "aws_wafv2_web_acl" "api_gateway" {
6+
count = local.waf_enabled ? 1 : 0
7+
name = "${local.workspace}-eligibility-signposting-api-waf"
8+
description = "WAF Web ACL for Eligibility Signposting API Gateway - Production"
9+
scope = "REGIONAL"
10+
11+
default_action {
12+
allow {}
13+
}
14+
15+
# Rule 1: AWS Managed - Amazon IP Reputation List
16+
# Blocks requests from IP addresses known to be malicious
17+
rule {
18+
name = "AWSManagedRulesAmazonIpReputationList"
19+
priority = 10
20+
21+
override_action {
22+
count {} # Start in count mode - change to none {} when ready to block
23+
}
24+
25+
statement {
26+
managed_rule_group_statement {
27+
vendor_name = "AWS"
28+
name = "AWSManagedRulesAmazonIpReputationList"
29+
}
30+
}
31+
32+
visibility_config {
33+
cloudwatch_metrics_enabled = true
34+
metric_name = "AWSManagedRulesAmazonIpReputationList"
35+
sampled_requests_enabled = true
36+
}
37+
}
38+
39+
# Rule 2: AWS Managed - Core Rule Set (includes OWASP Top 10)
40+
# Protects against common web exploits
41+
rule {
42+
name = "AWSManagedRulesCommonRuleSet"
43+
priority = 20
44+
45+
override_action {
46+
count {} # Start in count mode - change to none {} when ready to block
47+
}
48+
49+
statement {
50+
managed_rule_group_statement {
51+
vendor_name = "AWS"
52+
name = "AWSManagedRulesCommonRuleSet"
53+
}
54+
}
55+
56+
visibility_config {
57+
cloudwatch_metrics_enabled = true
58+
metric_name = "AWSManagedRulesCommonRuleSet"
59+
sampled_requests_enabled = true
60+
}
61+
}
62+
63+
# Rule 3: AWS Managed - Known Bad Inputs
64+
# Blocks request patterns known to be invalid or associated with exploitation
65+
rule {
66+
name = "AWSManagedRulesKnownBadInputsRuleSet"
67+
priority = 30
68+
69+
# Enforce BLOCK for Known Bad Inputs to mitigate Log4j (CKV_AWS_192)
70+
override_action {
71+
none {}
72+
}
73+
74+
statement {
75+
managed_rule_group_statement {
76+
vendor_name = "AWS"
77+
name = "AWSManagedRulesKnownBadInputsRuleSet"
78+
}
79+
}
80+
81+
visibility_config {
82+
cloudwatch_metrics_enabled = true
83+
metric_name = "AWSManagedRulesKnownBadInputsRuleSet"
84+
sampled_requests_enabled = true
85+
}
86+
}
87+
88+
# Rule 4: Rate-Based Rule - Overall rate limiting
89+
# Protects against DDoS and brute force attacks
90+
# Default: 2000 requests per 5 minutes per IP (adjust based on your traffic)
91+
rule {
92+
name = "RateLimitRule"
93+
priority = 40
94+
95+
action {
96+
count {} # Start in count mode - change to block {} when ready
97+
}
98+
99+
statement {
100+
rate_based_statement {
101+
limit = 2000 # Requests per 5-minute period per IP
102+
aggregate_key_type = "IP"
103+
}
104+
}
105+
106+
visibility_config {
107+
cloudwatch_metrics_enabled = true
108+
metric_name = "RateLimitRule"
109+
sampled_requests_enabled = true
110+
}
111+
}
112+
113+
# Rule 5: Geographic Monitoring Rule - Monitor non-UK traffic (COUNT only)
114+
# NHS-specific requirement: initially monitor requests originating from outside GB
115+
# This rule COUNTS any request whose geo country code is not GB (does not block)
116+
rule {
117+
name = "MonitorNonUK"
118+
priority = 50
119+
120+
action {
121+
count {}
122+
}
123+
124+
statement {
125+
not_statement {
126+
statement {
127+
geo_match_statement {
128+
country_codes = ["GB"] # United Kingdom only (does NOT include Crown Dependencies)
129+
}
130+
}
131+
}
132+
}
133+
134+
visibility_config {
135+
cloudwatch_metrics_enabled = true
136+
metric_name = "MonitorNonUK"
137+
sampled_requests_enabled = true
138+
}
139+
}
140+
141+
visibility_config {
142+
cloudwatch_metrics_enabled = true
143+
metric_name = "${local.workspace}-eligibility-signposting-api-waf"
144+
sampled_requests_enabled = true
145+
}
146+
147+
tags = merge(
148+
local.tags,
149+
{
150+
Name = "${local.workspace}-eligibility-signposting-api-waf"
151+
Stack = local.stack_name
152+
Environment = var.environment
153+
Purpose = "API Gateway WAF protection"
154+
}
155+
)
156+
}
157+
158+
# Associate WAF with API Gateway Stage
159+
resource "aws_wafv2_web_acl_association" "api_gateway" {
160+
count = local.waf_enabled ? 1 : 0
161+
resource_arn = aws_api_gateway_stage.eligibility-signposting-api.arn
162+
web_acl_arn = aws_wafv2_web_acl.api_gateway[0].arn
163+
164+
depends_on = [
165+
aws_api_gateway_stage.eligibility-signposting-api,
166+
aws_wafv2_web_acl.api_gateway
167+
]
168+
}
169+
170+
# CloudWatch Log Group for WAF logs
171+
resource "aws_cloudwatch_log_group" "waf" {
172+
count = local.waf_enabled ? 1 : 0
173+
name = "/aws/wafv2/${local.workspace}-eligibility-signposting-api"
174+
retention_in_days = 365
175+
kms_key_id = aws_kms_key.waf_logs[0].arn
176+
177+
tags = merge(
178+
local.tags,
179+
{
180+
Name = "${local.workspace}-eligibility-signposting-api-waf-logs"
181+
Stack = local.stack_name
182+
Environment = var.environment
183+
}
184+
)
185+
186+
depends_on = [
187+
aws_kms_key_policy.waf_logs
188+
]
189+
}
190+
191+
# KMS Key for WAF logs encryption
192+
resource "aws_kms_key" "waf_logs" {
193+
count = local.waf_enabled ? 1 : 0
194+
description = "KMS key for WAF CloudWatch logs encryption"
195+
deletion_window_in_days = 14
196+
enable_key_rotation = true
197+
198+
tags = merge(
199+
local.tags,
200+
{
201+
Name = "${local.workspace}-waf-logs-kms-key"
202+
Stack = local.stack_name
203+
Environment = var.environment
204+
}
205+
)
206+
}
207+
208+
resource "aws_kms_alias" "waf_logs" {
209+
count = local.waf_enabled ? 1 : 0
210+
name = "alias/${local.workspace}-waf-logs"
211+
target_key_id = aws_kms_key.waf_logs[0].key_id
212+
}
213+
214+
resource "aws_kms_key_policy" "waf_logs" {
215+
count = local.waf_enabled ? 1 : 0
216+
key_id = aws_kms_key.waf_logs[0].id
217+
policy = data.aws_iam_policy_document.waf_logs_kms[0].json
218+
}
219+
220+
data "aws_iam_policy_document" "waf_logs_kms" {
221+
count = local.waf_enabled ? 1 : 0
222+
223+
statement {
224+
sid = "Enable IAM User Permissions"
225+
effect = "Allow"
226+
principals {
227+
type = "AWS"
228+
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
229+
}
230+
actions = [
231+
"kms:DescribeKey",
232+
"kms:GetKeyPolicy",
233+
"kms:GetKeyRotationStatus",
234+
"kms:ListAliases",
235+
"kms:ListGrants",
236+
"kms:ListKeyPolicies",
237+
"kms:ListResourceTags",
238+
"kms:PutKeyPolicy",
239+
"kms:ScheduleKeyDeletion",
240+
"kms:CancelKeyDeletion",
241+
"kms:UpdateKeyDescription",
242+
"kms:EnableKeyRotation",
243+
"kms:DisableKeyRotation",
244+
"kms:EnableKey",
245+
"kms:DisableKey",
246+
"kms:TagResource",
247+
"kms:UntagResource",
248+
"kms:CreateGrant",
249+
"kms:RevokeGrant",
250+
"kms:RetireGrant",
251+
"kms:ListGrants"
252+
]
253+
resources = [aws_kms_key.waf_logs[0].arn]
254+
}
255+
256+
statement {
257+
sid = "Allow CloudWatch Logs"
258+
effect = "Allow"
259+
principals {
260+
type = "Service"
261+
identifiers = ["logs.amazonaws.com"]
262+
}
263+
actions = [
264+
"kms:Encrypt",
265+
"kms:Decrypt",
266+
"kms:ReEncrypt*",
267+
"kms:GenerateDataKey*",
268+
"kms:CreateGrant",
269+
"kms:DescribeKey"
270+
]
271+
resources = [aws_kms_key.waf_logs[0].arn]
272+
condition {
273+
test = "ArnLike"
274+
variable = "kms:EncryptionContext:aws:logs:arn"
275+
values = ["arn:aws:logs:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/wafv2/*"]
276+
}
277+
}
278+
}
279+
280+
# Enable WAF logging to CloudWatch
281+
resource "aws_wafv2_web_acl_logging_configuration" "api_gateway" {
282+
count = local.waf_enabled ? 1 : 0
283+
resource_arn = aws_wafv2_web_acl.api_gateway[0].arn
284+
log_destination_configs = [aws_cloudwatch_log_group.waf[0].arn]
285+
286+
# Redact sensitive data from logs
287+
redacted_fields {
288+
single_header {
289+
name = "authorization"
290+
}
291+
}
292+
293+
redacted_fields {
294+
single_header {
295+
name = "cookie"
296+
}
297+
}
298+
299+
depends_on = [
300+
aws_cloudwatch_log_group.waf,
301+
aws_wafv2_web_acl.api_gateway
302+
]
303+
}

0 commit comments

Comments
 (0)