GitHub Webhooks, API Gateway(공개), Lambda(프라이빗)를 사용하여 프라이빗 Jenkins를 안전하게 트리거하기

발행: (2025년 12월 25일 오후 04:52 GMT+9)
12 분 소요
원문: Dev.to

I’m happy to help translate the article, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source link you already provided) here? Once I have the article text, I’ll translate it into Korean while preserving all formatting, code blocks, and URLs.

아키텍처의 아이디어

핵심 아이디어는 간단합니다:

  1. API Gateway는 유일한 외부 공개 컴포넌트입니다.
  2. LambdaJenkins는 VPC 내부에 비공개로 유지됩니다.
  3. 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) – 공개 웹훅 엔드포인트.
  • AWS Lambda (VPC 내부) – 페이로드를 검증하고 요청을 전달합니다.
  • Private Jenkins – 동일한 VPC 내부에서 실행됩니다.
  • GitHub HMAC 서명 검증 – 페이로드 무결성을 보장합니다.
  • 별도의 고급 서비스는 없습니다 – 적절한 구성 요소를 적절한 위치에 배치한 것입니다.

단계별 가이드

1️⃣ Jenkins 설정 (프라이빗)

  • VPC 내부의 EC2 인스턴스(또는 VM)에서 Jenkins를 실행합니다.
  • 공용 IP가 없는 프라이빗 서브넷에 배치합니다.
  • 보안 그룹
    • 인바운드: Lambda 보안 그룹에서 오는 트래픽만 허용합니다.
    • 아웃바운드: Jenkins가 외부 접근이 필요하면 HTTPS를 허용합니다.

이 시점에서 Jenkins는 인터넷과 완전히 격리됩니다.

2️⃣ Lambda 함수 생성 (프라이빗)

SettingValue
RuntimePython 3.10
Timeout10–15 seconds
Memory128 MB
  • 함수를 프라이빗 서브넷에 연결합니다.
  • Jenkins에 도달하기 위해 아웃바운드 HTTPS를 허용하는 보안 그룹을 사용합니다.

3️⃣ API Gateway 생성 (공용 진입점)

⚠️ REST API를 사용하고, HTTP API는 사용하지 마세요.
REST API는 요청 검증 및 헤더에 대한 완전한 제어를 제공합니다.

  1. API Gateway → Create API → Choose REST API.
  2. 리소스를 생성합니다(예: /).
  3. POST 메서드를 추가합니다.
  4. 이 API만 인터넷에 노출됩니다.

4️⃣ API Gateway를 Lambda에 연결

  • POST 메서드의 Integration Request에서:
    • Integration type: Lambda Function
    • 생성한 Lambda를 선택합니다.
  • API를 배포합니다(예: dev 스테이지).

Lambda 프록시 통합을 활성화합니다 – 헤더, 본문 및 원시 요청 페이로드를 그대로 전달합니다.

5️⃣ 메서드 요청 구성 (주의사항)

  1. API Gateway → Resources → POST → Method Request를 엽니다.
  2. 다음과 같이 설정합니다:
ParameterValue
AuthorizationNone
Request ValidatorValidate body, query string parameters, and headers
API Key RequiredNo

왜 중요한가
요청 검증기를 사용하지 않으면 API Gateway가 X‑Hub‑Signature‑256와 같은 헤더를 삭제할 수 있습니다. 이 경우 Lambda에서 서명 검증이 항상 실패합니다.

  1. 설정을 변경한 후 API를 재배포합니다.

6️⃣ GitHub 웹훅 설정

GitHub 저장소에서:

Settings → Webhooks → Add webhook
FieldValue
Payload URLhttps://<api-id>.execute-api.<region>.amazonaws.com/dev/
Content typeapplication/json
Secretdemo-github-secret
EventsPush events (or any you need)

GitHub은 이제 다음을 전송합니다:

  • 원시 JSON 페이로드.
  • X‑Hub‑Signature‑256 헤더.

Lambda가 실제로 하는 일

Lambda 함수는 GitHub와 Jenkins 사이의 보안 게이트 역할을 합니다. 이 함수는 다음을 수행합니다.

  1. 읽기: 원시 webhook 페이로드를 읽습니다.
  2. 검증: GitHub HMAC 서명을 검증합니다.
  3. 확인: 브랜치(또는 기타 기준)를 확인합니다.
  4. 트리거: Jenkins 작업을 비공개로 실행합니다.

Lambda 코드 (프로덕션‑Ready)

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= 형태‑urlencoded 본문 처리 포함).
  • **verify_signature**는 타이밍 공격을 방지하기 위해 hmac.compare_digest를 사용합니다.
  • **trigger_jenkins**는 기본 인증(또는 API 토큰)을 이용해 프라이빗 VPC 네트워크를 통해 Jenkins 작업을 호출합니다.

Recap

구성 요소공개 / 비공개역할
GitHub공개웹훅 이벤트를 전송합니다.
API Gateway (REST)공개웹훅을 수신하고 원시 요청을 Lambda로 전달합니다.
Lambda비공개 (VPC)서명을 검증하고, 이벤트를 필터링하며, Jenkins를 트리거합니다.
Jenkins비공개 (VPC)빌드 파이프라인을 실행합니다.

Jenkins를 완전히 인터넷에서 차단하고 API Gateway와 Lambda를 게이트키퍼로 두면, 보안이 강화된 프로덕션‑레디 솔루션으로 GitHub‑트리거 빌드를 구현할 수 있습니다. 유일한 “주의점”은 API Gateway에서 요청 검증기를 활성화하여 서명 헤더가 제거되지 않도록 하는 것입니다. 이를 설정하면 흐름이 완벽하게 작동합니다.

Overview

This document explains a production‑ready AWS Lambda function that acts as a secure bridge between GitHub webhooks and a private Jenkins server.

It:

  1. 원시 웹훅 페이로드를 추출합니다.
  2. GitHub 서명을 검증합니다.
  3. 이벤트를 필터링합니다 (main 브랜치만).
  4. 내부 네트워크를 통해 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는 상수 시간 비교를 제공합니다.

메인 Lambda 핸들러

def lambda_handler(event, context):
    # 1️⃣ Extract and verify first – never parse JSON before validation
    raw_body, headers = extract_payload(event)

    if not verify_signature(raw_body, headers):
        return {"statusCode": 401, "body": "Invalid GitHub signature"}

    # 2️⃣ Safe to parse JSON now
    payload = json.loads(raw_body.decode("utf-8"))

    # 3️⃣ Process only pushes to the main branch
    ref = payload.get("ref", "")
    if not ref.endswith("/main"):
        return {"statusCode": 200, "body": "Not main branch"}

    # 4️⃣ Trigger Jenkins (privately)
    session = requests.Session()
    session.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN)

    # Handle Jenkins CSRF protection
    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 응답을 반환하여 웹훅을 만족시키면서 아무 작업도 하지 않습니다.

Lambda 코드 이해

단계수행 내용
Extract payload일반 텍스트와 application/x-www-form-urlencoded 본문을 모두 처리하고, 필요 시 Base64를 디코딩합니다.
Verify signature원시 페이로드 바이트를 사용해 HMAC‑SHA256 서명을 재생성하고 안전하게 비교합니다.
Filter events*/main에 대한 푸시인 경우에만 계속 진행합니다.
Trigger JenkinsJenkins API 토큰으로 인증하고, CSRF 크럼을 받아 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"
    }
Back to Blog

관련 글

더 보기 »