194 lines
6.0 KiB
Python
194 lines
6.0 KiB
Python
|
|
# =============================================================================
|
||
|
|
# 企微IT智能服务台 — Token异常检测定时任务
|
||
|
|
# =============================================================================
|
||
|
|
# 说明:定时检测Token异常使用行为:
|
||
|
|
# 1. 同一Token在多个不同IP使用(可能凭证泄露)
|
||
|
|
# 2. 检测时间窗口:1小时内
|
||
|
|
# 3. 告警阈值:>=3个不同IP
|
||
|
|
# 运行频率:每5分钟执行一次
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
import redis.asyncio as aioredis
|
||
|
|
|
||
|
|
from app.config import settings
|
||
|
|
from app.services.token_service import TokenService
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
# 异常检测配置
|
||
|
|
TOKEN_ANOMALY_IP_THRESHOLD = 3 # 同一Token使用不同IP数量阈值
|
||
|
|
TOKEN_ANOMALY_TIME_WINDOW = 3600 # 检测时间窗口(秒)
|
||
|
|
|
||
|
|
# Redis Key前缀
|
||
|
|
TOKEN_IP_PREFIX = "token_ip:"
|
||
|
|
|
||
|
|
|
||
|
|
async def detect_token_ip_anomaly():
|
||
|
|
"""检测Token异常使用行为。
|
||
|
|
|
||
|
|
检测逻辑:
|
||
|
|
1. 扫描所有 token_ip:* 的Key
|
||
|
|
2. 解析IP列表,统计不同IP数量
|
||
|
|
3. 超过阈值则触发告警
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
# 创建Redis客户端
|
||
|
|
redis_client = settings.create_redis_client()
|
||
|
|
token_service = TokenService(redis_client)
|
||
|
|
|
||
|
|
# 扫描所有Token IP记录
|
||
|
|
cursor = 0
|
||
|
|
anomaly_count = 0
|
||
|
|
anomalies = []
|
||
|
|
|
||
|
|
while True:
|
||
|
|
cursor, keys = await redis_client.scan(
|
||
|
|
cursor=cursor,
|
||
|
|
match=f"{TOKEN_IP_PREFIX}*",
|
||
|
|
count=100
|
||
|
|
)
|
||
|
|
|
||
|
|
for key in keys:
|
||
|
|
# 解析key获取token_hash
|
||
|
|
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
|
||
|
|
token_hash = key_str.replace(f"{TOKEN_IP_PREFIX}", "")
|
||
|
|
|
||
|
|
# 获取IP记录
|
||
|
|
data = await redis_client.get(key)
|
||
|
|
if not data:
|
||
|
|
continue
|
||
|
|
|
||
|
|
data_str = data.decode("utf-8") if isinstance(data, bytes) else data
|
||
|
|
|
||
|
|
# 解析IP列表
|
||
|
|
ips = set()
|
||
|
|
for entry in data_str.split(","):
|
||
|
|
if "@" in entry:
|
||
|
|
ip, _ = entry.rsplit("@", 1)
|
||
|
|
ips.add(ip)
|
||
|
|
|
||
|
|
# 检测异常
|
||
|
|
if len(ips) >= TOKEN_ANOMALY_IP_THRESHOLD:
|
||
|
|
anomaly_count += 1
|
||
|
|
anomalies.append({
|
||
|
|
"token_hash": token_hash,
|
||
|
|
"ip_count": len(ips),
|
||
|
|
"ips": list(ips),
|
||
|
|
"timestamp": datetime.now().isoformat()
|
||
|
|
})
|
||
|
|
|
||
|
|
logger.warning(
|
||
|
|
f"检测到Token异常使用: token_hash={token_hash}, "
|
||
|
|
f"ip_count={len(ips)}, ips={list(ips)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
if cursor == 0:
|
||
|
|
break
|
||
|
|
|
||
|
|
# 发送告警
|
||
|
|
if anomalies:
|
||
|
|
await _send_anomaly_alert(anomalies)
|
||
|
|
|
||
|
|
logger.info(f"Token异常检测完成: 检测到 {anomaly_count} 个异常")
|
||
|
|
|
||
|
|
await redis_client.aclose()
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Token异常检测任务执行失败: {e}", exc_info=True)
|
||
|
|
|
||
|
|
|
||
|
|
async def _send_anomaly_alert(anomalies: list):
|
||
|
|
"""发送Token异常告警。
|
||
|
|
|
||
|
|
通过企微机器人发送告警消息。
|
||
|
|
|
||
|
|
Args:
|
||
|
|
anomalies: 异常列表
|
||
|
|
"""
|
||
|
|
if not anomalies:
|
||
|
|
return
|
||
|
|
|
||
|
|
# 检查是否配置了webhook
|
||
|
|
webhook = getattr(settings, "content_audit_webhook", None)
|
||
|
|
if not webhook:
|
||
|
|
logger.warning("未配置 content_audit_webhook,跳过告警")
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
# 构建告警消息
|
||
|
|
lines = ["🚨 **Token异常使用告警**\n"]
|
||
|
|
lines.append(f"**检测时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
|
|
lines.append(f"**异常数量**: {len(anomalies)}")
|
||
|
|
lines.append("\n**异常详情:**")
|
||
|
|
|
||
|
|
for i, a in enumerate(anomalies[:5], 1): # 最多显示5条
|
||
|
|
ips_str = ", ".join(a["ips"][:3])
|
||
|
|
if len(a["ips"]) > 3:
|
||
|
|
ips_str += f" ... (+{len(a['ips']) - 3} more)"
|
||
|
|
lines.append(f"{i}. Token: `{a['token_hash'][:8]}...`")
|
||
|
|
lines.append(f" IP数量: {a['ip_count']}, IPs: {ips_str}")
|
||
|
|
|
||
|
|
if len(anomalies) > 5:
|
||
|
|
lines.append(f"\n... 还有 {len(anomalies) - 5} 条异常")
|
||
|
|
|
||
|
|
lines.append("\n⚠️ **建议**: 立即检查是否为凭证泄露,必要时禁用相关账户")
|
||
|
|
|
||
|
|
content = "\n".join(lines)
|
||
|
|
|
||
|
|
# 发送企微机器人消息
|
||
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
||
|
|
await client.post(
|
||
|
|
webhook,
|
||
|
|
json={
|
||
|
|
"msgtype": "markdown",
|
||
|
|
"markdown": {
|
||
|
|
"content": content
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info(f"Token异常告警已发送: {len(anomalies)} 条")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"发送Token异常告警失败: {e}")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_token_anomaly_detection():
|
||
|
|
"""测试用:模拟Token多IP使用场景。
|
||
|
|
|
||
|
|
创建测试数据,验证检测逻辑。
|
||
|
|
"""
|
||
|
|
import hashlib
|
||
|
|
|
||
|
|
redis_client = settings.create_redis_client()
|
||
|
|
token_service = TokenService(redis_client)
|
||
|
|
|
||
|
|
# 生成测试token
|
||
|
|
test_token = "test_token_anomaly_12345"
|
||
|
|
token_hash = hashlib.sha256(test_token.encode()).hexdigest()[:16]
|
||
|
|
|
||
|
|
# 模拟同一token使用3个不同IP
|
||
|
|
key = f"{TOKEN_IP_PREFIX}{token_hash}"
|
||
|
|
test_data = (
|
||
|
|
"192.168.1.100@2026-07-14T10:00:00,"
|
||
|
|
"192.168.1.101@2026-07-14T10:05:00,"
|
||
|
|
"192.168.1.102@2026-07-14T10:10:00"
|
||
|
|
)
|
||
|
|
|
||
|
|
await redis_client.setex(key, 3600, test_data)
|
||
|
|
logger.info(f"测试数据已创建: key={key}, data={test_data}")
|
||
|
|
|
||
|
|
await redis_client.aclose()
|
||
|
|
logger.info("测试数据创建完成,请运行检测任务验证")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
import asyncio
|
||
|
|
# 直接运行时执行测试
|
||
|
|
asyncio.run(test_token_anomaly_detection())
|