Files
graylog-ops/graylog_ops/notify.py
T
DeepFal 2d76ed6672 初始提交:恶意 IP 特征+行为融合检测系统
- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行
- 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN)
- 钉钉融合推送(加签、24h 节流、去重)
- 页面状态灯、CSV 导出、Docker 镜像构建文件
2026-10-18 05:03:05 +08:00

168 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Webhook notifications for newly convicted malicious IPs.
Supported payload flavours: WeCom (企业微信), DingTalk, Feishu, Slack and a
generic JSON body. Delivery failures are reported but never break detection.
"""
import base64
import hashlib
import re
import hmac
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from .geo import flag
DEFAULT_TITLE = "新增恶意 IP"
class Notifier:
def __init__(self, config):
self.kind = (config.get("type") or "generic").lower()
self.url = config.get("url") or ""
if not self.url:
raise ValueError("notify config is missing 'url'")
self.secret = config.get("secret") or ""
self.link_prefix = config.get("link_prefix") or ""
self.timeout = int(config.get("timeout") or 10)
self.title = config.get("title") or DEFAULT_TITLE
def link_for(self, ip):
if not self.link_prefix:
return ""
return self.link_prefix.rstrip("/") + "/#/ip/" + ip
@staticmethod
def local_time(value):
try:
text = str(value).replace("Z", "+00:00")
return datetime.fromisoformat(text).astimezone().strftime("%m-%d %H:%M")
except (ValueError, TypeError):
return str(value)
@staticmethod
def risk_label(score):
if score >= 90:
return "🔴 高危"
if score >= 70:
return "🟠 中危"
return "🟡 关注"
def render(self, entries):
count = len(entries)
heading = self.title if count == 1 else "新增 %d 个恶意 IP" % count
lines = ["### 🚨 %s" % heading, ""]
for index, entry in enumerate(entries, 1):
ip = entry.get("ip", "?")
geo = " · ".join(part for part in
(entry.get("country_name"), entry.get("region")) if part)
flag_text = flag(entry.get("country_code"))
place = ("%s %s" % (flag_text, geo)).strip() if geo else ""
score = float(entry.get("score") or 0)
lead = ("**%d. %s**" % (index, ip)) if count > 1 else ("**%s**" % ip)
lines.append("%s%s" % (lead, (" " + place) if place else ""))
lines.append("评分 **%.1f**(%s)|请求 **%s** 次"
% (score, self.risk_label(score), entry.get("total") or 0))
if entry.get("first_seen"):
lines.append("首次攻击 **%s**" % self.local_time(entry["first_seen"]))
rules = entry.get("rules_text")
if rules:
lines.append("")
lines.append("**命中类型**")
for part in rules.split(";"):
part = part.strip()
if not part:
continue
part = re.sub(r"\s+(\d+)\s*次$", r" × \1", part)
lines.append("- " + part)
link = self.link_for(ip)
if link:
lines.append("")
lines.append("[🔗 查看详情与证据](%s)" % link)
if index != count:
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
def signed_url(self, timestamp=None):
"""DingTalk custom robots with 加签 need timestamp + HMAC-SHA256."""
if self.kind != "dingtalk" or not self.secret:
return self.url
stamp = str(timestamp if timestamp is not None
else int(time.time() * 1000))
string_to_sign = stamp + "\n" + self.secret
digest = hmac.new(self.secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(digest).decode("ascii"))
separator = "&" if "?" in self.url else "?"
return "%s%stimestamp=%s&sign=%s" % (self.url, separator, stamp, sign)
def payload(self, text, title=None):
title = title or self.title
if self.kind == "wecom":
return {"msgtype": "markdown", "markdown": {"content": text}}
if self.kind == "dingtalk":
return {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
if self.kind == "feishu":
return {"msg_type": "text", "content": {"text": text}}
if self.kind == "slack":
return {"text": text}
return {"title": title, "text": text}
def post(self, text, title=None):
body = json.dumps(self.payload(text, title), ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
self.signed_url(), data=body, method="POST",
headers={"Content-Type": "application/json; charset=utf-8"})
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
reply = response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
raise RuntimeError("webhook HTTP %s: %s"
% (exc.code, exc.read()[:200].decode("utf-8", "replace")))
except urllib.error.URLError as exc:
raise RuntimeError("webhook unreachable: %s" % exc.reason)
if reply and self.kind in ("wecom", "dingtalk", "feishu"):
try:
parsed = json.loads(reply)
except ValueError:
return reply
code = parsed.get("errcode", parsed.get("code", 0))
if code:
raise RuntimeError("webhook rejected: %s" % reply[:200])
return reply
def send(self, entries):
if not entries:
return ""
return self.post(self.render(entries))
def send_test(self):
return self.post("**%s**\n\n这是一条测试推送,说明通知通道工作正常。"
% self.title, title=self.title + " · 测试")
def load_notifier(path):
with open(path, "r", encoding="utf-8") as handle:
return Notifier(json.load(handle))
def notifier_from_env():
"""Build a notifier from NOTIFY_* environment variables (container mode)."""
import os
url = (os.environ.get("NOTIFY_URL") or "").strip()
if not url:
return None
return Notifier({
"type": os.environ.get("NOTIFY_TYPE", "generic"),
"url": url,
"secret": os.environ.get("NOTIFY_SECRET", ""),
"link_prefix": os.environ.get("NOTIFY_LINK_PREFIX", ""),
"title": os.environ.get("NOTIFY_TITLE", "新增恶意 IP"),
})