Skip to content

Commit c84269b

Browse files
starfolkbotclaude
andcommitted
Harden STS calls with VPC endpoints
Add configurable interface VPC endpoints (STS, EC2, CloudWatch Logs) to the shared vpc module, each gated by its own variable. The quarantine VPC always creates all three so its traffic stays on the AWS network; the main VPC leaves them disabled for now. The shared vpc_endpoints_tls security group is always created. Lock down the quarantine function role: - Deny any call that does not originate from inside the quarantine VPC (aws:SourceVpc), excluding the Lambda-service-managed ENI and CloudWatch Logs actions so VPC networking and logging keep working. - Restrict the function's own code (lambda:SourceFunctionArn) to the three CloudWatch Logs actions and additionally require that its calls originate from inside the quarantine VPC, while still allowing the Lambda service to manage ENIs and deliver logs on the role's behalf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0da0579 commit c84269b

5 files changed

Lines changed: 209 additions & 4 deletions

File tree

main.tf

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@ module "main_vpc" {
6363
private_subnet_3_cidr = cidrsubnet(var.vpc_cidr, 3, 3)
6464
private_subnet_3_az = local.private_subnet_3_az
6565
enable_brainstore_ec2_ssm = var.enable_brainstore_ec2_ssm
66-
custom_tags = var.custom_tags
66+
67+
# Main VPC interface endpoints are disabled for now.
68+
enable_sts_vpc_endpoint = false
69+
enable_ec2_vpc_endpoint = false
70+
enable_logs_vpc_endpoint = false
71+
72+
custom_tags = var.custom_tags
6773
}
6874

6975
module "quarantine_vpc" {
@@ -82,7 +88,14 @@ module "quarantine_vpc" {
8288
private_subnet_2_az = local.quarantine_private_subnet_2_az
8389
private_subnet_3_cidr = cidrsubnet(var.quarantine_vpc_cidr, 3, 3)
8490
private_subnet_3_az = local.quarantine_private_subnet_3_az
85-
custom_tags = var.custom_tags
91+
92+
# The quarantine VPC always gets STS, EC2, and Logs interface endpoints so
93+
# that the quarantine function role can be locked to in-VPC traffic.
94+
enable_sts_vpc_endpoint = true
95+
enable_ec2_vpc_endpoint = true
96+
enable_logs_vpc_endpoint = true
97+
98+
custom_tags = var.custom_tags
8699
}
87100

88101
module "database" {

modules/services-common/iam-quarantine.tf

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,96 @@ resource "aws_iam_role_policy_attachment" "quarantine_function_role" {
8383
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
8484
}
8585

86+
# Lock the quarantine function role down so that it cannot make any AWS API call
87+
# unless the call originates from inside the quarantine VPC. A Deny with
88+
# StringNotEquals on aws:SourceVpc also fires when the key is absent (i.e. the
89+
# request did not traverse a VPC endpoint), which is exactly what we want: calls
90+
# that egress to public AWS endpoints are denied.
91+
#
92+
# The Lambda-service-managed ENI lifecycle and CloudWatch Logs calls
93+
# (granted by AWSLambdaVPCAccessExecutionRole) are made by the Lambda service on
94+
# the role's behalf and do NOT carry aws:SourceVpc, so they are excluded via
95+
# NotAction to avoid breaking VPC networking and function logging.
96+
resource "aws_iam_role_policy" "quarantine_function_vpc_lockdown" {
97+
count = var.enable_quarantine_vpc ? 1 : 0
98+
name = "${var.deployment_name}-QuarantineFunctionVpcLockdown"
99+
role = aws_iam_role.quarantine_function_role[0].id
100+
policy = jsonencode({ # nosemgrep
101+
Version = "2012-10-17"
102+
Statement = [
103+
{
104+
Sid = "DenyCallsNotFromQuarantineVpc"
105+
Effect = "Deny"
106+
NotAction = [
107+
"ec2:CreateNetworkInterface",
108+
"ec2:DescribeNetworkInterfaces",
109+
"ec2:DeleteNetworkInterface",
110+
"ec2:AssignPrivateIpAddresses",
111+
"ec2:UnassignPrivateIpAddresses",
112+
"logs:CreateLogGroup",
113+
"logs:CreateLogStream",
114+
"logs:PutLogEvents"
115+
]
116+
Resource = "*"
117+
Condition = {
118+
StringNotEquals = {
119+
"aws:SourceVpc" = var.quarantine_vpc_id
120+
}
121+
}
122+
}
123+
]
124+
})
125+
}
126+
127+
# Restrict the quarantined function's own code to CloudWatch Logs, and only when
128+
# the call originates from inside the quarantine VPC. The lambda:SourceFunctionArn
129+
# condition key is only present when the request is made by running function
130+
# code, so these Denies apply solely to the function code:
131+
# 1. it may use nothing other than the three CloudWatch Logs actions, and
132+
# 2. any call that does not carry aws:SourceVpc for the quarantine VPC (i.e.
133+
# did not traverse a VPC endpoint in that VPC) is denied.
134+
# Calls the Lambda service makes on the role's behalf (managing VPC ENIs,
135+
# delivering logs) do not carry lambda:SourceFunctionArn, so they are unaffected.
136+
resource "aws_iam_role_policy" "quarantine_function_code_logs_only" {
137+
count = var.enable_quarantine_vpc ? 1 : 0
138+
name = "${var.deployment_name}-QuarantineFunctionCodeLogsOnly"
139+
role = aws_iam_role.quarantine_function_role[0].id
140+
policy = jsonencode({ # nosemgrep
141+
Version = "2012-10-17"
142+
Statement = [
143+
{
144+
Sid = "DenyFunctionCodeExceptLogs"
145+
Effect = "Deny"
146+
NotAction = [
147+
"logs:CreateLogGroup",
148+
"logs:CreateLogStream",
149+
"logs:PutLogEvents"
150+
]
151+
Resource = "*"
152+
Condition = {
153+
ArnLike = {
154+
"lambda:SourceFunctionArn" = "arn:aws:lambda:*:*:function:*"
155+
}
156+
}
157+
},
158+
{
159+
Sid = "DenyFunctionCodeNotFromQuarantineVpc"
160+
Effect = "Deny"
161+
Action = "*"
162+
Resource = "*"
163+
Condition = {
164+
ArnLike = {
165+
"lambda:SourceFunctionArn" = "arn:aws:lambda:*:*:function:*"
166+
}
167+
StringNotEquals = {
168+
"aws:SourceVpc" = var.quarantine_vpc_id
169+
}
170+
}
171+
}
172+
]
173+
})
174+
}
175+
86176
# Policy attached to the API handler role for quarantine operations
87177
resource "aws_iam_policy" "api_handler_quarantine" {
88178
count = var.enable_quarantine_vpc ? 1 : 0

modules/vpc/main.tf

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,6 @@ resource "aws_vpc_endpoint" "s3" {
178178
}
179179

180180
resource "aws_security_group" "vpc_endpoints_tls" {
181-
count = var.enable_brainstore_ec2_ssm ? 1 : 0
182181
name = "${var.deployment_name}-${var.vpc_name}-vpc-endpoints"
183182
description = "Allow TLS inbound traffic from within VPC"
184183
vpc_id = aws_vpc.vpc.id
@@ -199,7 +198,7 @@ resource "aws_vpc_endpoint" "ec2_ssm_endpoint" {
199198
vpc_endpoint_type = "Interface"
200199

201200
security_group_ids = [
202-
aws_security_group.vpc_endpoints_tls[0].id,
201+
aws_security_group.vpc_endpoints_tls.id,
203202
]
204203

205204
private_dns_enabled = true
@@ -213,3 +212,73 @@ resource "aws_vpc_endpoint" "ec2_ssm_endpoint" {
213212
Name = "${var.deployment_name}-${var.vpc_name}-${each.key}-endpoint"
214213
}, local.common_tags)
215214
}
215+
216+
# Enabling private DNS on these interface endpoints makes the regional service
217+
# endpoint (e.g. sts.<region>.amazonaws.com) resolve to the VPC endpoint from
218+
# within the VPC. Callers must use the regional endpoint (not a legacy global
219+
# endpoint such as sts.amazonaws.com) for traffic to flow through it.
220+
resource "aws_vpc_endpoint" "sts" {
221+
count = var.enable_sts_vpc_endpoint ? 1 : 0
222+
vpc_id = aws_vpc.vpc.id
223+
service_name = "com.amazonaws.${data.aws_region.current.region}.sts"
224+
vpc_endpoint_type = "Interface"
225+
226+
security_group_ids = [
227+
aws_security_group.vpc_endpoints_tls.id,
228+
]
229+
230+
private_dns_enabled = true
231+
subnet_ids = [
232+
aws_subnet.private_subnet_1.id,
233+
aws_subnet.private_subnet_2.id,
234+
aws_subnet.private_subnet_3.id,
235+
]
236+
237+
tags = merge({
238+
Name = "${var.deployment_name}-${var.vpc_name}-sts-endpoint"
239+
}, local.common_tags)
240+
}
241+
242+
resource "aws_vpc_endpoint" "ec2" {
243+
count = var.enable_ec2_vpc_endpoint ? 1 : 0
244+
vpc_id = aws_vpc.vpc.id
245+
service_name = "com.amazonaws.${data.aws_region.current.region}.ec2"
246+
vpc_endpoint_type = "Interface"
247+
248+
security_group_ids = [
249+
aws_security_group.vpc_endpoints_tls.id,
250+
]
251+
252+
private_dns_enabled = true
253+
subnet_ids = [
254+
aws_subnet.private_subnet_1.id,
255+
aws_subnet.private_subnet_2.id,
256+
aws_subnet.private_subnet_3.id,
257+
]
258+
259+
tags = merge({
260+
Name = "${var.deployment_name}-${var.vpc_name}-ec2-endpoint"
261+
}, local.common_tags)
262+
}
263+
264+
resource "aws_vpc_endpoint" "logs" {
265+
count = var.enable_logs_vpc_endpoint ? 1 : 0
266+
vpc_id = aws_vpc.vpc.id
267+
service_name = "com.amazonaws.${data.aws_region.current.region}.logs"
268+
vpc_endpoint_type = "Interface"
269+
270+
security_group_ids = [
271+
aws_security_group.vpc_endpoints_tls.id,
272+
]
273+
274+
private_dns_enabled = true
275+
subnet_ids = [
276+
aws_subnet.private_subnet_1.id,
277+
aws_subnet.private_subnet_2.id,
278+
aws_subnet.private_subnet_3.id,
279+
]
280+
281+
tags = merge({
282+
Name = "${var.deployment_name}-${var.vpc_name}-logs-endpoint"
283+
}, local.common_tags)
284+
}

modules/vpc/outputs.tf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,18 @@ output "default_security_group_id" {
4242
description = "The ID of the default security group that is automatically created for the VPC"
4343
value = aws_vpc.vpc.default_security_group_id
4444
}
45+
46+
output "sts_vpc_endpoint_id" {
47+
description = "The ID of the STS interface VPC endpoint, or null when not created"
48+
value = one(aws_vpc_endpoint.sts[*].id)
49+
}
50+
51+
output "ec2_vpc_endpoint_id" {
52+
description = "The ID of the EC2 interface VPC endpoint, or null when not created"
53+
value = one(aws_vpc_endpoint.ec2[*].id)
54+
}
55+
56+
output "logs_vpc_endpoint_id" {
57+
description = "The ID of the CloudWatch Logs interface VPC endpoint, or null when not created"
58+
value = one(aws_vpc_endpoint.logs[*].id)
59+
}

modules/vpc/variables.tf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,21 @@ variable "enable_brainstore_ec2_ssm" {
6464
type = bool
6565
default = false
6666
}
67+
68+
variable "enable_sts_vpc_endpoint" {
69+
description = "Optional. true creates an interface VPC endpoint for STS in this VPC so STS calls stay on the AWS network."
70+
type = bool
71+
default = false
72+
}
73+
74+
variable "enable_ec2_vpc_endpoint" {
75+
description = "Optional. true creates an interface VPC endpoint for the EC2 API in this VPC so EC2 calls stay on the AWS network."
76+
type = bool
77+
default = false
78+
}
79+
80+
variable "enable_logs_vpc_endpoint" {
81+
description = "Optional. true creates an interface VPC endpoint for CloudWatch Logs in this VPC so log delivery stays on the AWS network."
82+
type = bool
83+
default = false
84+
}

0 commit comments

Comments
 (0)