buildrisk-radar

ADR-025 알림 — 경보와 같은 트랜잭션의 outbox, 알림함 · 서명된 웹훅

배경 경보는 화면을 열어야 보였다. 상용 조기경보 서비스는 관심 기업에 변화가 생기면 먼저 알려 준다. 알림은 외부로 나가는 첫 기능이라 빠짐 · 중복 · 폭주 · SSRF · 비밀 유출을 설계에서 막아야 한다.

결정

  1. Transactional outbox — AlertService 가 경보를 쓰는 같은 트랜잭션(규칙 평가 청크)에서 notify.outbox 에 사건을 남긴다. 경보는 생겼는데 알림이 빠지거나, 롤백된 경보가 알려지는 경우가 없다. 메시지 브로커 없이 DB 하나로(ADR-011 · 012 와 같은 원칙).
  2. 알릴 사건만 — NEW(그 규칙 · 대상에 열린 경보가 없다가 생김) · REOPENED(닫힌 경보가 다시 참). 기간 규칙이 다음 분기로 이어지기만 한 경우와 규칙 파라미터를 바꿔 같은 경보가 새 버전으로 다시 생긴 경우는 알리지 않는다 — 분기마다 · 규칙을 고칠 때마다 알림이 쏟아지지 않게.
  3. worker 가 나눔 — 30초마다 FOR UPDATE SKIP LOCKED 로 사건을 가져가 한 SQL 문으로 구독과 맞춘다(심각도 · 규칙 · 대상 유형 · 관심 목록만). 같은 사람 · 경보는 알림함에 한 번, 같은 웹훅 · 경보는 전송 한 번(유니크 키). worker 가 여럿이어도 한 번만 처리.
  4. 웹훅 전송 — HTTP 호출은 DB 트랜잭션 밖에서, 대기열 행은 2분 임대(SENDING)로 지킨다(죽은 worker 의 행은 임대가 끝나면 다시 가져감). 한 웹훅으로 가는 경보는 메시지 하나에 최대 20건을 묶는다. 실패하면 1분 · 5분 · 30분 · 2시간 · 12시간 뒤 다시 보내고 6번째에 포기(DEAD), 다시 보내도 같은 4xx(웹훅 삭제 등)와 주소 거부는 바로 포기.
  5. 채널 — 알림함(화면 머리말 종 · 1분마다 읽지 않은 수), Slack Incoming Webhook, 서명된 JSON(GENERIC). 웹훅은 관리자만 등록하고 웹훅으로 보내는 구독도 관리자만 만든다. 알림함 구독 · 관심 목록은 분석가도.

보안

위협 대응
SSRF — 관리자가 적은 주소로 서버가 요청 https · 기본 포트만, user@ · # 금지. 호스트를 풀어 모든 주소가 공인 주소일 때만(루프백 · 사설 · 169.254 메타데이터 · CGNAT · ULA · 멀티캐스트 거부, IPv4 를 품은 IPv6 — NAT64 · 6to4 · IPv4 호환 — 는 안의 IPv4 로 판단, Teredo · 문서용 대역 거부 — VERIFICATION 113). 등록할 때와 보낼 때마다 다시 검사, 리다이렉트 안 따라감, 연결 3초 · 응답 5초, 응답 본문은 읽지 않음
웹훅 주소 · 비밀 유출 (Slack 주소는 경로 자체가 토큰) AES-256-GCM 저장 암호화(키 = NOTIFY_SECRET_KEY 를 HMAC 으로 유도, 무작위 IV, 변조는 태그로 거부). 응답 · 화면에는 호스트만(https://hooks.slack.com/…), GENERIC 서명 비밀은 만들 때 한 번만 보여 줌
위조 · 재전송된 웹훅을 받는 쪽이 믿음 GENERIC: X-BuildRisk-Signature: sha256=HMAC(비밀, 타임스탬프 + "." + 본문) · X-BuildRisk-Timestamp · X-BuildRisk-Delivery(중복 제거용)
Slack 메시지 주입(<!channel> · 가짜 링크) 경보 제목 · 이름의 & < > 이스케이프
남의 알림 · 구독 보기 /api/v1/me/** 는 조회도 로그인 필요, 주인이 아니면 404(있는지도 알리지 않음). 변경은 기존 감사 로그에 남음

키가 없으면 웹훅 기능만 끄고(화면에 안내) 알림함은 동작한다. make env 가 키를 만든다.

고려했지만 하지 않은 것

남은 위험

검증

덧붙임 (2026-10-09, VERIFICATION 131) X-BuildRisk-Delivery 는 같은 전송 묶음(전송 행 집합)이면 다시 보내도 같은 값(UUID v3)이다 — 전에는 매번 새 UUID 라 ‘응답 시간 초과’로 다시 보낸 것을 받는 쪽이 걸러낼 수 없었다. 결과는 아직 내 임대(lease_until)인 행에만 적고, 한 번 가져간 전송은 90초 안에 보내지 못하면 시도 횟수를 되돌려 돌려놓는다(임대 2분을 넘기면 다른 worker 가 같은 행을 다시 보냄).

덧붙임 (2026-10-09, VERIFICATION 153) 다시 열린 경보는 같은 (웹훅, 경보) 전송 행을 다시 쓰므로, Delivery ID 를 행 번호만으로 만들면 재발 알림이 첫 전송과 같은 ID 라 받는 쪽이 중복으로 버렸다. 다시 열 때 전송 행의 시작 시각을 새로 적고 ID 에 (행 번호, 시작 시각)을 넣는다 — 같은 주기의 재시도는 여전히 같은 값.