安全触发私有 Jenkins,使用 GitHub Webhooks、API Gateway(公共)和 Lambda(私有)
Source: Dev.to
架构背后的理念
核心思路很简单:
- API Gateway 是唯一面向公网的组件。
- Lambda 和 Jenkins 保持在 VPC 内部私有。
- 在触发 Jenkins 之前,所有内容都会经过验证。
GitHub (Public)
|
| HTTPS Webhook
v
API Gateway (Public REST API)
|
v
AWS Lambda (Private – inside VPC)
|
v
Jenkins (Private – EC2 / VPC)
结果
- Jenkins 永不直接接触互联网流量。
- Lambda 成为安全闸门。
- 只有经过验证的 GitHub 事件才会触发构建。
我们使用的组件
- API Gateway(REST API) – 公共 webhook 端点。
- AWS Lambda(在 VPC 内)– 验证负载并转发请求。
- Private Jenkins – 在同一 VPC 中运行。
- GitHub HMAC 签名验证 – 确保负载完整性。
- 不使用花哨的服务——只是在恰当位置使用恰当的组件。
步骤‑逐步指南
1️⃣ 设置 Jenkins(私有)
- 在 VPC 内的 EC2 实例(或 VM)上运行 Jenkins。
- 将其放置在 私有子网 中,不分配公网 IP。
- 安全组
- 入方向:仅允许来自 Lambda 安全组的流量。
- 出方向:如果 Jenkins 需要访问外部,允许 HTTPS。
此时 Jenkins 完全与互联网隔离。
2️⃣ 创建 Lambda 函数(私有)
| 设置 | 值 |
|---|---|
| 运行时 | Python 3.10 |
| 超时时间 | 10–15 秒 |
| 内存 | 128 MB |
- 将函数关联到 私有子网。
- 使用 安全组,允许出站 HTTPS(用于访问 Jenkins)。
3️⃣ 创建 API Gateway(公共入口)
⚠️ 使用 REST API,而不是 HTTP API。
REST API 能够完整控制请求验证和头部。
- API Gateway → 创建 API → 选择 REST API。
- 创建资源(例如
/)。 - 添加 POST 方法。
- 该 API 将是唯一面向互联网暴露的入口。
4️⃣ 将 API Gateway 连接到 Lambda
- 在 POST 方法的 Integration Request 中:
- 集成类型:Lambda Function
- 选择你创建的 Lambda。
- 部署 API(例如,部署到
dev阶段)。
启用 Lambda 代理集成 —— 它会原样转发头部、正文以及原始请求负载。
5️⃣ 方法请求配置(关键点)
- 打开 API Gateway → Resources → POST → Method Request。
- 设置:
| 参数 | 值 |
|---|---|
| Authorization | None |
| Request Validator | Validate body, query string parameters, and headers |
| API Key Required | No |
为什么重要
若未启用请求验证器,API Gateway 可能会丢弃诸如X‑Hub‑Signature‑256的头部。这样 Lambda 中的签名验证就会始终失败。
- 更改设置后 重新部署 API。
6️⃣ 配置 GitHub Webhook
在你的 GitHub 仓库中:
Settings → Webhooks → Add webhook
| 字段 | 值 |
|---|---|
| Payload URL | https://<api-id>.execute-api.<region>.amazonaws.com/dev/ |
| Content type | application/json |
| Secret | demo-github-secret |
| Events | Push events(或你需要的其他事件) |
GitHub 现在会发送:
- 原始 JSON 负载。
X‑Hub‑Signature‑256头部。
Lambda 实际做了什么
Lambda 函数充当 GitHub 与 Jenkins 之间的安全网关。它会:
- 读取 原始 webhook 负载。
- 验证 GitHub 的 HMAC 签名。
- 检查 分支(或其他条件)。
- 私密触发 Jenkins 任务。
Lambda 代码(生产就绪)
import json
import hmac
import hashlib
import base64
import urllib.parse
import requests
from requests.auth import HTTPBasicAuth
# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------
GITHUB_SECRET = b"demo-github-secret"
JENKINS_URL = "https://jenkins.example.com"
JENKINS_USER = "demo-user"
JENKINS_TOKEN = "demo-api-token"
JOB_URL = f"{JENKINS_URL}/job/demo-pipeline/buildWithParameters"
# ----------------------------------------------------------------------
def extract_payload(event):
"""
Preserve the exact raw body GitHub signed.
"""
# Normalise header keys to lower‑case
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
body = event.get("body", "")
# GitHub may send the payload base64‑encoded (API Gateway default)
if event.get("isBase64Encoded"):
raw_body = base64.b64decode(body)
else:
raw_body = body.encode("utf-8")
# Some GitHub integrations wrap the JSON in a `payload=` form‑urlencoded string
if raw_body.startswith(b"payload="):
raw_body = urllib.parse.unquote_to_bytes(
raw_body.decode().replace("payload=", "", 1)
)
return raw_body, headers
def verify_signature(raw_body, headers):
"""
Verify the HMAC SHA‑256 signature sent by GitHub.
"""
signature_header = headers.get("x-hub-signature-256")
if not signature_header:
raise ValueError("Missing X-Hub-Signature-256 header")
# Header format: sha256=abcdef...
try:
_, received_sig = signature_header.split("=")
except ValueError:
raise ValueError("Malformed signature header")
mac = hmac.new(GITHUB_SECRET, raw_body, hashlib.sha256)
expected_sig = mac.hexdigest()
if not hmac.compare_digest(expected_sig, received_sig):
raise ValueError("Signature verification failed")
return True
def trigger_jenkins(payload):
"""
Call Jenkins with the appropriate parameters.
"""
data = {
"token": JENKINS_TOKEN,
# Example: forward the branch name
"BRANCH": payload.get("ref", "").split("/")[-1],
}
response = requests.post(
JOB_URL,
data=data,
auth=HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN),
verify=False, # set to True with proper certs
timeout=10,
)
response.raise_for_status()
return response.text
def lambda_handler(event, context):
try:
raw_body, headers = extract_payload(event)
verify_signature(raw_body, headers)
# Parse JSON *after* verification
payload = json.loads(raw_body)
# Optional: filter branches, actions, etc.
if payload.get("ref") != "refs/heads/main":
return {"statusCode": 200, "body": "Ignored non‑main branch"}
trigger_jenkins(payload)
return {"statusCode": 200, "body": "Jenkins job triggered"}
except Exception as e:
# Log the error (CloudWatch) and return a 400/500 as appropriate
print(f"Error: {e}")
return {"statusCode": 400, "body": str(e)}
代码要点
extract_payload保留 GitHub 签名的原始字节序列(包括对payload=表单编码体的处理)。verify_signature使用hmac.compare_digest防止时序攻击。trigger_jenkins通过基本认证(或 API Token)在私有 VPC 网络中调用 Jenkins 任务。
回顾
| 组件 | 公共 / 私有 | 角色 |
|---|---|---|
| GitHub | 公共 | 发送 webhook 事件。 |
| API Gateway (REST) | 公共 | 接收 webhook,将原始请求转发给 Lambda。 |
| Lambda | 私有 (VPC) | 验证签名,过滤事件,触发 Jenkins。 |
| Jenkins | 私有 (VPC) | 执行构建流水线。 |
通过让 Jenkins 完全脱离互联网,并让 API Gateway + Lambda 充当守门人,您可以获得一个 安全、可投入生产 的 GitHub 触发构建解决方案。唯一的“坑”是需要在 API Gateway 中启用请求验证器,以防签名头被剥离。设置好后,整个流程即可顺畅运行。
概述
本文档说明了一个可投入生产的 AWS Lambda 函数,该函数充当 GitHub webhook 与 私有 Jenkins 服务器 之间的安全桥梁。
它:
- 提取原始 webhook 负载。
- 验证 GitHub 签名。
- 过滤事件(仅限
main分支)。 - 通过内部网络触发 Jenkins 流水线。
验证 GitHub 签名
def verify_signature(raw_body, headers):
signature = headers.get("x-hub-signature-256")
if not signature:
return False
expected = "sha256=" + hmac.new(
GITHUB_SECRET,
raw_body,
hashlib.sha256
).hexdigest()
# Securely compare the two values to prevent timing attacks
return hmac.compare_digest(signature, expected)
- 如果缺少
x-hub-signature-256头部,请求会立即被拒绝。 - 预期的签名是根据负载的 原始字节 重新计算的。
hmac.compare_digest提供常量时间比较。
Source:
主 Lambda 处理程序
def lambda_handler(event, context):
# 1️⃣ 提取并验证 – 在验证之前绝不解析 JSON
raw_body, headers = extract_payload(event)
if not verify_signature(raw_body, headers):
return {"statusCode": 401, "body": "Invalid GitHub signature"}
# 2️⃣ 现在可以安全地解析 JSON
payload = json.loads(raw_body.decode("utf-8"))
# 3️⃣ 仅处理推送到 main 分支的情况
ref = payload.get("ref", "")
if not ref.endswith("/main"):
return {"statusCode": 200, "body": "Not main branch"}
# 4️⃣ 触发 Jenkins(私有)
session = requests.Session()
session.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN)
# 处理 Jenkins CSRF 防护
crumb = session.get(f"{JENKINS_URL}/crumbIssuer/api/json").json()
crumb_header = {crumb["crumbRequestField"]: crumb["crumb"]}
response = session.post(
JOB_URL,
headers=crumb_header,
params={"BRANCH": "main"},
timeout=10
)
return {
"statusCode": response.status_code,
"body": "Jenkins pipeline triggered"
}
- 在进一步处理之前会阻止无效请求,因此 Jenkins 永远不会收到这些请求。
- JSON 负载仅在签名校验通过后才会被解析。
- 对非
main分支返回200响应,使 webhook 保持满意但不执行任何操作。
理解 Lambda 代码
| 步骤 | 功能 |
|---|---|
| 提取负载 | 同时处理纯文本和 application/x-www-form-urlencoded 请求体,并在需要时解码 Base64。 |
| 验证签名 | 使用原始负载字节重新生成 HMAC‑SHA256 签名,并安全比较。 |
| 过滤事件 | 仅在推送到 */main 时继续。 |
| 触发 Jenkins | 使用 Jenkins API 令牌进行身份验证,获取 CSRF crumb,并通过 buildWithParameters 触发流水线。 |
完整的生产就绪 Lambda 代码
import json
import hmac
import hashlib
import base64
import urllib.parse
import requests
from requests.auth import HTTPBasicAuth
# ── Configuration ────────────────────────────────────────────────────────
GITHUB_SECRET = b"demo-github-secret"
JENKINS_URL = "https://jenkins.example.com"
JENKINS_USER = "demo-user"
JENKINS_TOKEN = "demo-api-token"
JOB_URL = f"{JENKINS_URL}/job/demo-pipeline/buildWithParameters"
# ────────────────────────────────────────────────────────────────────────
def extract_payload(event):
"""
Returns:
raw_body (bytes): the exact bytes that GitHub sent.
headers (dict): lower‑cased header dict for easy lookup.
"""
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
body = event.get("body", "")
# API Gateway may Base64‑encode the body
if event.get("isBase64Encoded"):
raw_body = base64.b64decode(body)
else:
raw_body = body.encode("utf-8")
# GitHub can send payload as `payload=...` (url‑encoded)
if raw_body.startswith(b"payload="):
raw_body = urllib.parse.unquote_to_bytes(
raw_body.decode().replace("payload=", "", 1)
)
return raw_body, headers
def verify_signature(raw_body, headers):
"""
Re‑creates the HMAC‑SHA256 signature and compares it with the header.
Returns True if the signature matches, False otherwise.
"""
signature = headers.get("x-hub-signature-256")
if not signature:
return False
expected = "sha256=" + hmac.new(
GITHUB_SECRET,
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
def lambda_handler(event, context):
raw_body, headers = extract_payload(event)
if not verify_signature(raw_body, headers):
return {"statusCode": 401, "body": "Invalid GitHub signature"}
payload = json.loads(raw_body.decode("utf-8"))
# Only act on pushes to the main branch
ref = payload.get("ref", "")
if not ref.endswith("/main"):
return {"statusCode": 200, "body": "Not main branch"}
# Authenticate to Jenkins
session = requests.Session()
session.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN)
# Obtain CSRF crumb
crumb = session.get(f"{JENKINS_URL}/crumbIssuer/api/json").json()
crumb_header = {crumb["crumbRequestField"]: crumb["crumb"]}
# Trigger the pipeline
response = session.post(
JOB_URL,
headers=crumb_header,
params={"BRANCH": "main"},
timeout=10
)
return {
"statusCode": response.status_code,
"body": "Jenkins pipeline triggered"
}