初始提交:恶意 IP 特征+行为融合检测系统
- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行 - 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN) - 钉钉融合推送(加签、24h 节流、去重) - 页面状态灯、CSV 导出、Docker 镜像构建文件
This commit is contained in:
commit
2d76ed6672
42 files changed
+10645
No files matched your search
@@ -0,0 +1,167 @@
|
||||
"""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"),
|
||||
})
|
||||
Reference in new issue
Block a user