Skip to content

Commit 89c4d04

Browse files
committed
feat: Use DataDog to monitor lambda for proxy and limit API Gateway access only to GitHub CIDRs
1 parent a58e390 commit 89c4d04

File tree

12 files changed

+244
-103
lines changed

12 files changed

+244
-103
lines changed

main.tf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,21 @@ resource "random_password" "redis_password" {
300300
length = 12
301301
special = false
302302
}
303+
304+
module "github_reverse_proxy" {
305+
count = var.deploy_github_reverse_proxy ? 1 : 0
306+
307+
source = "./modules/github_reverse_proxy"
308+
309+
deployment_name = var.deployment_name
310+
environment = var.environment
311+
region = var.provider_region
312+
vpc_cidr = local.vpc_cidr
313+
vpc_id = local.vpc_id
314+
vpc_private_subnets = local.vpc_private_subnets
315+
github_cidrs = var.github_cidrs
316+
datadog_api_key = var.datadog_api_key
317+
use_private_egress = var.lb_internal
318+
319+
private_system_endpoint = module.load_balancer.load_balancer_dns
320+
}

modules/github_reverse_proxy/README.md

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,10 @@
22

33
To facilitate an internal load balancer, GitHub webhooks still need to have a way to reach the application. This is achieved by creating a reverse proxy for GitHub webhooks. This Terraform module deploys an AWS Lambda function inside a VPC, integrates it with API Gateway, and ensures least privilege access. The Lambda function receives webhooks from GitHub, processes them, and forwards them to a private system inside a VPC.
44

5-
The module includes:
6-
- A Lambda function attached to a VPC.
7-
- Integration with API Gateway to expose the Lambda function as an HTTP endpoint.
8-
- A deny policy to follow the principle of least privilege, preventing the Lambda function code from making certain Amazon EC2 API calls.
9-
- Required IAM roles and permissions for VPC access and logging.
10-
- A Security Group to allow access to the private endpoint in the VPC.
11-
125
## Features
136
- Deploys an AWS Lambda function inside a VPC.
147
- Integrates the Lambda function with API Gateway for receiving webhooks.
8+
- Limits access to the API Gateway only to request from GitHub CIDR ranges.
159
- Configures the necessary IAM roles, including the `AWSLambdaVPCAccessExecutionRole`.
1610
- Implements a custom deny policy to prevent the Lambda function from making EC2 network-related API calls.
17-
- CloudWatch logging for monitoring and troubleshooting.
18-
19-
## Usage
20-
21-
```hcl
22-
module "lambda_vpc_webhook" {
23-
source = "./path_to_your_module" # Replace with your module path
24-
25-
# Variables
26-
vpc_id = "vpc-12345678"
27-
vpc_private_subnets = ["subnet-abcdefgh", "subnet-ijklmnop"]
28-
github_secret = "your-github-webhook-secret"
29-
private_system_endpoint = "the ip of the internal load balancer"
30-
}
31-
32-
output "api_gateway_url" {
33-
value = module.lambda_vpc_webhook.api_gateway_url
34-
}
35-
```
11+
- DataDog Lambda extension for logging and monitoring.

modules/github_reverse_proxy/iam.tf

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
data "aws_caller_identity" "current" {}
2-
31
resource "aws_iam_role" "lambda_role" {
42
name = "${var.deployment_name}-lambda-github-webhook-role"
53

@@ -15,6 +13,26 @@ resource "aws_iam_role" "lambda_role" {
1513
})
1614
}
1715

16+
resource "aws_iam_policy" "lambda_secrets_policy" {
17+
name = "lambda-secrets-policy"
18+
policy = jsonencode({
19+
Version: "2012-10-17",
20+
Statement: [
21+
{
22+
Effect: "Allow",
23+
Action: "secretsmanager:GetSecretValue",
24+
Resource: aws_secretsmanager_secret.datadog_api_key.arn
25+
}
26+
]
27+
})
28+
}
29+
30+
# Attach the policy to the Lambda execution role
31+
resource "aws_iam_role_policy_attachment" "lambda_secrets_policy_attachment" {
32+
role = aws_iam_role.lambda_role.name
33+
policy_arn = aws_iam_policy.lambda_secrets_policy.arn
34+
}
35+
1836
resource "aws_iam_role_policy_attachment" "lambda_vpc_access_policy" {
1937
role = aws_iam_role.lambda_role.name
2038
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
@@ -68,7 +86,7 @@ resource "aws_iam_policy" "lambda_deny_ec2_policy" {
6886
Condition = {
6987
"ArnEquals": {
7088
"lambda:SourceFunctionArn": [
71-
"arn:aws:lambda:${data.aws_caller_identity.current.account_id}:function:${aws_lambda_function.github_webhook_handler.function_name}"
89+
"arn:aws:lambda:${data.aws_caller_identity.current.account_id}:function:${aws_lambda_alias.prod_alias.function_name}"
7290
]
7391
}
7492
}
@@ -80,4 +98,4 @@ resource "aws_iam_policy" "lambda_deny_ec2_policy" {
8098
resource "aws_iam_role_policy_attachment" "lambda_deny_ec2_attachment" {
8199
role = aws_iam_role.lambda_role.name
82100
policy_arn = aws_iam_policy.lambda_deny_ec2_policy.arn
83-
}
101+
}

modules/github_reverse_proxy/lambda_function.py

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,52 @@
11
import json
2-
import hashlib
3-
import hmac
2+
import logging
43
import os
54
import urllib3
65

7-
def verify_github_signature(headers, body, secret):
8-
"""
9-
Verifies the GitHub webhook signature using the provided secret.
10-
"""
11-
signature = headers.get('X-Hub-Signature-256')
6+
from datadog_lambda.logger import initialize_logging
127

13-
if not signature:
14-
print("No signature found in headers.")
15-
return False
168

17-
# Create the HMAC digest
18-
hmac_gen = hmac.new(key=secret.encode(), msg=body, digestmod=hashlib.sha256)
19-
expected_signature = f'sha256={hmac_gen.hexdigest()}'
9+
initialize_logging(__name__)
10+
logger = logging.getLogger(__name__)
11+
# logger.setLevel(logging.INFO)
2012

21-
return hmac.compare_digest(signature, expected_signature)
2213

23-
def forward_to_private_system(data, private_endpoint):
14+
def forward_to_private_system(data, private_endpoint, headers):
2415
"""
2516
Forward the data to the private system endpoint.
2617
"""
27-
http = urllib3.PoolManager()
28-
headers = {'Content-Type': 'application/json'}
29-
30-
response = http.request('POST', private_endpoint, body=json.dumps(data), headers=headers)
31-
18+
http = urllib3.PoolManager(cert_reqs='CERT_NONE') # Disable SSL certificate verification
19+
response = http.request(
20+
'POST', private_endpoint, body=data, headers=headers, assert_same_host=False
21+
)
3222
return response.status, response.data
3323

3424
def lambda_handler(event, context):
35-
github_secret = os.getenv('GITHUB_SECRET')
3625
private_system_endpoint = os.getenv('PRIVATE_SYSTEM_ENDPOINT')
26+
logger.info(f"Private system endpoint: {private_system_endpoint}")
3727

38-
headers = event.get('headers', {})
3928
body = event.get('body', '')
40-
41-
# Verify GitHub signature
42-
if not verify_github_signature(headers, body.encode('utf-8'), github_secret):
43-
print("Signature verification failed.")
44-
return {
45-
'statusCode': 403,
46-
'body': json.dumps('Forbidden: Signature verification failed')
47-
}
48-
49-
# Parse the body as JSON
50-
try:
51-
payload = json.loads(body)
52-
except json.JSONDecodeError as e:
53-
print(f"JSON parsing error: {e}")
29+
if not body:
30+
logger.error("No body found in the event")
5431
return {
5532
'statusCode': 400,
56-
'body': json.dumps('Bad Request: Invalid JSON payload')
33+
'body': json.dumps('Bad Request: No payload found')
5734
}
35+
incoming_headers = event.get('headers', {})
5836

59-
# Forward the payload to the private system
60-
status, response = forward_to_private_system(payload, private_system_endpoint)
37+
logger.info("Starting to forward the payload")
38+
status, response = forward_to_private_system(
39+
body, private_system_endpoint, incoming_headers
40+
)
41+
logger.info(f"Forwarding status: {status}")
6142

6243
if status == 200:
6344
return {
6445
'statusCode': 200,
6546
'body': json.dumps('Webhook processed and forwarded')
6647
}
6748
else:
68-
print(f"Error forwarding to private system: {response}")
49+
logger.error(f"Error forwarding to private system ({status}): {response}")
6950
return {
7051
'statusCode': 500,
7152
'body': json.dumps('Internal Server Error: Failed to forward webhook')
827 Bytes
Binary file not shown.
Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,100 @@
1-
resource "null_resource" "zip_lambda_function" {
2-
provisioner "local-exec" {
3-
command = "zip -j lambda_function.zip lambda_function.py"
4-
}
1+
data "aws_caller_identity" "current" {}
2+
data "aws_region" "current" {}
53

6-
triggers = {
7-
py_source = filemd5("lambda_function.py")
8-
}
4+
# resource "null_resource" "zip_lambda_function" {
5+
# provisioner "local-exec" {
6+
# command = "zip -j ${path.module}/lambda_function.zip ${path.module}/lambda_function.py"
7+
# }
8+
9+
# triggers = {
10+
# py_source = filemd5("${path.module}/lambda_function.py")
11+
# }
12+
# }
13+
14+
data "archive_file" "zip_lambda_function" {
15+
type = "zip"
16+
source_file = "${path.module}/lambda_function.py"
17+
output_path = "${path.module}/lambda_function.zip"
18+
}
19+
20+
21+
resource "aws_secretsmanager_secret" "datadog_api_key" {
22+
name = "datadog_api_key"
23+
description = "Datadog API Key used for monitoring Lambda"
924
}
1025

11-
resource "aws_lambda_function" "github_webhook_handler" {
26+
resource "aws_secretsmanager_secret_version" "datadog_api_key_version" {
27+
secret_id = aws_secretsmanager_secret.datadog_api_key.id
28+
secret_string = var.datadog_api_key # This should be the Datadog API key (input as a variable)
29+
}
30+
31+
module "lambda_datadog" {
32+
source = "DataDog/lambda-datadog/aws"
33+
version = "1.4.0"
34+
1235
function_name = "${var.deployment_name}-github-webhook-handler"
1336
role = aws_iam_role.lambda_role.arn
14-
handler = "lambda_function.lambda_handler" # Python handler
37+
handler = "lambda_function.lambda_handler"
1538
runtime = "python3.12"
39+
memory_size = 256
40+
timeout = 30
1641

17-
filename = "${path.module}/lambda_function.zip"
18-
source_code_hash = filebase64sha256("${path.module}/lambda_function.zip")
42+
publish = true
1943

20-
environment {
21-
variables = {
22-
GITHUB_SECRET = var.github_secret
23-
PRIVATE_SYSTEM_ENDPOINT = var.private_system_endpoint
24-
}
25-
}
44+
filename = data.archive_file.zip_lambda_function.output_path
45+
source_code_hash = data.archive_file.zip_lambda_function.output_base64sha256
2646

27-
vpc_config {
28-
subnet_ids = var.vpc_private_subnets
29-
security_group_ids = length(var.security_group_ids) > 0 ? var.security_group_ids : [aws_security_group.lambda_sg.id]
47+
environment_variables = {
48+
"DD_API_KEY_SECRET_ARN" : aws_secretsmanager_secret.datadog_api_key.arn
49+
"DD_ENV" : var.environment
50+
"DD_SERVICE" : "github-webhook-service"
51+
"DD_SITE": "datadoghq.com"
52+
"DD_VERSION" : "1.0.0"
53+
"DD_EXTENSION_VERSION": "next"
54+
"DD_SERVERLESS_LOGS_ENABLED": "true"
55+
"DD_LOG_LEVEL": "INFO"
56+
"DD_TAGS": "deployment:${var.deployment_name}"
57+
"PRIVATE_SYSTEM_ENDPOINT" : "https://${var.private_system_endpoint}/integrations/github/v1/app_hook"
3058
}
3159

60+
vpc_config_subnet_ids = var.vpc_private_subnets
61+
vpc_config_security_group_ids = length(var.security_group_ids) > 0 ? var.security_group_ids : [aws_security_group.lambda_sg.id]
62+
63+
datadog_extension_layer_version = 63
64+
datadog_python_layer_version = 98
65+
3266
# Depend on the zip operation
33-
depends_on = [null_resource.zip_lambda_function]
67+
depends_on = [data.archive_file.zip_lambda_function]
68+
}
69+
70+
resource "aws_lambda_alias" "prod_alias" {
71+
name = "prod"
72+
function_name = module.lambda_datadog.function_name
73+
function_version = module.lambda_datadog.version
74+
}
75+
76+
resource "aws_lambda_provisioned_concurrency_config" "example" {
77+
function_name = aws_lambda_alias.prod_alias.function_name
78+
provisioned_concurrent_executions = 1
79+
qualifier = aws_lambda_alias.prod_alias.name
80+
}
81+
82+
resource "aws_lambda_function_event_invoke_config" "concurrency_limit" {
83+
function_name = aws_lambda_alias.prod_alias.function_name
84+
qualifier = aws_lambda_alias.prod_alias.name
85+
maximum_retry_attempts = 2
86+
}
87+
88+
resource "aws_lambda_permission" "lambda_permission" {
89+
statement_id = "AllowExecutionFromAPIGateway"
90+
action = "lambda:InvokeFunction"
91+
function_name = aws_lambda_alias.prod_alias.function_name
92+
qualifier = aws_lambda_alias.prod_alias.name
93+
principal = "apigateway.amazonaws.com"
94+
95+
# The /* part allows invocation from any stage, method and resource path
96+
# within API Gateway.
97+
source_arn = "${aws_api_gateway_rest_api.webhook_api.execution_arn}/*"
3498
}
3599

36100
# API Gateway to act as a reverse proxy
@@ -60,7 +124,7 @@ resource "aws_api_gateway_integration" "lambda_integration" {
60124
http_method = aws_api_gateway_method.post_webhook.http_method
61125
integration_http_method = "POST"
62126
type = "AWS_PROXY"
63-
uri = aws_lambda_function.github_webhook_handler.invoke_arn
127+
uri = aws_lambda_alias.prod_alias.invoke_arn
64128
}
65129

66130
# Deployment of API Gateway
@@ -69,4 +133,31 @@ resource "aws_api_gateway_deployment" "api_deployment" {
69133
stage_name = "prod"
70134

71135
depends_on = [aws_api_gateway_integration.lambda_integration]
72-
}
136+
}
137+
138+
resource "aws_api_gateway_rest_api_policy" "github_ip_restriction" {
139+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
140+
141+
policy = jsonencode({
142+
Version = "2012-10-17",
143+
Statement = [
144+
{
145+
Effect: "Allow",
146+
Principal: "*",
147+
Action: "execute-api:Invoke",
148+
Resource: "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.webhook_api.id}/*/POST/*",
149+
},
150+
{
151+
Effect: "Deny",
152+
Principal: "*",
153+
Action: "execute-api:Invoke",
154+
Resource: "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.webhook_api.id}/*/POST/*",
155+
Condition: {
156+
"NotIpAddress": {
157+
"aws:SourceIp": var.github_cidrs
158+
}
159+
}
160+
}
161+
]
162+
})
163+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
output "api_gateway_url" {
2-
value = aws_api_gateway_deployment.api_deployment.invoke_url
2+
value = "${aws_api_gateway_deployment.api_deployment.invoke_url}/${aws_api_gateway_resource.webhook_resource.path_part}"
33
}
44

0 commit comments

Comments
 (0)