🎯 목표설정
팀 근두운 인프라의 SIEM 코어인 Wazuh가 각 엔드포인트에 설치된 Agent로부터의 로그, S3 버킷에 저장되어 있는 AWS 시스템(CloudTrail, VPC Flow Logs, ALB Access Logs, WAF Logs, GuardDuty Findings) 이벤트들을 Wazuh Manager가 수집해 다양한 보안 이벤트를 중앙 수집하고, 탐지룰에 매핑된 Alert를 대시보드에서 보여주고 있다.
우리는 대시보드의 Alert를 확인하고, 시스템의 위협상황을 모니터링해야 한다. 하지만, 24시간 대시보드만 보고 있는 것은 사실상 불가능하다.
따라서, 이번 과정에서는 Wazuh에서 Alert가 발생하면 팀 디스코드 채널로 알림을 보내 관리자가 대시보드를 보고 있지 않아도, Alert를 확인할 수 있도록 할 예정이다.
🧑🏻🔧 구축과정
구축하기 전, 먼저 웹훅(Webhook) 개념을 짚고 넘어가보자.
웹훅은 특정 이벤트가 발생했을 때 실시간으로 다른 애플리케이션에 데이터를 보내주는 HTTP 기반의 알림 기능이다.
수신 측 웹훅 URL을 송신 측에 등록해 두면, 송신 측에 특정 이벤트가 발생했을 때 HTTP POST 요청으로 이벤트 데이터를 수신 측으로 보내고, 수신 측은 이 데이터를 수신하여 내부적으로 로직을 처리해 알림을 생성하는 것이다.
sequenceDiagram
participant A as 수신 측
participant B as 송신 측
A-->B: 1️⃣ 수신 측 웹훅 URL을 송신 측에 등록
B->>B: 2️⃣ 특정 이벤트 발생
B->>A: 3️⃣ HTTP POST 요청(이벤트 데이터)
A->>A: 4️⃣ 데이터 수신 및 내부 로직 처리<br/>(알림 생성)
A->>B: 5️⃣ 성공 응답 (HTTP 2XX)디스코드는 이러한 웹훅 기능을 매우 간편하게 지원해준다.
알림을 받을 텍스트 채널을 선택하고, 설정에서 웹후크 만들기를 통해 웹훅을 생성한다.

그러면 웹후크가 만들어지면서 웹후크 URL(
https://discord.com/…)이 같이 생성되어, 디스코드가 웹훅을 통해 알림을 받을 준비를 마친다.웹후크 URL은 메시지 전송 권한을 가진 토큰값이 포함되기 때문에 노출되지 않도록 주의해야 한다. 노출될 경우, 누구나 내 채널에 메시지를 보낼 수 있게 된다.
이제 수신 측(디스코드)에서 준비사항은 끝났다. 다음으로는 송신 측(Wazuh) 설정을 진행해보자.
Wazuh Manager는 Integrator라는 모듈을 통해 외부 API 및 알림 도구에 연결할 수 있도록 지원해주고 있다.
WazuhExternal API integration - Wazuh server · Wazuh documentation

External API integration - Wazuh server · Wazuh documentation
The Wazuh Integrator module allows Wazuh to connect to external APIs and alerting tools. Learn more in this section of the documentation.
기본적으로 Slack, PagerDuty, VirusTotal, Shuffle, Maltiverse에 대한 연동 스크립트는 제공하고 있지만 디스코드의 경우는 전송 스크립트를 별도로 작성해주어야 한다.
custom- 접두사를 사용하면, 사용자 정의 연동 스크립트를 등록할 수 있다.
Wazuh Manager 쉘에 접속해, /var/ossec/integrations/ 경로에 가보면 위에서 언급했던 기본 통합 서비스들의 스크립트 파일이 있는 것을 볼 수 있다.
우리는 디스코드와 연동할 것이기 때문에 해당 경로에
custom-discord라는 이름의 파일을 만들어 스크립트 코드를 작성하였다.(지피티 도움…)custom-discord 스크립트 코드
#!/usr/bin/python3 # Wazuh 경고를 읽고 발생 시각을 한국 시간(KST)으로 변환한 뒤, # Discord 웹훅으로 메시지를 전송합니다. import json import logging import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone, timedelta # 전송 성공·실패를 파일에 기록합니다. # 인증 정보가 포함된 웹훅 URL은 기록하지 않습니다. logging.basicConfig( filename="/var/ossec/logs/integrations.log", level=logging.INFO, format="%(asctime)s custom-discord %(levelname)s: %(message)s", ) # 한국 시간(KST)은 세계 기준 시간인 UTC보다 9시간 빠릅니다. KST = timezone(timedelta(hours=9)) def format_kst(raw_timestamp): """Wazuh 발생 시각을 읽어 한국 시간(KST)으로 변환합니다.""" # 발생 시각이 없으면 기본 문구를 반환합니다. if not raw_timestamp: return "알 수 없음" # 앞뒤 공백을 제거하고, UTC를 나타내는 Z를 +0000으로 바꿉니다. timestamp = str(raw_timestamp).strip() if timestamp.endswith("Z"): timestamp = timestamp[:-1] + "+0000" # 시간대가 +00:00 또는 +09:00 형태라면 콜론을 제거합니다. # +0000, +0900 형태로 통일해 Python 버전 간 차이를 줄입니다. if ( len(timestamp) >= 6 and timestamp[-6] in ("+", "-") and timestamp[-3] == ":" ): timestamp = timestamp[:-3] + timestamp[-2:] # %f는 소수점 이하 초, %z는 UTC와의 시간 차이를 뜻합니다. # 소수점 이하 초가 있는 형식과 없는 형식을 모두 지원합니다. formats = ( "%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", ) for timestamp_format in formats: try: # 지정한 형식에 맞춰 문자열을 날짜·시간 객체로 읽습니다. occurred_at = datetime.strptime(timestamp, timestamp_format) # 원본 시간대를 기준으로 KST로 변환합니다. # 이미 한국 시간이면 9시간을 중복으로 더하지 않습니다. return occurred_at.astimezone(KST).strftime( "%Y-%m-%d %H:%M:%S KST" ) except ValueError: # 현재 형식과 일치하지 않으면 다음 형식을 시도합니다. continue # 시간대를 알 수 없거나 형식이 다르면 원본을 표시합니다. # 시간 변환이 실패해도 경고 전송은 계속합니다. return f"{raw_timestamp} (시간 형식 또는 시간대 확인 필요)" def main(): """Wazuh 경고 파일을 읽고 Discord에 메시지를 전송합니다.""" # Wazuh가 전달하는 실행 인자: # 1번: 경고 파일 경로 # 2번: API 키 — Discord 연동에서는 사용하지 않습니다. # 3번: 웹훅 URL if len(sys.argv) < 4: raise ValueError("필수 실행 인자가 없습니다.") alert_path = sys.argv[1] webhook_url = sys.argv[3] # 경고를 잘못된 주소로 보내지 않도록 Discord 주소인지 확인합니다. # HTTPS는 통신 내용을 암호화하는 연결 방식입니다. url = urllib.parse.urlsplit(webhook_url) if ( url.scheme != "https" or url.hostname != "discord.com" or not url.path.startswith("/api/webhooks/") ): raise ValueError("Discord 웹훅 URL을 확인하세요.") # JSON 형식의 경고 파일을 읽습니다. # JSON은 이름과 값의 쌍으로 정보를 표현하는 데이터 형식입니다. with open(alert_path, encoding="utf-8") as alert_file: alert = json.load(alert_file) # rule은 경고를 발생시킨 탐지 규칙입니다. # agent는 Wazuh가 감시하는 장비입니다. rule = alert.get("rule") or {} agent = alert.get("agent") or {} # 원본 발생 시각을 한국 시간으로 변환합니다. display_timestamp = format_kst(alert.get("timestamp")) # Discord에 보낼 경고 요약을 만듭니다. # 민감한 정보가 포함될 수 있는 원본 로그(full_log)는 제외합니다. message = ( "🚨 Wazuh 경고\n" f"발생 시각: {display_timestamp}\n" f"장비: {agent.get('name') or '알 수 없음'}\n" f"규칙 ID: {rule.get('id') or '알 수 없음'}\n" f"경고 수준: {rule.get('level', '알 수 없음')}\n" f"내용: {rule.get('description') or '설명 없음'}" ) # payload는 Discord에 전송할 데이터입니다. payload = { # 메시지가 너무 길어지지 않도록 길이를 제한합니다. "content": message[:1800], # 경고에 @everyone 등이 포함되어도 단체 호출을 하지 않습니다. "allowed_mentions": {"parse": []}, } # wait=true를 추가해 Discord의 메시지 처리 응답을 기다립니다. query = urllib.parse.parse_qs(url.query) query["wait"] = ["true"] target_url = urllib.parse.urlunsplit( url._replace( query=urllib.parse.urlencode(query, doseq=True) ) ) # POST는 서버에 데이터를 보내는 요청 방식입니다. # 한글이 정상적으로 전달되도록 UTF-8로 인코딩합니다. request = urllib.request.Request( target_url, data=json.dumps( payload, ensure_ascii=False, ).encode("utf-8"), headers={ "Content-Type": "application/json", "User-Agent": "Wazuh-Discord-Integration/1.0", }, method="POST", ) # 응답을 무한정 기다리지 않도록 10초 제한을 둡니다. with urllib.request.urlopen(request, timeout=10) as response: logging.info("전송 성공: HTTP %s", response.status) if __name__ == "__main__": try: main() except urllib.error.HTTPError as error: # HTTP 상태 코드는 Discord의 요청 처리 결과를 나타냅니다. # 예: 429는 짧은 시간에 요청을 너무 많이 보냈다는 의미입니다. logging.error("Discord 전송 실패: HTTP %s", error.code) sys.exit(1) except Exception as error: # 오류 전문에 웹훅 URL이 포함될 수 있어 오류 종류만 기록합니다. logging.error("전송 실패: %s", type(error).__name__) sys.exit(1)
파일 작성이 완료됐으면,
wazuh-integratord가 이 스크립트를 읽고 실행할 수 있도록 소유자와 실행 권한을 기본 서비스 스크립트 파일과 동일하게 맞춰 준다.# 파일의 소유자를 root, 소유 그룹을 wazuh로 지정 sudo chown root:wazuh /var/ossec/integrations/custom-discord # Wazuh가 읽고 실행할 수 있으며, 수정은 관리자만 가능하도록 권한 설정 sudo chmod 750 /var/ossec/integrations/custom-discord

다음은 해당 모듈의 스크립트를 Wazuh Manager가 처리할 수 있도록 등록하는 절차가 필요하다.
Wazuh는
/var/ossec/etc/ 경로에 ossec.conf(XML 형식)라는 설정파일을 통해 에이전트 통신, 로그 수집, FIM 등 Wazuh Manager의 주요 동작을 설정하고 있는데, Intergrator 모듈도 ossec.conf 설정파일에 추가해주면 Wazuh와 Discord 웹훅을 연동할 수 있다.기존
<ossec_config> 태그 사이에 <integration> 블록을 추가<ossec_config> <!-- 기존 설정 --> <integration> <!-- Integrator 모듈 스크립트 파일명 --> <name>custom-discord</name> <!-- 아까 생성한 Discord 웹훅 URL --> <hook_url>웹훅 URL</hook_url> <!-- 전송할 경고의 최소 수준(숫자만 입력) --> <level>경고 수준</level> <alert_format>json</alert_format> </integration> </ossec_config>
사용한 옵션 외에
<rule_id>, <group>, <event_location> 등의 옵션도 사용할 수 있다.설정파일 수정이 끝났으면, 적용을 위해 Wazuh Manager 서비스를 재시작 해야한다.
# Wazuh Manager 서비스 재시작 sudo systemctl restart wazuh-manager # Wazuh Manager 서비스 상태 확인 sudo systemctl status wazuh-manager
재시작 후 서비스가
active(running) 상태라면, 조건에 맞는 Wazuh Alert 발생 시 디스코드로 알림이 오는 것을 확인할 수 있을 것이다.
Wazuh Alert → Discord 세부 동작
sequenceDiagram
participant A as Discord
participant B as Wazuh
A-->B: 웹훅 URL 연동
B->>B: 1️⃣ Alert 발생
B-->>B: 2️⃣ 전송 조건 확인<br/>(조건 만족 시 3️⃣ 진행)
B->>B: 3️⃣ Integrator 모듈이 custom-discord 실행
B->>A: 4️⃣ 스크립트가 Alert를 Discord 메시지 형식의 JSON으로 변환 후<br/>HTTP POST 전송
A->>A: 5️⃣ JSON 페이로드를 해석하여<br/>Discord 메시지 표시
💁 마무리
팀 근두운 CERT 인프라의 핵심인 Wazuh SIEM의 Alert를 Discord와 연동하였다.
해당 과정을 통해 매번 화면을 모니터링하지 않고도 실시간으로 위협을 확인할 수 있게 되었으며, 보안 관제 모니터링 인프라를 성공적으로 확보하였다.
