初始提交:恶意 IP 特征+行为融合检测系统
- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行 - 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN) - 钉钉融合推送(加签、24h 节流、去重) - 页面状态灯、CSV 导出、Docker 镜像构建文件
This commit is contained in:
commit
2d76ed6672
42 files changed
+10645
No files matched your search
+24
@@ -0,0 +1,24 @@
|
||||
# 凭据
|
||||
graylog-ops.json
|
||||
notify.json
|
||||
config.local.json
|
||||
*.token
|
||||
|
||||
# 运行时数据/采集产物
|
||||
data/
|
||||
collect-*/
|
||||
graylog-snapshot-*/
|
||||
graylog-collect-*/
|
||||
snapshots/
|
||||
*.tar.gz
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# Docker 构建临时目录(由 Dockerfile 生成 staging)
|
||||
docker-build/app/
|
||||
docker-build/seed/
|
||||
@@ -0,0 +1,672 @@
|
||||
# graylog-ops
|
||||
|
||||
Graylog **4.0.x** 只读信息采集 + 封禁判断工具,服务于"用业务访问日志自动封 IP"这条链路。
|
||||
|
||||
## 边界(先说清楚)
|
||||
|
||||
Graylog 是**旁路日志系统**:它能看到、能聚合、能告警,但**不在业务流量链路上,不能自己封 IP**。
|
||||
本工具同样只读:不建流、不改索引、不建事件、不下发任何封禁。
|
||||
|
||||
```
|
||||
外部客户端
|
||||
│
|
||||
▼
|
||||
深信服 AD 7.0.8R5(应用交付 / 负载均衡 / 反代)
|
||||
│ ▲
|
||||
│ └── 将来封禁的执行点大概率在这里(黑名单 / 访问控制)
|
||||
▼
|
||||
业务系统(nginx 等) ──日志──► Graylog ──► graylog-ops(本工具:采集 + 判断)
|
||||
│
|
||||
└──► {ip, action, score, reasons}
|
||||
```
|
||||
|
||||
## 深信服 AD 在链路里的影响(关键)
|
||||
|
||||
AD 挡在业务系统前面,会直接改变"日志里的客户端 IP"的含义,排查顺序:
|
||||
|
||||
1. **AD 是否透传真实客户端 IP**:HTTP 类型的业务看 AD 的"真实客户端 IP / X-Forwarded-For"配置;
|
||||
四层转发通常保留源地址,七层反代不配置就会变成 AD 自己的地址。
|
||||
2. **后端 nginx 的 `$remote_addr` 是不是 AD 地址**。若是,日志里必须加
|
||||
`$http_x_forwarded_for`(或 AD 提供的专用头),并在采集端落成独立字段。
|
||||
3. 用 `discover` 验证:如果候选字段的"私有 IP 占比"很高、且 `source` 只有一个取值,
|
||||
基本可以判定拿到的是 AD/反代地址,而不是真实客户端。
|
||||
4. 把 AD 的管理地址、业务 VIP、回源地址写进白名单,避免把自己的设备封掉。
|
||||
|
||||
## 为什么先做"信息获取 + 判断"
|
||||
|
||||
封禁规则依赖四个事实,猜错任何一个都会封错人:
|
||||
|
||||
1. **真实客户端 IP 落在哪个字段**(`source` 往往是采集器/反代地址,不是访问者)
|
||||
2. 日志里有哪些可做判据的字段(`status` / `request_uri` / `user_agent` / `response_time` …)
|
||||
3. 量级(每秒多少条,决定窗口和阈值)
|
||||
4. 现有 stream / 索引 / 事件定义是怎么组织的
|
||||
|
||||
`discover` 回答 1、2;`histogram` / `top-ips` 回答 3;`snapshot` / `streams` / `alerts` 回答 4;
|
||||
`judge` 把这些聚合成"封谁、为什么、怎么处理"。
|
||||
|
||||
## 快速开始
|
||||
|
||||
零第三方依赖,只用 Python 3.7+ 标准库。
|
||||
|
||||
```bash
|
||||
cd graylog-ops
|
||||
|
||||
# 方式 A:环境变量
|
||||
export GRAYLOG_URL=http://edu-master:9002
|
||||
export GRAYLOG_TOKEN='从 Graylog UI 创建的 API token'
|
||||
bin/graylog-ops probe
|
||||
|
||||
# 方式 B:配置文件(推荐,token 不落 shell history)
|
||||
cp config.example.json graylog-ops.json
|
||||
# 编辑 graylog-ops.json 填 url / token
|
||||
bin/graylog-ops --config graylog-ops.json probe
|
||||
|
||||
# 方式 C:临时参数
|
||||
bin/graylog-ops --url http://edu-master:9002 --token "$TOKEN" probe
|
||||
```
|
||||
|
||||
> 认证方式:**API token 当用户名,字面量密码 `token`**(4.0 文档的写法)。
|
||||
> 工具内部已按此拼 `Authorization: Basic base64(<token>:token)`,你只需要给 `--token`。
|
||||
> 非 GET 请求会自动带 `X-Requested-By`(Graylog 的 CSRF 保护要求)。
|
||||
>
|
||||
> **token 放哪**:写进 `graylog-ops.json`(已在 `.gitignore` 里)并 `chmod 600`。
|
||||
> 只需要读权限的 token,**不需要管理员账号密码**;用完在 UI 里一键吊销。
|
||||
> 不要把 token 贴到聊天、工单或截图里——`--config` 读文件时它不会出现在命令行或输出中。
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `probe` | 连通性 / 认证 / 16 个接口的可用性矩阵,**第一步跑这个** |
|
||||
| `system` | 版本、节点、索引器健康、deflector、license |
|
||||
| `streams [--rules]` | stream 列表(分流与权限边界),`--rules` 额外拉规则 |
|
||||
| `inputs` | input 类型、监听地址、端口 |
|
||||
| `fields` | Graylog 已注册字段及类型 |
|
||||
| `indices` | index set 轮转/保留策略、索引大小 |
|
||||
| `alerts` | 现有事件定义与通知方式(改造前先看,别重复建) |
|
||||
| `discover` | **采样日志,推断真实客户端 IP 字段**(核心) |
|
||||
| `judge` | **把聚合信号判成封禁候选**(只读,dry-run 语义) |
|
||||
| `top-ips` | `terms` 聚合,默认 `--field client_ip`,看 top IP 与计数 |
|
||||
| `terms` | 任意字段的 terms 聚合 |
|
||||
| `messages` | 拉原始消息(表格 / `--jsonl`) |
|
||||
| `histogram` | 消息量时间分布,估算阈值与容量 |
|
||||
| `snapshot` | 全量环境信息落盘(每个资源一个 JSON + manifest) |
|
||||
| `raw` | 直接调用任意 API 路径(支持 `--method`/`--body`),应对版本差异 |
|
||||
| `collect` | 一条命令产出 probe/discover/judge/snapshot 整套信息 |
|
||||
| `watch` | 持续检测恶意公网 IP + SQLite 清单 + 本地 Web 面板 |
|
||||
|
||||
通用参数:`--json`(机器可读)、`--out FILE`(写 JSON)、`-v`(打印每个 HTTP 请求)。
|
||||
|
||||
## 五步摸底
|
||||
|
||||
```bash
|
||||
# 1) 能不能通、这个版本到底有哪些接口
|
||||
bin/graylog-ops probe
|
||||
|
||||
# 2) 客户端 IP 在哪个字段(最关键的结论),同时生成 profile
|
||||
bin/graylog-ops discover --range 3600 --stream 业务访问日志 \
|
||||
--save-profile profiles/edu.json
|
||||
|
||||
# 3) 用真实数据看阈值是否合理
|
||||
bin/graylog-ops top-ips --field client_ip --query 'status:401' --range 300 --size 30
|
||||
bin/graylog-ops histogram --range 3600 --interval 5m
|
||||
|
||||
# 4) 判断:谁该封、为什么
|
||||
bin/graylog-ops judge --profile profiles/edu.json --range 300 \
|
||||
--allow allowlist.example.txt --explain 5
|
||||
|
||||
# 5) 整体快照存档,方便离线分析和复盘
|
||||
bin/graylog-ops snapshot --dir snapshots/$(date +%F) --sample 500
|
||||
```
|
||||
|
||||
> 机器上要能同时访问 `10.180.x`(Graylog)和 `10.230.x`(AD),否则先在跳板机跑。
|
||||
|
||||
`discover` 的输出(示例数据):
|
||||
|
||||
```
|
||||
采样 8 条消息 query=* range=3600s stream=(all)
|
||||
|
||||
分数 字段 出现率 IP占比 不同IP 私有IP IP版本 样例
|
||||
---- --------- ------ ------ ------ ------ ------ ----------------------------
|
||||
95.0 client_ip 100% 100% 4 12% v4 203.0.113.5, 198.51.100.7
|
||||
45.0 source 100% 100% 1 100% v4 10.180.254.17
|
||||
|
||||
推荐字段: client_ip (confidence=high)
|
||||
- 字段 `client_ip` 的 IP 占比 100%,样例 203.0.113.5, 198.51.100.7, 192.0.2.44
|
||||
! `source` 只有 1 个不同取值,而 `client_ip` 有 4 个:`source` 是采集器/反代,封禁必须用 `client_ip`
|
||||
```
|
||||
|
||||
## 判断规则(judge)
|
||||
|
||||
固定的 5 个 `terms` 查询 + 3 个统计量,不随 IP 数量增加查询次数:
|
||||
|
||||
| 信号 | 查询(字段名来自 profile) | 权重 | 饱和点 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `auth_fail` | `status:(401 OR 403)` | 45 | 10 次 | 认证失败 |
|
||||
| `scanner_ua` | `http_user_agent:(sqlmap OR nikto OR nuclei OR nmap OR …)` | 50 | 2 次 | 扫描器特征 |
|
||||
| `sensitive_path` | `request_uri:(wp-login.php OR phpmyadmin OR actuator OR …)` | 25 | 5 次 | 敏感路径探测 |
|
||||
| `not_found` | `status:404` | 20 | 20 次 | 目录/文件探测 |
|
||||
| `bad_method` | `request_method:(PUT OR DELETE OR TRACE OR CONNECT)` | 10 | 5 次 | 异常方法 |
|
||||
| `rate` | 请求数 / 200 | 25 | 200 次 | 绝对量 |
|
||||
| `spike` | 请求量 ≥ 候选中位数 4 倍 | 15 | 64 倍 | 相对异常 |
|
||||
| `auth_ratio` | 失败占比 ≥ 50% 且 ≥ 10 次 | 25 | — | 几乎全是失败 |
|
||||
|
||||
动作映射:
|
||||
|
||||
| 条件 | 动作 |
|
||||
| --- | --- |
|
||||
| 有攻击证据 且 score ≥ 75 | `ban_1h` |
|
||||
| 有攻击证据 且 score ≥ 55 | `ban_30m` |
|
||||
| score ≥ 40 | `observe`(只记录,不封) |
|
||||
| 其余 | `ignore` |
|
||||
|
||||
三条安全约束(比阈值本身更重要):
|
||||
|
||||
1. **内网地址默认硬排除**(RFC1918 / 回环 / 链路本地 / CGNAT),避免把 AD、LB、NAT 出口封掉;
|
||||
`--include-internal` 可关闭,但一般不该关。
|
||||
2. **白名单硬排除**:`--allow` 支持多个文件,一行一个 IP/CIDR,`#` 注释。
|
||||
把 AD 管理地址、业务 VIP、回源地址、办公出口、监控探针都写进去。
|
||||
3. **纯流量不封**(`require_evidence`):没有任何攻击特征、只是请求量大的客户端,
|
||||
最多判 `observe`,不会因为"量大"被自动拉黑。
|
||||
|
||||
`judge` 输出示例:
|
||||
|
||||
```
|
||||
窗口 300s query=* field=client_ip stream=(all) 候选池 4(中位请求数 35.0)
|
||||
|
||||
得分 动作 客户端 IP 请求数 依据
|
||||
---- ------ ------------ ------ -----------------------------------------------
|
||||
98.1 ban_1h 192.0.2.99 25 扫描器 UA 25 次; 敏感路径 25 次; 404 探测 25 次
|
||||
75.0 ban_1h 203.0.113.66 40 认证失败 40 次; 失败请求占 100%
|
||||
|
||||
已排除(不参与判分):
|
||||
10.180.5.5 30 内部地址(RFC1918/回环/CGNAT)
|
||||
```
|
||||
|
||||
**信号命中 0 个 IP 时要注意**:可能是该窗口确实没有这类流量,也可能是查询模板跟字段分词不匹配
|
||||
(比如 `request_uri` 在 ES 里的分词方式和你以为的不一样)。用 profile 覆盖 `query` 后再跑,
|
||||
`judge` 会把每个信号的实际查询和命中数都打出来。
|
||||
|
||||
## 持续检测服务(watch)
|
||||
|
||||
```bash
|
||||
# 持续运行(面板 + 检测循环)
|
||||
bin/graylog-ops --config graylog-ops.json watch \
|
||||
--stream F5_Accesslog --profile profiles/f5.json \
|
||||
--interval 60 --window 900 --db data/ips.db \
|
||||
--port 8088 --allow allowlist.example.txt
|
||||
|
||||
# 只跑一轮(离线分析,比如 8 小时)
|
||||
bin/graylog-ops --config graylog-ops.json watch --once \
|
||||
--stream F5_Accesslog --profile profiles/f5.json \
|
||||
--window 28800 --db data/ips-8h.db
|
||||
|
||||
# 只提供面板,浏览已有数据库
|
||||
bin/graylog-ops --config graylog-ops.json watch --serve-only \
|
||||
--db data/ips-8h.db --port 8089
|
||||
```
|
||||
|
||||
- 面板:`http://127.0.0.1:8088`(30 秒自动刷新)
|
||||
- JSON API:`/api/ips?tier=malicious&limit=100`、`/api/stats`、`/api/ip/<ip>`
|
||||
- 存储:`data/ips.db`(SQLite)。IP 连续 `--forget` 秒(默认 7200)不再出现就自动移除
|
||||
- 名单:`--export PATH` 每轮把高置信恶意 IP 写成纯文本(默认剔除 `shared_egress`)
|
||||
|
||||
### 面板(给同事看的)
|
||||
|
||||
**只有一个地址** `http://<本机IP>:8088`(只读,无鉴权,仅限内网使用)。
|
||||
历史(17–18 日 25 小时)与实时检测共用同一个 SQLite(`data/ips.db`),
|
||||
所以打开就是一个完整的大列表:
|
||||
|
||||
- **核心名单**(默认视图):只列高置信恶意 IP,默认按首次攻击倒序(最新在前),点任意一行进详情
|
||||
- 所有判定为恶意的 IP(含此前标为「疑似共享出口」的)统一归入核心名单,不再有「待确认」档
|
||||
- **详情页**:评分环、请求总量、平均频率、峰值时段、活跃时段数、命中规则与次数、
|
||||
请求频率分布图、状态码分布、请求方法分布、访问域名分布,以及**按判定类型抽样的证据**
|
||||
(优先抽攻击类请求;高流量 IP 不会把日志刷屏),附处置建议
|
||||
- 搜索框可按 IP 或判定原因过滤;**排序字段与方向是两个独立下拉**(首次攻击 / 最近攻击 / 评分 / 请求数 × 升序 / 降序)
|
||||
- **单击任意 IP 地址即可复制**(列表和详情页标题都支持,带 toast 提示)
|
||||
- 列表 30 秒自动刷新(可关闭)
|
||||
|
||||
界面截图:[列表页](docs/screenshots/list.png) · [详情页](docs/screenshots/detail.png)
|
||||
- **一键导出**(右上角两个下拉,都作用于当前筛选/排序结果):
|
||||
- 「复制 IP ▾」→ 逗号分隔 / 换行分隔(换行版可直接粘贴给 ipset、防火墙批量导入)
|
||||
- 「导出 ▾」→ CSV(完整字段) / 仅 IP 列表(每行一个)
|
||||
- 只读 API:`/api/ips?scope=core|all`、`/api/ip/<ip>`、`/api/stats`
|
||||
- 导出接口(脚本可直接拉):
|
||||
- `GET /api/export.csv?scope=core|all&sort=&order=` → CSV(含等级/评分/首次攻击/原因)
|
||||
- `GET /api/export.txt?scope=...` → 纯 IP 列表,每行一个
|
||||
|
||||
证据**完全按需生成**,本地库只存判定结果(56 个 IP 约 36 KB),点开详情时才连 Graylog:
|
||||
|
||||
- **9 个查询并发**(URI/UA/状态码/方法/域名/服务端 IP/VIP + 时间分布 + 样本),首次打开约 0.3–0.5 秒,
|
||||
结果在内存缓存 10 分钟,再打开约 1 毫秒;进程重启后重新拉取
|
||||
- 样本走 views 的 `messages` 搜索:旧版 `/search/universal/relative` 在 25 小时窗口 + 复合查询下
|
||||
会被 Graylog 断连,表现就是"只有分布没有样本"(WebShell 这类 IP 尤其明显)
|
||||
- 样本按**判定类型**分组:每种类型各保留最近 3 条(两种类型 6 条、三种类型 9 条,以此类推),
|
||||
同一秒内的重复请求会合并;没有命中规则时退化为最近 5 条通用请求
|
||||
- 每条样本展示源日志的**完整字段**:客户端/服务端 `IP:端口`、Host、VIP(bigip)、VS、
|
||||
地区、响应时间 `rt`、请求长度、Referer、采集来源,以及原始日志行
|
||||
- **长字段可点击弹窗**查看完整内容(URI / UA / Referer / Host / 原始日志行),Esc 或点遮罩关闭
|
||||
- **轻量快照兜底**:每次查看详情时顺手存一份 1–2 KB 的摘要(URI/状态/方法/VIP/时间分布),
|
||||
Graylog 索引轮转(约 10 天)后自动回退到快照,并明确标注"日志已过期,以下为判定时的快照"
|
||||
- 负载:每轮固定 6 个 terms 查询 + 最多 `--verify` 次核验,**与 IP 数量无关**
|
||||
(本环境 15 分钟窗口约 3.5–5 秒/轮)
|
||||
|
||||
规则(速率类信号按窗口归一化到 15 分钟基准,行为类信号不缩放):
|
||||
|
||||
| 规则 | 权重 | 饱和 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 敏感路径探测 | 65 | 2 次 | `/actuator`、`/.env`、`/.git`、`/wp-login`、`/druid`、`/nacos`、`/swagger`、`/console`、`/ISAPI`… |
|
||||
| 扫描器 UA | 55 | 1 次 | sqlmap / nikto / nuclei / nmap / zgrab … |
|
||||
| 登录失败(401/403) | 55 | 8 次 | 登录类 URI 上的认证失败 |
|
||||
| 爬虫 UA | 50 | 20 次 | DotBot 等 |
|
||||
| 登录接口高频 | 15 | 2000 次 | 只作上下文,单独不足以定性(学生也会反复登录) |
|
||||
| 404 占比 | 25 | 比例 ≥50% 且 ≥20 次 | 目录/文件探测 |
|
||||
|
||||
分级:**≥60 恶意 / ≥30 可疑 / 其余观察**(观察级分数 <`--min-observe-score`(10) 不入库)。
|
||||
安全约束:内网与白名单硬排除;单一接口高频的自动化客户端自动降级为 `automation`;
|
||||
候选池取「所有规则命中的 IP ∪ 访问量 top」,因此**低流量扫描器不会被漏掉**。
|
||||
|
||||
### 地理信息(离线 GeoIP)
|
||||
|
||||
- 列表新增「国家/地区」列(国旗 + 中文国名 + 省/州),详情页也显示
|
||||
- 数据来自 **DB-IP Lite**(免费、CC BY 4.0),已构建为本地 SQLite:`data/geo/geo.db`(144MB,360 万条 IPv4 段)
|
||||
- 检测时自动补全;已有 IP 在启动时自动回填
|
||||
- 更新地理库(每月):
|
||||
|
||||
```bash
|
||||
cd data/geo
|
||||
curl -O https://download.db-ip.com/free/dbip-city-lite-2026-10.csv.gz
|
||||
gunzip -kf dbip-city-lite-2026-10.csv.gz
|
||||
cd ../.. && python3 -m graylog_ops.geo \
|
||||
--city-csv data/geo/dbip-city-lite-2026-10.csv --out data/geo/geo.db
|
||||
```
|
||||
|
||||
- 关闭地理功能:`--geo-db ""`
|
||||
|
||||
### 新增 IP 推送通知
|
||||
|
||||
- 只有 **新增恶意 IP** 或 **观察/可疑 → 恶意升级** 才会推送;同一 IP 不会重复推
|
||||
- 支持企业微信 / 钉钉 / 飞书 / Slack / 通用 JSON(`type` 字段选择)
|
||||
- 推送失败只打印日志,不影响检测循环
|
||||
- 配置:复制 `notify.example.json` 为 `notify.json`(已在 .gitignore 中),填 webhook:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "dingtalk",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=你的TOKEN",
|
||||
"secret": "SEC你的加签密钥",
|
||||
"link_prefix": "http://179.253.234.124:12139/s/301b.../",
|
||||
"title": "新增恶意 IP"
|
||||
}
|
||||
```
|
||||
|
||||
| type | 说明 |
|
||||
| --- | --- |
|
||||
| `wecom` | 企业微信群机器人(markdown) |
|
||||
| `dingtalk` | 钉钉自定义机器人(markdown,支持 `secret` 加签) |
|
||||
| `feishu` | 飞书自定义机器人(text) |
|
||||
| `slack` | Slack Incoming Webhook |
|
||||
| `generic` | POST `{"title","text"}`,可接自己的网关 |
|
||||
|
||||
测试与自检:
|
||||
|
||||
```bash
|
||||
# 发一条测试消息(验证 webhook 是否通)
|
||||
./bin/graylog-ops --config graylog-ops.json watch --notify-config notify.json --notify-test \
|
||||
--stream F5_Accesslog --profile profiles/f5.json --db data/ips.db
|
||||
```
|
||||
|
||||
推送内容示例:
|
||||
|
||||
```
|
||||
**新增恶意 IP**
|
||||
**112.32.1.211**(中国 · 广东)
|
||||
评分 100.0 · 请求 108 次
|
||||
首次攻击 2026-10-17T18:24:09Z
|
||||
命中:Spring/中间件探测 40 次;配置/凭证探测 8 次
|
||||
[查看详情](http://179.253.234.124:12139/s/301b.../#/ip/112.32.1.211)
|
||||
```
|
||||
|
||||
### 状态指示灯
|
||||
|
||||
页面右上角有一个圆点,表示后端与 Graylog 的连接状态:
|
||||
|
||||
- 绿色:最近一轮检测成功
|
||||
- 红色:最近一轮检测失败
|
||||
- 黄色:状态过期
|
||||
- 灰色:尚未开始检测
|
||||
|
||||
不展示文字,悬停可看提示。
|
||||
|
||||
### 行为检测(已并入主服务,单进程)
|
||||
|
||||
特征检测和行为检测现在跑在**同一个进程**里:
|
||||
|
||||
- 特征检测线程:每 60 秒一轮(原有逻辑)
|
||||
- 行为检测线程:每 15 分钟一轮(24h 窗口,纯行为评分,不依赖规则和名单)
|
||||
- 同一个 Web 前端:融合首页 + 融合详情
|
||||
|
||||
**融合名单规则**:
|
||||
|
||||
- 只有「建议封禁」才展示和推送:特征命中攻击证据,或行为判定为建议封禁(非共享出口)
|
||||
- 共享出口/NAT、人工确认、数据不足等行为结果**不展示、不推送**
|
||||
- 一个 IP 只出现一行,来源标「特征 / 行为 / 双来源」
|
||||
- 首页按 `first_banned_at`(首次进入建议封禁的时间)倒序,**每个 IP 只置顶一次**,后续再命中不改变位置
|
||||
- 历史封禁 IP 永久保留在列表里(不参与 24h 再推)
|
||||
|
||||
**推送**:
|
||||
|
||||
- 融合成一条消息,同一 IP 永远不重复推
|
||||
- 同一 IP 24 小时内最多推一次;24 小时后仍在活动(最近 24h 有流量)的 IP 会再推一条
|
||||
- 首次部署时历史 IP 静默(不补推)
|
||||
|
||||
**详情页**:同一 IP 融合展示「行为检测画像」和「特征检测证据」。
|
||||
|
||||
相关参数:`--behavior-interval 900`(行为刷新间隔)、`--no-behavior`(关闭行为检测)。
|
||||
|
||||
### ASN / IP 归属
|
||||
|
||||
- 列表「位置 / 归属」合并为一列、上下两行:第一行国家/地区,第二行图标 + 厂商/机构(如 ☁️ 阿里云、📱 中国移动)
|
||||
- 悬停显示 ASN 号、完整组织名和网络类型
|
||||
- 详情页显示 ASN 号 + 厂商 + 组织全名 + 类型
|
||||
- CSV 导出包含 `asn, asn_org, asn_brand, asn_type`
|
||||
- 品牌映射在 `asn_brand_map.json`(可继续加厂商),数据库 `data/geo/asn.db`
|
||||
- 更新方式(DB-IP ASN Lite,每月):
|
||||
|
||||
```bash
|
||||
curl -O https://download.db-ip.com/free/dbip-asn-lite-2026-10.csv.gz
|
||||
gunzip -kf dbip-asn-lite-2026-10.csv.gz
|
||||
python3 -m graylog_ops.asn --csv data/geo/dbip-asn-lite-2026-10.csv --out data/geo/asn.db
|
||||
```
|
||||
|
||||
关闭 ASN:`--asn-db ""`
|
||||
|
||||
### 名单语义(重要)
|
||||
|
||||
- **永久保留**:一旦某个 IP 被判定为恶意(`ever_malicious=1` + `last_malicious_at`),
|
||||
就永远不会被 `--forget` 清理,即使它后来降级为可疑/观察,也仍留在核心名单和导出名单里。
|
||||
只有"从未判过恶意"的观察级 IP 才会按 `--forget` 过期删除。
|
||||
- **时间字段全部来自日志,不是检测时间**:
|
||||
- 首次攻击 = 日志中最早命中攻击规则的时间(最多回溯 36 小时)
|
||||
- 最近攻击 = 日志中最近一次命中攻击规则的时间
|
||||
- 最近出现 = 该 IP 最后一条日志的时间
|
||||
- `hits` 只是"被检测到的轮次",不是攻击次数
|
||||
- **只有窗口内计数发生变化时才回查并更新时间**,避免滚动窗口把"最近攻击"不断刷成当前时间
|
||||
- 历史数据用 `watch --fix-times`(别名 `--fix-first-seen`)修正
|
||||
- **默认排序 = 首次攻击倒序(最新攻击的在最上面)**,同一发现时间下最近还在攻击的排前面;
|
||||
面板右上角可切换「最近攻击(最新优先)」「评分」「请求数」。
|
||||
- `blocklist.txt` 导出的是**历史累计**的全部恶意 IP,不是当前窗口快照。
|
||||
- 乱码处理:源日志里非 UTF-8 的字段会被清洗,识别为二进制时显示 `[非 UTF-8 数据]`,
|
||||
URI/方法/状态码等字段不会再把乱码带进页面。
|
||||
|
||||
### 启动方式(按需,不挂后台服务)
|
||||
|
||||
按当前约定**不安装 launchd/systemd**,需要看的时候手动起(DSH/终端重启后进程会消失,重新执行即可):
|
||||
|
||||
```bash
|
||||
cd "/Users/deepfal/Code/Work/ip sec/graylog-ops"
|
||||
./bin/graylog-ops --config graylog-ops.json watch \
|
||||
--stream F5_Accesslog --profile profiles/f5.json \
|
||||
--interval 60 --window 3600 --forget 604800 \
|
||||
--db data/ips.db --host 0.0.0.0 --port 8088 \
|
||||
--allow allowlist.example.txt --export data/blocklist.txt --warm-limit 5
|
||||
```
|
||||
|
||||
停止:Ctrl+C(或用 `pkill -f "graylog_ops watch"`)。
|
||||
|
||||
维护命令:
|
||||
|
||||
```bash
|
||||
# 用日志重新校准历史 IP 的首次攻击 / 最近攻击 / 最近出现时间(幂等)
|
||||
./bin/graylog-ops --config graylog-ops.json watch --fix-times \
|
||||
--stream F5_Accesslog --profile profiles/f5.json --db data/ips.db
|
||||
|
||||
# 为名单里的恶意 IP 预生成证据快照
|
||||
./bin/graylog-ops --config graylog-ops.json watch --warm-only --warm-limit 200 \
|
||||
--stream F5_Accesslog --profile profiles/f5.json --db data/ips.db
|
||||
```
|
||||
|
||||
## 两日实测结果(本地 10-17 00:00 – 10-18 01:00)
|
||||
|
||||
窗口 89,600 秒(约 24.9 小时),12 条规则,`--size 600`:
|
||||
|
||||
**55 个候选 → 48 个恶意 = 44 个高置信 + 4 个疑似共享出口;4 个可疑。**
|
||||
|
||||
| 探测类型 | 典型路径 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| Spring/中间件 | `/actuator*`、`/druid*`、`/nacos*`、`/swagger*`、`/jmx*`、`/heapdump*`、`/console*`、`/manager/html*`、`/solr*` | probe_java |
|
||||
| 配置/凭证 | `/.env*`、`/.git*`、`/.svn*`、`/.aws*`、`/.ssh*`、`/appsettings*`、`/WEB-INF*`、`/backup*` | probe_config |
|
||||
| CMS | `/wp-*`、`/xmlrpc.php`、`/phpmyadmin*`、`/administrator*`、`/joomla*`、`/thinkphp*` | probe_cms |
|
||||
| IoT/路由器 | `/ISAPI*`、`/LAPI*`、`/HNAP1*`、`/boaform*`、`/GponForm*`、`/cgi-bin/luci*` | probe_iot |
|
||||
| DevOps/监控 | `/jenkins*`、`/grafana*`、`/kibana*`、`/zabbix*`、`/prometheus*`、`/consul*`、`/harbor*` | probe_devops |
|
||||
| WebShell/上传 | `/shell.php`、`/cmd.php`、`/eval-stdin.php`、`/phpinfo*`、`/vendor/phpunit*` | probe_webshell |
|
||||
| 注入/穿越 | `etc/passwd`、`jndi:`、`union select`、`%2e%2e`、`%252f`、`base64_decode` | exploit_payload |
|
||||
| 登录撞库 | `/login*`、`/passport*`、`/captcha*` + 401/403 | login_attack / login_burst |
|
||||
| 扫描器/爬虫 | sqlmap/nikto/nuclei/ffuf…、DotBot | bad_ua / bot_ua |
|
||||
|
||||
高置信名单(截至本轮回填,已写入 `data/blocklist.txt`):
|
||||
|
||||
```
|
||||
117.136.27.147 113.31.155.142 113.31.155.219 116.176.186.63
|
||||
39.144.210.4 120.232.115.57 39.144.210.0 186.241.107.91
|
||||
91.92.242.37 36.159.247.84 113.201.250.120 42.91.116.131
|
||||
124.152.12.48 120.83.247.23 223.198.163.145 217.60.242.58
|
||||
112.32.8.112 119.42.146.230 120.214.218.174 213.165.242.55
|
||||
216.219.95.217 190.122.240.80 91.194.91.202 182.16.82.226
|
||||
109.105.209.20 42.94.151.105 5.188.86.234 209.99.189.217
|
||||
165.154.218.226 115.190.159.225 51.158.145.102 111.229.129.233
|
||||
223.123.92.48 222.83.114.16 109.105.209.18 43.136.79.172
|
||||
91.92.243.72 35.209.176.178 47.121.125.205 109.205.176.10
|
||||
130.12.180.171 130.12.180.168
|
||||
```
|
||||
|
||||
疑似共享出口 / NAT(4 个,**排除在名单外**):42.92.144.29(653344)、27.185.218.213(125241)、118.181.187.248(30978)、42.94.148.133(22757)
|
||||
|
||||
判定公式:`(请求量 >= 50000 且 探测占比 < 2%) 或 (请求量 >= 20000 且 探测占比 < 0.5%)`
|
||||
→ 标记 `risk=shared_egress`,导出名单时自动剔除。
|
||||
|
||||
> 规则收紧记录:`/uploadfiles/*`(正常文件目录)和 `/database/*`(正常业务路径)曾被
|
||||
> 误判为 WebShell/配置探测,已从前缀里移除;`/config.json`、`/settings.json` 同理。
|
||||
|
||||
## profile 格式
|
||||
|
||||
`discover --save-profile` 生成的骨架,`judge --profile` 读取:
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"client_ip": "client_ip",
|
||||
"status": "status",
|
||||
"uri": "request_uri",
|
||||
"ua": "http_user_agent",
|
||||
"method": "request_method"
|
||||
},
|
||||
"signals": {
|
||||
"sensitive_path": {"query": "request_uri:wp-login", "weight": 30, "saturate": 3}
|
||||
},
|
||||
"thresholds": {
|
||||
"min_requests": 30,
|
||||
"ban_score": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields` 可被命令行覆盖(`--field` / `--status-field` / `--uri-field` / `--ua-field` / `--method-field`),
|
||||
`signals` 里 `enabled: false` 可以关掉某个信号。
|
||||
|
||||
## Graylog 4.0.8 接口笔记
|
||||
|
||||
| 用途 | 路径 |
|
||||
| --- | --- |
|
||||
| 版本/集群 | `GET /api/system` |
|
||||
| 节点 | `GET /api/system/cluster/nodes` |
|
||||
| 索引器健康 | `GET /api/system/indexer/cluster/health` |
|
||||
| 字段类型 | `GET /api/system/fields` |
|
||||
| stream | `GET /api/streams` |
|
||||
| input | `GET /api/system/inputs` |
|
||||
| index set | `GET /api/system/indices/index_sets?skip=0&limit=100` |
|
||||
| 索引 | `GET /api/system/indexer/indices` |
|
||||
| 事件定义 | `GET /api/events/definitions?page=1&per_page=50` |
|
||||
| 通知 | `GET /api/events/notifications` |
|
||||
| 相对时间搜索 | `GET /api/search/universal/relative?query=*&range=300&limit=100&sort=timestamp:desc` |
|
||||
| 绝对时间搜索 | `GET /api/search/universal/absolute?query=*&from=...&to=...` |
|
||||
| terms 聚合 | `GET /api/search/universal/terms?field=client_ip&range=300&size=50` |
|
||||
| 直方图 | `GET /api/search/universal/histogram?query=*&range=3600&interval=1m` |
|
||||
| 统计 | `GET /api/search/universal/stats?field=...&stats=cardinality,count` |
|
||||
| stream 过滤 | 追加 `filter=streams:<streamId>` |
|
||||
|
||||
4.0 的关键点:
|
||||
|
||||
- **legacy 搜索 API 是主路径**。`/api/views/search`(pivot)在 4.0 已经存在,但 DTO 与 5.x/6.x 差异大,
|
||||
本工具不用它,避免被版本升级打断。需要时可用 `raw` 直接调。
|
||||
- `size` / `limit` 在 terms 端点上的参数名历史上不一致,客户端遇到 400 会自动换另一个再试,`probe` 会显示实际生效的那个。
|
||||
- 时间一律 **UTC ISO8601**,返回形如 `2026-10-17T15:00:00.000Z`。
|
||||
- 搜索返回是 `{"messages": [{"message": {...}}, ...]}`,工具已摊平。
|
||||
- 4.0.8 跑在 Java 8 / CentOS 7 上,`/api/system` 不返回 ES 版本;后端是 ES 6.8 或 7.10。
|
||||
|
||||
## 4.0.8 实测踩坑(本环境已验证)
|
||||
|
||||
这几条是拿着真实 token 打 edu-master 得出的,不是文档抄的:
|
||||
|
||||
1. **`terms` / `histogram` / `stats` 在 4.0 已移除**(返回 404)。
|
||||
聚合必须走 views API:`POST /api/views/search/sync`(pivot)。
|
||||
工具会自动探测:views 可用就用 views,3.x 上自动回退到 legacy 端点。
|
||||
pivot 的行组是 `{"type":"values","field":"uri","limit":50}`(注意是 `field` 不是 `fields`)。
|
||||
2. **字段是 keyword 语义**,查询有三条铁律:
|
||||
- `uri:ketang` → 0 条;`uri:/ketang-studyapi/apis*` → 46 万条(只有**尾部通配符**有效)
|
||||
- **未转义的 `/` 会被 Lucene 当成正则分隔符**:`uri:/manager/html*` 会误命中所有含
|
||||
`html` token 的文档(实测 9079 条,全是 UEditor 的 JS 静态文件)。必须写成
|
||||
`uri:\/manager\/html*`,或引号精确匹配 `uri:"/wp-login.php"`
|
||||
- 前导通配符 `uri:*admin*` 被禁用(直接 500/400)
|
||||
judge 的默认信号查询已按此修正(`ua:sqlmap*`、`uri:\/actuator*`)。
|
||||
3. **部分 F5 VS 的 HTTP 字段为空串**:1 小时样本里 `uri` 约 98% 有值、`status` 约 48%、
|
||||
`ua` 约 48%。四层 VS 只有 `client_ip/server_ip/method` 等基础字段。
|
||||
4. `status` 是混格式字符串(`200 OK` / `404` / `502 Bad Gateway`),`status:404` 仍能按
|
||||
token 命中;`status_code` 字段全空,不要用。
|
||||
5. `event_type` 只有 `req`,不存在 req/resp 双份,不需要去重。
|
||||
|
||||
## 本环境实测结论(2026-10-18)
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 集群 | 4 节点:`edu-master`(master) + `edu-slave1/3/4`,2354 个 active shard,deflector `graylog_457` |
|
||||
| 数据链路 | **F5**(`10.180.241.2/3`) --syslog--> Graylog。深信服 AD 不在 F5 访问日志链路上(它对应的是 DNS 类业务) |
|
||||
| 活跃 stream | `F5_Accesslog`(约 11 万条/5 分钟)、`F5 Attack Logs`、`有源业务数据-Nat-Stream`(NAT 会话)、`syslog_vpn_stream`(VPN);`站群`、`全量站点数据` **24h 内无数据** |
|
||||
| 量级 | `F5_Accesslog` 约 130 万条/小时(≈360 条/秒) |
|
||||
| 字段 | `client_ip` / `server_ip` / `uri` / `host` / `ua` / `status` / `method` / `bigip` / `vs`;**没有 XFF 字段** |
|
||||
| 关键事实 | `10.185.9.41`(46%) 和 `10.185.9.15`(45%) 两个**内网 IP 占 91% 流量**,是内部 API 轮询(`/ketang-studyapi/apis/*`,一半是 404),必须白名单/内网排除;外网用户 F5 直见真实 IP,所以 `client_ip` 可用于封禁 |
|
||||
|
||||
真实判定样例(1 小时窗口,`--verify 5`):
|
||||
|
||||
```
|
||||
100.0 ban_1h 186.241.107.91 1164 认证失败 24; 敏感路径 36; 404 探测 156
|
||||
68.5 ban_30m 113.201.250.120 612 认证失败 3; 敏感路径 6; 404 探测 5
|
||||
54.0 observe 124.70.2.111 666 认证失败 333(集中在单接口,已降级)
|
||||
```
|
||||
|
||||
- `186.241.107.91` 挖到 `/actuator/env`、`/actuator/xyzabc`(fuzz)、`/actuator;/health`(分号绕过尝试)
|
||||
- `113.201.250.120` 同样在打 `/actuator/env`
|
||||
- `124.70.2.111` 每 **10.6 秒** POST 同一个业务 API、稳定 403 —— 自动化客户端 token 过期,
|
||||
被 `--verify` 识别并降级为 `observe`,不误封
|
||||
|
||||
`--verify N` 会对前 N 个 ban 候选多做一次 URI 聚合:若流量 90% 以上集中在单一非登录接口,
|
||||
判定为"疑似业务客户端/自动化任务"并降级。这是本环境实测后加的必要规则。
|
||||
|
||||
## 权限
|
||||
|
||||
只读账号给这些权限就够(缺哪个会报 403,`probe` 会逐条标出来):
|
||||
|
||||
```
|
||||
messages:read searches:relative searches:absolute
|
||||
searches:keyword streams:read inputs:read
|
||||
indexsets:read eventdefinitions:read eventnotifications:read
|
||||
```
|
||||
|
||||
首次摸底建议直接用管理员 token,确认链路通了再收窄到只读 token。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 纯只读,不提供写入/告警创建(下一阶段再做,会单独走 `--apply` 路径)。
|
||||
- `judge` 是单字段聚合:只按 `client_ip` 分组,不做多字段联合(如 IP×URI)。
|
||||
这是刻意的取舍——固定查询次数,代价是拿不到"同一 IP 命中几种不同敏感路径"这类信号。
|
||||
需要时用 `--explain` 抽样人工看。
|
||||
- 每类聚合最多看 top `--size`(默认 200)个 IP;超出部分不参与判分。
|
||||
- `discover` 用 offset 分页采样,适合几千条量级;十万级请改用 `terms` / `histogram`。
|
||||
- 大时间窗 + 高基数 `terms` 会打 ES,`--range` 别一上来就 7d;先 `histogram` 看量。
|
||||
|
||||
## 与执行侧衔接(下一步)
|
||||
|
||||
判断层已经产出 `{ip, action, score, reasons, signals}`。执行侧有两个候选:
|
||||
|
||||
1. **Graylog 事件 + HTTP 通知**:事件定义按 `client_ip` 分组 count 超阈值 → 通知到 ban-service。
|
||||
优点是近实时;缺点是阈值表达力有限,复杂判定还得在服务里做。
|
||||
2. **定时跑 `judge`**(如每 1 分钟)+ 直接调 AD 接口:
|
||||
|
||||
```
|
||||
cron: graylog-ops judge --range 120 --json --out /var/lib/ban/candidates.json
|
||||
→ ban-service 读 candidates
|
||||
→ 白名单/冷却/幂等校验
|
||||
→ 调 AD 黑名单 API 下发
|
||||
→ TTL 到期自动删除 / 复核
|
||||
```
|
||||
|
||||
**AD 接口这块先不猜**。截图里管理入口是
|
||||
`https://10.230.13.25:4433/index?x_id=<...>#mod=/mod_runtime/homepage/index`,
|
||||
`x_id` 看着像会话/鉴权参数,前端是 SPA 路由,真正的 XHR 端点得抓包确认。需要你做的只有一件事:
|
||||
|
||||
> 在能访问 AD 的机器上打开 Chrome DevTools → Network → 勾选 Fetch/XHR →
|
||||
> 手工做一遍"添加/删除一条 IP 黑名单" → 右键 `Copy as cURL`(或导出 HAR)发我。
|
||||
|
||||
需要覆盖的请求:登录、会话刷新、业务/节点列表、黑名单增删查、下发后的生效确认。
|
||||
拿到之后我补一个 `sangfor` 模块(登录态维持、黑名单 CRUD、幂等、限流),复用现有的 HTTP 重试/错误归一。
|
||||
|
||||
## 环境备忘
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| Graylog | 4.0.8+6b8c55d @ `edu-master:9002`(4 节点,Java 8 / CentOS 7) |
|
||||
| 深信服 AD | 7.0.8R5,UI `https://10.230.13.25:4433`,HA(主)`10.1.1.1`,业务 5 / 节点 3 |
|
||||
| 网络 | 开发机可访问 `10.180.254.17:9002` 与 `10.230.13.25:4433` |
|
||||
| 凭据 | `graylog-ops.json`(chmod 600,已 gitignore),只读 token |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
python3 tests/demo.py # 用假服务预览各命令输出,不需要真 Graylog
|
||||
python3 -m unittest discover -s tests # 41 项单测 + 端到端测试
|
||||
```
|
||||
|
||||
测试不需要真实 Graylog:`tests/fake_graylog.py` 起一个本地 HTTP 服务,按 4.0 的响应结构回包,
|
||||
并实现了一个很小的 Lucene 子集(`field:value`、`field:(a OR b)`)来验证 `judge` 的查询构造。
|
||||
要加新接口,先在这个假服务里加路由,再补一条 e2e。
|
||||
|
||||
```
|
||||
graylog_ops/
|
||||
http.py urllib 封装:Basic auth、X-Requested-By、重试、错误归一
|
||||
config.py CLI / 环境变量 / JSON 文件三级配置
|
||||
client.py 4.0 REST 接口 -> 普通 dict/list,容忍响应形状差异
|
||||
detect.py 客户端 IP 字段推断(IP 解析、内部地址判定、评分)
|
||||
judge.py 判断层:信号采集、基线、评分、动作映射、白名单
|
||||
commands.py 各子命令(构建 JSON + 人读渲染)
|
||||
render.py CJK 宽度对齐的表格
|
||||
tests/
|
||||
fake_graylog.py 假 Graylog 4.0 服务(含查询子集)
|
||||
test_detect.py 字段推断单测
|
||||
test_judge.py 判断层测试(暴力破解/扫描/正常大流量/内网/白名单)
|
||||
test_e2e.py CLI 端到端
|
||||
```
|
||||
|
||||
## 排错
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
| --- | --- |
|
||||
| 连接超时 | 服务在 **9002** 端口(9000 是空等)。9002 也不通时走 VPN / 内网跳板机 |
|
||||
| 401 | token 错或已吊销;确认是"API token"不是 session token |
|
||||
| 403 | 账号缺权限,看 `probe` 里哪项 FAIL |
|
||||
| 404 | 该版本没这个接口,用 `raw /api/...` 验证,或 `probe` 看矩阵 |
|
||||
| 400 / 422 | 参数不被接受,加 `-v` 看实际请求 URL |
|
||||
| `discover` 没结果 | 时间窗内无数据 / stream 过滤错 / 查询语法错 / 无权限(静默返回空) |
|
||||
| `discover` 推荐的还是代理 IP | 采集端没解析 XFF;需要 AD 透传 + nginx `log_format` 落真实 IP |
|
||||
| `judge` 没候选 | 窗口内 IP 数低于 `--min-requests`,或分数低于 `--min-score`;先 `--all` 看全量 |
|
||||
| `judge` 信号命中 0 | 查询模板与字段分词不匹配(见"判断规则"末尾),用 profile 覆盖 query |
|
||||
| 封禁候选里出现自己的设备 | 白名单没写全;把 AD 地址/VIP/回源/监控补进 `--allow` 文件 |
|
||||
@@ -0,0 +1,22 @@
|
||||
# 封禁白名单(judge --allow 使用)
|
||||
# 一行一个 IP 或 CIDR,# 之后是注释。
|
||||
# 这里列的东西永远不会进入封禁候选,请按实际环境补全。
|
||||
|
||||
# --- 基础设施 ---
|
||||
10.180.254.17 # Graylog 主机 edu-master
|
||||
10.230.13.25 # 深信服 AD 管理地址(UI :4433)
|
||||
|
||||
# --- 深信服 AD ---
|
||||
10.1.1.1 # AD HA/设备地址(截图显示,按实际确认)
|
||||
# 10.230.13.0/24 # AD 所在管理网段,酌情放开
|
||||
|
||||
# --- 业务 VIP / 反代回源 ---
|
||||
# 填 AD 上配置的业务 VIP 与后端真实服务器地址
|
||||
# 10.1.1.0/24
|
||||
|
||||
# --- 办公出口 / 集中 NAT ---
|
||||
# 注意:整个出口网段拉黑会中断整栋楼的上网,务必确认
|
||||
# 10.180.0.0/16
|
||||
|
||||
# --- 监控 / 探测 ---
|
||||
# 监控探针、拨测、健康检查来源
|
||||
@@ -0,0 +1,951 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behaviour analysis prototype v2 (read-only).
|
||||
|
||||
Computes 24h traffic features per client IP from Graylog pivot aggregations,
|
||||
derives a behaviour profile, combines it with the existing rule score into a
|
||||
single risk score with an action recommendation, and serves the result on a
|
||||
separate port. It never writes to the production sqlite database.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
import sqlite3
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
from graylog_ops.http import HttpClient # noqa: E402
|
||||
from graylog_ops.client import GraylogClient, normalize_histogram # noqa: E402
|
||||
from graylog_ops import watch # noqa: E402
|
||||
|
||||
STREAM = "streams:6ac83059a6878e26256fcacc"
|
||||
WINDOW = 86400
|
||||
DATA_FILE = os.path.join(ROOT, "data", "behavior_prototype.json")
|
||||
DB_FILE = os.path.join(ROOT, "data", "ips.db")
|
||||
|
||||
INTERNAL = ("10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.2", "172.3")
|
||||
|
||||
UA_SDK = ("curl", "wget", "python-requests", "python-urllib", "go-http-client",
|
||||
"okhttp", "java/", "apache-httpclient", "httpcomponents",
|
||||
"libredtail", "axios", "node-fetch", "aiohttp", "scrapy",
|
||||
"postmanruntime", "insomnia", "unityplayer", "dart/", "httpie",
|
||||
"guzzle", "faraday", "restsharp", "libwww", "eeo/", "academy_widget")
|
||||
UA_BROWSER = ("chrome", "firefox", "safari", "edg/", "opr/", "gecko/20100101")
|
||||
|
||||
|
||||
ASN_CSV = os.path.join(ROOT, "data", "geo", "dbip-asn-lite-2026-10.csv")
|
||||
_ASN_CACHE = {}
|
||||
|
||||
|
||||
def _load_asn():
|
||||
if _ASN_CACHE:
|
||||
return _ASN_CACHE
|
||||
import bisect
|
||||
starts, ranges = [], []
|
||||
try:
|
||||
with open(ASN_CSV, encoding="utf-8", errors="replace") as fh:
|
||||
for row in csv.reader(fh):
|
||||
if len(row) < 4 or ":" in row[0]:
|
||||
continue
|
||||
try:
|
||||
first = int(ipaddress.IPv4Address(row[0]))
|
||||
last = int(ipaddress.IPv4Address(row[1]))
|
||||
except ValueError:
|
||||
continue
|
||||
starts.append(first)
|
||||
ranges.append((last, row[2], row[3]))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
order = sorted(range(len(starts)), key=lambda i: starts[i])
|
||||
starts = [starts[i] for i in order]
|
||||
ranges = [ranges[i] for i in order]
|
||||
_ASN_CACHE.update({"starts": starts, "ranges": ranges, "bisect": bisect})
|
||||
return _ASN_CACHE
|
||||
|
||||
|
||||
def asn_info(ip):
|
||||
cache = _load_asn()
|
||||
starts = cache.get("starts") or []
|
||||
if not starts:
|
||||
return None
|
||||
try:
|
||||
value = int(ipaddress.IPv4Address(ip))
|
||||
except ValueError:
|
||||
return None
|
||||
index = cache["bisect"].bisect_right(starts, value) - 1
|
||||
if index >= 0:
|
||||
last, asn, org = cache["ranges"][index]
|
||||
if last >= value:
|
||||
return {"asn": asn, "org": org, "shared": shared_type(org)}
|
||||
return None
|
||||
|
||||
|
||||
def shared_type(org):
|
||||
text = (org or "").lower()
|
||||
cloud = ("alibaba", "aliyun", "tencent", "huawei cloud", "aws", "amazon",
|
||||
"google", "microsoft", "digitalocean", "vultr", "linode", "hetzner",
|
||||
"ovh", "wowrack", "idc", "data center", "datacenter", "cloud",
|
||||
"hosting", "colo")
|
||||
if any(token in text for token in cloud):
|
||||
return "hosting"
|
||||
if "china mobile" in text or "中国移动" in text or "cmnet" in text:
|
||||
return "mobile"
|
||||
if ("china unicom" in text or "china telecom" in text
|
||||
or "chinanet" in text or "联通" in text or "电信" in text
|
||||
or "broadband" in text):
|
||||
return "broadband"
|
||||
if "education" in text or "cernet" in text or "教育" in text:
|
||||
return "education"
|
||||
return "unknown"
|
||||
|
||||
|
||||
ORG_CN = (("china mobile", "中国移动"), ("china unicom", "中国联通"),
|
||||
("china telecom", "中国电信"), ("chinanet", "中国电信"),
|
||||
("cernet", "中国教育网"), ("education", "教育网"),
|
||||
("alibaba", "阿里云"), ("aliyun", "阿里云"),
|
||||
("huawei cloud", "华为云"), ("tencent", "腾讯云"),
|
||||
("wowrack", "Wowrack 机房"), ("vmiss", "VMISS 机房"),
|
||||
("idc", "IDC 机房"))
|
||||
|
||||
|
||||
def org_cn(org):
|
||||
text = (org or "").lower()
|
||||
for token, name in ORG_CN:
|
||||
if token in text:
|
||||
return name
|
||||
return org or "未知"
|
||||
|
||||
|
||||
SHARE_LABEL = {"mobile": "移动网络", "broadband": "宽带 ISP",
|
||||
"education": "教育网", "hosting": "云主机/机房",
|
||||
"unknown": "未知网络"}
|
||||
|
||||
|
||||
def humanize(item):
|
||||
total = item["total"]
|
||||
parts = []
|
||||
if item["uri_card"] >= 20:
|
||||
parts.append("访问了 %s 个不同路径" % format(item["uri_card"], ","))
|
||||
if item["four_xx"] >= 20:
|
||||
parts.append("产生 %s 次失败请求(失败率 %.0f%%)"
|
||||
% (format(int(item["four_xx"]), ","), item["pct4"]))
|
||||
elif item["pct4"] >= 20:
|
||||
parts.append("失败率 %.0f%%" % item["pct4"])
|
||||
if item["coverage"] <= 30 and item["peak_share"] >= 25:
|
||||
parts.append("流量集中在很短的时间内")
|
||||
if item["login_ratio"] >= 20:
|
||||
parts.append("大量请求集中在登录接口")
|
||||
if item["host_card"] >= 2:
|
||||
parts.append("同时访问了 %d 个站点" % item["host_card"])
|
||||
if item["ua_family"] in ("sdk", "fake-browser", "other"):
|
||||
parts.append("使用脚本/非浏览器客户端")
|
||||
behavior = item.get("behavior_type") or "异常自动化"
|
||||
tail = {
|
||||
"目录扫描": "符合目录扫描特征",
|
||||
"短时扫描": "符合短时扫描特征",
|
||||
"登录爆破": "符合登录爆破特征",
|
||||
"伪造爬虫 UA": "伪装成搜索引擎爬虫,且请求大量失败",
|
||||
"采集爬虫": "是采集爬虫",
|
||||
"异常自动化": "行为高度自动化,不像正常用户",
|
||||
}.get(behavior, "行为异常")
|
||||
if parts:
|
||||
return "24 小时内" + ",".join(parts) + "——" + tail + "。"
|
||||
return "24 小时内请求 %d 次,%s。" % (total, tail)
|
||||
|
||||
|
||||
def load_client():
|
||||
cfg = json.load(open(os.path.join(ROOT, "graylog-ops.json")))
|
||||
return GraylogClient(HttpClient(cfg["url"], cfg["token"], "token"))
|
||||
|
||||
|
||||
def pivot(client, series, row_groups, column_groups=None, query="*",
|
||||
limit=300, timeout=180000):
|
||||
body = {"queries": [{
|
||||
"id": "q1",
|
||||
"timerange": {"type": "relative", "range": WINDOW},
|
||||
"query": {"type": "elasticsearch", "query_string": query},
|
||||
"filter": {"type": "or", "filters": [{"type": "stream",
|
||||
"id": STREAM.split(":")[-1]}]},
|
||||
"search_types": [{
|
||||
"type": "pivot", "id": "st1", "series": series,
|
||||
"row_groups": [{"type": "values", "field": f, "limit": limit}
|
||||
for f in row_groups],
|
||||
"column_groups": column_groups or [], "sort": [], "rollup": True,
|
||||
}],
|
||||
}], "parameters": []}
|
||||
data = client.http.request("POST", "/api/views/search/sync",
|
||||
params={"timeout": timeout}, body=body)
|
||||
return data["results"]["q1"]["search_types"]["st1"]
|
||||
|
||||
|
||||
def rows_to_dict(search_type, series_id):
|
||||
out = {}
|
||||
for row in search_type.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
if not key:
|
||||
continue
|
||||
ip = str(key[0])
|
||||
for value in row.get("values") or []:
|
||||
if (value.get("key") or [None])[0] == series_id:
|
||||
try:
|
||||
out[ip] = float(value.get("value") or 0)
|
||||
except (TypeError, ValueError):
|
||||
out[ip] = 0.0
|
||||
return out
|
||||
|
||||
|
||||
def hourly_matrix(search_type):
|
||||
out = {}
|
||||
for row in search_type.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
if not key:
|
||||
continue
|
||||
counts = []
|
||||
for value in row.get("values") or []:
|
||||
stamp = (value.get("key") or [None])[0]
|
||||
if not stamp or "cnt" not in (value.get("key") or []):
|
||||
continue
|
||||
try:
|
||||
counts.append((stamp, float(value.get("value") or 0)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out[str(key[0])] = counts
|
||||
return out
|
||||
|
||||
|
||||
def bucket_stats(counts):
|
||||
active = [c for c in counts if c > 0]
|
||||
if not counts:
|
||||
return {"coverage": 0.0, "cv": None, "peak_share": 0.0}
|
||||
cv = (statistics.pstdev(active) / statistics.fmean(active)
|
||||
if len(active) > 2 and statistics.fmean(active) else None)
|
||||
peak = max(counts) if counts else 0
|
||||
return {"coverage": len(active) / len(counts) * 100,
|
||||
"cv": cv,
|
||||
"peak_share": (peak / sum(counts) * 100) if sum(counts) else 0.0}
|
||||
|
||||
|
||||
def uri_template(uri):
|
||||
path = str(uri or "").split("?")[0]
|
||||
path = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "{uuid}", path)
|
||||
path = re.sub(r"[0-9a-fA-F]{16,}", "{hex}", path)
|
||||
path = re.sub(r"\d+", "{n}", path)
|
||||
return path
|
||||
|
||||
|
||||
def ua_family(ua):
|
||||
text = str(ua or "").strip()
|
||||
if not text:
|
||||
return "empty"
|
||||
lower = text.lower()
|
||||
if any(token in lower for token in UA_SDK):
|
||||
return "sdk"
|
||||
if lower.startswith("mozilla/") and any(t in lower for t in UA_BROWSER):
|
||||
return "browser"
|
||||
if lower.startswith("mozilla/5.0"):
|
||||
return "fake-browser"
|
||||
return "other"
|
||||
|
||||
|
||||
def ua_garbage(ua_card, top_ua):
|
||||
"""The F5 UA field carries binary garbage on some VS: detect it."""
|
||||
if ua_card > 50:
|
||||
return True
|
||||
text = str(top_ua or "")
|
||||
if not text:
|
||||
return False
|
||||
printable = sum(1 for char in text if 32 <= ord(char) < 127 or ord(char) > 127)
|
||||
return printable / max(1, len(text)) < 0.85
|
||||
|
||||
|
||||
def rule_data(client, fields):
|
||||
rules = {r["name"]: {"title": r["title"], "query": r["query"](fields),
|
||||
"weight": r["weight"]}
|
||||
for r in watch.RULE_DEFS}
|
||||
hits = {}
|
||||
|
||||
def run(item):
|
||||
name, meta = item
|
||||
try:
|
||||
st = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query=meta["query"], limit=300)
|
||||
return name, rows_to_dict(st, "cnt")
|
||||
except Exception:
|
||||
return name, {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
for name, counts in pool.map(run, rules.items()):
|
||||
for ip, count in counts.items():
|
||||
if count > 0:
|
||||
hits.setdefault(ip, {})[name] = int(count)
|
||||
return rules, hits
|
||||
|
||||
|
||||
def detail_for(client, ip):
|
||||
q = 'client_ip:"%s"' % ip
|
||||
detail = {"uri": [], "ua": [], "host": []}
|
||||
try:
|
||||
st = pivot(client, [{"type": "count", "id": "cnt"}], ["uri"],
|
||||
query=q, limit=40)
|
||||
for row in st.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
value = (row.get("values") or [{}])[0].get("value")
|
||||
if key:
|
||||
detail["uri"].append((str(key[0]), int(float(value or 0))))
|
||||
except Exception:
|
||||
pass
|
||||
for label, field, size in (("ua", "ua", 3), ("host", "host", 2)):
|
||||
try:
|
||||
st = pivot(client, [{"type": "count", "id": "cnt"}], [field],
|
||||
query=q, limit=size)
|
||||
for row in st.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
value = (row.get("values") or [{}])[0].get("value")
|
||||
if key:
|
||||
detail[label].append((str(key[0]), int(float(value or 0))))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
hist = client.histogram(query=q, range_seconds=WINDOW, interval="5m",
|
||||
filter_str=STREAM)
|
||||
detail["buckets5m"] = [b["count"] for b in normalize_histogram(hist)]
|
||||
except Exception:
|
||||
detail["buckets5m"] = []
|
||||
return ip, detail
|
||||
|
||||
|
||||
def gather_details(client, ips):
|
||||
out = {}
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
for ip, detail in pool.map(lambda value: detail_for(client, value), ips):
|
||||
out[ip] = detail
|
||||
return out
|
||||
|
||||
|
||||
def rule_score_for(total, hits):
|
||||
tier, score, reasons = watch.classify(total, hits)
|
||||
return tier, score, reasons
|
||||
|
||||
|
||||
def behavior_profile(feat):
|
||||
"""Return (category, adjustment, confidence, notes) from behaviour only."""
|
||||
notes = []
|
||||
total = feat["total"]
|
||||
coverage = feat["coverage"]
|
||||
cv = feat["cv"]
|
||||
pct2, pct4 = feat["pct2"], feat["pct4"]
|
||||
status_ok = feat["status_known"] >= 10
|
||||
ua_ok = not feat["ua_garbage"]
|
||||
ua_card = feat["ua_card"]
|
||||
|
||||
if not ua_ok:
|
||||
notes.append("UA 字段异常(%d 种)" % ua_card)
|
||||
if not status_ok:
|
||||
notes.append("status 字段缺失(仅 %.0f%%)" % feat["status_known"])
|
||||
if total < 100:
|
||||
notes.append("24h 仅 %d 次请求,行为样本不足" % total)
|
||||
return "insufficient", -10, "低", notes
|
||||
|
||||
if coverage >= 80 and cv is not None and cv <= 1.2 and ua_ok and ua_card <= 2:
|
||||
if status_ok and pct4 >= 50:
|
||||
notes.append("长期稳定轮询、%.0f%% 失败" % pct4)
|
||||
return "integration_error", -35, "中", notes
|
||||
notes.append("覆盖率 %.0f%%、小时CV %.2f、UA %d 个%s"
|
||||
% (coverage, cv, ua_card,
|
||||
("、2xx %.0f%%" % pct2) if status_ok else ""))
|
||||
return "server", -35, "高" if status_ok else "中", notes
|
||||
if coverage >= 80 and cv is not None and cv > 1.2 and ua_ok and ua_card <= 2:
|
||||
if (status_ok and pct2 >= 85) or not status_ok:
|
||||
notes.append("覆盖率 %.0f%%、小时CV %.2f,周期性批量任务" % (coverage, cv))
|
||||
return "batch_job", -30, "中", notes
|
||||
if ua_ok and ua_card >= 5 and feat["total"] >= 300:
|
||||
notes.append("%d 种 UA、请求 %d 次,疑似共享出口/NAT"
|
||||
% (ua_card, total))
|
||||
return "shared_nat", -40, "中", notes
|
||||
if ((feat["uri_templates"] >= 200 and total >= 500 and ua_ok
|
||||
and ua_card <= 3 and (not status_ok or pct2 >= 80))
|
||||
or (feat["get_ratio"] >= 85 and feat["uri_templates"] >= 100
|
||||
and total >= 1000 and ua_ok)):
|
||||
notes.append("访问 %d 个 URI 模板,UA 稳定%s"
|
||||
% (feat["uri_templates"],
|
||||
("、2xx %.0f%%" % pct2) if status_ok else ""))
|
||||
return "crawler", -15, "中", notes
|
||||
if status_ok and pct4 >= 50 and total >= 1000 and ua_ok and ua_card <= 3:
|
||||
notes.append("请求 %d 次、%.0f%% 失败,疑似鉴权/配置异常" % (total, pct4))
|
||||
return "integration_error", -35, "中", notes
|
||||
if coverage <= 30 and ua_card >= 3 and (not status_ok or pct4 >= 20):
|
||||
notes.append("短时活跃、%d 种 UA、%.0f%% 4xx" % (ua_card, pct4))
|
||||
return "script_candidate", 10, "低", notes
|
||||
if coverage <= 30 and (not status_ok or pct2 >= 90):
|
||||
notes.append("短时活跃、成功率 %.0f%%" % pct2)
|
||||
return "human", -25, "低", notes
|
||||
notes.append("覆盖率 %.0f%%、CV %s、4xx %.0f%%"
|
||||
% (coverage, ("%.2f" % cv) if cv is not None else "-", pct4))
|
||||
return "uncertain", 0, "低", notes
|
||||
|
||||
|
||||
def combine(rule_tier, rule_score, behavior_cat, behavior_adj):
|
||||
"""Hybrid risk score + action recommendation."""
|
||||
score = rule_score + behavior_adj
|
||||
server_like = behavior_cat in ("server", "batch_job", "shared_nat",
|
||||
"integration_error")
|
||||
if behavior_cat == "insufficient":
|
||||
server_like = False
|
||||
risk = max(0.0, min(100.0, score))
|
||||
if rule_score >= 60:
|
||||
action = "人工确认" if server_like else "封禁"
|
||||
elif risk >= 45:
|
||||
action = "人工确认"
|
||||
elif risk >= 25:
|
||||
action = "观察"
|
||||
else:
|
||||
action = "正常"
|
||||
if server_like and action == "封禁":
|
||||
action = "人工确认"
|
||||
return round(risk, 1), action, server_like
|
||||
|
||||
|
||||
def behavior_score(feat):
|
||||
"""Behaviour-only risk score. Uses no rule hits and no known list."""
|
||||
total = feat["total"]
|
||||
if total < 10:
|
||||
return 0, ["请求过少"]
|
||||
score = 0
|
||||
signals = []
|
||||
pct4 = feat["pct4"]
|
||||
if pct4 >= 50:
|
||||
score += 30
|
||||
signals.append("高失败率 %.0f%%" % pct4)
|
||||
elif pct4 >= 30:
|
||||
score += 15
|
||||
signals.append("失败率 %.0f%%" % pct4)
|
||||
if feat["uri_card"] >= 20:
|
||||
score += 20
|
||||
signals.append("路径分散(%d 个 URI)" % feat["uri_card"])
|
||||
elif feat["uri_card"] >= 8:
|
||||
score += 8
|
||||
if feat["coverage"] <= 30 and feat["peak_share"] >= 25:
|
||||
score += 20
|
||||
signals.append("短时爆发(覆盖 %.0f%%、峰值 %.0f%%)"
|
||||
% (feat["coverage"], feat["peak_share"]))
|
||||
if feat["login_ratio"] >= 20:
|
||||
score += 15
|
||||
signals.append("登录接口集中 %.0f%%" % feat["login_ratio"])
|
||||
if feat["host_card"] >= 2:
|
||||
score += 5
|
||||
signals.append("多站点(%d 个 Host)" % feat["host_card"])
|
||||
if feat["ua_card"] == 1:
|
||||
score += 5
|
||||
if feat["ua_family"] in ("sdk", "fake-browser", "other"):
|
||||
score += 10
|
||||
signals.append("自动化 UA(%s)" % feat["ua_family"])
|
||||
elif feat["ua_family"] == "browser":
|
||||
score -= 5
|
||||
if feat.get("four_xx", 0) >= 100 and pct4 >= 20:
|
||||
score += 10
|
||||
signals.append("失败请求 %d 次" % feat["four_xx"])
|
||||
# business/integration client: mostly POST or a few steady endpoints
|
||||
if feat["get_ratio"] < 30:
|
||||
score -= 25
|
||||
signals.append("非 GET 为主(业务接口)")
|
||||
if pct4 < 15 and feat["pct2"] >= 80:
|
||||
score -= 20
|
||||
signals.append("低失败率(采集/浏览)")
|
||||
if (feat["coverage"] >= 80 and feat["cv"] is not None
|
||||
and feat["cv"] <= 1.2 and feat["uri_templates"] <= 8):
|
||||
score -= 20
|
||||
signals.append("长期稳定单接口")
|
||||
if feat["uri_templates"] >= 15:
|
||||
score += 10
|
||||
signals.append("路径模板分散(%d 个)" % feat["uri_templates"])
|
||||
if feat["status_known"] < 10:
|
||||
score -= 10
|
||||
signals.append("状态字段缺失")
|
||||
return max(0, min(100, score)), signals
|
||||
|
||||
|
||||
def empty_feat(ip):
|
||||
return {
|
||||
"ip": ip, "total": 0, "uri_card": 0, "ua_card": 0, "host_card": 0,
|
||||
"method_card": 0, "get_ratio": 0.0, "login_ratio": 0.0,
|
||||
"pct2": 0.0, "pct3": 0.0, "pct4": 0.0, "pct5": 0.0,
|
||||
"status_known": 0.0, "coverage": 0.0, "cv": None, "peak_share": 0.0,
|
||||
"coverage5m": 0.0, "cv5m": None, "peak_share5m": 0.0,
|
||||
"uri_templates": 0, "ua_family": "", "ua_garbage": False,
|
||||
"top_uri": "", "top_ua": "",
|
||||
}
|
||||
|
||||
|
||||
def finalize_feat(feat, status_map, get_map, login_map, hourly_map):
|
||||
ip = feat["ip"]
|
||||
buckets = [c for _, c in hourly_map.get(ip, [])]
|
||||
hour = bucket_stats(buckets)
|
||||
sum_status = sum(status_map[cls].get(ip, 0) for cls in status_map)
|
||||
total = feat["total"]
|
||||
feat.update({
|
||||
"get_ratio": get_map.get(ip, 0) / total * 100 if total else 0.0,
|
||||
"login_ratio": login_map.get(ip, 0) / total * 100 if total else 0.0,
|
||||
"pct2": status_map["2*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct3": status_map["3*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct4": status_map["4*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct5": status_map["5*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"status_known": (sum_status / total * 100) if total else 0.0,
|
||||
"coverage": hour["coverage"],
|
||||
"cv": hour["cv"],
|
||||
"peak_share": hour["peak_share"],
|
||||
"four_xx": status_map["4*"].get(ip, 0),
|
||||
})
|
||||
return feat
|
||||
|
||||
|
||||
def analyze():
|
||||
client = load_client()
|
||||
profile = json.load(open(os.path.join(ROOT, "profiles", "f5.json")))
|
||||
fields = profile.get("fields", profile)
|
||||
|
||||
known = {}
|
||||
try:
|
||||
db = sqlite3.connect(DB_FILE)
|
||||
db.row_factory = sqlite3.Row
|
||||
for row in db.execute("select ip, ever_malicious from ip_stats"):
|
||||
known[row["ip"]] = bool(row["ever_malicious"])
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
print("拉取 24h 全量聚合 ...", flush=True)
|
||||
st = pivot(client,
|
||||
[{"type": "count", "id": "cnt"},
|
||||
{"type": "card", "field": "uri", "id": "uri_card"},
|
||||
{"type": "card", "field": "ua", "id": "ua_card"},
|
||||
{"type": "card", "field": "host", "id": "host_card"},
|
||||
{"type": "card", "field": "method", "id": "method_card"}],
|
||||
["client_ip"], limit=600)
|
||||
total = rows_to_dict(st, "cnt")
|
||||
uri_card = rows_to_dict(st, "uri_card")
|
||||
ua_card = rows_to_dict(st, "ua_card")
|
||||
host_card = rows_to_dict(st, "host_card")
|
||||
method_card = rows_to_dict(st, "method_card")
|
||||
|
||||
status = {cls: {} for cls in ("2*", "3*", "4*", "5*")}
|
||||
for cls in status:
|
||||
st_cls = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="status:%s" % cls, limit=600)
|
||||
status[cls] = rows_to_dict(st_cls, "cnt")
|
||||
get_count = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="method:GET", limit=600), "cnt")
|
||||
LOGIN_QUERY = ("uri:(\\/login* OR \\/passport* OR \\/captcha* "
|
||||
"OR \\/jsxy\\/api\\/login*)")
|
||||
login_count = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query=LOGIN_QUERY, limit=600), "cnt")
|
||||
st_hour = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
column_groups=[{"type": "time", "field": "timestamp",
|
||||
"interval": "1h"}], limit=600)
|
||||
hourly = hourly_matrix(st_hour)
|
||||
|
||||
# IPs with the most 4xx are the ones a behaviour model should look at;
|
||||
# they are usually outside the volume top-600.
|
||||
st_4xx = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="status:4*", limit=800)
|
||||
four_xx = rows_to_dict(st_4xx, "cnt")
|
||||
|
||||
base = {}
|
||||
for ip, count in total.items():
|
||||
if count <= 0:
|
||||
continue
|
||||
feat = empty_feat(ip)
|
||||
feat["total"] = int(count)
|
||||
feat["uri_card"] = int(uri_card.get(ip, 0))
|
||||
feat["ua_card"] = int(ua_card.get(ip, 0))
|
||||
feat["host_card"] = int(host_card.get(ip, 0))
|
||||
feat["method_card"] = int(method_card.get(ip, 0))
|
||||
base[ip] = finalize_feat(feat, status, get_count, login_count, hourly)
|
||||
|
||||
extra = sorted(set(four_xx) - set(total), key=lambda ip: -four_xx[ip])
|
||||
extra = [ip for ip in extra if not ip.startswith(INTERNAL)][:600]
|
||||
print("补充 %d 个高 4xx IP 的计数 ..." % len(extra), flush=True)
|
||||
for offset in range(0, len(extra), 80):
|
||||
chunk = extra[offset:offset + 80]
|
||||
chunk_query = " OR ".join('client_ip:"%s"' % ip for ip in chunk)
|
||||
st = pivot(client,
|
||||
[{"type": "count", "id": "cnt"},
|
||||
{"type": "card", "field": "uri", "id": "uri_card"},
|
||||
{"type": "card", "field": "ua", "id": "ua_card"},
|
||||
{"type": "card", "field": "host", "id": "host_card"},
|
||||
{"type": "card", "field": "method", "id": "method_card"}],
|
||||
["client_ip"], query=chunk_query, limit=200)
|
||||
totals = rows_to_dict(st, "cnt")
|
||||
uris = rows_to_dict(st, "uri_card")
|
||||
uas = rows_to_dict(st, "ua_card")
|
||||
hosts = rows_to_dict(st, "host_card")
|
||||
methods = rows_to_dict(st, "method_card")
|
||||
chunk_status = {}
|
||||
for cls in ("2*", "3*", "4*", "5*"):
|
||||
chunk_status[cls] = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='status:%s AND (%s)' % (cls, chunk_query), limit=200),
|
||||
"cnt")
|
||||
chunk_get = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='method:GET AND (%s)' % chunk_query, limit=200), "cnt")
|
||||
chunk_login = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='%s AND (%s)' % (LOGIN_QUERY, chunk_query), limit=200),
|
||||
"cnt")
|
||||
chunk_hour = hourly_matrix(pivot(
|
||||
client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
column_groups=[{"type": "time", "field": "timestamp",
|
||||
"interval": "1h"}], query=chunk_query, limit=200))
|
||||
for ip in chunk:
|
||||
if ip not in totals or totals[ip] <= 0:
|
||||
continue
|
||||
for cls in chunk_status:
|
||||
status[cls].update(chunk_status[cls])
|
||||
ctotal = totals[ip]
|
||||
feat = empty_feat(ip)
|
||||
feat["total"] = int(ctotal)
|
||||
feat["uri_card"] = int(uris.get(ip, 0))
|
||||
feat["ua_card"] = int(uas.get(ip, 0))
|
||||
feat["host_card"] = int(hosts.get(ip, 0))
|
||||
feat["method_card"] = int(methods.get(ip, 0))
|
||||
base[ip] = finalize_feat(feat, status, chunk_get, chunk_login, chunk_hour)
|
||||
get_count.update(chunk_get)
|
||||
login_count.update(chunk_login)
|
||||
|
||||
print("候选 %d 个,预筛细节 ..." % len(base), flush=True)
|
||||
shortlist = []
|
||||
for ip, feat in base.items():
|
||||
if ip.startswith(INTERNAL):
|
||||
continue
|
||||
if feat["total"] >= 10 and (feat["pct4"] >= 20 or feat["four_xx"] >= 20
|
||||
or feat["uri_card"] >= 12):
|
||||
shortlist.append(ip)
|
||||
shortlist.sort(key=lambda ip: -base[ip]["four_xx"])
|
||||
shortlist = shortlist[:260]
|
||||
details = gather_details(client, shortlist)
|
||||
|
||||
geo = None
|
||||
try:
|
||||
from graylog_ops.geo import GeoIP
|
||||
geo = GeoIP(os.path.join(ROOT, "data", "geo", "geo.db"))
|
||||
except (FileNotFoundError, sqlite3.Error):
|
||||
geo = None
|
||||
|
||||
flagged = []
|
||||
for ip, feat in base.items():
|
||||
if ip.startswith(INTERNAL):
|
||||
continue
|
||||
detail = details.get(ip) or {}
|
||||
five = bucket_stats(detail.get("buckets5m") or [])
|
||||
uris = detail.get("uri") or []
|
||||
uas = detail.get("ua") or []
|
||||
feat["coverage5m"] = five["coverage"]
|
||||
feat["cv5m"] = five["cv"]
|
||||
feat["peak_share5m"] = five["peak_share"]
|
||||
feat["uri_templates"] = len({uri_template(uri) for uri, _ in uris})
|
||||
feat["top_uri"] = uris[0][0] if uris else ""
|
||||
feat["top_ua"] = uas[0][0] if uas else ""
|
||||
feat["ua_family"] = ua_family(feat["top_ua"])
|
||||
feat["ua_garbage"] = ua_garbage(feat["ua_card"], feat["top_ua"])
|
||||
|
||||
score, signals = behavior_score(feat)
|
||||
if score < 60 or len(signals) < 2:
|
||||
continue
|
||||
crawler_tokens = ("sogou", "baiduspider", "googlebot", "bingbot",
|
||||
"yandex", "bytespider", "ahrefs", "semrush", "mj12",
|
||||
"dotbot", "petalbot")
|
||||
is_crawler_ua = any(token in feat["top_ua"].lower()
|
||||
for token in crawler_tokens)
|
||||
if is_crawler_ua:
|
||||
behavior_type = ("伪造爬虫 UA" if feat["pct4"] >= 50
|
||||
else "采集爬虫")
|
||||
elif feat["login_ratio"] >= 20:
|
||||
behavior_type = "登录爆破"
|
||||
elif feat["pct4"] >= 50 and feat["uri_card"] >= 20:
|
||||
behavior_type = "目录扫描"
|
||||
elif feat["coverage"] <= 30 and feat["peak_share"] >= 25:
|
||||
behavior_type = "短时扫描"
|
||||
elif feat["uri_templates"] >= 100 and feat["pct2"] >= 70:
|
||||
behavior_type = "内容抓取"
|
||||
else:
|
||||
behavior_type = "异常自动化"
|
||||
geo_info = geo.lookup(ip) if geo else None
|
||||
feat.update({
|
||||
"score": score,
|
||||
"signals": signals,
|
||||
"behavior_type": behavior_type,
|
||||
"in_list": known.get(ip, False),
|
||||
"country_code": (geo_info or {}).get("country_code", ""),
|
||||
"country_name": (geo_info or {}).get("country_name", ""),
|
||||
"region": (geo_info or {}).get("region", ""),
|
||||
"city": (geo_info or {}).get("city", ""),
|
||||
})
|
||||
info = asn_info(ip) or {"asn": "", "org": "未知", "shared": "unknown"}
|
||||
shared = info["shared"]
|
||||
org = info["org"] or "未知"
|
||||
multi_device = feat["ua_card"] >= 5 or feat["host_card"] >= 10
|
||||
if shared in ("mobile", "education"):
|
||||
verdict, vclass = "仅告警(共享出口)", "v-watch"
|
||||
share_note = ("出口属于%s(%s),大概率有大量用户共享;"
|
||||
"整体封禁会误伤,建议只告警或按 UA/路径单独拦截。"
|
||||
% (org_cn(org), SHARE_LABEL[shared]))
|
||||
elif shared == "broadband" and multi_device:
|
||||
verdict, vclass = "仅告警(疑似多用户)", "v-watch"
|
||||
share_note = ("出口属于%s(宽带 ISP),且该 IP 出现 %d 种 UA / %d 个站点,"
|
||||
"疑似多用户共享,整体封禁可能误伤。"
|
||||
% (org_cn(org), feat["ua_card"], feat["host_card"]))
|
||||
elif shared == "broadband":
|
||||
if feat["score"] >= 75:
|
||||
verdict, vclass = "建议封禁", "v-ban"
|
||||
else:
|
||||
verdict, vclass = "建议人工确认", "v-review"
|
||||
share_note = ("出口属于%s(宽带 ISP),但该 IP 只有 %d 种 UA,"
|
||||
"更像单台设备/家庭网络,封禁影响面小。"
|
||||
% (org_cn(org), feat["ua_card"]))
|
||||
elif shared == "hosting":
|
||||
verdict, vclass = "建议封禁", "v-ban"
|
||||
share_note = "出口属于%s(云主机/机房),不是家庭或移动网络,封禁影响面小。" % org_cn(org)
|
||||
else:
|
||||
verdict, vclass = "建议人工确认", "v-review"
|
||||
share_note = "出口属于%s,归属不明确,建议人工核实后再决定。" % org_cn(org)
|
||||
feat.update({
|
||||
"verdict": verdict, "vclass": vclass,
|
||||
"reason_text": humanize(feat),
|
||||
"share_note": share_note,
|
||||
"asn": info["asn"], "asn_org": org, "shared_type": shared,
|
||||
})
|
||||
flagged.append(feat)
|
||||
|
||||
flagged.sort(key=lambda item: (-item["score"], -item["total"]))
|
||||
metrics = {
|
||||
"flagged": len(flagged),
|
||||
"new": sum(1 for item in flagged if not item["in_list"]),
|
||||
"known": sum(1 for item in flagged if item["in_list"]),
|
||||
"high": sum(1 for item in flagged if item["score"] >= 80),
|
||||
"ban": sum(1 for item in flagged if item["verdict"] == "建议封禁"),
|
||||
"watch": sum(1 for item in flagged if item["verdict"].startswith("仅告警")),
|
||||
"review": sum(1 for item in flagged if item["verdict"] == "建议人工确认"),
|
||||
"candidates": len(base),
|
||||
}
|
||||
payload = {
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"window_seconds": WINDOW,
|
||||
"items": flagged,
|
||||
"metrics": metrics,
|
||||
}
|
||||
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||
with open(DATA_FILE, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False)
|
||||
if geo is not None:
|
||||
geo.close()
|
||||
return payload
|
||||
|
||||
|
||||
def serve(port):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json; charset=utf-8"):
|
||||
raw = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/data.json"):
|
||||
try:
|
||||
with open(DATA_FILE, encoding="utf-8") as fh:
|
||||
return self._send(200, fh.read())
|
||||
except FileNotFoundError:
|
||||
return self._send(404, '{"error":"no data"}')
|
||||
return self._send(200, PAGE, "text/html; charset=utf-8")
|
||||
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
print("行为分析原型: http://0.0.0.0:%d/ (Ctrl+C 退出)" % port, flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>行为检测 · 独立判定</title>
|
||||
<style>
|
||||
:root{--bg:#F6F4EE;--surface:#FFF;--surface-2:#FBFAF6;--ink:#242119;--ink-2:#4B463B;
|
||||
--muted:#8B8577;--line:#E8E3D9;--accent:#C15F3C;--accent-soft:#F4E6DE;--green:#2E6B4F;
|
||||
--green-soft:#E3EEE7;--amber:#8F6418;--amber-soft:#F6EBD2;--red:#A63A28;--red-soft:#F6E1DB;
|
||||
--blue:#34557F;--blue-soft:#E6ECF5;--radius:14px;--shadow:0 1px 2px rgba(36,33,25,.05),0 10px 30px rgba(36,33,25,.045)}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
|
||||
header{padding:18px 28px;border-bottom:1px solid var(--line);background:rgba(246,244,238,.9);
|
||||
backdrop-filter:blur(10px);position:sticky;top:0;z-index:20;display:flex;justify-content:space-between;align-items:center}
|
||||
h1{font-family:Georgia,"Songti SC",serif;font-size:19px;margin:0;font-weight:600}
|
||||
.sub{color:var(--muted);font-size:12px;margin-top:2px}
|
||||
main{padding:24px 28px 80px;max-width:1200px;margin:0 auto}
|
||||
.summary{font-size:15px;line-height:1.9;margin-bottom:16px}
|
||||
.summary b{color:var(--accent-deep)}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;box-shadow:var(--shadow)}
|
||||
.card .n{font-family:Georgia,serif;font-size:25px;font-weight:600}
|
||||
.card .k{color:var(--muted);font-size:12px;margin-top:2px}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
||||
input[type=search]{flex:1;min-width:240px;padding:10px 14px;border:1px solid var(--line);
|
||||
border-radius:10px;background:var(--surface);font-size:13px;outline:none}
|
||||
.chips{display:flex;gap:2px;background:#EFEAE0;border:1px solid var(--line);padding:3px;border-radius:10px;flex-wrap:wrap}
|
||||
.chip{border:0;background:transparent;padding:6px 11px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--ink-2)}
|
||||
.chip.active{background:var(--surface);color:var(--accent);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.08)}
|
||||
.item{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:16px 20px;margin-bottom:10px;box-shadow:var(--shadow)}
|
||||
.line1{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.ip{font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:600}
|
||||
.geo{color:var(--ink-2);font-size:13px}
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:12px;white-space:nowrap;font-weight:600}
|
||||
.v-ban{background:var(--red-soft);color:var(--red)}
|
||||
.v-watch{background:var(--blue-soft);color:var(--blue)}
|
||||
.v-review{background:var(--amber-soft);color:var(--amber)}
|
||||
.b-new{background:var(--red-soft);color:var(--red);font-weight:500}
|
||||
.b-known{background:#EFEBE2;color:var(--muted);font-weight:500}
|
||||
.score{margin-left:auto;color:var(--muted);font-size:12px}
|
||||
.reason{font-size:14px;color:var(--ink);margin:9px 0 4px;line-height:1.75}
|
||||
.note{font-size:13px;color:var(--muted);margin-bottom:2px}
|
||||
details{margin-top:8px}
|
||||
summary{cursor:pointer;color:var(--accent-deep);font-size:12.5px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
|
||||
gap:6px 18px;margin-top:8px;font-size:12.5px;color:var(--ink-2)}
|
||||
.grid b{color:var(--muted);font-weight:500;margin-right:6px}
|
||||
.grid .wide{grid-column:1/-1;word-break:break-all}
|
||||
.empty{padding:48px;text-align:center;color:var(--muted)}
|
||||
.algo{margin-top:22px;background:var(--surface);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:14px 18px;box-shadow:var(--shadow)}
|
||||
.algo summary{cursor:pointer;color:var(--accent-deep);font-weight:600;font-size:13px}
|
||||
.algo ul{margin:10px 0 0;padding-left:18px}
|
||||
.algo li{font-size:12.5px;color:var(--ink-2);margin:5px 0;line-height:1.6}
|
||||
@media (max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}}
|
||||
</style></head><body>
|
||||
<header><div><h1>行为检测 · 独立判定</h1>
|
||||
<div class="sub" id="sub">不用规则、不看名单,纯行为特征算出的恶意 IP · 只读</div></div>
|
||||
<div class="muted" id="meta">加载中…</div></header>
|
||||
<main>
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="cards" id="cards"></div>
|
||||
<div class="toolbar">
|
||||
<input type="search" id="q" placeholder="搜索 IP / 国家 / 原因">
|
||||
<div class="chips" id="chips"></div>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
<details class="algo">
|
||||
<summary>算法说明(纯行为评分,不依赖规则和名单)</summary>
|
||||
<ul>
|
||||
<li><b>范围</b>:24h 内全部公网 IP(约 1000+ 个候选),先按失败率/路径分散预筛,再抽样细节。</li>
|
||||
<li><b>评分信号</b>:高失败率、路径分散、短时爆发、登录接口集中、多站点、自动化 UA、
|
||||
路径模板分散、失败请求量大;业务接口(非 GET 为主)和长期稳定单接口会降权。</li>
|
||||
<li><b>判定</b>:行为分 ≥60 且至少 2 个信号 → 行为判定恶意。</li>
|
||||
<li><b>建议动作</b>:出口是移动/宽带/教育网(共享网络)→ 仅告警,避免误伤;
|
||||
出口是云主机/机房 → 建议封禁;其余 → 人工确认。</li>
|
||||
<li><b>名单</b>列只用于事后对比:红色「新发现」= 不在现有规则名单。</li>
|
||||
</ul>
|
||||
</details>
|
||||
</main>
|
||||
<script>
|
||||
const state = {flt:'all', q:'', items:[], metrics:null};
|
||||
const $ = s => document.querySelector(s);
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const flagOf = code => (!code || code.length !== 2 || code === 'ZZ') ? ''
|
||||
: String.fromCodePoint(...[...code.toUpperCase()].map(ch => 0x1F1E6 + ch.charCodeAt(0) - 65));
|
||||
const fmt = n => n >= 10000 ? (n/10000).toFixed(1)+'万' : (n||0).toLocaleString();
|
||||
function filtered(){
|
||||
const q = state.q.trim().toLowerCase();
|
||||
return state.items.filter(it => {
|
||||
if (state.flt === 'ban' && it.verdict !== '建议封禁') return false;
|
||||
if (state.flt === 'watch' && !it.verdict.startsWith('仅告警')) return false;
|
||||
if (state.flt === 'review' && it.verdict !== '建议人工确认') return false;
|
||||
if (state.flt === 'new' && it.in_list) return false;
|
||||
if (!q) return true;
|
||||
const hay = [it.ip, it.country_name, it.region, it.reason_text,
|
||||
it.share_note, it.asn_org, ...(it.signals||[])].join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
function render(){
|
||||
const rows = filtered();
|
||||
$('#list').innerHTML = rows.map(it => `<div class="item">
|
||||
<div class="line1">
|
||||
<span class="ip">${esc(it.ip)}</span>
|
||||
<span class="geo">${flagOf(it.country_code)} ${esc([it.country_name, it.region].filter(Boolean).join(' · ') || '未知地区')}</span>
|
||||
<span class="badge ${it.vclass}">${esc(it.verdict)}</span>
|
||||
${it.in_list ? '<span class="badge b-known">名单内</span>' : '<span class="badge b-new">新发现</span>'}
|
||||
<span class="score">行为分 ${it.score}</span>
|
||||
</div>
|
||||
<div class="reason">${esc(it.reason_text)}</div>
|
||||
<div class="note">${esc(it.share_note)}</div>
|
||||
<details><summary>查看技术细节</summary>
|
||||
<div class="grid">
|
||||
<div><b>请求量</b>${fmt(it.total)} 次</div>
|
||||
<div><b>失败率</b>${it.pct4.toFixed(0)}%</div>
|
||||
<div><b>不同路径</b>${it.uri_card}</div>
|
||||
<div><b>路径模板</b>${it.uri_templates}</div>
|
||||
<div><b>活跃时段</b>${it.coverage.toFixed(0)}%</div>
|
||||
<div><b>峰值占比</b>${it.peak_share.toFixed(0)}%</div>
|
||||
<div><b>站点数</b>${it.host_card}</div>
|
||||
<div><b>UA 数</b>${it.ua_card}(${esc(it.ua_family || '-')})</div>
|
||||
<div><b>ASN</b>${esc(it.asn_org)}</div>
|
||||
<div class="wide"><b>Top URI</b>${esc(it.top_uri || '-')}</div>
|
||||
<div class="wide"><b>Top UA</b>${esc(it.top_ua || '-')}</div>
|
||||
<div class="wide"><b>信号</b>${(it.signals||[]).map(esc).join(';')}</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>`).join('') || '<div class="empty">没有匹配的记录</div>';
|
||||
$('#meta').textContent = `共 ${state.items.length} 个(显示 ${rows.length})`;
|
||||
}
|
||||
function cards(){
|
||||
const m = state.metrics || {};
|
||||
const items = [
|
||||
['判出恶意', m.flagged || 0], ['建议封禁', m.ban || 0],
|
||||
['仅告警(共享出口)', m.watch || 0], ['建议人工确认', m.review || 0],
|
||||
['新发现', m.new || 0],
|
||||
];
|
||||
$('#cards').innerHTML = items.map(([label, value]) =>
|
||||
`<div class="card"><div class="n">${value}</div><div class="k">${label}</div></div>`).join('');
|
||||
$('#summary').innerHTML = `行为分析在 <b>${m.candidates||0}</b> 个候选 IP 中判出 <b>${m.flagged||0}</b> 个恶意,`
|
||||
+ `其中 <b>${m.new||0}</b> 个不在现有规则名单里;建议封禁 <b>${m.ban||0}</b> 个,`
|
||||
+ `共享出口仅告警 <b>${m.watch||0}</b> 个,人工确认 <b>${m.review||0}</b> 个。`;
|
||||
const chips = [['all','全部'],['ban','建议封禁'],['watch','仅告警'],['review','人工确认'],['new','新发现']];
|
||||
$('#chips').innerHTML = chips.map(([key,label]) =>
|
||||
`<button class="chip ${state.flt===key?'active':''}" data-flt="${key}">${label}</button>`).join('');
|
||||
$('#chips').querySelectorAll('.chip').forEach(el => el.addEventListener('click', () => {
|
||||
state.flt = el.dataset.flt; cards(); render();
|
||||
}));
|
||||
}
|
||||
fetch('/data.json').then(r=>r.json()).then(data => {
|
||||
state.items = data.items || []; state.metrics = data.metrics || {};
|
||||
$('#sub').textContent = `不用规则、不看名单,纯行为特征算出的恶意 IP · 生成于 ${data.generated_at} · 只读`;
|
||||
cards(); render();
|
||||
}).catch(err => { $('#meta').textContent = '加载失败: ' + err; });
|
||||
$('#q').addEventListener('input', e => { state.q = e.target.value; render(); });
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--analyze", action="store_true")
|
||||
parser.add_argument("--serve", action="store_true")
|
||||
parser.add_argument("--port", type=int, default=8090)
|
||||
args = parser.parse_args()
|
||||
if args.analyze or not os.path.exists(DATA_FILE):
|
||||
payload = analyze()
|
||||
print("分析完成: %d 个 IP" % len(payload["items"]), flush=True)
|
||||
if args.serve:
|
||||
serve(args.port)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"items": [
|
||||
{"keywords": ["alibaba", "aliyun"], "brand": "阿里云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["tencent"], "brand": "腾讯云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["huawei cloud", "huaweicloud"], "brand": "华为云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["baidu"], "brand": "百度云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["kingsoft", "ksyun"], "brand": "金山云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["jd cloud", "jingdong"], "brand": "京东云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["ucloud"], "brand": "UCloud", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["ctyun", "china telecom cloud"], "brand": "天翼云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["china mobile cloud", "cmcloud"], "brand": "移动云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["cucloud", "china unicom cloud"], "brand": "联通云", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["amazon", "aws"], "brand": "AWS", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["microsoft"], "brand": "Azure", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["google"], "brand": "Google Cloud", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["oracle"], "brand": "Oracle Cloud", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["ibm"], "brand": "IBM Cloud", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["digitalocean", "digital ocean"], "brand": "DigitalOcean", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["vultr", "constant company"], "brand": "Vultr", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["linode", "akamai"], "brand": "Akamai/Linode", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["hetzner"], "brand": "Hetzner", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["ovh"], "brand": "OVH", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["contabo"], "brand": "Contabo", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["scaleway"], "brand": "Scaleway", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["rackspace"], "brand": "Rackspace", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["upcloud"], "brand": "UpCloud", "type": "cloud", "icon": "☁️"},
|
||||
{"keywords": ["equinix"], "brand": "Equinix", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["digital realty"], "brand": "Digital Realty", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["21vianet", "vianet", "世纪互联"], "brand": "世纪互联", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["chinanet idc", "idc network"], "brand": "IDC 机房", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["wowrack", "colocation", "colo"], "brand": "IDC 机房", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["data center", "datacenter", "hosting"], "brand": "IDC 机房", "type": "idc", "icon": "🏢"},
|
||||
{"keywords": ["china mobile", "cmnet"], "brand": "中国移动", "type": "mobile", "icon": "📱"},
|
||||
{"keywords": ["china unicom", "china169"], "brand": "中国联通", "type": "broadband", "icon": "🏠"},
|
||||
{"keywords": ["chinanet", "china telecom", "chinatelecom"], "brand": "中国电信", "type": "broadband", "icon": "🏠"},
|
||||
{"keywords": ["china broadcasting", "cbnet", "广电"], "brand": "中国广电", "type": "broadband", "icon": "🏠"},
|
||||
{"keywords": ["great wall broadband", "gwbn"], "brand": "长城宽带", "type": "broadband", "icon": "🏠"},
|
||||
{"keywords": ["dr.peng", "pengbo", "鹏博士"], "brand": "鹏博士", "type": "broadband", "icon": "🏠"},
|
||||
{"keywords": ["cernet", "education network", "education and research", "university", "college", "教育"], "brand": "教育网", "type": "education", "icon": "🎓"},
|
||||
{"keywords": ["government", "gov.cn"], "brand": "政府机构", "type": "enterprise", "icon": "🏛️"}
|
||||
],
|
||||
"defaults": {"brand": "未知", "type": "unknown", "icon": "❓"}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper so the tool can be called as `bin/graylog-ops ...` from anywhere.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export PYTHONPATH="${here}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
exec python3 -m graylog_ops "$@"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"url": "http://edu-master:9002",
|
||||
"token": "REPLACE_WITH_API_TOKEN",
|
||||
"insecure": false,
|
||||
"timeout": 30,
|
||||
"retries": 2
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
DATA_DIR=/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# application code (no credentials, no config files)
|
||||
COPY app/graylog_ops /app/graylog_ops
|
||||
COPY app/bin /app/bin
|
||||
COPY app/profiles /app/profiles
|
||||
COPY app/allowlist.example.txt /app/allowlist.example.txt
|
||||
COPY app/asn_brand_map.json /app/asn_brand_map.json
|
||||
|
||||
# seed data: current production snapshot (sqlite + geo + asn + blocklist)
|
||||
COPY seed /app/seed
|
||||
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8088
|
||||
|
||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s \
|
||||
CMD python3 -c "import os,urllib.request,sys; \
|
||||
url='http://127.0.0.1:%s/healthz' % os.environ.get('APP_PORT','8088'); \
|
||||
sys.exit(0 if urllib.request.urlopen(url, timeout=4).status == 200 else 1)"
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
DATA_DIR="${DATA_DIR:-/data}"
|
||||
if [ ! -f "$DATA_DIR/ips.db" ]; then
|
||||
echo "[init] 初始化数据目录: $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
cp -a /app/seed/. "$DATA_DIR"/
|
||||
fi
|
||||
|
||||
set -- watch \
|
||||
--stream "${GRAYLOG_STREAM:-F5_Accesslog}" \
|
||||
--profile "${PROFILE:-/app/profiles/f5.json}" \
|
||||
--interval "${CHECK_INTERVAL:-60}" \
|
||||
--window "${WINDOW:-3600}" \
|
||||
--forget "${FORGET:-604800}" \
|
||||
--db "$DATA_DIR/ips.db" \
|
||||
--host 0.0.0.0 \
|
||||
--port "${APP_PORT:-8088}" \
|
||||
--path-prefix "${APP_PATH_PREFIX:-}" \
|
||||
--geo-db "$DATA_DIR/geo/geo.db" \
|
||||
--asn-db "$DATA_DIR/geo/asn.db" \
|
||||
--allow /app/allowlist.example.txt \
|
||||
--export "$DATA_DIR/blocklist.txt" \
|
||||
--behavior-interval "${BEHAVIOR_INTERVAL:-900}" \
|
||||
--warm-limit "${WARM_LIMIT:-5}"
|
||||
|
||||
if [ "${NO_BEHAVIOR:-0}" = "1" ]; then
|
||||
set -- "$@" --no-behavior
|
||||
fi
|
||||
exec python3 -m graylog_ops "$@"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,8 @@
|
||||
"""Read-only Graylog inspection toolkit.
|
||||
|
||||
Target API: Graylog 4.0.x (legacy ``/api/search/universal`` surface).
|
||||
No third-party dependencies: stdlib only, so it also runs on old CentOS 7
|
||||
python3 installations.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
"""OFFLINE ASN lookup + provider branding (DB-IP ASN Lite)."""
|
||||
import bisect
|
||||
import csv
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DEFAULT_MAP = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"asn_brand_map.json")
|
||||
|
||||
|
||||
def load_brand_map(path=None):
|
||||
try:
|
||||
with open(path or DEFAULT_MAP, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
data = {"items": [], "defaults": {"brand": "未知", "type": "unknown",
|
||||
"icon": "❓"}}
|
||||
return data
|
||||
|
||||
|
||||
def classify_org(org, brand_map=None):
|
||||
brand_map = brand_map or load_brand_map()
|
||||
text = (org or "").lower()
|
||||
for item in brand_map.get("items", []):
|
||||
if any(keyword in text for keyword in item.get("keywords", [])):
|
||||
return item["brand"], item["type"], item["icon"]
|
||||
fallback = brand_map.get("defaults") or {}
|
||||
return (fallback.get("brand", "未知"), fallback.get("type", "unknown"),
|
||||
fallback.get("icon", "❓"))
|
||||
|
||||
|
||||
def build_asn_db(csv_path, out_path):
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(out_path)
|
||||
conn.execute("DROP TABLE IF EXISTS asn")
|
||||
conn.execute("CREATE TABLE asn (start INTEGER PRIMARY KEY, end INTEGER, "
|
||||
"asn TEXT, org TEXT)")
|
||||
batch, total = [], 0
|
||||
|
||||
def flush():
|
||||
nonlocal batch
|
||||
if batch:
|
||||
conn.executemany("INSERT OR REPLACE INTO asn VALUES (?,?,?,?)", batch)
|
||||
batch = []
|
||||
|
||||
with open(csv_path, encoding="utf-8", errors="replace") as handle:
|
||||
for row in csv.reader(handle):
|
||||
if len(row) < 4 or ":" in row[0]:
|
||||
continue
|
||||
try:
|
||||
first = int(ipaddress.IPv4Address(row[0]))
|
||||
last = int(ipaddress.IPv4Address(row[1]))
|
||||
except ValueError:
|
||||
continue
|
||||
batch.append((first, last, row[2], row[3]))
|
||||
total += 1
|
||||
if len(batch) >= 100000:
|
||||
flush()
|
||||
conn.commit()
|
||||
flush()
|
||||
conn.commit()
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.close()
|
||||
return total
|
||||
|
||||
|
||||
class AsnDB:
|
||||
def __init__(self, path, brand_map_path=None):
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self.brand_map = load_brand_map(brand_map_path)
|
||||
self._prefix = None
|
||||
with self.conn:
|
||||
try:
|
||||
self.conn.execute("PRAGMA query_only=ON")
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
def lookup(self, ip):
|
||||
try:
|
||||
value = int(ipaddress.IPv4Address(ip))
|
||||
except ValueError:
|
||||
return None
|
||||
row = self.conn.execute(
|
||||
"SELECT end, asn, org FROM asn WHERE start <= ? "
|
||||
"ORDER BY start DESC LIMIT 1", (value,)).fetchone()
|
||||
if not row or row[0] < value:
|
||||
return None
|
||||
brand, kind, icon = classify_org(row[2], self.brand_map)
|
||||
return {"asn": row[1], "org": row[2], "brand": brand,
|
||||
"type": kind, "icon": icon}
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.conn.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--csv", default="data/geo/dbip-asn-lite-2026-10.csv")
|
||||
parser.add_argument("--out", default="data/geo/asn.db")
|
||||
args = parser.parse_args()
|
||||
print("imported %d ranges -> %s" % (build_asn_db(args.csv, args.out), args.out))
|
||||
@@ -0,0 +1,695 @@
|
||||
"""Behaviour detection running inside the main service.
|
||||
|
||||
It scans 24h of F5 traffic with Graylog pivot aggregations, scores each IP by
|
||||
behaviour only (no rule hits, no known list), and stores the result in SQLite.
|
||||
A background thread keeps it refreshed; the web UI reads the stored result.
|
||||
"""
|
||||
import csv
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .http import GraylogError
|
||||
from .asn import AsnDB
|
||||
|
||||
WINDOW = 86400
|
||||
INTERNAL = ("10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.2", "172.3")
|
||||
|
||||
UA_SDK = ("curl", "wget", "python-requests", "python-urllib", "go-http-client",
|
||||
"okhttp", "java/", "apache-httpclient", "httpcomponents",
|
||||
"libredtail", "axios", "node-fetch", "aiohttp", "scrapy",
|
||||
"postmanruntime", "insomnia", "unityplayer", "dart/", "httpie",
|
||||
"guzzle", "faraday", "restsharp", "libwww", "eeo/", "academy_widget")
|
||||
UA_BROWSER = ("chrome", "firefox", "safari", "edg/", "opr/", "gecko/20100101")
|
||||
|
||||
CRAWLER_TOKENS = ("sogou", "baiduspider", "googlebot", "bingbot", "yandex",
|
||||
"bytespider", "ahrefs", "semrush", "mj12", "dotbot",
|
||||
"petalbot")
|
||||
|
||||
RULE_TITLES = {
|
||||
"probe_java": "Spring/中间件探测", "probe_config": "配置/凭证探测",
|
||||
"probe_cms": "CMS 探测", "probe_iot": "IoT/路由器探测",
|
||||
"probe_devops": "DevOps/监控探测", "probe_webshell": "WebShell/上传探测",
|
||||
"exploit_payload": "漏洞利用 payload", "login_attack": "登录失败尝试",
|
||||
"login_burst": "登录接口高频", "bad_ua": "扫描器 UA",
|
||||
"bot_ua": "僵尸网络 UA", "not_found": "404 探测",
|
||||
}
|
||||
|
||||
ASN_CACHE = {}
|
||||
|
||||
|
||||
def _asn_csv(root):
|
||||
geo_dir = os.path.join(root, "data", "geo")
|
||||
try:
|
||||
names = sorted(name for name in os.listdir(geo_dir)
|
||||
if name.startswith("dbip-asn-lite-") and name.endswith(".csv"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return os.path.join(geo_dir, names[-1]) if names else None
|
||||
|
||||
|
||||
def _load_asn(path):
|
||||
if ASN_CACHE:
|
||||
return ASN_CACHE
|
||||
starts, ranges = [], []
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for row in csv.reader(fh):
|
||||
if len(row) < 4 or ":" in row[0]:
|
||||
continue
|
||||
try:
|
||||
first = int(ipaddress.IPv4Address(row[0]))
|
||||
last = int(ipaddress.IPv4Address(row[1]))
|
||||
except ValueError:
|
||||
continue
|
||||
starts.append(first)
|
||||
ranges.append((last, row[2], row[3]))
|
||||
except (FileNotFoundError, TypeError):
|
||||
pass
|
||||
order = sorted(range(len(starts)), key=lambda i: starts[i])
|
||||
ASN_CACHE.update({"starts": [starts[i] for i in order],
|
||||
"ranges": [ranges[i] for i in order]})
|
||||
return ASN_CACHE
|
||||
|
||||
|
||||
def shared_type(org):
|
||||
text = (org or "").lower()
|
||||
cloud = ("alibaba", "aliyun", "tencent", "huawei cloud", "aws", "amazon",
|
||||
"google", "microsoft", "digitalocean", "vultr", "linode", "hetzner",
|
||||
"ovh", "wowrack", "idc", "data center", "datacenter", "cloud",
|
||||
"hosting", "colo")
|
||||
if any(token in text for token in cloud):
|
||||
return "hosting"
|
||||
if "china mobile" in text or "中国移动" in text or "cmnet" in text:
|
||||
return "mobile"
|
||||
if ("china unicom" in text or "china telecom" in text or "chinanet" in text
|
||||
or "联通" in text or "电信" in text or "broadband" in text):
|
||||
return "broadband"
|
||||
if "education" in text or "cernet" in text or "教育" in text:
|
||||
return "education"
|
||||
return "unknown"
|
||||
|
||||
|
||||
ORG_CN = (("china mobile", "中国移动"), ("china unicom", "中国联通"),
|
||||
("china telecom", "中国电信"), ("chinanet", "中国电信"),
|
||||
("cernet", "中国教育网"), ("education", "教育网"),
|
||||
("alibaba", "阿里云"), ("aliyun", "阿里云"), ("huawei cloud", "华为云"),
|
||||
("tencent", "腾讯云"), ("wowrack", "Wowrack 机房"),
|
||||
("vmiss", "VMISS 机房"), ("idc", "IDC 机房"))
|
||||
|
||||
TYPE_TO_SHARED = {"mobile": "mobile", "broadband": "broadband",
|
||||
"education": "education", "cloud": "hosting", "idc": "hosting"}
|
||||
|
||||
SHARE_LABEL = {"mobile": "移动网络", "broadband": "宽带 ISP",
|
||||
"education": "教育网", "hosting": "云主机/机房", "unknown": "未知网络"}
|
||||
|
||||
|
||||
def org_cn(org):
|
||||
text = (org or "").lower()
|
||||
for token, name in ORG_CN:
|
||||
if token in text:
|
||||
return name
|
||||
return org or "未知"
|
||||
|
||||
|
||||
def asn_info(ip, root):
|
||||
cache = _load_asn(_asn_csv(root))
|
||||
starts = cache.get("starts") or []
|
||||
if not starts:
|
||||
return {"asn": "", "org": "未知", "shared": "unknown"}
|
||||
try:
|
||||
value = int(ipaddress.IPv4Address(ip))
|
||||
except ValueError:
|
||||
return {"asn": "", "org": "未知", "shared": "unknown"}
|
||||
import bisect
|
||||
index = bisect.bisect_right(starts, value) - 1
|
||||
if index >= 0:
|
||||
last, asn, org = cache["ranges"][index]
|
||||
if last >= value:
|
||||
return {"asn": asn, "org": org, "shared": shared_type(org)}
|
||||
return {"asn": "", "org": "未知", "shared": "unknown"}
|
||||
|
||||
|
||||
def pivot(client, series, row_groups, column_groups=None, query="*",
|
||||
limit=300, timeout=180000):
|
||||
body = {"queries": [{
|
||||
"id": "q1",
|
||||
"timerange": {"type": "relative", "range": WINDOW},
|
||||
"query": {"type": "elasticsearch", "query_string": query},
|
||||
"filter": {"type": "or", "filters": [{"type": "stream",
|
||||
"id": "6ac83059a6878e26256fcacc"}]},
|
||||
"search_types": [{
|
||||
"type": "pivot", "id": "st1", "series": series,
|
||||
"row_groups": [{"type": "values", "field": f, "limit": limit}
|
||||
for f in row_groups],
|
||||
"column_groups": column_groups or [], "sort": [], "rollup": True,
|
||||
}],
|
||||
}], "parameters": []}
|
||||
data = client.http.request("POST", "/api/views/search/sync",
|
||||
params={"timeout": timeout}, body=body)
|
||||
return data["results"]["q1"]["search_types"]["st1"]
|
||||
|
||||
|
||||
def rows_to_dict(search_type, series_id):
|
||||
out = {}
|
||||
for row in search_type.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
if not key:
|
||||
continue
|
||||
ip = str(key[0])
|
||||
for value in row.get("values") or []:
|
||||
if (value.get("key") or [None])[0] == series_id:
|
||||
try:
|
||||
out[ip] = float(value.get("value") or 0)
|
||||
except (TypeError, ValueError):
|
||||
out[ip] = 0.0
|
||||
return out
|
||||
|
||||
|
||||
def hourly_matrix(search_type):
|
||||
out = {}
|
||||
for row in search_type.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
if not key:
|
||||
continue
|
||||
counts = []
|
||||
for value in row.get("values") or []:
|
||||
stamp = (value.get("key") or [None])[0]
|
||||
if not stamp or "cnt" not in (value.get("key") or []):
|
||||
continue
|
||||
try:
|
||||
counts.append(float(value.get("value") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out[str(key[0])] = counts
|
||||
return out
|
||||
|
||||
|
||||
def bucket_stats(counts):
|
||||
active = [c for c in counts if c > 0]
|
||||
if not counts:
|
||||
return {"coverage": 0.0, "cv": None, "peak_share": 0.0}
|
||||
cv = (statistics.pstdev(active) / statistics.fmean(active)
|
||||
if len(active) > 2 and statistics.fmean(active) else None)
|
||||
total = sum(counts)
|
||||
return {"coverage": len(active) / len(counts) * 100,
|
||||
"cv": cv,
|
||||
"peak_share": (max(counts) / total * 100) if total else 0.0}
|
||||
|
||||
|
||||
def uri_template(uri):
|
||||
import re
|
||||
path = str(uri or "").split("?")[0]
|
||||
path = re.sub(
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{12}", "{uuid}", path)
|
||||
path = re.sub(r"[0-9a-fA-F]{16,}", "{hex}", path)
|
||||
path = re.sub(r"\d+", "{n}", path)
|
||||
return path
|
||||
|
||||
|
||||
def ua_family(ua):
|
||||
text = str(ua or "").strip()
|
||||
if not text:
|
||||
return "empty"
|
||||
lower = text.lower()
|
||||
if any(token in lower for token in UA_SDK):
|
||||
return "sdk"
|
||||
if lower.startswith("mozilla/") and any(t in lower for t in UA_BROWSER):
|
||||
return "browser"
|
||||
if lower.startswith("mozilla/5.0"):
|
||||
return "fake-browser"
|
||||
return "other"
|
||||
|
||||
|
||||
def ua_garbage(ua_card, top_ua):
|
||||
if ua_card > 50:
|
||||
return True
|
||||
text = str(top_ua or "")
|
||||
if not text:
|
||||
return False
|
||||
printable = sum(1 for char in text if 32 <= ord(char) < 127 or ord(char) > 127)
|
||||
return printable / max(1, len(text)) < 0.85
|
||||
|
||||
|
||||
def behavior_score(feat):
|
||||
total = feat["total"]
|
||||
if total < 10:
|
||||
return 0, ["请求过少"]
|
||||
score = 0
|
||||
signals = []
|
||||
pct4 = feat["pct4"]
|
||||
if pct4 >= 50:
|
||||
score += 30
|
||||
signals.append("高失败率 %.0f%%" % pct4)
|
||||
elif pct4 >= 30:
|
||||
score += 15
|
||||
signals.append("失败率 %.0f%%" % pct4)
|
||||
if feat["uri_card"] >= 20:
|
||||
score += 20
|
||||
signals.append("路径分散(%d 个 URI)" % feat["uri_card"])
|
||||
elif feat["uri_card"] >= 8:
|
||||
score += 8
|
||||
if feat["coverage"] <= 30 and feat["peak_share"] >= 25:
|
||||
score += 20
|
||||
signals.append("短时爆发(覆盖 %.0f%%、峰值 %.0f%%)"
|
||||
% (feat["coverage"], feat["peak_share"]))
|
||||
if feat["login_ratio"] >= 20:
|
||||
score += 15
|
||||
signals.append("登录接口集中 %.0f%%" % feat["login_ratio"])
|
||||
if feat["host_card"] >= 2:
|
||||
score += 5
|
||||
signals.append("多站点(%d 个 Host)" % feat["host_card"])
|
||||
if feat["ua_card"] == 1:
|
||||
score += 5
|
||||
if feat["ua_family"] in ("sdk", "fake-browser", "other"):
|
||||
score += 10
|
||||
signals.append("自动化 UA(%s)" % feat["ua_family"])
|
||||
elif feat["ua_family"] == "browser":
|
||||
score -= 5
|
||||
if feat.get("four_xx", 0) >= 100 and pct4 >= 20:
|
||||
score += 10
|
||||
signals.append("失败请求 %d 次" % int(feat["four_xx"]))
|
||||
if feat["get_ratio"] < 30:
|
||||
score -= 25
|
||||
signals.append("非 GET 为主(业务接口)")
|
||||
if pct4 < 15 and feat["pct2"] >= 80:
|
||||
score -= 20
|
||||
signals.append("低失败率(采集/浏览)")
|
||||
if (feat["coverage"] >= 80 and feat["cv"] is not None
|
||||
and feat["cv"] <= 1.2 and feat["uri_templates"] <= 8):
|
||||
score -= 20
|
||||
signals.append("长期稳定单接口")
|
||||
if feat["uri_templates"] >= 15:
|
||||
score += 10
|
||||
signals.append("路径模板分散(%d 个)" % feat["uri_templates"])
|
||||
if feat["status_known"] < 10:
|
||||
score -= 10
|
||||
signals.append("状态字段缺失")
|
||||
return max(0, min(100, score)), signals
|
||||
|
||||
|
||||
def humanize(item):
|
||||
parts = []
|
||||
if item["uri_card"] >= 20:
|
||||
parts.append("访问了 %s 个不同路径" % format(item["uri_card"], ","))
|
||||
if item["four_xx"] >= 20:
|
||||
parts.append("产生 %s 次失败请求(失败率 %.0f%%)"
|
||||
% (format(int(item["four_xx"]), ","), item["pct4"]))
|
||||
elif item["pct4"] >= 20:
|
||||
parts.append("失败率 %.0f%%" % item["pct4"])
|
||||
if item["coverage"] <= 30 and item["peak_share"] >= 25:
|
||||
parts.append("流量集中在很短的时间内")
|
||||
if item["login_ratio"] >= 20:
|
||||
parts.append("大量请求集中在登录接口")
|
||||
if item["host_card"] >= 2:
|
||||
parts.append("同时访问了 %d 个站点" % item["host_card"])
|
||||
if item["ua_family"] in ("sdk", "fake-browser", "other"):
|
||||
parts.append("使用脚本/非浏览器客户端")
|
||||
behavior = item.get("behavior_type") or "异常自动化"
|
||||
tail = {"目录扫描": "符合目录扫描特征", "短时扫描": "符合短时扫描特征",
|
||||
"登录爆破": "符合登录爆破特征",
|
||||
"伪造爬虫 UA": "伪装成搜索引擎爬虫,且请求大量失败",
|
||||
"采集爬虫": "是采集爬虫", "异常自动化": "行为高度自动化,不像正常用户"}.get(
|
||||
behavior, "行为异常")
|
||||
if parts:
|
||||
return "24 小时内" + ",".join(parts) + "——" + tail + "。"
|
||||
return "24 小时内请求 %d 次,%s。" % (item["total"], tail)
|
||||
|
||||
|
||||
def behavior_type(feat, top_ua):
|
||||
tokens = CRAWLER_TOKENS
|
||||
if any(token in (top_ua or "").lower() for token in tokens):
|
||||
return "伪造爬虫 UA" if feat["pct4"] >= 50 else "采集爬虫"
|
||||
if feat["login_ratio"] >= 20:
|
||||
return "登录爆破"
|
||||
if feat["pct4"] >= 50 and feat["uri_card"] >= 20:
|
||||
return "目录扫描"
|
||||
if feat["coverage"] <= 30 and feat["peak_share"] >= 25:
|
||||
return "短时扫描"
|
||||
if feat["uri_templates"] >= 100 and feat["pct2"] >= 70:
|
||||
return "内容抓取"
|
||||
return "异常自动化"
|
||||
|
||||
|
||||
def empty_feat(ip):
|
||||
return {"ip": ip, "total": 0, "uri_card": 0, "ua_card": 0, "host_card": 0,
|
||||
"method_card": 0, "get_ratio": 0.0, "login_ratio": 0.0,
|
||||
"pct2": 0.0, "pct3": 0.0, "pct4": 0.0, "pct5": 0.0,
|
||||
"status_known": 0.0, "coverage": 0.0, "cv": None, "peak_share": 0.0,
|
||||
"uri_templates": 0, "ua_family": "", "ua_garbage": False,
|
||||
"top_uri": "", "top_ua": "", "four_xx": 0}
|
||||
|
||||
|
||||
class BehaviorAnalyzer:
|
||||
def __init__(self, client, store, fields, filter_str=None, root=None,
|
||||
interval=900, window=WINDOW, notifier=None):
|
||||
self.client = client
|
||||
self.store = store
|
||||
self.fields = fields
|
||||
self.filter_str = filter_str
|
||||
self.root = root or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
self.interval = max(120, int(interval or 900))
|
||||
self.window = int(window or WINDOW)
|
||||
self.notifier = notifier
|
||||
self.last_run = None
|
||||
self.last_error = None
|
||||
self.runs = 0
|
||||
self.asn = None
|
||||
try:
|
||||
asn_path = os.path.join(self.root, "data", "geo", "asn.db")
|
||||
if os.path.exists(asn_path):
|
||||
self.asn = AsnDB(asn_path)
|
||||
except Exception:
|
||||
self.asn = None
|
||||
self.geo = None
|
||||
try:
|
||||
from .geo import GeoIP
|
||||
geo_path = os.path.join(self.root, "data", "geo", "geo.db")
|
||||
if os.path.exists(geo_path):
|
||||
self.geo = GeoIP(geo_path)
|
||||
except Exception:
|
||||
self.geo = None
|
||||
|
||||
# ---------- data collection ----------
|
||||
def _details(self, ips):
|
||||
def run(ip):
|
||||
q = 'client_ip:"%s"' % ip
|
||||
detail = {"uri": [], "ua": [], "buckets5m": []}
|
||||
try:
|
||||
st = pivot(self.client, [{"type": "count", "id": "cnt"}],
|
||||
["uri"], query=q, limit=40)
|
||||
for row in st.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
value = (row.get("values") or [{}])[0].get("value")
|
||||
if key:
|
||||
detail["uri"].append((str(key[0]), int(float(value or 0))))
|
||||
except GraylogError:
|
||||
pass
|
||||
try:
|
||||
st = pivot(self.client, [{"type": "count", "id": "cnt"}],
|
||||
["ua"], query=q, limit=3)
|
||||
for row in st.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
value = (row.get("values") or [{}])[0].get("value")
|
||||
if key:
|
||||
detail["ua"].append((str(key[0]), int(float(value or 0))))
|
||||
except GraylogError:
|
||||
pass
|
||||
try:
|
||||
hist = self.client.histogram(query=q, range_seconds=self.window,
|
||||
interval="5m",
|
||||
filter_str=self.filter_str)
|
||||
from .client import normalize_histogram
|
||||
detail["buckets5m"] = [b["count"]
|
||||
for b in normalize_histogram(hist)]
|
||||
except GraylogError:
|
||||
pass
|
||||
return ip, detail
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
out = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
for ip, detail in pool.map(run, ips):
|
||||
out[ip] = detail
|
||||
return out
|
||||
|
||||
def analyze(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
fields = self.fields
|
||||
client = self.client
|
||||
|
||||
st = pivot(client,
|
||||
[{"type": "count", "id": "cnt"},
|
||||
{"type": "card", "field": "uri", "id": "uri_card"},
|
||||
{"type": "card", "field": "ua", "id": "ua_card"},
|
||||
{"type": "card", "field": "host", "id": "host_card"},
|
||||
{"type": "card", "field": "method", "id": "method_card"}],
|
||||
["client_ip"], limit=600)
|
||||
total = rows_to_dict(st, "cnt")
|
||||
uri_card = rows_to_dict(st, "uri_card")
|
||||
ua_card = rows_to_dict(st, "ua_card")
|
||||
host_card = rows_to_dict(st, "host_card")
|
||||
method_card = rows_to_dict(st, "method_card")
|
||||
|
||||
status = {}
|
||||
for cls in ("2*", "3*", "4*", "5*"):
|
||||
status[cls] = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="status:%s" % cls, limit=600), "cnt")
|
||||
get_count = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="method:GET", limit=600), "cnt")
|
||||
LOGIN_QUERY = ("uri:(\\\\/login* OR \\\\/passport* OR \\\\/captcha* "
|
||||
"OR \\\\/jsxy\\\\/api\\\\/login*)")
|
||||
login_count = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query=LOGIN_QUERY, limit=600), "cnt")
|
||||
hourly = hourly_matrix(pivot(
|
||||
client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
column_groups=[{"type": "time", "field": "timestamp", "interval": "1h"}],
|
||||
limit=600))
|
||||
four_xx = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query="status:4*", limit=800), "cnt")
|
||||
|
||||
def build(ip, count, uris, uas, hosts, methods, status_map, get_map,
|
||||
login_map, hourly_map):
|
||||
hour = bucket_stats(hourly_map.get(ip, []))
|
||||
sum_status = sum(status_map[cls].get(ip, 0) for cls in status_map)
|
||||
feat = empty_feat(ip)
|
||||
feat.update({
|
||||
"total": int(count),
|
||||
"uri_card": int(uris.get(ip, 0)),
|
||||
"ua_card": int(uas.get(ip, 0)),
|
||||
"host_card": int(hosts.get(ip, 0)),
|
||||
"method_card": int(methods.get(ip, 0)),
|
||||
"get_ratio": get_map.get(ip, 0) / count * 100 if count else 0.0,
|
||||
"login_ratio": login_map.get(ip, 0) / count * 100 if count else 0.0,
|
||||
"pct2": status_map["2*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct3": status_map["3*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct4": status_map["4*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"pct5": status_map["5*"].get(ip, 0) / max(1, sum_status) * 100,
|
||||
"status_known": (sum_status / count * 100) if count else 0.0,
|
||||
"coverage": hour["coverage"], "cv": hour["cv"],
|
||||
"peak_share": hour["peak_share"],
|
||||
"four_xx": status_map["4*"].get(ip, 0),
|
||||
})
|
||||
return feat
|
||||
|
||||
base = {}
|
||||
for ip, count in total.items():
|
||||
if count <= 0:
|
||||
continue
|
||||
base[ip] = build(ip, count, uri_card, ua_card, host_card,
|
||||
method_card, status, get_count, login_count, hourly)
|
||||
|
||||
extra = sorted(set(four_xx) - set(total), key=lambda ip: -four_xx[ip])
|
||||
extra = [ip for ip in extra if not ip.startswith(INTERNAL)][:600]
|
||||
for offset in range(0, len(extra), 80):
|
||||
chunk = extra[offset:offset + 80]
|
||||
chunk_query = " OR ".join('client_ip:"%s"' % ip for ip in chunk)
|
||||
st = pivot(client,
|
||||
[{"type": "count", "id": "cnt"},
|
||||
{"type": "card", "field": "uri", "id": "uri_card"},
|
||||
{"type": "card", "field": "ua", "id": "ua_card"},
|
||||
{"type": "card", "field": "host", "id": "host_card"},
|
||||
{"type": "card", "field": "method", "id": "method_card"}],
|
||||
["client_ip"], query=chunk_query, limit=200)
|
||||
totals = rows_to_dict(st, "cnt")
|
||||
uris = rows_to_dict(st, "uri_card")
|
||||
uas = rows_to_dict(st, "ua_card")
|
||||
hosts = rows_to_dict(st, "host_card")
|
||||
methods = rows_to_dict(st, "method_card")
|
||||
chunk_status = {}
|
||||
for cls in ("2*", "3*", "4*", "5*"):
|
||||
chunk_status[cls] = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='status:%s AND (%s)' % (cls, chunk_query),
|
||||
limit=200), "cnt")
|
||||
chunk_get = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='method:GET AND (%s)' % chunk_query, limit=200), "cnt")
|
||||
chunk_login = rows_to_dict(
|
||||
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
query='%s AND (%s)' % (LOGIN_QUERY, chunk_query),
|
||||
limit=200), "cnt")
|
||||
chunk_hour = hourly_matrix(pivot(
|
||||
client, [{"type": "count", "id": "cnt"}], ["client_ip"],
|
||||
column_groups=[{"type": "time", "field": "timestamp",
|
||||
"interval": "1h"}], query=chunk_query, limit=200))
|
||||
for cls in chunk_status:
|
||||
status[cls].update(chunk_status[cls])
|
||||
for ip in chunk:
|
||||
if ip not in totals or totals[ip] <= 0:
|
||||
continue
|
||||
get_count[ip] = chunk_get.get(ip, 0)
|
||||
login_count[ip] = chunk_login.get(ip, 0)
|
||||
base[ip] = build(ip, totals[ip], uris, uas, hosts, methods,
|
||||
status, get_count, login_count, chunk_hour)
|
||||
|
||||
shortlist = [ip for ip, feat in base.items()
|
||||
if not ip.startswith(INTERNAL) and feat["total"] >= 10
|
||||
and (feat["pct4"] >= 20 or feat["four_xx"] >= 20
|
||||
or feat["uri_card"] >= 12)]
|
||||
shortlist.sort(key=lambda ip: -base[ip]["four_xx"])
|
||||
shortlist = shortlist[:260]
|
||||
details = self._details(shortlist)
|
||||
|
||||
items = []
|
||||
for ip, feat in base.items():
|
||||
if ip.startswith(INTERNAL):
|
||||
continue
|
||||
detail = details.get(ip) or {}
|
||||
five = bucket_stats(detail.get("buckets5m") or [])
|
||||
uris = detail.get("uri") or []
|
||||
uas = detail.get("ua") or []
|
||||
feat["uri_templates"] = len({uri_template(uri) for uri, _ in uris})
|
||||
feat["top_uri"] = uris[0][0] if uris else ""
|
||||
feat["top_ua"] = uas[0][0] if uas else ""
|
||||
feat["ua_family"] = ua_family(feat["top_ua"])
|
||||
feat["ua_garbage"] = ua_garbage(feat["ua_card"], feat["top_ua"])
|
||||
feat["coverage5m"] = five["coverage"]
|
||||
score, signals = behavior_score(feat)
|
||||
if score < 60 or len(signals) < 2:
|
||||
continue
|
||||
feat["score"] = score
|
||||
feat["signals"] = signals
|
||||
feat["behavior_type"] = behavior_type(feat, feat["top_ua"])
|
||||
info = (self.asn.lookup(ip) if self.asn is not None else None) or {
|
||||
"asn": "", "org": "未知", "brand": "未知",
|
||||
"type": "unknown", "icon": "❓"}
|
||||
shared = TYPE_TO_SHARED.get(info["type"], "unknown")
|
||||
multi = feat["ua_card"] >= 5 or feat["host_card"] >= 10
|
||||
if shared in ("mobile", "education"):
|
||||
verdict = "仅告警(共享出口)"
|
||||
note = ("出口属于%s(%s),大概率有大量用户共享。"
|
||||
% (info["brand"], SHARE_LABEL[shared]))
|
||||
elif shared == "broadband" and multi:
|
||||
verdict = "仅告警(疑似多用户)"
|
||||
note = ("出口属于%s(宽带 ISP),但该 IP 出现 %d 种 UA / %d 个站点,"
|
||||
"疑似多用户共享。" % (info["brand"], feat["ua_card"],
|
||||
feat["host_card"]))
|
||||
elif shared == "broadband":
|
||||
verdict = "建议封禁" if score >= 75 else "建议人工确认"
|
||||
note = ("出口属于%s(宽带 ISP),但只有 %d 种 UA,更像单台设备。"
|
||||
% (info["brand"], feat["ua_card"]))
|
||||
elif shared == "hosting":
|
||||
verdict = "建议封禁"
|
||||
note = "出口属于%s(云主机/机房)。" % info["brand"]
|
||||
else:
|
||||
verdict = "建议人工确认"
|
||||
note = "出口属于%s,归属不明确。" % info["brand"]
|
||||
geo = None
|
||||
if self.geo is not None:
|
||||
geo = self.geo.lookup(ip)
|
||||
feat.update({
|
||||
"country_code": (geo or {}).get("country_code", ""),
|
||||
"country_name": (geo or {}).get("country_name", ""),
|
||||
"region": (geo or {}).get("region", ""),
|
||||
"city": (geo or {}).get("city", ""),
|
||||
"verdict": verdict, "share_note": note,
|
||||
"reason_text": humanize(feat),
|
||||
"asn": info["asn"], "asn_org": info["org"],
|
||||
"asn_brand": info["brand"], "asn_type": info["type"],
|
||||
"asn_icon": info["icon"], "shared_type": shared,
|
||||
})
|
||||
items.append(feat)
|
||||
items.sort(key=lambda item: (-item["score"], -item["total"]))
|
||||
return items
|
||||
|
||||
def run_once(self):
|
||||
started = time.time()
|
||||
try:
|
||||
items = self.analyze()
|
||||
self.store.replace_behavior_profiles(items)
|
||||
self.store.sync_fused()
|
||||
self.last_run = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
self.last_error = None
|
||||
self.runs += 1
|
||||
print("[%s] 行为分析: %d 个违规 IP,%d 个建议封禁,耗时 %.1fs"
|
||||
% (self.last_run, len(items),
|
||||
sum(1 for item in items if item["verdict"] == "建议封禁"),
|
||||
time.time() - started), flush=True)
|
||||
if self.notifier is not None:
|
||||
notify_fused(self.store, self.notifier, self.root)
|
||||
return items
|
||||
except Exception as exc: # keep the service alive
|
||||
self.last_error = str(exc)
|
||||
print("[行为分析失败] %s" % exc, flush=True)
|
||||
return []
|
||||
|
||||
def run_forever(self):
|
||||
time.sleep(8)
|
||||
while True:
|
||||
self.run_once()
|
||||
time.sleep(self.interval)
|
||||
|
||||
|
||||
TYPE_LABEL = {"cloud": "云服务商", "idc": "IDC/机房", "mobile": "移动网络",
|
||||
"broadband": "宽带", "education": "教育网",
|
||||
"enterprise": "企业/政府", "unknown": "未知"}
|
||||
|
||||
|
||||
def _local_time(value):
|
||||
import datetime as _dt
|
||||
try:
|
||||
stamp = _dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return stamp.astimezone().strftime("%m-%d %H:%M")
|
||||
except (TypeError, ValueError):
|
||||
return str(value or "-")
|
||||
|
||||
|
||||
_PUSH_ERROR_AT = 0
|
||||
|
||||
|
||||
def notify_fused(store, notifier, root=None):
|
||||
"""Push a single merged message for IPs due for a reminder (24h)."""
|
||||
due = store.due_pushes(active_hours=24, cooldown_hours=24)
|
||||
if not due:
|
||||
return 0
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
lines = ["**建议封禁 · 融合检测**", ""]
|
||||
for row in due[:20]:
|
||||
geo = " · ".join(part for part in (row.get("country_name"),
|
||||
row.get("region")) if part)
|
||||
lines.append("**%s**%s" % (row["ip"], ("(%s)" % geo) if geo else ""))
|
||||
source = ("双来源" if row.get("sources") == "both"
|
||||
else ("行为检测" if row.get("sources") == "behavior"
|
||||
else "特征检测"))
|
||||
brand = row.get("asn_brand") or ""
|
||||
if brand or row.get("asn"):
|
||||
lines.append("归属:%s %s%s"
|
||||
% (row.get("asn_icon") or "", brand or "未知",
|
||||
(" · AS" + str(row["asn"])) if row.get("asn") else ""))
|
||||
score = max(float(row.get("feature_score") or 0),
|
||||
float(row.get("behavior_score") or 0))
|
||||
lines.append("来源:%s · 发现:%s"
|
||||
% (source, _local_time(row.get("first_banned_at"))))
|
||||
lines.append("评分 %.0f · 请求 %s 次"
|
||||
% (score, format(int(row.get("total") or 0), ",")))
|
||||
if int(row.get("push_count") or 0) > 0:
|
||||
lines.append("⏰ 该 IP 仍在活动(第 %d 次提醒)"
|
||||
% (int(row["push_count"]) + 1))
|
||||
if row.get("fused_reason"):
|
||||
lines.append("依据:" + row["fused_reason"])
|
||||
link = notifier.link_for(row["ip"])
|
||||
if link:
|
||||
lines.append("[查看详情](%s)" % link)
|
||||
lines.append("")
|
||||
try:
|
||||
notifier.post("\n".join(lines).strip())
|
||||
except Exception as exc:
|
||||
# 失败时不刷屏:每 5 分钟最多提示一次;下一轮会自动重试
|
||||
global _PUSH_ERROR_AT
|
||||
now = time.time()
|
||||
if now - _PUSH_ERROR_AT > 300:
|
||||
print("推送失败(每 5 分钟最多提示一次): %s" % exc, flush=True)
|
||||
_PUSH_ERROR_AT = now
|
||||
return 0
|
||||
store.mark_pushed([row["ip"] for row in due[:20]], now)
|
||||
return len(due[:20])
|
||||
@@ -0,0 +1,289 @@
|
||||
"""CLI entry point."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import __version__, commands
|
||||
from .client import GraylogClient
|
||||
from .config import ConfigError, load_config
|
||||
from .http import GraylogError, HttpClient
|
||||
|
||||
EPILOG = """\
|
||||
示例:
|
||||
# 1) 先确认连通性、权限和这个版本支持哪些接口
|
||||
graylog-ops --url http://edu-master:9000 --token $GRAYLOG_TOKEN probe
|
||||
|
||||
# 2) 摸清"客户端 IP 到底在哪个字段"(最关键的一步)
|
||||
graylog-ops discover --range 3600 --stream 业务访问日志
|
||||
|
||||
# 3) 按条件看 top IP,判断阈值是否合理
|
||||
graylog-ops top-ips --field client_ip --query 'status:401' --range 300 --size 30
|
||||
|
||||
# 3.5) 生成 profile,然后做封禁判断(只读,dry-run 语义)
|
||||
graylog-ops discover --range 3600 --save-profile profiles/edu.json
|
||||
graylog-ops judge --profile profiles/edu.json --range 300 --allow allowlist.txt --explain 5
|
||||
|
||||
# 4) 把环境信息整体落盘,便于离线分析/复盘
|
||||
graylog-ops snapshot --dir snapshots/$(date +%F) --sample 500
|
||||
|
||||
# 5) 探测未知接口(版本差异时用)
|
||||
graylog-ops raw /api/system/inputs
|
||||
graylog-ops raw /api/search/universal/terms --param field=source --param range=60
|
||||
|
||||
# 6) 一次性产出整套信息(probe/discover/judge/snapshot),便于离线分析
|
||||
graylog-ops collect --dir collect-2026-10-18 --range 3600 --allow allowlist.example.txt
|
||||
|
||||
环境变量: GRAYLOG_URL GRAYLOG_TOKEN GRAYLOG_USER GRAYLOG_PASSWORD
|
||||
GRAYLOG_INSECURE GRAYLOG_TIMEOUT GRAYLOG_RETRIES
|
||||
配置文件: ~/.config/graylog-ops/config.json 或 ./graylog-ops.json
|
||||
"""
|
||||
|
||||
|
||||
def _add_common(parser, suppress):
|
||||
"""Add connection/output flags.
|
||||
|
||||
With ``suppress=True`` the defaults are SUPPRESS so a sub-parser never
|
||||
clobbers a value that was already parsed by the root parser.
|
||||
"""
|
||||
|
||||
def default(value):
|
||||
return argparse.SUPPRESS if suppress else value
|
||||
|
||||
parser.add_argument("--url", metavar="URL", default=default(None),
|
||||
help="Graylog 地址,如 http://edu-master:9000")
|
||||
parser.add_argument("--token", metavar="TOKEN", default=default(None),
|
||||
help="API token(推荐;设置后忽略 --user/--password)")
|
||||
parser.add_argument("--user", metavar="USER", default=default(None),
|
||||
help="用户名(配合 --password)")
|
||||
parser.add_argument("--password", metavar="PASSWORD", default=default(None),
|
||||
help="密码")
|
||||
parser.add_argument("--insecure", action="store_true",
|
||||
default=argparse.SUPPRESS if suppress else False,
|
||||
help="跳过 HTTPS 证书校验")
|
||||
parser.add_argument("--timeout", type=float, metavar="SECONDS", default=default(None),
|
||||
help="单请求超时,默认 30")
|
||||
parser.add_argument("--retries", type=int, metavar="N", default=default(None),
|
||||
help="失败重试次数,默认 2")
|
||||
parser.add_argument("--config", metavar="PATH", default=default(None),
|
||||
help="JSON 配置文件路径")
|
||||
parser.add_argument("--json", action="store_true",
|
||||
default=argparse.SUPPRESS if suppress else False,
|
||||
help="输出 JSON(便于管道/脚本消费)")
|
||||
parser.add_argument("--out", metavar="PATH", default=default(None),
|
||||
help="把结果 JSON 写入文件")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
default=argparse.SUPPRESS if suppress else False,
|
||||
help="打印每个 HTTP 请求")
|
||||
|
||||
|
||||
def build_parser():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
_add_common(common, suppress=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="graylog-ops",
|
||||
description="Graylog 4.0 只读信息采集工具(为 IP 封禁规则摸底)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
)
|
||||
_add_common(parser, suppress=False)
|
||||
parser.add_argument("--version", action="version", version="graylog-ops %s" % __version__)
|
||||
|
||||
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
||||
|
||||
def add(name, handler, help_text):
|
||||
cmd = sub.add_parser(name, parents=[common], help=help_text, description=help_text)
|
||||
cmd.set_defaults(func=handler)
|
||||
return cmd
|
||||
|
||||
add("probe", commands.cmd_probe, "检查连通性、认证、接口可用性矩阵")
|
||||
add("system", commands.cmd_system, "节点/索引器/版本/license 概要")
|
||||
add("streams", commands.cmd_streams, "列出 stream(分流与权限边界)").add_argument(
|
||||
"--rules", action="store_true", help="同时拉取每个 stream 的规则(N+1 请求)")
|
||||
add("inputs", commands.cmd_inputs, "列出 input 及监听地址")
|
||||
add("fields", commands.cmd_fields, "列出 Graylog 已注册字段与类型")
|
||||
add("indices", commands.cmd_indices, "index set 与索引大小/保留策略")
|
||||
add("alerts", commands.cmd_alerts, "现有事件定义与通知方式(改造前先看)").add_argument(
|
||||
"--limit", type=int, default=None, help="最多返回条数")
|
||||
|
||||
discover = add("discover", commands.cmd_discover, "采样日志,推断真实客户端 IP 字段")
|
||||
discover.add_argument("--stream", help="stream id 或标题子串")
|
||||
discover.add_argument("--query", default="*", help="查询条件,默认 *")
|
||||
discover.add_argument("--range", type=int, default=3600, help="时间窗(秒),默认 3600")
|
||||
discover.add_argument("--sample", type=int, default=500, help="采样消息数,默认 500")
|
||||
discover.add_argument("--save-profile", metavar="PATH",
|
||||
help="把推断出的字段映射写成 profile,供 judge 使用")
|
||||
|
||||
judge = add("judge", commands.cmd_judge,
|
||||
"把聚合信号判成封禁候选(只读,不执行封禁)")
|
||||
judge.add_argument("--profile", metavar="PATH", help="discover --save-profile 生成的文件")
|
||||
judge.add_argument("--range", type=int, default=300, help="时间窗(秒),默认 300")
|
||||
judge.add_argument("--query", default="*", help="限定查询范围,默认 *")
|
||||
judge.add_argument("--stream", help="stream id 或标题子串")
|
||||
judge.add_argument("--field", help="客户端 IP 字段(覆盖 profile)")
|
||||
judge.add_argument("--status-field", help="状态码字段")
|
||||
judge.add_argument("--uri-field", help="URI 字段")
|
||||
judge.add_argument("--ua-field", help="User-Agent 字段")
|
||||
judge.add_argument("--method-field", help="HTTP 方法字段")
|
||||
judge.add_argument("--allow", action="append", metavar="FILE",
|
||||
help="白名单文件(IP/CIDR,可重复)")
|
||||
judge.add_argument("--include-internal", action="store_true",
|
||||
help="允许内网地址进入候选(默认排除)")
|
||||
judge.add_argument("--min-requests", type=int, help="低于该请求数的 IP 不参与判分")
|
||||
judge.add_argument("--min-score", type=float, help="低于该分数的候选不输出")
|
||||
judge.add_argument("--top", type=int, default=30, help="最多输出条数,默认 30")
|
||||
judge.add_argument("--size", type=int, default=200, help="每类聚合返回的 IP 数上限,默认 200")
|
||||
judge.add_argument("--verify", type=int, default=0, metavar="N",
|
||||
help="对前 N 个候选反查 URI 集中度(识别自动化客户端,每候选多 1 次查询)")
|
||||
judge.add_argument("--explain", type=int, default=0, metavar="N",
|
||||
help="为前 N 个候选附带样例消息")
|
||||
judge.add_argument("--all", action="store_true", help="输出全部候选(含 ignore)")
|
||||
|
||||
watch = add("watch", commands.cmd_watch,
|
||||
"持续检测恶意公网 IP + 本地 Web 面板(SQLite 持久化)")
|
||||
watch.add_argument("--profile", metavar="PATH", help="字段映射 profile")
|
||||
watch.add_argument("--stream", help="只检测某个 stream")
|
||||
watch.add_argument("--interval", type=int, default=60, help="检测间隔(秒),默认 60")
|
||||
watch.add_argument("--window", type=int, default=900, help="检测时间窗(秒),默认 900")
|
||||
watch.add_argument("--host", default="127.0.0.1", help="面板监听地址")
|
||||
watch.add_argument("--port", type=int, default=8088, help="面板端口,默认 8088")
|
||||
watch.add_argument("--db", default="data/ips.db", help="SQLite 路径")
|
||||
watch.add_argument("--behavior-interval", type=int, default=900,
|
||||
help="行为检测刷新间隔(秒,默认 900)")
|
||||
watch.add_argument("--no-behavior", action="store_true",
|
||||
help="关闭行为检测线程,只跑特征检测")
|
||||
watch.add_argument("--notify-config", default="notify.json",
|
||||
help="新增恶意 IP 的 webhook 配置(不存在则不推送)")
|
||||
watch.add_argument("--notify-test", action="store_true",
|
||||
help="发送一条测试推送后退出")
|
||||
watch.add_argument("--asn-db", default="data/geo/asn.db",
|
||||
help="ASN 库路径(空字符串禁用)")
|
||||
watch.add_argument("--geo-db", default="data/geo/geo.db",
|
||||
help="DB-IP Lite 生成的离线地理库路径(空字符串禁用)")
|
||||
watch.add_argument("--path-prefix", metavar="PATH", default="",
|
||||
help="只在该路径前缀下提供服务,根路径返回 404(用于对外分享)")
|
||||
watch.add_argument("--forget", type=int, default=7200, help="多久没再出现就从列表移除(秒)")
|
||||
watch.add_argument("--allow", action="append", metavar="FILE", help="白名单文件,可重复")
|
||||
watch.add_argument("--field", help="客户端 IP 字段")
|
||||
watch.add_argument("--status-field", help="状态码字段")
|
||||
watch.add_argument("--uri-field", help="URI 字段")
|
||||
watch.add_argument("--ua-field", help="User-Agent 字段")
|
||||
watch.add_argument("--min-requests", type=int, default=3, help="低于该请求数不参与判定")
|
||||
watch.add_argument("--size", type=int, default=200, help="每类聚合返回的 IP 数上限")
|
||||
watch.add_argument("--verify", type=int, default=8, help="对前 N 个候选做 URI 核验")
|
||||
watch.add_argument("--min-observe-score", type=float, default=10.0,
|
||||
help="观察级候选低于该分数不入库,默认 10")
|
||||
watch.add_argument("--top", type=int, default=50, help="--once 模式下打印的条数")
|
||||
watch.add_argument("--export", metavar="PATH",
|
||||
help="把高置信恶意 IP 导出为纯文本名单(排除疑似 NAT)")
|
||||
watch.add_argument("--once", action="store_true", help="只跑一轮后退出(用于离线分析)")
|
||||
watch.add_argument("--warm-limit", type=int, default=5,
|
||||
help="每轮为多少个恶意 IP 预生成证据快照,默认 5")
|
||||
watch.add_argument("--fix-times", "--fix-first-seen", dest="fix_first_seen",
|
||||
action="store_true",
|
||||
help="回查日志,修正首次攻击 / 最近攻击 / 最近出现时间")
|
||||
watch.add_argument("--warm-only", action="store_true",
|
||||
help="只预热证据快照后退出(配合 --warm-limit)")
|
||||
watch.add_argument("--serve-only", action="store_true",
|
||||
help="只提供面板,不做检测(用于浏览已有数据库)")
|
||||
|
||||
collect = add("collect", commands.cmd_collect,
|
||||
"一条命令产出 probe/discover/judge/snapshot 整套信息")
|
||||
collect.add_argument("--dir", help="输出目录,默认 graylog-collect-<时间戳>")
|
||||
collect.add_argument("--range", type=int, default=3600, help="discover 的时间窗(秒)")
|
||||
collect.add_argument("--judge-range", type=int, default=300, help="judge 的时间窗(秒)")
|
||||
collect.add_argument("--stream", help="只针对某个 stream(传给 discover/judge)")
|
||||
collect.add_argument("--allow", action="append", metavar="FILE", help="白名单文件,可重复")
|
||||
|
||||
for name, default_field in (("terms", None), ("top-ips", "client_ip")):
|
||||
cmd = add(name, commands.cmd_terms,
|
||||
"按字段做 terms 聚合(top-ips 是 field=client_ip 的快捷方式)")
|
||||
cmd.add_argument("--field", required=(default_field is None), default=default_field,
|
||||
help="聚合字段")
|
||||
cmd.add_argument("--query", default="*", help="查询条件,默认 *")
|
||||
cmd.add_argument("--range", type=int, default=300, help="时间窗(秒),默认 300")
|
||||
cmd.add_argument("--size", type=int, default=50, help="返回条数,默认 50")
|
||||
cmd.add_argument("--min-count", type=int, default=None, help="过滤低于该计数的项")
|
||||
cmd.add_argument("--stream", help="stream id 或标题子串")
|
||||
cmd.add_argument("--from", dest="from_ts", metavar="ISO8601", help="绝对时间起(UTC)")
|
||||
cmd.add_argument("--to", dest="to_ts", metavar="ISO8601", help="绝对时间止(UTC)")
|
||||
|
||||
messages = add("messages", commands.cmd_messages, "拉取原始消息(JSON/表格/JSONL)")
|
||||
messages.add_argument("--query", default="*", help="查询条件,默认 *")
|
||||
messages.add_argument("--range", type=int, default=300, help="时间窗(秒),默认 300")
|
||||
messages.add_argument("--limit", type=int, default=50, help="条数,默认 50")
|
||||
messages.add_argument("--offset", type=int, default=0, help="偏移")
|
||||
messages.add_argument("--fields", help="只取这些字段,逗号分隔")
|
||||
messages.add_argument("--stream", help="stream id 或标题子串")
|
||||
messages.add_argument("--jsonl", action="store_true", help="每行一个 JSON 消息")
|
||||
|
||||
histogram = add("histogram", commands.cmd_histogram, "消息量时间分布(估算阈值/容量)")
|
||||
histogram.add_argument("--query", default="*", help="查询条件,默认 *")
|
||||
histogram.add_argument("--range", type=int, default=3600, help="时间窗(秒),默认 3600")
|
||||
histogram.add_argument("--interval", default="1m", help="时间桶,如 1m/5m/1h")
|
||||
histogram.add_argument("--stream", help="stream id 或标题子串")
|
||||
|
||||
snapshot = add("snapshot", commands.cmd_snapshot, "整体环境快照落到目录(每个资源一个 json)")
|
||||
snapshot.add_argument("--dir", help="输出目录,默认 graylog-snapshot-<时间戳>")
|
||||
snapshot.add_argument("--sample", type=int, default=0, help="附带采样消息条数,默认 0")
|
||||
|
||||
raw = add("raw", commands.cmd_raw, "直接调用任意 API 路径(探索未封装的接口)")
|
||||
raw.add_argument("path", help="如 /api/system/inputs")
|
||||
raw.add_argument("--method", default="GET", help="HTTP 方法,默认 GET")
|
||||
raw.add_argument("--body", metavar="JSON", help="JSON 请求体,或 @文件路径")
|
||||
raw.add_argument("--param", action="append", metavar="K=V", help="查询参数,可重复")
|
||||
raw.add_argument("--text", action="store_true", help="按文本返回(非 JSON)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not getattr(args, "command", None):
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
try:
|
||||
config = load_config(args)
|
||||
except ConfigError as exc:
|
||||
print("配置错误: %s" % exc, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
http_client = HttpClient(
|
||||
config.url,
|
||||
username=config.credentials[0],
|
||||
password=config.credentials[1],
|
||||
timeout=config.timeout,
|
||||
retries=config.retries,
|
||||
insecure=config.insecure,
|
||||
verbose=bool(getattr(args, "verbose", False)),
|
||||
)
|
||||
ctx = commands.Context(args, config, GraylogClient(http_client))
|
||||
|
||||
try:
|
||||
return args.func(ctx, args) or 0
|
||||
except GraylogError as exc:
|
||||
print("Graylog API 错误: %s" % exc, file=sys.stderr)
|
||||
if exc.status == 401:
|
||||
print(
|
||||
"提示: token 认证是把 token 当用户名、字面量 token 当密码;"
|
||||
"确认 token 未吊销且账号有对应权限",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif exc.status == 403:
|
||||
print("提示: 账号缺少该资源权限(如 messages:read / searches:*)", file=sys.stderr)
|
||||
elif exc.status == 404:
|
||||
print(
|
||||
"提示: 该版本可能没有这个接口。先跑 `probe` 看可用性,"
|
||||
"或用 `raw /api/...` 直接验证",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif exc.status in (400, 422):
|
||||
print("提示: 参数不被该版本接受,加 -v 查看实际请求的 URL", file=sys.stderr)
|
||||
return 1
|
||||
except BrokenPipeError:
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("", file=sys.stderr)
|
||||
return 130
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Thin, defensive wrappers around the Graylog 4.0 REST API.
|
||||
|
||||
Every method returns plain Python data (dict/list) that has been normalised
|
||||
just enough for callers not to care about cosmetic response differences.
|
||||
Anything that is version specific and ambiguous is passed through untouched
|
||||
and documented as such.
|
||||
|
||||
Endpoint reference for 4.0 lives in the server itself:
|
||||
``http://<graylog>:9002/api/api-browser/``
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from .http import GraylogError
|
||||
|
||||
|
||||
def as_list(data, *keys):
|
||||
"""Best effort extraction of a list from a Graylog response."""
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
for value in data.values():
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
|
||||
|
||||
def dig(data, *keys, **kwargs):
|
||||
default = kwargs.get("default")
|
||||
current = data
|
||||
for key in keys:
|
||||
if not isinstance(current, dict):
|
||||
return default
|
||||
current = current.get(key)
|
||||
if current is None:
|
||||
return default
|
||||
return current
|
||||
|
||||
|
||||
def normalize_terms(data):
|
||||
"""Turn a terms aggregation response into ``[{"term", "count"}, ...]``.
|
||||
|
||||
Graylog has shipped a couple of shapes for this over the years, so accept
|
||||
the common ones instead of assuming a single layout.
|
||||
"""
|
||||
items = as_list(data, "terms", "values", "results")
|
||||
rows = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
term = item.get("term", item.get("value", item.get("name")))
|
||||
count = item.get("count", item.get("doc_count", item.get("hits", 0)))
|
||||
rows.append({"term": term, "count": count})
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
rows.append({"term": item[0], "count": item[1]})
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_histogram(data):
|
||||
"""Turn a histogram response into ``[{"timestamp", "count"}, ...]``."""
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
results = data.get("results")
|
||||
rows = []
|
||||
if isinstance(results, dict):
|
||||
for timestamp, value in results.items():
|
||||
if isinstance(value, dict):
|
||||
count = value.get("count", value.get("doc_count", 0))
|
||||
else:
|
||||
count = value
|
||||
rows.append({"timestamp": timestamp, "count": count or 0})
|
||||
elif isinstance(results, list):
|
||||
for item in results:
|
||||
if isinstance(item, dict):
|
||||
rows.append({
|
||||
"timestamp": item.get("timestamp", item.get("time")),
|
||||
"count": item.get("count", 0),
|
||||
})
|
||||
rows.sort(key=lambda row: str(row["timestamp"]))
|
||||
return rows
|
||||
|
||||
|
||||
class GraylogClient:
|
||||
def __init__(self, http):
|
||||
self.http = http
|
||||
# Which aggregation backend to use. Graylog 4.0 removed the legacy
|
||||
# /api/search/universal/{terms,histogram,stats} endpoints, so 4.x must
|
||||
# use the views API (POST /api/views/search/sync). 3.x has no views API.
|
||||
self.agg_backend = None
|
||||
# Legacy fallback only: some builds only accept "size" on terms,
|
||||
# others tolerate/require "limit".
|
||||
self.terms_size_param = "size"
|
||||
|
||||
# ---------------------------------------------------------------- system
|
||||
def system_info(self):
|
||||
return self.http.get("/api/system") or {}
|
||||
|
||||
def cluster_nodes(self):
|
||||
return as_list(self.http.get("/api/system/cluster/nodes"), "nodes")
|
||||
|
||||
def indexer_health(self):
|
||||
return self.http.get("/api/system/indexer/cluster/health") or {}
|
||||
|
||||
def indexer_cluster_name(self):
|
||||
return self.http.get("/api/system/indexer/cluster/name") or ""
|
||||
|
||||
def deflector(self):
|
||||
return self.http.get("/api/system/deflector") or {}
|
||||
|
||||
def field_types(self):
|
||||
data = self.http.get("/api/system/fields")
|
||||
if isinstance(data, dict):
|
||||
fields = data.get("fields")
|
||||
if isinstance(fields, list):
|
||||
return fields
|
||||
if isinstance(fields, dict):
|
||||
return [{"name": name, "type": kind} for name, kind in fields.items()]
|
||||
return as_list(data, "fields")
|
||||
|
||||
def license_info(self):
|
||||
return self.http.get("/api/system/license") or {}
|
||||
|
||||
def index_sets(self):
|
||||
data = self.http.get("/api/system/indices/index_sets", {"skip": 0, "limit": 100})
|
||||
return as_list(data, "index_sets")
|
||||
|
||||
def indices(self):
|
||||
data = self.http.get("/api/system/indexer/indices")
|
||||
if isinstance(data, dict):
|
||||
# Strict per-key access: as_list()'s "first list in the dict"
|
||||
# fallback would merge the same list three times here.
|
||||
merged = []
|
||||
for key in ("indices", "closed_indices", "reopened_indices"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
merged.extend(value)
|
||||
return merged
|
||||
return as_list(data)
|
||||
|
||||
# --------------------------------------------------------------- streams
|
||||
def streams(self):
|
||||
return as_list(self.http.get("/api/streams"), "streams")
|
||||
|
||||
def stream(self, stream_id):
|
||||
return self.http.get("/api/streams/%s" % stream_id) or {}
|
||||
|
||||
# ---------------------------------------------------------------- inputs
|
||||
def inputs(self):
|
||||
return as_list(self.http.get("/api/system/inputs"), "inputs")
|
||||
|
||||
def extractors(self, input_id):
|
||||
data = self.http.get("/api/system/inputs/%s/extractors" % input_id)
|
||||
return as_list(data, "extractors")
|
||||
|
||||
# ------------------------------------------------------------- pipelines
|
||||
def pipelines(self):
|
||||
return as_list(self.http.get("/api/system/pipelines/pipeline"), "pipelines")
|
||||
|
||||
def pipeline_rules(self):
|
||||
return as_list(self.http.get("/api/system/pipelines/rule"), "rules")
|
||||
|
||||
# ---------------------------------------------------------------- events
|
||||
def _paged(self, path, key, limit=None, per_page=50, params=None):
|
||||
items = []
|
||||
page = 1
|
||||
while True:
|
||||
query = dict(params or {})
|
||||
query.update({"page": page, "per_page": per_page})
|
||||
data = self.http.get(path, query)
|
||||
batch = as_list(data, key)
|
||||
if not batch:
|
||||
return items
|
||||
items.extend(batch)
|
||||
if limit is not None and len(items) >= limit:
|
||||
return items[:limit]
|
||||
# Trust the server's total when it tells us there is nothing left;
|
||||
# this also stops runaway pagination on servers that ignore `page`.
|
||||
total = dig(data, "total")
|
||||
if isinstance(total, int) and len(items) >= total:
|
||||
return items
|
||||
if len(batch) < per_page:
|
||||
return items
|
||||
page += 1
|
||||
if page > 500: # defensive stop
|
||||
return items
|
||||
|
||||
def event_definitions(self, limit=None, per_page=50):
|
||||
return self._paged(
|
||||
"/api/events/definitions",
|
||||
"event_definitions",
|
||||
limit=limit,
|
||||
per_page=per_page,
|
||||
params={"sort": "title", "order": "asc"},
|
||||
)
|
||||
|
||||
def notifications(self, limit=None, per_page=50):
|
||||
return self._paged(
|
||||
"/api/events/notifications", "notifications", limit=limit, per_page=per_page
|
||||
)
|
||||
|
||||
def events(self, limit=None, per_page=50):
|
||||
data = self.http.get("/api/events", {"page": 1, "per_page": per_page})
|
||||
items = as_list(data, "events")
|
||||
return items[:limit] if limit else items
|
||||
|
||||
# --------------------------------------------------------------- sidecar
|
||||
def sidecars(self):
|
||||
return as_list(self.http.get("/api/sidecars"), "sidecars")
|
||||
|
||||
# ---------------------------------------------------------------- search
|
||||
@staticmethod
|
||||
def _time_params(range_seconds=None, from_ts=None, to_ts=None):
|
||||
if from_ts and to_ts:
|
||||
return {"from": from_ts, "to": to_ts}
|
||||
return {"range": int(range_seconds if range_seconds is not None else 300)}
|
||||
|
||||
def search_relative(self, query="*", range_seconds=300, limit=100, offset=0,
|
||||
sort="timestamp:desc", fields=None, filter_str=None):
|
||||
params = {"query": query or "*", "limit": int(limit), "offset": int(offset), "sort": sort}
|
||||
params.update(self._time_params(range_seconds=range_seconds))
|
||||
if fields:
|
||||
params["fields"] = ",".join(fields)
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
data = self.http.get("/api/search/universal/relative", params)
|
||||
return self._messages(data)
|
||||
|
||||
def search_absolute(self, query="*", from_ts=None, to_ts=None, limit=100, offset=0,
|
||||
sort="timestamp:desc", fields=None, filter_str=None):
|
||||
params = {"query": query or "*", "limit": int(limit), "offset": int(offset), "sort": sort}
|
||||
params.update(self._time_params(from_ts=from_ts, to_ts=to_ts))
|
||||
if fields:
|
||||
params["fields"] = ",".join(fields)
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
data = self.http.get("/api/search/universal/absolute", params)
|
||||
return self._messages(data)
|
||||
|
||||
@staticmethod
|
||||
def _messages(data):
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
messages = data.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
result = []
|
||||
for item in messages:
|
||||
if isinstance(item, dict) and isinstance(item.get("message"), dict):
|
||||
message = dict(item["message"])
|
||||
if item.get("index") and "_index" not in message:
|
||||
message["_index"] = item["index"]
|
||||
result.append(message)
|
||||
elif isinstance(item, dict):
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------- views aggregation
|
||||
BASE_PATH = "/api/views/search"
|
||||
|
||||
@staticmethod
|
||||
def _timerange(range_seconds=None, from_ts=None, to_ts=None):
|
||||
if from_ts and to_ts:
|
||||
return {"type": "absolute", "from": from_ts, "to": to_ts}
|
||||
return {"type": "relative", "range": int(range_seconds or 300)}
|
||||
|
||||
@staticmethod
|
||||
def _stream_filter(filter_str):
|
||||
"""Translate the legacy ``streams:<id>[,<id>]`` filter to a views filter."""
|
||||
if not filter_str or not filter_str.startswith("streams:"):
|
||||
return None
|
||||
ids = [part.strip() for part in filter_str[len("streams:"):].split(",") if part.strip()]
|
||||
if not ids:
|
||||
return None
|
||||
return {"type": "or", "filters": [{"type": "stream", "id": sid} for sid in ids]}
|
||||
|
||||
def _views_pivot(self, query, timerange, row_groups, column_groups, series,
|
||||
sort, filter_str=None, timeout_ms=60000):
|
||||
search_type = {
|
||||
"type": "pivot",
|
||||
"id": "st1",
|
||||
"series": series,
|
||||
"row_groups": row_groups,
|
||||
"column_groups": column_groups,
|
||||
"sort": sort,
|
||||
"rollup": True,
|
||||
}
|
||||
query_object = {
|
||||
"id": "q1",
|
||||
"timerange": timerange,
|
||||
"query": {"type": "elasticsearch", "query_string": query or "*"},
|
||||
"search_types": [search_type],
|
||||
}
|
||||
stream_filter = self._stream_filter(filter_str)
|
||||
if stream_filter:
|
||||
query_object["filter"] = stream_filter
|
||||
body = {"queries": [query_object], "parameters": []}
|
||||
data = self.http.request(
|
||||
"POST", self.BASE_PATH + "/sync",
|
||||
params={"timeout": int(timeout_ms)}, body=body,
|
||||
)
|
||||
results = ((data or {}).get("results") or {}).get("q1") or {}
|
||||
errors = results.get("errors") or []
|
||||
if errors:
|
||||
raise GraylogError(
|
||||
"views search error: %s" % json.dumps(errors, ensure_ascii=False)[:400],
|
||||
path=self.BASE_PATH + "/sync", method="POST",
|
||||
)
|
||||
search_types = results.get("search_types") or {}
|
||||
if isinstance(search_types, list):
|
||||
search_types = {item.get("id"): item for item in search_types
|
||||
if isinstance(item, dict)}
|
||||
return search_types.get("st1") or {}
|
||||
|
||||
def _terms_views(self, field, query, size, range_seconds, from_ts, to_ts, filter_str):
|
||||
search_type = self._views_pivot(
|
||||
query=query,
|
||||
timerange=self._timerange(range_seconds, from_ts, to_ts),
|
||||
row_groups=[{"type": "values", "field": field, "limit": int(size)}],
|
||||
column_groups=[],
|
||||
series=[{"type": "count", "id": "c1"}],
|
||||
sort=[{"type": "pivot", "field": "c1", "direction": "Descending"}],
|
||||
filter_str=filter_str,
|
||||
)
|
||||
terms = []
|
||||
for row in search_type.get("rows") or []:
|
||||
key = row.get("key") or []
|
||||
if not key:
|
||||
continue # rollup row (total)
|
||||
values = row.get("values") or []
|
||||
count = values[0].get("value") if values else 0
|
||||
terms.append({"term": key[0], "count": count})
|
||||
return {
|
||||
"field": field,
|
||||
"terms": terms,
|
||||
"total": search_type.get("total", sum(row["count"] or 0 for row in terms)),
|
||||
}
|
||||
|
||||
def _histogram_views(self, query, range_seconds, interval, filter_str, from_ts, to_ts):
|
||||
search_type = self._views_pivot(
|
||||
query=query,
|
||||
timerange=self._timerange(range_seconds, from_ts, to_ts),
|
||||
row_groups=[],
|
||||
column_groups=[{"type": "time", "field": "timestamp", "interval": interval}],
|
||||
series=[{"type": "count", "id": "c1"}],
|
||||
sort=[],
|
||||
filter_str=filter_str,
|
||||
)
|
||||
results = {}
|
||||
for row in search_type.get("rows") or []:
|
||||
for item in row.get("values") or []:
|
||||
key = item.get("key") or []
|
||||
# Column keys are [timestamp, series_id]; the pivot rollup row
|
||||
# only carries [series_id] and must not become a time bucket.
|
||||
if len(key) < 2:
|
||||
continue
|
||||
results[key[0]] = {"count": item.get("value", 0)}
|
||||
return {"results": results, "interval": interval}
|
||||
|
||||
# ------------------------------------------------------------- aggregation
|
||||
def search_views_messages(self, query="*", range_seconds=300, limit=5,
|
||||
offset=0, filter_str=None, from_ts=None, to_ts=None,
|
||||
timeout_ms=60000, sort_order="DESC"):
|
||||
"""Message search through the views API (the legacy endpoint is flaky
|
||||
on long windows with complex queries and drops the connection)."""
|
||||
search_type = {
|
||||
"type": "messages",
|
||||
"id": "st1",
|
||||
"limit": int(limit),
|
||||
"offset": int(offset),
|
||||
"sort": [{"field": "timestamp",
|
||||
"order": "ASC" if str(sort_order).upper() == "ASC" else "DESC"}],
|
||||
}
|
||||
query_object = {
|
||||
"id": "q1",
|
||||
"timerange": self._timerange(range_seconds, from_ts, to_ts),
|
||||
"query": {"type": "elasticsearch", "query_string": query or "*"},
|
||||
"search_types": [search_type],
|
||||
}
|
||||
stream_filter = self._stream_filter(filter_str)
|
||||
if stream_filter:
|
||||
query_object["filter"] = stream_filter
|
||||
body = {"queries": [query_object], "parameters": []}
|
||||
data = self.http.request(
|
||||
"POST", self.BASE_PATH + "/sync",
|
||||
params={"timeout": int(timeout_ms)}, body=body,
|
||||
)
|
||||
results = ((data or {}).get("results") or {}).get("q1") or {}
|
||||
errors = results.get("errors") or []
|
||||
if errors:
|
||||
raise GraylogError(
|
||||
"views message search error: %s" % json.dumps(errors, ensure_ascii=False)[:300],
|
||||
path=self.BASE_PATH + "/sync", method="POST",
|
||||
)
|
||||
search_types = results.get("search_types") or {}
|
||||
if isinstance(search_types, list):
|
||||
search_types = {item.get("id"): item for item in search_types
|
||||
if isinstance(item, dict)}
|
||||
search_type_result = search_types.get("st1") or {}
|
||||
return self._messages({"messages": search_type_result.get("messages") or []})
|
||||
|
||||
def terms(self, field, query="*", size=50, range_seconds=300,
|
||||
from_ts=None, to_ts=None, filter_str=None):
|
||||
if self.agg_backend in (None, "views"):
|
||||
try:
|
||||
data = self._terms_views(field, query, size, range_seconds,
|
||||
from_ts, to_ts, filter_str)
|
||||
self.agg_backend = "views"
|
||||
return data
|
||||
except GraylogError as exc:
|
||||
if exc.status != 404 or self.agg_backend == "views":
|
||||
raise
|
||||
self.agg_backend = "legacy"
|
||||
return self._terms_legacy(field, query, size, range_seconds,
|
||||
from_ts, to_ts, filter_str)
|
||||
|
||||
def _terms_legacy(self, field, query, size, range_seconds, from_ts, to_ts, filter_str):
|
||||
params = {"query": query or "*", "field": field}
|
||||
params.update(self._time_params(range_seconds, from_ts, to_ts))
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
params[self.terms_size_param] = int(size)
|
||||
try:
|
||||
return self.http.get("/api/search/universal/terms", params)
|
||||
except GraylogError as exc:
|
||||
# Older/newer builds disagree on the parameter name; retry once.
|
||||
if exc.status in (400, 422) and self.terms_size_param == "size":
|
||||
params.pop("size", None)
|
||||
params["limit"] = int(size)
|
||||
data = self.http.get("/api/search/universal/terms", params)
|
||||
self.terms_size_param = "limit"
|
||||
return data
|
||||
raise
|
||||
|
||||
def histogram(self, query="*", range_seconds=3600, interval="1m",
|
||||
filter_str=None, from_ts=None, to_ts=None):
|
||||
if self.agg_backend in (None, "views"):
|
||||
try:
|
||||
data = self._histogram_views(query, range_seconds, interval,
|
||||
filter_str, from_ts, to_ts)
|
||||
self.agg_backend = "views"
|
||||
return data
|
||||
except GraylogError as exc:
|
||||
if exc.status != 404 or self.agg_backend == "views":
|
||||
raise
|
||||
self.agg_backend = "legacy"
|
||||
params = {"query": query or "*", "interval": interval}
|
||||
params.update(self._time_params(range_seconds, from_ts, to_ts))
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
return self.http.get("/api/search/universal/histogram", params)
|
||||
|
||||
def stats(self, field, query="*", range_seconds=300, stats=None,
|
||||
filter_str=None, from_ts=None, to_ts=None):
|
||||
params = {"query": query or "*", "field": field}
|
||||
params.update(self._time_params(range_seconds, from_ts, to_ts))
|
||||
if stats:
|
||||
params["stats"] = ",".join(stats)
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
return self.http.get("/api/search/universal/stats", params)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
"""Configuration resolution.
|
||||
|
||||
Precedence (highest wins):
|
||||
1. command line flags
|
||||
2. environment variables (GRAYLOG_URL, GRAYLOG_TOKEN, ...)
|
||||
3. JSON config file (--config, ./graylog-ops.json,
|
||||
~/.config/graylog-ops/config.json)
|
||||
|
||||
File format::
|
||||
|
||||
{
|
||||
"url": "http://edu-master:9000",
|
||||
"token": "xxxxxxxx",
|
||||
"insecure": false,
|
||||
"timeout": 30
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
ENV_KEYS = {
|
||||
"url": "GRAYLOG_URL",
|
||||
"token": "GRAYLOG_TOKEN",
|
||||
"user": "GRAYLOG_USER",
|
||||
"password": "GRAYLOG_PASSWORD",
|
||||
"insecure": "GRAYLOG_INSECURE",
|
||||
"timeout": "GRAYLOG_TIMEOUT",
|
||||
"retries": "GRAYLOG_RETRIES",
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG_PATHS = (
|
||||
os.path.expanduser("~/.config/graylog-ops/config.json"),
|
||||
"graylog-ops.json",
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""User facing configuration problem."""
|
||||
|
||||
|
||||
def _truthy(value):
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on", "y")
|
||||
|
||||
|
||||
def _read_file(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except OSError as exc:
|
||||
raise ConfigError("cannot read config %s: %s" % (path, exc))
|
||||
except ValueError as exc:
|
||||
raise ConfigError("config %s is not valid JSON: %s" % (path, exc))
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError("config %s must contain a JSON object" % path)
|
||||
return data
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, url, token=None, user=None, password=None,
|
||||
insecure=False, timeout=30.0, retries=2, source=None):
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.insecure = bool(insecure)
|
||||
self.timeout = float(timeout)
|
||||
self.retries = int(retries)
|
||||
self.source = source
|
||||
|
||||
@property
|
||||
def credentials(self):
|
||||
"""(username, password) pair for HTTP Basic auth."""
|
||||
if self.token:
|
||||
# Graylog token auth: username is the token, password is "token".
|
||||
return self.token, "token"
|
||||
return self.user, self.password
|
||||
|
||||
@property
|
||||
def auth_kind(self):
|
||||
return "token" if self.token else "user/password"
|
||||
|
||||
|
||||
def _pick(values, key, default=None):
|
||||
value = values.get(key, default)
|
||||
return value
|
||||
|
||||
|
||||
def load_config(args):
|
||||
"""Build a Config from CLI args, env vars and the config file."""
|
||||
values = {}
|
||||
chosen = None
|
||||
|
||||
explicit = getattr(args, "config", None)
|
||||
if explicit:
|
||||
if not os.path.exists(explicit):
|
||||
raise ConfigError("config file not found: %s" % explicit)
|
||||
values.update(_read_file(explicit))
|
||||
chosen = explicit
|
||||
else:
|
||||
for candidate in DEFAULT_CONFIG_PATHS:
|
||||
if os.path.exists(candidate):
|
||||
values.update(_read_file(candidate))
|
||||
chosen = candidate
|
||||
break
|
||||
|
||||
for key, env_name in ENV_KEYS.items():
|
||||
env_value = os.environ.get(env_name)
|
||||
if env_value is not None and env_value.strip() != "":
|
||||
values[key] = env_value
|
||||
|
||||
for key in ("url", "token", "user", "password", "timeout", "retries"):
|
||||
flag_value = getattr(args, key, None)
|
||||
if flag_value is not None and flag_value != "":
|
||||
values[key] = flag_value
|
||||
if getattr(args, "insecure", False):
|
||||
values["insecure"] = True
|
||||
|
||||
url = values.get("url")
|
||||
if not url:
|
||||
raise ConfigError(
|
||||
"no Graylog URL: pass --url, set GRAYLOG_URL, or add \"url\" to the config file"
|
||||
)
|
||||
url = str(url).strip()
|
||||
if "://" not in url:
|
||||
url = "http://" + url
|
||||
|
||||
token = (values.get("token") or "").strip() or None
|
||||
user = (values.get("user") or "").strip() or None
|
||||
password = values.get("password")
|
||||
if not token and not (user and password):
|
||||
raise ConfigError(
|
||||
"no credentials: pass --token or --user/--password "
|
||||
"(env GRAYLOG_TOKEN / GRAYLOG_USER + GRAYLOG_PASSWORD)"
|
||||
)
|
||||
|
||||
timeout = values.get("timeout", 30)
|
||||
retries = values.get("retries", 2)
|
||||
try:
|
||||
timeout = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
raise ConfigError("timeout must be a number, got %r" % (timeout,))
|
||||
try:
|
||||
retries = int(retries)
|
||||
except (TypeError, ValueError):
|
||||
raise ConfigError("retries must be an integer, got %r" % (retries,))
|
||||
|
||||
insecure = values.get("insecure", False)
|
||||
if isinstance(insecure, str):
|
||||
insecure = _truthy(insecure)
|
||||
|
||||
return Config(
|
||||
url=url,
|
||||
token=token,
|
||||
user=user,
|
||||
password=password,
|
||||
insecure=bool(insecure),
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
source=chosen,
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Field heuristics: find the field that actually holds the client IP.
|
||||
|
||||
The ban pipeline is only as good as the field it keys on. In a typical
|
||||
deployment behind a reverse proxy / CDN, the field you *think* is the client
|
||||
IP is the proxy's address:
|
||||
|
||||
* ``source`` -- the host that shipped the log line to Graylog
|
||||
* ``gl2_remote_ip`` -- the host that opened the GELF/Beats connection
|
||||
* ``remote_addr`` -- could be the proxy if XFF is not parsed
|
||||
|
||||
So instead of assuming, sample real messages and score every field by
|
||||
"how often does this field hold an IP address" plus "does the field name look
|
||||
like a client address".
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
# Field names that strongly suggest a client address.
|
||||
STRONG_HINT = re.compile(
|
||||
r"(?i)client[_. -]?ip|remote[_. -]?addr|real[_. -]?ip|"
|
||||
r"(^|[_. -])ip([_. -]|$)|x[_. -]?forwarded[_. -]?for|"
|
||||
r"(^|[_. -])xff([_. -]|$)|src[_. -]?ip|c[_. -]?ip"
|
||||
)
|
||||
|
||||
WEAK_HINT = re.compile(
|
||||
r"(?i)addr|host|client|proxy|peer|origin|caller|visitor|remote|forward|endpoint"
|
||||
)
|
||||
|
||||
# Graylog internals that are never the client IP (gl2_remote_ip is the shipper).
|
||||
INTERNAL_PREFIXES = ("gl2_", "_")
|
||||
INTERNAL_NAMES = ("timestamp", "streams", "message")
|
||||
|
||||
HTTP_HINTS = {
|
||||
"status": re.compile(r"(?i)^(http[_. ]?)?status([_. ]?code)?$|^response([_. ]?(code|status))?$|^code$"),
|
||||
"uri": re.compile(r"(?i)(request[_. ]?)?uri$|^url$|^path$|^request$|^endpoint$|^target$"),
|
||||
"method": re.compile(r"(?i)^(http[_. ]?)?method$|^verb$"),
|
||||
"user_agent": re.compile(r"(?i)user[_. -]?agent|^ua$|^agent$"),
|
||||
"host": re.compile(r"(?i)^(http[_. ]?)?host$|^vhost$|^domain$|^server_name$"),
|
||||
"bytes": re.compile(r"(?i)bytes|^size$|^body_bytes_sent$"),
|
||||
"referer": re.compile(r"(?i)refer(r)?er$"),
|
||||
"duration": re.compile(r"(?i)request[_. ]?time|response[_. ]?time|duration|latency|elapsed"),
|
||||
}
|
||||
|
||||
# Deliberately not using ``ipaddress.is_private``: that property also covers
|
||||
# documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) and
|
||||
# other special-use blocks, which would make every public test address look
|
||||
# like an internal proxy. For ban decisions "internal" means RFC1918 plus
|
||||
# loopback, link-local and CGNAT.
|
||||
_INTERNAL_V4 = tuple(ipaddress.ip_network(cidr) for cidr in (
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", # CGNAT
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
))
|
||||
_INTERNAL_V6 = tuple(ipaddress.ip_network(cidr) for cidr in (
|
||||
"::1/128",
|
||||
"fc00::/7", # unique local
|
||||
"fe80::/10", # link local
|
||||
))
|
||||
|
||||
|
||||
def is_internal(ip):
|
||||
networks = _INTERNAL_V4 if ip.version == 4 else _INTERNAL_V6
|
||||
return any(ip in network for network in networks)
|
||||
|
||||
|
||||
def as_ip(value):
|
||||
"""Return an ``ipaddress`` object if *value* looks like a bare IP.
|
||||
|
||||
Accepts ``1.2.3.4``, ``2001:db8::1``, ``[2001:db8::1]:443`` and
|
||||
``1.2.3.4:5678``. Rejects CIDRs, hostnames and arbitrary integers.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip().strip('"')
|
||||
if not text or len(text) > 64:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(text)
|
||||
except ValueError:
|
||||
pass
|
||||
if text.startswith("[") and "]" in text:
|
||||
try:
|
||||
return ipaddress.ip_address(text[1:text.index("]")])
|
||||
except ValueError:
|
||||
return None
|
||||
if text.count(":") == 1 and "." in text:
|
||||
host = text.split(":", 1)[0]
|
||||
try:
|
||||
return ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _empty(name):
|
||||
return {
|
||||
"name": name,
|
||||
"present": 0,
|
||||
"ip_messages": 0,
|
||||
"ip_values": 0,
|
||||
"distinct": set(),
|
||||
"distinct_ip": set(),
|
||||
"private": 0,
|
||||
"versions": set(),
|
||||
"examples": [],
|
||||
}
|
||||
|
||||
|
||||
def _analyse(messages):
|
||||
total = len(messages)
|
||||
stats = {}
|
||||
embedded = 0
|
||||
for message in messages:
|
||||
embedded_here = False
|
||||
for name, raw in message.items():
|
||||
values = raw if isinstance(raw, list) else [raw]
|
||||
entry = stats.get(name)
|
||||
if entry is None:
|
||||
entry = stats[name] = _empty(name)
|
||||
emitted = False
|
||||
has_ip = False
|
||||
for value in values:
|
||||
if value is None or value == "":
|
||||
continue
|
||||
text = str(value)[:160]
|
||||
entry["distinct"].add(text)
|
||||
emitted = True
|
||||
ip = as_ip(value)
|
||||
if ip is not None:
|
||||
has_ip = True
|
||||
entry["ip_values"] += 1
|
||||
entry["distinct_ip"].add(str(ip))
|
||||
entry["versions"].add(ip.version)
|
||||
if is_internal(ip):
|
||||
entry["private"] += 1
|
||||
text_ip = str(ip)
|
||||
if len(entry["examples"]) < 3 and text_ip not in entry["examples"]:
|
||||
entry["examples"].append(text_ip)
|
||||
if name == "message":
|
||||
embedded_here = True
|
||||
if emitted:
|
||||
entry["present"] += 1
|
||||
if has_ip:
|
||||
entry["ip_messages"] += 1
|
||||
if embedded_here:
|
||||
embedded += 1
|
||||
return stats, total, embedded
|
||||
|
||||
|
||||
def _score(name, entry, total):
|
||||
present = max(entry["present"], 1)
|
||||
ip_rate = entry["ip_messages"] / present
|
||||
presence = entry["present"] / max(total, 1)
|
||||
score = 60.0 * ip_rate + 5.0 * presence
|
||||
notes = []
|
||||
|
||||
if STRONG_HINT.search(name):
|
||||
score += 30
|
||||
elif WEAK_HINT.search(name):
|
||||
score += 10
|
||||
|
||||
if name == "source":
|
||||
score -= 20
|
||||
notes.append("`source` 是日志发送方(shipper/反代),通常不是真实客户端")
|
||||
elif name == "gl2_remote_ip":
|
||||
score -= 25
|
||||
notes.append("`gl2_remote_ip` 是向 Graylog 发送消息的对端地址,不是访问者")
|
||||
return score, notes
|
||||
|
||||
|
||||
def _confidence(best):
|
||||
if not best:
|
||||
return "none"
|
||||
if best["score"] >= 90 and best["ip_rate"] >= 0.95:
|
||||
return "high"
|
||||
if best["score"] >= 60:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def analyse_messages(messages):
|
||||
"""Build the field report used by ``graylog-ops discover``."""
|
||||
stats, total, embedded = _analyse(messages)
|
||||
if total == 0:
|
||||
return {
|
||||
"sampled": 0,
|
||||
"fields": [],
|
||||
"candidates": [],
|
||||
"http_fields": {},
|
||||
"embedded_ip_in_message_rate": 0.0,
|
||||
"recommendation": {
|
||||
"field": None,
|
||||
"confidence": "none",
|
||||
"notes": ["没有采到消息:检查时间范围、查询条件、stream 过滤或权限"],
|
||||
"warnings": [],
|
||||
},
|
||||
"sample_message": None,
|
||||
}
|
||||
|
||||
fields = []
|
||||
candidates = []
|
||||
for name, entry in stats.items():
|
||||
if entry["present"] == 0:
|
||||
continue
|
||||
ip_rate = entry["ip_messages"] / entry["present"]
|
||||
row = {
|
||||
"name": name,
|
||||
"present": entry["present"],
|
||||
"presence": round(entry["present"] / total, 4),
|
||||
"distinct": len(entry["distinct"]),
|
||||
"distinct_ips": len(entry["distinct_ip"]),
|
||||
"ip_rate": round(ip_rate, 4),
|
||||
"examples": entry["examples"],
|
||||
}
|
||||
fields.append(row)
|
||||
|
||||
if name in INTERNAL_NAMES or name.startswith(INTERNAL_PREFIXES):
|
||||
continue
|
||||
if ip_rate <= 0:
|
||||
continue
|
||||
score, notes = _score(name, entry, total)
|
||||
private_ratio = (entry["private"] / entry["ip_values"]) if entry["ip_values"] else 0.0
|
||||
candidates.append({
|
||||
"field": name,
|
||||
"score": round(score, 1),
|
||||
"ip_rate": round(ip_rate, 4),
|
||||
"presence": round(entry["present"] / total, 4),
|
||||
"distinct_ips": len(entry["distinct_ip"]),
|
||||
"private_ratio": round(private_ratio, 4),
|
||||
"versions": sorted(entry["versions"]),
|
||||
"examples": entry["examples"],
|
||||
"notes": notes,
|
||||
})
|
||||
|
||||
fields.sort(key=lambda row: (-row["present"], row["name"]))
|
||||
candidates.sort(key=lambda row: (-row["score"], -row["distinct_ips"]))
|
||||
|
||||
http_fields = {kind: [] for kind in HTTP_HINTS}
|
||||
for name in stats:
|
||||
for kind, pattern in HTTP_HINTS.items():
|
||||
if pattern.search(name):
|
||||
http_fields[kind].append(name)
|
||||
http_fields = {kind: names for kind, names in http_fields.items() if names}
|
||||
|
||||
best = candidates[0] if candidates else None
|
||||
source_entry = stats.get("source")
|
||||
warnings = []
|
||||
notes = []
|
||||
|
||||
if best:
|
||||
notes.append(
|
||||
"字段 `%s` 的 IP 占比 %.0f%%,样例 %s"
|
||||
% (best["field"], best["ip_rate"] * 100, ", ".join(best["examples"][:3]) or "-")
|
||||
)
|
||||
if source_entry and len(source_entry["distinct"]) <= 2 and best["distinct_ips"] > 2:
|
||||
warnings.append(
|
||||
"`source` 只有 %d 个不同取值,而 `%s` 有 %d 个:`source` 是采集器/反代,"
|
||||
"封禁必须用 `%s`" % (len(source_entry["distinct"]), best["field"],
|
||||
best["distinct_ips"], best["field"])
|
||||
)
|
||||
if best["private_ratio"] >= 0.5:
|
||||
warnings.append(
|
||||
"`%s` 中 %.0f%% 是内网地址(私有/回环),可能仍是 LB 或代理地址,"
|
||||
"需要确认 X-Forwarded-For 是否已在采集侧解析"
|
||||
% (best["field"], best["private_ratio"] * 100)
|
||||
)
|
||||
else:
|
||||
notes.append("没有发现任何字段存放纯 IP 值")
|
||||
|
||||
# Only worth flagging when there is no usable field: then the IP lives in
|
||||
# the free-text `message` and has to be extracted server side.
|
||||
if embedded / max(total, 1) >= 0.5 and not best:
|
||||
warnings.append(
|
||||
"%.0f%% 的消息在 `message` 文本里内嵌了 IP。要么在 input 上加 extractor / "
|
||||
"pipeline 解析成独立字段,要么在采集端(nginx log_format)直接输出 client_ip 字段"
|
||||
% (100.0 * embedded / total)
|
||||
)
|
||||
|
||||
# HTTP-ish fields help with the next phase (per-URI thresholds), surface them.
|
||||
if not best and http_fields:
|
||||
warnings.append("只找到 HTTP 相关字段,没有 IP 字段,见 http_fields")
|
||||
|
||||
return {
|
||||
"sampled": total,
|
||||
"fields": fields,
|
||||
"candidates": candidates,
|
||||
"http_fields": http_fields,
|
||||
"embedded_ip_in_message_rate": round(embedded / total, 4),
|
||||
"recommendation": {
|
||||
"field": best["field"] if best else None,
|
||||
"confidence": _confidence(best),
|
||||
"notes": notes,
|
||||
"warnings": warnings,
|
||||
},
|
||||
"sample_message": messages[0] if messages else None,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Offline GeoIP lookup backed by the free DB-IP Lite CSV dumps.
|
||||
|
||||
The city dump has no country name, so a small code -> name table is shipped
|
||||
here; anything unknown falls back to the ISO code.
|
||||
"""
|
||||
import csv
|
||||
import ipaddress
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
COUNTRY_CN = {
|
||||
"CN": "中国", "HK": "中国香港", "TW": "中国台湾", "MO": "中国澳门",
|
||||
"US": "美国", "CA": "加拿大", "MX": "墨西哥", "BR": "巴西", "AR": "阿根廷",
|
||||
"CL": "智利", "CO": "哥伦比亚", "PE": "秘鲁", "VE": "委内瑞拉",
|
||||
"GB": "英国", "IE": "爱尔兰", "FR": "法国", "DE": "德国", "NL": "荷兰",
|
||||
"BE": "比利时", "LU": "卢森堡", "CH": "瑞士", "AT": "奥地利", "IT": "意大利",
|
||||
"ES": "西班牙", "PT": "葡萄牙", "GR": "希腊", "PL": "波兰", "CZ": "捷克",
|
||||
"SK": "斯洛伐克", "HU": "匈牙利", "RO": "罗马尼亚", "BG": "保加利亚",
|
||||
"HR": "克罗地亚", "RS": "塞尔维亚", "SI": "斯洛文尼亚", "LT": "立陶宛",
|
||||
"LV": "拉脱维亚", "EE": "爱沙尼亚", "FI": "芬兰", "SE": "瑞典",
|
||||
"NO": "挪威", "DK": "丹麦", "IS": "冰岛", "UA": "乌克兰", "BY": "白俄罗斯",
|
||||
"RU": "俄罗斯", "TR": "土耳其", "IL": "以色列", "SA": "沙特阿拉伯",
|
||||
"AE": "阿联酋", "QA": "卡塔尔", "KW": "科威特", "IR": "伊朗", "IQ": "伊拉克",
|
||||
"EG": "埃及", "ZA": "南非", "NG": "尼日利亚", "KE": "肯尼亚", "MA": "摩洛哥",
|
||||
"ET": "埃塞俄比亚", "IN": "印度", "PK": "巴基斯坦", "BD": "孟加拉国",
|
||||
"LK": "斯里兰卡", "NP": "尼泊尔", "MM": "缅甸", "KH": "柬埔寨", "LA": "老挝",
|
||||
"TH": "泰国", "VN": "越南", "MY": "马来西亚", "SG": "新加坡", "ID": "印度尼西亚",
|
||||
"PH": "菲律宾", "JP": "日本", "KR": "韩国", "MN": "蒙古", "KZ": "哈萨克斯坦",
|
||||
"UZ": "乌兹别克斯坦", "GE": "格鲁吉亚", "AM": "亚美尼亚", "AZ": "阿塞拜疆",
|
||||
"AU": "澳大利亚", "NZ": "新西兰", "ZZ": "未知",
|
||||
}
|
||||
|
||||
CN_REGION = {
|
||||
"Beijing": "北京", "Tianjin": "天津", "Hebei": "河北", "Shanxi": "山西",
|
||||
"Inner Mongolia": "内蒙古", "Liaoning": "辽宁", "Jilin": "吉林",
|
||||
"Heilongjiang": "黑龙江", "Shanghai": "上海", "Jiangsu": "江苏",
|
||||
"Zhejiang": "浙江", "Anhui": "安徽", "Fujian": "福建", "Jiangxi": "江西",
|
||||
"Shandong": "山东", "Henan": "河南", "Hubei": "湖北", "Hunan": "湖南",
|
||||
"Guangdong": "广东", "Guangxi": "广西", "Hainan": "海南", "Chongqing": "重庆",
|
||||
"Sichuan": "四川", "Guizhou": "贵州", "Yunnan": "云南", "Tibet": "西藏",
|
||||
"Shaanxi": "陕西", "Gansu": "甘肃", "Qinghai": "青海", "Ningxia": "宁夏",
|
||||
"Xinjiang": "新疆", "Taiwan": "台湾", "Hong Kong": "香港", "Macau": "澳门",
|
||||
}
|
||||
|
||||
|
||||
def flag(cc):
|
||||
if not cc or len(cc) != 2 or not cc.isalpha() or cc == "ZZ":
|
||||
return ""
|
||||
return "".join(chr(0x1F1E6 + ord(char) - 65) for char in cc.upper())
|
||||
|
||||
|
||||
def country_name(cc):
|
||||
return COUNTRY_CN.get(cc, cc or "")
|
||||
|
||||
|
||||
def region_name(cc, region):
|
||||
if cc == "CN" and region:
|
||||
return CN_REGION.get(region, region)
|
||||
return region or ""
|
||||
|
||||
|
||||
def build_geo_db(city_csv, out_db, country_csv=None):
|
||||
"""Import the DB-IP city CSV (IPv4 only) into a small SQLite database."""
|
||||
os.makedirs(os.path.dirname(out_db) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(out_db)
|
||||
conn.execute("DROP TABLE IF EXISTS geo")
|
||||
conn.execute("CREATE TABLE geo (start INTEGER PRIMARY KEY, end INTEGER, "
|
||||
"cc TEXT, region TEXT, city TEXT)")
|
||||
batch = []
|
||||
total = 0
|
||||
|
||||
def flush():
|
||||
nonlocal batch
|
||||
if batch:
|
||||
conn.executemany("INSERT OR REPLACE INTO geo VALUES (?,?,?,?,?)", batch)
|
||||
batch = []
|
||||
|
||||
with open(city_csv, newline="", encoding="utf-8", errors="replace") as fh:
|
||||
for row in csv.reader(fh):
|
||||
if len(row) < 6:
|
||||
continue
|
||||
start, end, cc = row[0], row[1], row[3]
|
||||
if ":" in start or ":" in end or not cc:
|
||||
continue
|
||||
try:
|
||||
first = int(ipaddress.IPv4Address(start))
|
||||
last = int(ipaddress.IPv4Address(end))
|
||||
except ValueError:
|
||||
continue
|
||||
batch.append((first, last, cc, row[4], row[5]))
|
||||
total += 1
|
||||
if len(batch) >= 100000:
|
||||
flush()
|
||||
conn.commit()
|
||||
flush()
|
||||
conn.commit()
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.close()
|
||||
return total
|
||||
|
||||
|
||||
class GeoIP:
|
||||
"""Read-only lookup over the generated geo.db."""
|
||||
|
||||
def __init__(self, path):
|
||||
if not path or not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
self.conn = sqlite3.connect("file:%s?mode=ro" % path, uri=True,
|
||||
check_same_thread=False)
|
||||
|
||||
def lookup(self, ip):
|
||||
try:
|
||||
value = int(ipaddress.ip_address(ip))
|
||||
except ValueError:
|
||||
return None
|
||||
row = self.conn.execute(
|
||||
"SELECT end, cc, region, city FROM geo WHERE start <= ? "
|
||||
"ORDER BY start DESC LIMIT 1", (value,)).fetchone()
|
||||
if not row or row[0] < value:
|
||||
return None
|
||||
return {"country_code": row[1], "country_name": country_name(row[1]),
|
||||
"region": region_name(row[1], row[2]), "city": row[3] or ""}
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.conn.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--city-csv", default="data/geo/dbip-city-lite-2026-10.csv")
|
||||
parser.add_argument("--out", default="data/geo/geo.db")
|
||||
args = parser.parse_args()
|
||||
count = build_geo_db(args.city_csv, args.out)
|
||||
print("imported %d IPv4 ranges -> %s" % (count, args.out))
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Minimal HTTP layer for the Graylog REST API.
|
||||
|
||||
Deliberately built on urllib so the tool has no third-party dependencies and
|
||||
can be copied onto old jump hosts. Handles the three things Graylog's API
|
||||
demands that plain urllib does not do for you:
|
||||
|
||||
* HTTP Basic auth (API token used as username with the literal password
|
||||
``token``, or a real username/password pair),
|
||||
* the ``X-Requested-By`` header required by Graylog's CSRF protection on all
|
||||
non-GET requests,
|
||||
* bounded retries with backoff for 429/5xx and transient connection errors.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
USER_AGENT = "graylog-ops/0.1 (read-only recon)"
|
||||
RETRY_STATUS = frozenset((429, 500, 502, 503, 504))
|
||||
|
||||
|
||||
class GraylogError(Exception):
|
||||
"""Any failure talking to the Graylog API."""
|
||||
|
||||
def __init__(self, message, status=None, body=None, path=None, method=None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.path = path
|
||||
self.method = method
|
||||
|
||||
def __str__(self):
|
||||
parts = []
|
||||
if self.status is not None:
|
||||
parts.append("HTTP %s" % self.status)
|
||||
if self.method and self.path:
|
||||
parts.append("%s %s" % (self.method, self.path))
|
||||
parts.append(self.message)
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _basic_auth(username, password):
|
||||
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _error_detail(body):
|
||||
"""Pull a human readable message out of a Graylog/ES error body."""
|
||||
if not body:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (TypeError, ValueError):
|
||||
return body.strip()[:300]
|
||||
if isinstance(data, dict):
|
||||
for key in ("message", "error", "description"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(data.get("error"), dict):
|
||||
reason = data["error"].get("reason")
|
||||
if isinstance(reason, str):
|
||||
return reason
|
||||
return body.strip()[:300]
|
||||
|
||||
|
||||
class HttpClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url,
|
||||
username=None,
|
||||
password=None,
|
||||
timeout=30.0,
|
||||
retries=2,
|
||||
insecure=False,
|
||||
verbose=False,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = float(timeout)
|
||||
self.retries = max(0, int(retries))
|
||||
self.verbose = verbose
|
||||
self.auth = None
|
||||
if username is not None:
|
||||
self.auth = _basic_auth(username, "" if password is None else password)
|
||||
|
||||
handlers = []
|
||||
if insecure:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
handlers.append(urllib.request.HTTPSHandler(context=context))
|
||||
# build_opener keeps the default ProxyHandler, so HTTP(S)_PROXY env vars
|
||||
# keep working for environments that need a proxy to reach Graylog.
|
||||
self.opener = urllib.request.build_opener(*handlers)
|
||||
|
||||
# -- url helpers -----------------------------------------------------
|
||||
def url(self, path, params=None):
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
target = self.base_url + path
|
||||
if params:
|
||||
clean = {}
|
||||
for key, value in params.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
value = "true" if value else "false"
|
||||
clean[key] = value
|
||||
if clean:
|
||||
sep = "&" if "?" in target else "?"
|
||||
target += sep + urllib.parse.urlencode(clean, doseq=True)
|
||||
return target
|
||||
|
||||
# -- request core ----------------------------------------------------
|
||||
def request(self, method, path, params=None, body=None, expect_json=True):
|
||||
method = method.upper()
|
||||
url = self.url(path, params)
|
||||
payload = None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
"User-Agent": USER_AGENT,
|
||||
# Graylog rejects state changing requests without this header.
|
||||
"X-Requested-By": "graylog-ops",
|
||||
}
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if self.auth:
|
||||
headers["Authorization"] = self.auth
|
||||
|
||||
started = time.time()
|
||||
last_error = None
|
||||
for attempt in range(self.retries + 1):
|
||||
request = urllib.request.Request(
|
||||
url, data=payload, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=self.timeout) as response:
|
||||
status = response.getcode()
|
||||
raw = response.read()
|
||||
self._debug(method, url, status, started)
|
||||
if not raw.strip():
|
||||
return None
|
||||
text = raw.decode("utf-8", "replace")
|
||||
if not expect_json:
|
||||
return text
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
raise GraylogError(
|
||||
"response was not JSON",
|
||||
status=status,
|
||||
body=text[:500],
|
||||
path=path,
|
||||
method=method,
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode("utf-8", "replace")
|
||||
detail = _error_detail(text)
|
||||
self._debug(method, url, exc.code, started)
|
||||
last_error = GraylogError(
|
||||
detail or ("request rejected" if exc.code != 401 else "authentication failed"),
|
||||
status=exc.code,
|
||||
body=text[:2000],
|
||||
path=path,
|
||||
method=method,
|
||||
)
|
||||
if exc.code in RETRY_STATUS and attempt < self.retries:
|
||||
self._sleep(attempt, exc.headers.get("Retry-After"))
|
||||
continue
|
||||
raise last_error
|
||||
except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
self._debug(method, url, "ERR", started)
|
||||
last_error = GraylogError(
|
||||
"connection failed: %s" % reason,
|
||||
path=path,
|
||||
method=method,
|
||||
)
|
||||
if attempt < self.retries:
|
||||
self._sleep(attempt, None)
|
||||
continue
|
||||
raise last_error
|
||||
raise last_error # pragma: no cover - loop always raises or returns
|
||||
|
||||
# -- internals -------------------------------------------------------
|
||||
def _sleep(self, attempt, retry_after):
|
||||
delay = (1.5 ** attempt) + random.uniform(0, 0.3)
|
||||
try:
|
||||
if retry_after is not None:
|
||||
delay = max(delay, float(retry_after))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
time.sleep(min(delay, 30.0))
|
||||
|
||||
def _debug(self, method, url, status, started):
|
||||
if not self.verbose:
|
||||
return
|
||||
elapsed = int((time.time() - started) * 1000)
|
||||
sys.stderr.write("[http] %s %s -> %s (%dms)\n" % (method, url, status, elapsed))
|
||||
sys.stderr.flush()
|
||||
|
||||
# -- convenience -----------------------------------------------------
|
||||
def get(self, path, params=None):
|
||||
return self.request("GET", path, params=params)
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Judgement layer: aggregated Graylog signals -> ranked ban candidates.
|
||||
|
||||
This module is **read only**. It answers one question:
|
||||
|
||||
"在这个时间窗里,哪些客户端 IP 看起来像在攻击,为什么,建议怎么处理?"
|
||||
|
||||
It does not touch the enforcement side (Sangfor AD / WAF / iptables). The
|
||||
output is a list of ``{ip, score, action, reasons, signals}`` which the next
|
||||
phase can feed into whatever blacklist API we end up using.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* A **fixed number of ``terms`` queries** (one per signal) keeps the load on
|
||||
Elasticsearch predictable no matter how many IPs show up in the window.
|
||||
* Signal queries are templates over field names. The report exposes each
|
||||
signal's query and hit count, so they can be tuned against real data. A
|
||||
signal that matched 0 IPs usually means the query does not fit the field
|
||||
mapping (e.g. ``request_uri`` is a keyword-ish analyzed field).
|
||||
* **Volume alone never bans** (``require_evidence``). A busy but legitimate
|
||||
client must not be blackholed by a rate threshold alone; at least one
|
||||
behavioural signal (auth failures / scanner UA / sensitive path / 404
|
||||
probing) has to be present for a ban recommendation.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
|
||||
from . import detect
|
||||
from .client import normalize_terms
|
||||
from .http import GraylogError
|
||||
|
||||
DEFAULT_FIELDS = {
|
||||
"client_ip": "client_ip",
|
||||
"status": "status",
|
||||
"uri": "request_uri",
|
||||
"ua": "http_user_agent",
|
||||
"method": "request_method",
|
||||
}
|
||||
|
||||
# name, title, required field keys, query template, weight, saturation count
|
||||
SIGNAL_DEFS = (
|
||||
{
|
||||
"name": "auth_fail",
|
||||
"title": "认证失败",
|
||||
"requires": ("status",),
|
||||
"query": lambda fields: "%s:(401 OR 403)" % fields["status"],
|
||||
"weight": 45.0,
|
||||
"saturate": 10.0,
|
||||
},
|
||||
{
|
||||
"name": "scanner_ua",
|
||||
"title": "扫描器 UA",
|
||||
"requires": ("ua",),
|
||||
"query": lambda fields: (
|
||||
"%s:(sqlmap* OR nikto* OR nuclei* OR nmap* OR masscan* OR zgrab* OR "
|
||||
"wpscan* OR gobuster* OR dirbuster* OR dirsearch* OR acunetix*)"
|
||||
% fields["ua"]
|
||||
),
|
||||
"weight": 50.0,
|
||||
"saturate": 2.0,
|
||||
},
|
||||
{
|
||||
"name": "sensitive_path",
|
||||
"title": "敏感路径",
|
||||
"requires": ("uri",),
|
||||
# Slashes must be escaped, otherwise Lucene treats /.../ as a regex.
|
||||
"query": lambda fields: (
|
||||
"%s:(\\/wp-login* OR \\/phpmyadmin* OR \\/.env* OR \\/actuator* "
|
||||
"OR \\/cgi-bin* OR \\/manager\\/html*)"
|
||||
% fields["uri"]
|
||||
),
|
||||
"weight": 25.0,
|
||||
"saturate": 5.0,
|
||||
},
|
||||
{
|
||||
"name": "not_found",
|
||||
"title": "404 探测",
|
||||
"requires": ("status",),
|
||||
"query": lambda fields: "%s:404" % fields["status"],
|
||||
"weight": 20.0,
|
||||
"saturate": 20.0,
|
||||
},
|
||||
{
|
||||
"name": "bad_method",
|
||||
"title": "异常方法",
|
||||
"requires": ("method",),
|
||||
"query": lambda fields: "%s:(PUT OR DELETE OR TRACE OR CONNECT)" % fields["method"],
|
||||
"weight": 10.0,
|
||||
"saturate": 5.0,
|
||||
},
|
||||
)
|
||||
|
||||
# URIs containing these hints mean auth failures are likely credential
|
||||
# attacks rather than an integration with a broken/expired token.
|
||||
AUTH_PATH_HINTS = ("login", "signin", "auth", "token", "captcha", "oauth", "password")
|
||||
|
||||
DEFAULT_THRESHOLDS = {
|
||||
"min_requests": 20, # ignore IPs with fewer requests in the window
|
||||
"min_score": 40.0, # below this: ignore
|
||||
"short_ban_score": 55.0, # >= this: short ban recommendation
|
||||
"ban_score": 75.0, # >= this: ban recommendation
|
||||
"rate_weight": 25.0,
|
||||
"rate_saturate": 200.0, # requests in window for full rate points
|
||||
"spike_weight": 15.0,
|
||||
"auth_ratio_weight": 25.0,
|
||||
"require_evidence": True,
|
||||
"evidence_signals": ("auth_fail", "scanner_ua", "sensitive_path", "not_found", "bad_method"),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- profile
|
||||
def load_profile(path):
|
||||
if not path:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except OSError as exc:
|
||||
raise SystemExit("无法读取 profile %s: %s" % (path, exc))
|
||||
except ValueError as exc:
|
||||
raise SystemExit("profile %s 不是合法 JSON: %s" % (path, exc))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("profile %s 必须是 JSON 对象" % path)
|
||||
return data
|
||||
|
||||
|
||||
def build_fields(profile, args=None):
|
||||
fields = dict(DEFAULT_FIELDS)
|
||||
for key, value in (profile.get("fields") or {}).items():
|
||||
if value:
|
||||
fields[key] = value
|
||||
|
||||
flag_map = {
|
||||
"client_ip": "field",
|
||||
"status": "status_field",
|
||||
"uri": "uri_field",
|
||||
"ua": "ua_field",
|
||||
"method": "method_field",
|
||||
}
|
||||
for key, attribute in flag_map.items():
|
||||
value = getattr(args, attribute, None) if args is not None else None
|
||||
if value:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def build_signals(fields, overrides=None):
|
||||
overrides = overrides or {}
|
||||
signals = []
|
||||
for definition in SIGNAL_DEFS:
|
||||
if any(not fields.get(key) for key in definition["requires"]):
|
||||
continue
|
||||
signal = {
|
||||
"name": definition["name"],
|
||||
"title": definition["title"],
|
||||
"query": definition["query"](fields),
|
||||
"weight": float(definition["weight"]),
|
||||
"saturate": float(definition["saturate"]),
|
||||
}
|
||||
override = overrides.get(signal["name"]) or {}
|
||||
if override.get("enabled") is False:
|
||||
continue
|
||||
if override.get("query"):
|
||||
signal["query"] = override["query"]
|
||||
if override.get("weight") is not None:
|
||||
signal["weight"] = float(override["weight"])
|
||||
if override.get("saturate") is not None:
|
||||
signal["saturate"] = float(override["saturate"])
|
||||
signals.append(signal)
|
||||
return signals
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- allowlist
|
||||
class Allowlist:
|
||||
"""Never ban infrastructure, internal addresses or explicitly listed nets."""
|
||||
|
||||
def __init__(self, entries=None, exclude_internal=True):
|
||||
self.entries = list(entries or [])
|
||||
self.exclude_internal = exclude_internal
|
||||
|
||||
def reason(self, ip_text):
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_text)
|
||||
except ValueError:
|
||||
return None
|
||||
for network, source in self.entries:
|
||||
if ip.version == network.version and ip in network:
|
||||
return "白名单 %s" % source
|
||||
if self.exclude_internal and detect.is_internal(ip):
|
||||
return "内部地址(RFC1918/回环/CGNAT)"
|
||||
return None
|
||||
|
||||
|
||||
def load_allowlist(paths):
|
||||
entries = []
|
||||
for path in paths or []:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.readlines()
|
||||
except OSError as exc:
|
||||
raise SystemExit("无法读取白名单 %s: %s" % (path, exc))
|
||||
for number, line in enumerate(lines, 1):
|
||||
text = line.split("#", 1)[0].strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(text, strict=False)
|
||||
except ValueError:
|
||||
raise SystemExit("白名单 %s:%d 不是合法 IP/CIDR: %r" % (path, number, text))
|
||||
entries.append((network, "%s:%d" % (path, number)))
|
||||
return entries
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- collection
|
||||
def collect_signals(client, field, signals, window_seconds, query="*",
|
||||
filter_str=None, size=200):
|
||||
"""Run one terms query per signal plus one for the total request counts."""
|
||||
term_frame = lambda data: {
|
||||
row["term"]: int(row["count"] or 0)
|
||||
for row in normalize_terms(data)
|
||||
if row["term"] not in (None, "")
|
||||
}
|
||||
|
||||
totals = term_frame(client.terms(
|
||||
field=field, query=query, size=size,
|
||||
range_seconds=window_seconds, filter_str=filter_str,
|
||||
))
|
||||
|
||||
per_signal = {}
|
||||
errors = []
|
||||
for signal in signals:
|
||||
try:
|
||||
data = client.terms(
|
||||
field=field, query=signal["query"], size=size,
|
||||
range_seconds=window_seconds, filter_str=filter_str,
|
||||
)
|
||||
per_signal[signal["name"]] = term_frame(data)
|
||||
except GraylogError as exc:
|
||||
per_signal[signal["name"]] = {}
|
||||
errors.append({
|
||||
"signal": signal["name"],
|
||||
"status": exc.status,
|
||||
"error": exc.message,
|
||||
})
|
||||
return totals, per_signal, errors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- verify
|
||||
def verify_candidates(client, candidates, fields, window_seconds,
|
||||
filter_str=None, limit=5, size=10):
|
||||
"""Drill into top candidates: is the traffic one automated endpoint poller?
|
||||
|
||||
A client that hammers a *single* business API at a steady rate is usually an
|
||||
integration with a broken/expired token, not an attacker. Banning it breaks
|
||||
a business system, so such candidates are downgraded to ``observe`` and the
|
||||
decision is left to a human.
|
||||
"""
|
||||
uri_field = fields.get("uri")
|
||||
client_field = fields.get("client_ip")
|
||||
if not uri_field or not client_field:
|
||||
return
|
||||
|
||||
for candidate in candidates[:limit]:
|
||||
if not candidate["action"].startswith("ban"):
|
||||
continue
|
||||
query = '%s:"%s"' % (client_field, candidate["ip"])
|
||||
try:
|
||||
data = client.terms(
|
||||
field=uri_field, query=query, size=size,
|
||||
range_seconds=window_seconds, filter_str=filter_str,
|
||||
)
|
||||
except GraylogError as exc:
|
||||
candidate["verify_error"] = exc.message
|
||||
continue
|
||||
|
||||
rows = [(row["term"], row["count"]) for row in normalize_terms(data)
|
||||
if row["term"] not in (None, "")]
|
||||
total = sum(count for _, count in rows)
|
||||
candidate["verify"] = {
|
||||
"distinct_uris": len(rows),
|
||||
"total_requests": total,
|
||||
"top_uri": rows[0][0] if rows else None,
|
||||
"top_uri_ratio": round(rows[0][1] / total, 4) if rows and total else 0.0,
|
||||
}
|
||||
if not rows or not total:
|
||||
continue
|
||||
|
||||
top_uri, top_count = rows[0]
|
||||
ratio = top_count / float(total)
|
||||
login_like = any(hint in str(top_uri).lower() for hint in AUTH_PATH_HINTS)
|
||||
candidate["verify"]["login_like"] = login_like
|
||||
|
||||
if ratio >= 0.9 and len(rows) <= 2 and not login_like:
|
||||
candidate["verify"]["automation_suspected"] = True
|
||||
candidate["action"] = "observe"
|
||||
candidate["score"] = round(min(candidate["score"], 54.0), 1)
|
||||
candidate["reasons"].append(
|
||||
"流量集中在单一接口 %s(%.0f%%),疑似业务客户端/自动化任务,已降级为 observe"
|
||||
% (top_uri, ratio * 100)
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- scoring
|
||||
def score_candidates(totals, per_signal, signals, thresholds, allowlist):
|
||||
pool = {ip: count for ip, count in totals.items()
|
||||
if count >= thresholds["min_requests"]}
|
||||
median = statistics.median(pool.values()) if pool else 0
|
||||
|
||||
candidates = []
|
||||
excluded = []
|
||||
|
||||
for ip, requests in pool.items():
|
||||
reason = allowlist.reason(ip)
|
||||
if reason:
|
||||
excluded.append({"ip": ip, "requests": requests, "reason": reason})
|
||||
continue
|
||||
|
||||
points = {}
|
||||
counts = {}
|
||||
reasons = []
|
||||
|
||||
for signal in signals:
|
||||
count = per_signal.get(signal["name"], {}).get(ip, 0)
|
||||
if not count:
|
||||
continue
|
||||
counts[signal["name"]] = count
|
||||
contributed = signal["weight"] * min(1.0, count / signal["saturate"])
|
||||
points[signal["name"]] = round(contributed, 1)
|
||||
reasons.append("%s %d 次" % (signal["title"], count))
|
||||
|
||||
rate_points = thresholds["rate_weight"] * min(
|
||||
1.0, requests / float(thresholds["rate_saturate"]))
|
||||
points["rate"] = round(rate_points, 1)
|
||||
|
||||
if median and requests >= max(50, median * 4):
|
||||
ratio = requests / float(median)
|
||||
spike = thresholds["spike_weight"] * min(1.0, math.log(ratio, 2) / 6.0)
|
||||
points["spike"] = round(spike, 1)
|
||||
reasons.append("请求量是候选中位数的 %.0f 倍" % ratio)
|
||||
|
||||
auth = counts.get("auth_fail", 0)
|
||||
auth_ratio = (auth / float(requests)) if requests else 0.0
|
||||
if auth >= 10 and auth_ratio >= 0.5:
|
||||
points["auth_ratio"] = thresholds["auth_ratio_weight"]
|
||||
reasons.append("失败请求占 %.0f%%" % (auth_ratio * 100))
|
||||
|
||||
score = min(100.0, sum(points.values()))
|
||||
evidence = any(counts.get(name) for name in thresholds["evidence_signals"])
|
||||
|
||||
if evidence and score >= thresholds["ban_score"]:
|
||||
action = "ban_1h"
|
||||
elif evidence and score >= thresholds["short_ban_score"]:
|
||||
action = "ban_30m"
|
||||
elif score >= thresholds["min_score"]:
|
||||
action = "observe"
|
||||
else:
|
||||
action = "ignore"
|
||||
|
||||
if not evidence and thresholds["require_evidence"] and action in ("ban_1h", "ban_30m"):
|
||||
action = "observe"
|
||||
reasons.append("仅请求量异常、无攻击特征,不自动封禁")
|
||||
|
||||
candidates.append({
|
||||
"ip": ip,
|
||||
"score": round(score, 1),
|
||||
"requests": requests,
|
||||
"action": action,
|
||||
"evidence": evidence,
|
||||
"auth_ratio": round(auth_ratio, 4),
|
||||
"signals": counts,
|
||||
"points": points,
|
||||
"reasons": reasons,
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda row: (-row["score"], -row["requests"], row["ip"]))
|
||||
excluded.sort(key=lambda row: (-row["requests"], row["ip"]))
|
||||
|
||||
signal_stats = []
|
||||
for signal in signals:
|
||||
matched = per_signal.get(signal["name"], {})
|
||||
signal_stats.append({
|
||||
"name": signal["name"],
|
||||
"title": signal["title"],
|
||||
"query": signal["query"],
|
||||
"ips_matched": len(matched),
|
||||
"requests_matched": sum(matched.values()),
|
||||
})
|
||||
|
||||
return {
|
||||
"median_requests": median,
|
||||
"candidate_pool": len(pool),
|
||||
"candidates": candidates,
|
||||
"excluded": excluded,
|
||||
"signal_stats": signal_stats,
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Terminal rendering helpers (CJK-aware column widths)."""
|
||||
|
||||
import unicodedata
|
||||
|
||||
|
||||
def display_width(text):
|
||||
width = 0
|
||||
for char in str(text):
|
||||
if unicodedata.east_asian_width(char) in ("W", "F"):
|
||||
width += 2
|
||||
else:
|
||||
width += 1
|
||||
return width
|
||||
|
||||
|
||||
def clip(text, limit=60):
|
||||
text = "" if text is None else str(text)
|
||||
text = text.replace("\n", " ").replace("\r", " ").replace("\t", " ")
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: max(0, limit - 1)] + "…"
|
||||
|
||||
|
||||
def table(headers, rows, align=None):
|
||||
"""Render a plain-text table. *align* is a list of ``l``/``r`` per column."""
|
||||
rows = [["" if cell is None else str(cell) for cell in row] for row in rows]
|
||||
if align is None:
|
||||
align = ["l"] * len(headers)
|
||||
widths = [display_width(header) for header in headers]
|
||||
for row in rows:
|
||||
for index, cell in enumerate(row[: len(widths)]):
|
||||
widths[index] = max(widths[index], display_width(cell))
|
||||
|
||||
def render(cells):
|
||||
parts = []
|
||||
for index, cell in enumerate(cells):
|
||||
padding = widths[index] - display_width(cell)
|
||||
if align[index] == "r":
|
||||
parts.append(" " * padding + cell)
|
||||
else:
|
||||
parts.append(cell + " " * padding)
|
||||
return " ".join(parts).rstrip()
|
||||
|
||||
lines = [render(headers), " ".join("-" * width for width in widths)]
|
||||
lines.extend(render(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def human_bytes(size):
|
||||
if not isinstance(size, (int, float)):
|
||||
return str(size)
|
||||
value = float(size)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if value < 1024 or unit == "TiB":
|
||||
if unit == "B":
|
||||
return "%d B" % value
|
||||
return "%.1f %s" % (value, unit)
|
||||
value /= 1024.0
|
||||
return str(size)
|
||||
|
||||
|
||||
def percent(part, total):
|
||||
if not total:
|
||||
return "-"
|
||||
return "%.1f%%" % (100.0 * part / total)
|
||||
|
||||
|
||||
def kv_block(pairs):
|
||||
"""Render aligned ``key: value`` lines."""
|
||||
if not pairs:
|
||||
return ""
|
||||
width = max(display_width(key) for key, _ in pairs)
|
||||
lines = []
|
||||
for key, value in pairs:
|
||||
padding = " " * (width - display_width(key))
|
||||
lines.append("%s%s : %s" % (key, padding, "" if value is None else value))
|
||||
return "\n".join(lines)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"type": "dingtalk",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=REPLACE_WITH_TOKEN",
|
||||
"secret": "SEC_REPLACE_WITH_SIGN_SECRET",
|
||||
"link_prefix": "http://179.253.234.124:12139/s/301b0525d852feacedcbf813a422f6e5/",
|
||||
"title": "新增恶意 IP"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"fields": {
|
||||
"client_ip": "client_ip",
|
||||
"status": "status",
|
||||
"uri": "uri",
|
||||
"ua": "ua",
|
||||
"method": "method",
|
||||
"host": "host"
|
||||
},
|
||||
"signals": {
|
||||
"bad_method": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"min_requests": 30,
|
||||
"min_score": 40.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Preview the CLI output without a real Graylog instance.
|
||||
|
||||
Runs a handful of commands against the bundled fake Graylog 4.0 server so you
|
||||
can see the exact shape of the tables/JSON before pointing the tool at
|
||||
edu-master.
|
||||
|
||||
python3 tests/demo.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from fake_graylog import JUDGE_MESSAGES, FakeGraylog # noqa: E402
|
||||
from graylog_ops import cli # noqa: E402
|
||||
|
||||
COMMANDS = (
|
||||
["probe"],
|
||||
["streams"],
|
||||
["discover", "--range", "3600"],
|
||||
["top-ips", "--field", "client_ip", "--query", "status:401", "--size", "5"],
|
||||
["judge", "--range", "300", "--explain", "3"],
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
server = FakeGraylog(messages=JUDGE_MESSAGES).start()
|
||||
base = ["--url", server.url, "--token", "testtoken"]
|
||||
try:
|
||||
for command in COMMANDS:
|
||||
print("=" * 78)
|
||||
print("$ graylog-ops " + " ".join(command))
|
||||
print("-" * 78)
|
||||
captured = io.StringIO()
|
||||
with redirect_stdout(captured):
|
||||
cli.main(base + command)
|
||||
print(captured.getvalue().rstrip())
|
||||
print()
|
||||
finally:
|
||||
server.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,468 @@
|
||||
"""A tiny fake Graylog 4.0 server used by the end-to-end tests.
|
||||
|
||||
It intentionally mimics the response envelopes of the real 4.0 API (including
|
||||
the ``{"messages": [{"message": {...}}]}`` shape of the legacy search API) so
|
||||
the client code is exercised the same way it will be against edu-master.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
TOKEN = "testtoken"
|
||||
EXPECTED_AUTH = "Basic " + base64.b64encode(
|
||||
("%s:token" % TOKEN).encode("utf-8")
|
||||
).decode("ascii")
|
||||
|
||||
VERSION = "4.0.8+6b8c55d"
|
||||
|
||||
MESSAGES = [
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:00.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "203.0.113.5",
|
||||
"status": 401,
|
||||
"request_method": "POST",
|
||||
"request_uri": "/api/login",
|
||||
"http_user_agent": "python-requests/2.31",
|
||||
"response_time": 0.011,
|
||||
"message": '203.0.113.5 - - "POST /api/login HTTP/1.1" 401 0.011',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:05.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "203.0.113.5",
|
||||
"status": 401,
|
||||
"request_method": "POST",
|
||||
"request_uri": "/api/login",
|
||||
"http_user_agent": "python-requests/2.31",
|
||||
"response_time": 0.009,
|
||||
"message": '203.0.113.5 - - "POST /api/login HTTP/1.1" 401 0.009',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:10.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "203.0.113.5",
|
||||
"status": 401,
|
||||
"request_method": "POST",
|
||||
"request_uri": "/api/login",
|
||||
"http_user_agent": "python-requests/2.31",
|
||||
"response_time": 0.010,
|
||||
"message": '203.0.113.5 - - "POST /api/login HTTP/1.1" 401 0.010',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:15.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "198.51.100.7",
|
||||
"status": 401,
|
||||
"request_method": "GET",
|
||||
"request_uri": "/admin",
|
||||
"http_user_agent": "Mozilla/5.0",
|
||||
"response_time": 0.021,
|
||||
"message": '198.51.100.7 - - "GET /admin HTTP/1.1" 401 0.021',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:20.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "198.51.100.7",
|
||||
"status": 403,
|
||||
"request_method": "GET",
|
||||
"request_uri": "/admin",
|
||||
"http_user_agent": "Mozilla/5.0",
|
||||
"response_time": 0.018,
|
||||
"message": '198.51.100.7 - - "GET /admin HTTP/1.1" 403 0.018',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:25.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "192.0.2.44",
|
||||
"status": 404,
|
||||
"request_method": "GET",
|
||||
"request_uri": "/wp-login.php",
|
||||
"http_user_agent": "sqlmap/1.7",
|
||||
"response_time": 0.006,
|
||||
"message": '192.0.2.44 - - "GET /wp-login.php HTTP/1.1" 404 0.006',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:30.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "192.0.2.44",
|
||||
"status": 404,
|
||||
"request_method": "GET",
|
||||
"request_uri": "/.env",
|
||||
"http_user_agent": "sqlmap/1.7",
|
||||
"response_time": 0.005,
|
||||
"message": '192.0.2.44 - - "GET /.env HTTP/1.1" 404 0.005',
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-10-17T15:00:35.000Z",
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "10.180.2.9",
|
||||
"status": 200,
|
||||
"request_method": "GET",
|
||||
"request_uri": "/health",
|
||||
"http_user_agent": "kube-probe/1.25",
|
||||
"response_time": 0.002,
|
||||
"message": '10.180.2.9 - - "GET /health HTTP/1.1" 200 0.002',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_judge_messages():
|
||||
"""Synthetic traffic for the judgement tests (brute force / scanner / legit)."""
|
||||
rows = []
|
||||
|
||||
def add(ip, count, status, uri, agent, method="GET"):
|
||||
for index in range(count):
|
||||
rows.append({
|
||||
"timestamp": "2026-10-17T15:%02d:%02d.000Z" % (index // 60, index % 60),
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": ip,
|
||||
"status": status,
|
||||
"request_method": method,
|
||||
"request_uri": uri,
|
||||
"http_user_agent": agent,
|
||||
"message": '%s - "%s %s HTTP/1.1" %d' % (ip, method, uri, status),
|
||||
})
|
||||
|
||||
add("203.0.113.66", 40, 401, "/api/login", "python-requests/2.31", "POST")
|
||||
add("192.0.2.99", 25, 404, "/wp-login.php", "sqlmap/1.7")
|
||||
add("198.51.100.200", 50, 200, "/index", "Mozilla/5.0")
|
||||
add("10.180.5.5", 30, 401, "/api/login", "Mozilla/5.0", "POST")
|
||||
return rows
|
||||
|
||||
|
||||
JUDGE_MESSAGES = _build_judge_messages()
|
||||
|
||||
CLAUSE = re.compile(r'^([A-Za-z_][A-Za-z0-9_.]*):(\([^)]*\)|"[^"]*"|[^\s]+)$')
|
||||
|
||||
|
||||
def _clause_values(raw):
|
||||
raw = raw.strip()
|
||||
if raw.startswith("(") and raw.endswith(")"):
|
||||
return [part.strip().strip('"') for part in raw[1:-1].split(" OR ") if part.strip()]
|
||||
return [raw.strip('"')]
|
||||
|
||||
|
||||
def matches(message, query):
|
||||
"""Very small subset of Lucene, enough to exercise the judge signal queries."""
|
||||
query = (query or "*").strip()
|
||||
if query in ("", "*"):
|
||||
return True
|
||||
clause = CLAUSE.match(query)
|
||||
if clause is None:
|
||||
return True # unsupported syntax: do not filter (test double only)
|
||||
field, raw = clause.groups()
|
||||
value = message.get(field)
|
||||
if value is None:
|
||||
return False
|
||||
text = str(value).lower()
|
||||
for candidate in _clause_values(raw):
|
||||
# Mirror Lucene: `foo*` is a prefix match, `\/` is an escaped slash.
|
||||
needle = candidate.lower().replace("\\/", "/")
|
||||
if needle.endswith("*"):
|
||||
if needle[:-1] and text.startswith(needle[:-1]):
|
||||
return True
|
||||
continue
|
||||
if needle and needle in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def aggregate(messages, body):
|
||||
"""Minimal views-pivot implementation (one query, one search type)."""
|
||||
query = (body.get("queries") or [{}])[0]
|
||||
query_string = (query.get("query") or {}).get("query_string") or "*"
|
||||
filtered = [message for message in messages if matches(message, query_string)]
|
||||
|
||||
stream_filter = query.get("filter")
|
||||
stream_ids = set()
|
||||
if isinstance(stream_filter, dict):
|
||||
for item in stream_filter.get("filters") or []:
|
||||
if isinstance(item, dict) and item.get("id"):
|
||||
stream_ids.add(item["id"])
|
||||
if stream_ids:
|
||||
filtered = [message for message in filtered
|
||||
if set(message.get("streams") or []) & stream_ids]
|
||||
|
||||
search_type = (query.get("search_types") or [{}])[0]
|
||||
series_id = ((search_type.get("series") or [{}])[0]).get("id", "c1")
|
||||
row_groups = search_type.get("row_groups") or []
|
||||
column_groups = search_type.get("column_groups") or []
|
||||
|
||||
rows = []
|
||||
total = 0
|
||||
if row_groups:
|
||||
field = row_groups[0].get("field")
|
||||
limit = int(row_groups[0].get("limit") or 10)
|
||||
counts = {}
|
||||
for message in filtered:
|
||||
value = message.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
counts[str(value)] = counts.get(str(value), 0) + 1
|
||||
total = sum(counts.values())
|
||||
ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:limit]
|
||||
for key, count in ordered:
|
||||
rows.append({"key": [key], "source": "leaf",
|
||||
"values": [{"key": [series_id], "value": count,
|
||||
"rollup": True, "source": "row-leaf"}]})
|
||||
rows.append({"key": [], "source": "rollup",
|
||||
"values": [{"key": [series_id], "value": total,
|
||||
"rollup": True, "source": "row-leaf"}]})
|
||||
elif column_groups:
|
||||
buckets = {}
|
||||
for message in filtered:
|
||||
stamp = str(message.get("timestamp", ""))
|
||||
key = stamp[:16] + ":00.000Z"
|
||||
buckets[key] = buckets.get(key, 0) + 1
|
||||
total = sum(buckets.values())
|
||||
for key in sorted(buckets):
|
||||
rows.append({"key": [], "source": "leaf",
|
||||
"values": [{"key": [key, series_id], "value": buckets[key],
|
||||
"rollup": False, "source": "col-leaf"}]})
|
||||
return {"id": search_type.get("id", "st1"), "type": "pivot",
|
||||
"total": total, "rows": rows}
|
||||
|
||||
|
||||
class FakeGraylog:
|
||||
def __init__(self, require_auth=True, messages=None, views_enabled=True):
|
||||
self.require_auth = require_auth
|
||||
self.messages = list(MESSAGES if messages is None else messages)
|
||||
self.views_enabled = views_enabled
|
||||
self.terms_size_param = "size"
|
||||
self.requests = []
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # silence test output
|
||||
pass
|
||||
|
||||
def _send(self, code, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw_body = self.rfile.read(length) if length else b""
|
||||
owner.requests.append((path, query))
|
||||
|
||||
if owner.require_auth and self.headers.get("Authorization") != EXPECTED_AUTH:
|
||||
return self._send(401, {"message": "Unauthorized"})
|
||||
if not owner.views_enabled:
|
||||
return self._send(404, {"message": "Sorry, Page Not Found"})
|
||||
if path == "/api/views/search/sync":
|
||||
try:
|
||||
body = json.loads(raw_body or b"{}")
|
||||
except ValueError:
|
||||
return self._send(400, {"message": "invalid json"})
|
||||
result = aggregate(owner.messages, body)
|
||||
return self._send(200, {
|
||||
"execution": {"done": True, "cancelled": False,
|
||||
"completed_exceptionally": False},
|
||||
"results": {"q1": {"errors": [], "search_types": {"st1": result}}},
|
||||
"search_id": "mock-search",
|
||||
})
|
||||
return self._send(404, {"message": "not found: %s" % path})
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
|
||||
owner.requests.append((path, query))
|
||||
|
||||
if owner.require_auth and self.headers.get("Authorization") != EXPECTED_AUTH:
|
||||
return self._send(401, {"message": "Unauthorized"})
|
||||
|
||||
if path == "/api/system":
|
||||
return self._send(200, {
|
||||
"version": VERSION,
|
||||
"cluster_id": "6b8c55d0-dead-beef-0000-000000000001",
|
||||
"hostname": "edu-master",
|
||||
"is_processing": True,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"locale": "zh_CN",
|
||||
})
|
||||
if path == "/api/system/cluster/nodes":
|
||||
return self._send(200, {"total": 1, "nodes": [{
|
||||
"node_id": "node-1",
|
||||
"hostname": "edu-master",
|
||||
"version": VERSION,
|
||||
"lifecycle": "RUNNING",
|
||||
"is_leader": True,
|
||||
"lb_status": "ALIVE",
|
||||
}]})
|
||||
if path == "/api/system/indexer/cluster/health":
|
||||
return self._send(200, {
|
||||
"status": "green",
|
||||
"cluster_name": "graylog",
|
||||
"number_of_nodes": 1,
|
||||
})
|
||||
if path == "/api/system/deflector":
|
||||
return self._send(200, {"index": "graylog_0", "is_up": True})
|
||||
if path == "/api/system/fields":
|
||||
return self._send(200, {"fields": [
|
||||
{"name": "client_ip", "type": "ip"},
|
||||
{"name": "status", "type": "long"},
|
||||
{"name": "request_uri", "type": "string"},
|
||||
]})
|
||||
if path == "/api/system/license":
|
||||
return self._send(200, {"license": {"type": "opensource"}})
|
||||
if path == "/api/system/inputs":
|
||||
return self._send(200, {"total": 1, "inputs": [{
|
||||
"id": "5f0000000000000000000001",
|
||||
"title": "GELF UDP",
|
||||
"type": "org.graylog2.inputs.gelf.udp.GELFUDPInput",
|
||||
"global": True,
|
||||
"configuration": {"bind_address": "0.0.0.0", "port": 12201},
|
||||
}]})
|
||||
if path == "/api/streams":
|
||||
return self._send(200, {"total": 2, "streams": [
|
||||
{
|
||||
"id": "5f00000000000000000000aa",
|
||||
"title": "业务访问日志",
|
||||
"description": "nginx access log",
|
||||
"disabled": False,
|
||||
"rules": [],
|
||||
},
|
||||
{
|
||||
"id": "000000000000000000000001",
|
||||
"title": "Default Stream",
|
||||
"description": "default",
|
||||
"disabled": False,
|
||||
"rules": [],
|
||||
},
|
||||
]})
|
||||
if path == "/api/system/indices/index_sets":
|
||||
return self._send(200, {"total": 1, "index_sets": [{
|
||||
"id": "5f00000000000000000000bb",
|
||||
"title": "Default index set",
|
||||
"index_prefix": "graylog",
|
||||
"shards": 4,
|
||||
"replicas": 0,
|
||||
"rotation_strategy": "time",
|
||||
"rotation_strategy_configuration": {"type": "time", "period": "P1D"},
|
||||
"retention_strategy": "delete",
|
||||
"retention_strategy_configuration": {"max_number_of_indices": 10},
|
||||
}]})
|
||||
if path == "/api/system/indexer/indices":
|
||||
return self._send(200, {"indices": [{
|
||||
"index_name": "graylog_0",
|
||||
"index_set_id": "5f00000000000000000000bb",
|
||||
"size": 1048576,
|
||||
"begin": "2026-10-17T00:00:00.000Z",
|
||||
"end": "2026-10-18T00:00:00.000Z",
|
||||
}]})
|
||||
if path == "/api/events/definitions":
|
||||
return self._send(200, {
|
||||
"total": 1, "count": 1, "page": 1, "per_page": 50,
|
||||
"event_definitions": [{
|
||||
"id": "5f00000000000000000000cc",
|
||||
"title": "401 风暴",
|
||||
"priority": 3,
|
||||
"disabled": False,
|
||||
"config": {"type": "aggregation-v1"},
|
||||
"notifications": [],
|
||||
}],
|
||||
})
|
||||
if path == "/api/events/notifications":
|
||||
return self._send(200, {"total": 1, "notifications": [{
|
||||
"id": "5f00000000000000000000dd",
|
||||
"title": "ban webhook",
|
||||
"config": {"type": "http-notification-v1",
|
||||
"url": "http://127.0.0.1:8080/ban"},
|
||||
}]})
|
||||
if path == "/api/events":
|
||||
return self._send(200, {"total_events": 0, "events": []})
|
||||
if path == "/api/sidecars":
|
||||
return self._send(200, {"total": 0, "sidecars": []})
|
||||
if path in ("/api/system/pipelines/pipeline", "/api/system/pipelines/rule"):
|
||||
return self._send(200, [])
|
||||
if path == "/api/views":
|
||||
return self._send(200, {"total": 0, "views": [], "page": 1, "per_page": 50})
|
||||
if path == "/api/search/universal/relative":
|
||||
try:
|
||||
limit = int(query.get("limit", 100))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
try:
|
||||
offset = int(query.get("offset", 0))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
matched = [item for item in owner.messages
|
||||
if matches(item, query.get("query", "*"))]
|
||||
page = matched[offset:offset + limit]
|
||||
return self._send(200, {
|
||||
"messages": [{"message": dict(item), "index": "graylog_0"} for item in page],
|
||||
"total_results": len(matched),
|
||||
"time": 3,
|
||||
"built_query": "mock",
|
||||
})
|
||||
if path == "/api/search/universal/terms":
|
||||
if owner.terms_size_param == "limit" and "size" in query:
|
||||
return self._send(400, {"message": "Parameter 'size' is not allowed"})
|
||||
field = query.get("field", "source")
|
||||
param = "size" if "size" in query else ("limit" if "limit" in query else None)
|
||||
try:
|
||||
size = int(query.get(param, 50)) if param else 50
|
||||
except ValueError:
|
||||
size = 50
|
||||
counts = {}
|
||||
query_string = query.get("query", "*")
|
||||
for message in owner.messages:
|
||||
if not matches(message, query_string):
|
||||
continue
|
||||
value = message.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
counts[str(value)] = counts.get(str(value), 0) + 1
|
||||
terms = sorted(
|
||||
({"term": key, "count": value} for key, value in counts.items()),
|
||||
key=lambda row: (-row["count"], row["term"]),
|
||||
)[:size]
|
||||
return self._send(200, {
|
||||
"field": field,
|
||||
"terms": terms,
|
||||
"missing": 0,
|
||||
"other": 0,
|
||||
"total": len(terms),
|
||||
})
|
||||
if path == "/api/search/universal/histogram":
|
||||
return self._send(200, {
|
||||
"results": {
|
||||
"2026-10-17T15:00:00.000Z": {"count": 3},
|
||||
"2026-10-17T15:01:00.000Z": {"count": 5},
|
||||
},
|
||||
"interval": "1m",
|
||||
"time": 2,
|
||||
})
|
||||
return self._send(404, {"message": "not found: %s" % path})
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = None
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return "http://127.0.0.1:%d" % self.port
|
||||
|
||||
def start(self):
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
if self.thread is not None:
|
||||
self.thread.join(timeout=5)
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
from graylog_ops.asn import AsnDB, build_asn_db, classify_org # noqa: E402
|
||||
|
||||
|
||||
class AsnTests(unittest.TestCase):
|
||||
def test_build_and_lookup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
csv_path = os.path.join(directory, "asn.csv")
|
||||
with open(csv_path, "w", encoding="utf-8") as handle:
|
||||
handle.write('1.0.1.0,1.0.3.255,4134,"Chinanet"\n')
|
||||
handle.write('8.8.8.0,8.8.8.255,15169,"Google LLC"\n')
|
||||
handle.write('47.0.0.0,47.0.0.255,37963,'
|
||||
'"Hangzhou Alibaba Advertising Co.,Ltd."\n')
|
||||
handle.write('2001:db8::,2001:db8::ffff,15169,"Google LLC"\n')
|
||||
db_path = os.path.join(directory, "asn.db")
|
||||
count = build_asn_db(csv_path, db_path)
|
||||
self.assertEqual(count, 3) # IPv6 skipped
|
||||
|
||||
asn = AsnDB(db_path)
|
||||
try:
|
||||
china = asn.lookup("1.0.2.1")
|
||||
self.assertEqual(china["brand"], "中国电信")
|
||||
self.assertEqual(china["type"], "broadband")
|
||||
aliyun = asn.lookup("47.0.0.1")
|
||||
self.assertEqual(aliyun["brand"], "阿里云")
|
||||
self.assertEqual(aliyun["type"], "cloud")
|
||||
self.assertEqual(aliyun["icon"], "☁️")
|
||||
self.assertIsNone(asn.lookup("9.9.9.9"))
|
||||
finally:
|
||||
asn.close()
|
||||
|
||||
def test_classify_org(self):
|
||||
self.assertEqual(classify_org("China Mobile Communications Corporation")[:2],
|
||||
("中国移动", "mobile"))
|
||||
self.assertEqual(classify_org("China Education and Research Network Center")[:2],
|
||||
("教育网", "education"))
|
||||
self.assertEqual(classify_org("Wowrack.com")[:2], ("IDC 机房", "idc"))
|
||||
self.assertEqual(classify_org("Some Unknown ISP")[:2], ("未知", "unknown"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
from graylog_ops import watch # noqa: E402
|
||||
|
||||
NOW = "2026-10-17T16:00:00Z"
|
||||
|
||||
|
||||
def _behavior(ip, verdict="建议封禁", score=75):
|
||||
return {
|
||||
"ip": ip, "score": score, "verdict": verdict,
|
||||
"signals": ["高失败率"], "reason_text": "24 小时内扫描了大量路径。",
|
||||
"share_note": "", "behavior_type": "目录扫描",
|
||||
"asn": "4538", "asn_org": "教育网", "shared_type": "education",
|
||||
"total": 50, "pct4": 60.0, "uri_card": 20, "uri_templates": 15,
|
||||
"ua_card": 1, "host_card": 2, "coverage": 100.0, "peak_share": 50.0,
|
||||
"top_uri": "/.env", "top_ua": "curl/8.0",
|
||||
"country_code": "CN", "country_name": "中国", "region": "北京",
|
||||
"city": "Beijing",
|
||||
}
|
||||
|
||||
|
||||
class FusedTests(unittest.TestCase):
|
||||
def test_feature_and_behavior_merge(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
store.upsert({"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 91.0, "total": 120, "window": 3600,
|
||||
"rules": {"probe_java": 10},
|
||||
"reasons": ["探测 10 次"]}, NOW)
|
||||
store.sync_fused(NOW)
|
||||
fused = store.fused_items()
|
||||
self.assertEqual(len(fused), 1)
|
||||
self.assertEqual(fused[0]["sources"], "feature")
|
||||
self.assertEqual(fused[0]["first_banned_at"], NOW)
|
||||
self.assertIn("特征命中", fused[0]["fused_reason"])
|
||||
|
||||
# behaviour-only ban is merged
|
||||
store.replace_behavior_profiles([_behavior("198.51.100.9")])
|
||||
store.sync_fused("2026-10-17T16:10:00Z")
|
||||
merged = {item["ip"]: item for item in store.fused_items()}
|
||||
self.assertIn("198.51.100.9", merged)
|
||||
self.assertEqual(merged["198.51.100.9"]["sources"], "behavior")
|
||||
self.assertEqual(merged["203.0.113.9"]["sources"], "feature")
|
||||
|
||||
# same IP on both sides becomes "both"
|
||||
store.upsert({"ip": "198.51.100.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 80.0, "total": 50, "window": 3600,
|
||||
"rules": {"probe_java": 6},
|
||||
"reasons": ["探测 6 次"]}, "2026-10-17T16:11:00Z")
|
||||
store.sync_fused("2026-10-17T16:12:00Z")
|
||||
merged = {item["ip"]: item for item in store.fused_items()}
|
||||
self.assertEqual(merged["198.51.100.9"]["sources"], "both")
|
||||
|
||||
# watch-only behaviour must not enter the block list
|
||||
store.replace_behavior_profiles([
|
||||
_behavior("192.0.2.44", verdict="仅告警(共享出口)")])
|
||||
store.sync_fused("2026-10-17T16:20:00Z")
|
||||
ips = {item["ip"] for item in store.fused_items()}
|
||||
self.assertNotIn("192.0.2.44", ips)
|
||||
self.assertIn("203.0.113.9", ips) # feature rows stay forever
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_push_throttle_24h(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
store.replace_behavior_profiles([_behavior("198.51.100.7")])
|
||||
store.sync_fused(now)
|
||||
# first sync suppresses historical rows; a fresh detection is due
|
||||
with store.lock, store.conn:
|
||||
store.conn.execute(
|
||||
"UPDATE ip_fused SET last_pushed_at=NULL WHERE ip=?",
|
||||
("198.51.100.7",))
|
||||
due = store.due_pushes(active_hours=24, cooldown_hours=24)
|
||||
self.assertEqual([row["ip"] for row in due], ["198.51.100.7"])
|
||||
|
||||
store.mark_pushed(["198.51.100.7"], now)
|
||||
self.assertEqual(store.due_pushes(24, 24), []) # throttled
|
||||
|
||||
old = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime(time.time() - 25 * 3600))
|
||||
with store.lock, store.conn:
|
||||
store.conn.execute(
|
||||
"UPDATE ip_fused SET last_pushed_at=? WHERE ip=?",
|
||||
(old, "198.51.100.7"))
|
||||
due = store.due_pushes(active_hours=24, cooldown_hours=24)
|
||||
self.assertEqual([row["ip"] for row in due], ["198.51.100.7"])
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Unit tests for the client-IP field heuristics."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from graylog_ops import detect # noqa: E402
|
||||
|
||||
|
||||
def message(client_ip, source="10.180.254.17", **extra):
|
||||
payload = {
|
||||
"timestamp": "2026-10-17T15:00:00.000Z",
|
||||
"source": source,
|
||||
"client_ip": client_ip,
|
||||
"status": 401,
|
||||
"request_uri": "/api/login",
|
||||
"message": '%s - - "POST /api/login HTTP/1.1" 401 0.01' % client_ip,
|
||||
}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
class AsIpTests(unittest.TestCase):
|
||||
def test_ipv4(self):
|
||||
self.assertEqual(str(detect.as_ip("203.0.113.5")), "203.0.113.5")
|
||||
|
||||
def test_ipv6(self):
|
||||
self.assertEqual(str(detect.as_ip("2001:db8::1")), "2001:db8::1")
|
||||
|
||||
def test_bracketed_ipv6_with_port(self):
|
||||
self.assertEqual(str(detect.as_ip("[2001:db8::1]:443")), "2001:db8::1")
|
||||
|
||||
def test_ipv4_with_port(self):
|
||||
self.assertEqual(str(detect.as_ip("192.0.2.44:8080")), "192.0.2.44")
|
||||
|
||||
def test_rejects_cidr(self):
|
||||
self.assertIsNone(detect.as_ip("10.0.0.0/8"))
|
||||
|
||||
def test_rejects_hostname(self):
|
||||
self.assertIsNone(detect.as_ip("edu-master"))
|
||||
|
||||
def test_rejects_bad_octet(self):
|
||||
self.assertIsNone(detect.as_ip("999.1.1.1"))
|
||||
|
||||
def test_rejects_empty_and_number(self):
|
||||
self.assertIsNone(detect.as_ip(""))
|
||||
self.assertIsNone(detect.as_ip(12345))
|
||||
|
||||
|
||||
class AnalyseTests(unittest.TestCase):
|
||||
def test_empty_sample(self):
|
||||
report = detect.analyse_messages([])
|
||||
self.assertEqual(report["sampled"], 0)
|
||||
self.assertIsNone(report["recommendation"]["field"])
|
||||
self.assertEqual(report["recommendation"]["confidence"], "none")
|
||||
|
||||
def test_recommends_client_ip_over_source(self):
|
||||
messages = [message(ip) for ip in (
|
||||
"203.0.113.5", "203.0.113.5", "203.0.113.5",
|
||||
"198.51.100.7", "198.51.100.7", "192.0.2.44", "192.0.2.44",
|
||||
"10.180.2.9",
|
||||
)]
|
||||
report = detect.analyse_messages(messages)
|
||||
self.assertEqual(report["recommendation"]["field"], "client_ip")
|
||||
self.assertEqual(report["recommendation"]["confidence"], "high")
|
||||
|
||||
candidates = {row["field"]: row for row in report["candidates"]}
|
||||
self.assertIn("source", candidates)
|
||||
self.assertNotIn("message", candidates)
|
||||
self.assertLess(candidates["source"]["score"], candidates["client_ip"]["score"])
|
||||
# The constant `source` must not outrank the varying client address.
|
||||
self.assertEqual(candidates["source"]["distinct_ips"], 1)
|
||||
|
||||
def test_warns_when_source_is_a_proxy(self):
|
||||
messages = [message("203.0.113.%d" % index) for index in range(1, 7)]
|
||||
report = detect.analyse_messages(messages)
|
||||
joined = " ".join(report["recommendation"]["warnings"])
|
||||
self.assertIn("source", joined)
|
||||
|
||||
def test_private_ratio_note(self):
|
||||
messages = [message("10.180.2.%d" % index) for index in range(1, 7)]
|
||||
report = detect.analyse_messages(messages)
|
||||
best = report["recommendation"]["field"]
|
||||
self.assertEqual(best, "client_ip")
|
||||
joined = " ".join(report["recommendation"]["warnings"])
|
||||
self.assertIn("内网", joined)
|
||||
|
||||
def test_no_ip_field_at_all(self):
|
||||
messages = [{"source": "edu-master", "message": "no address here"}] * 3
|
||||
report = detect.analyse_messages(messages)
|
||||
self.assertIsNone(report["recommendation"]["field"])
|
||||
self.assertEqual(report["candidates"], [])
|
||||
|
||||
def test_http_fields_detected(self):
|
||||
report = detect.analyse_messages([message("203.0.113.5")])
|
||||
self.assertIn("status", report["http_fields"])
|
||||
self.assertIn("uri", report["http_fields"])
|
||||
|
||||
|
||||
class InternalAddressTests(unittest.TestCase):
|
||||
"""`is_internal` must not use ipaddress.is_private: that flag also covers
|
||||
documentation ranges such as 203.0.113.0/24."""
|
||||
|
||||
def test_rfc1918(self):
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("10.1.2.3")))
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("172.16.5.5")))
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("192.168.1.1")))
|
||||
|
||||
def test_documentation_range_is_not_internal(self):
|
||||
self.assertFalse(detect.is_internal(detect.as_ip("203.0.113.5")))
|
||||
self.assertFalse(detect.is_internal(detect.as_ip("2001:db8::1")))
|
||||
|
||||
def test_loopback_link_local_and_cgnat(self):
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("127.0.0.1")))
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("169.254.1.1")))
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("100.64.1.1")))
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("::1")))
|
||||
|
||||
def test_ipv6_unique_local(self):
|
||||
self.assertTrue(detect.is_internal(detect.as_ip("fd00::1")))
|
||||
self.assertFalse(detect.is_internal(detect.as_ip("2400:cb00::1")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""End-to-end tests: run the real CLI against the fake Graylog server."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from fake_graylog import MESSAGES, FakeGraylog # noqa: E402
|
||||
from graylog_ops import cli # noqa: E402
|
||||
|
||||
|
||||
class CliEndToEndTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = FakeGraylog().start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.stop()
|
||||
|
||||
def setUp(self):
|
||||
self.server.terms_size_param = "size"
|
||||
self.server.requests.clear()
|
||||
|
||||
def run_cli(self, *argv, json_mode=True):
|
||||
args = ["--url", self.server.url, "--token", "testtoken"]
|
||||
if json_mode:
|
||||
args.append("--json")
|
||||
args.extend(argv)
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
code = cli.main(args)
|
||||
return code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
# ------------------------------------------------------------- probe
|
||||
def test_probe_json(self):
|
||||
code, out, err = self.run_cli("probe")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
self.assertIn("4.0.8", data["system"]["version"])
|
||||
self.assertEqual(data["indexer"]["status"], "green")
|
||||
failed = [check["name"] for check in data["checks"] if not check["ok"]]
|
||||
self.assertEqual(failed, [], "unexpected failing endpoints: %s" % failed)
|
||||
|
||||
def test_probe_human_output(self):
|
||||
code, out, err = self.run_cli("probe", json_mode=False)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("检查项", out)
|
||||
self.assertIn("4.0.8", out)
|
||||
self.assertIn("全部", out)
|
||||
|
||||
# ---------------------------------------------------------- discover
|
||||
def test_discover_finds_client_ip(self):
|
||||
code, out, err = self.run_cli("discover", "--range", "3600")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(data["sampled"], len(MESSAGES))
|
||||
self.assertEqual(data["recommendation"]["field"], "client_ip")
|
||||
self.assertEqual(data["recommendation"]["confidence"], "high")
|
||||
self.assertNotIn("message", [row["field"] for row in data["candidates"]])
|
||||
self.assertIn("status", data["http_fields"])
|
||||
self.assertIn("uri", data["http_fields"])
|
||||
|
||||
def test_discover_stream_title_resolution(self):
|
||||
code, out, err = self.run_cli("discover", "--stream", "业务访问")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(data["stream_id"], "5f00000000000000000000aa")
|
||||
|
||||
def test_discover_unknown_stream_fails_cleanly(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
cli.main(["--url", self.server.url, "--token", "testtoken",
|
||||
"discover", "--stream", "does-not-exist"])
|
||||
self.assertNotEqual(ctx.exception.code, 0)
|
||||
|
||||
# ------------------------------------------------------------- terms
|
||||
def test_top_ips(self):
|
||||
code, out, err = self.run_cli("top-ips", "--field", "client_ip", "--size", "10")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(data["field"], "client_ip")
|
||||
self.assertEqual(len(data["terms"]), 4)
|
||||
self.assertEqual(data["terms"][0]["term"], "203.0.113.5")
|
||||
self.assertEqual(data["terms"][0]["count"], 3)
|
||||
|
||||
def test_terms_use_views_backend(self):
|
||||
"""4.x removed the legacy terms endpoint, so aggregation must POST."""
|
||||
code, out, err = self.run_cli("top-ips", "--field", "client_ip")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertTrue(json.loads(out)["terms"])
|
||||
posts = [path for path, _ in self.server.requests
|
||||
if path == "/api/views/search/sync"]
|
||||
self.assertTrue(posts, "expected a views search POST")
|
||||
|
||||
def test_legacy_fallback_when_views_missing(self):
|
||||
"""Graylog 3.x has the legacy terms endpoint but no views API."""
|
||||
server = FakeGraylog(views_enabled=False).start()
|
||||
server.terms_size_param = "limit"
|
||||
try:
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
code = cli.main(["--url", server.url, "--token", "testtoken", "--json",
|
||||
"terms", "--field", "client_ip"])
|
||||
self.assertEqual(code, 0, stderr.getvalue())
|
||||
self.assertTrue(json.loads(stdout.getvalue())["terms"])
|
||||
term_calls = [query for path, query in server.requests
|
||||
if path.endswith("/universal/terms")]
|
||||
self.assertEqual(len(term_calls), 2)
|
||||
self.assertIn("size", term_calls[0])
|
||||
self.assertIn("limit", term_calls[1])
|
||||
self.assertNotIn("size", term_calls[1])
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
# ----------------------------------------------------------- snapshot
|
||||
def test_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
code, out, err = self.run_cli("snapshot", "--dir", directory, "--sample", "5")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
failed = [row for row in data["resources"] if row["status"] != "ok"]
|
||||
self.assertEqual(failed, [])
|
||||
self.assertTrue(os.path.exists(os.path.join(directory, "manifest.json")))
|
||||
self.assertTrue(os.path.exists(os.path.join(directory, "system.json")))
|
||||
self.assertEqual(data["sampled_messages"], 5)
|
||||
messages_path = os.path.join(directory, "messages.jsonl")
|
||||
with open(messages_path, "r", encoding="utf-8") as handle:
|
||||
lines = [line for line in handle if line.strip()]
|
||||
self.assertEqual(len(lines), 5)
|
||||
json.loads(lines[0])
|
||||
|
||||
# -------------------------------------------------------------- raw
|
||||
def test_raw_endpoint(self):
|
||||
code, out, err = self.run_cli("raw", "/api/system/inputs")
|
||||
self.assertEqual(code, 0, err)
|
||||
data = json.loads(out)
|
||||
self.assertIn("inputs", data)
|
||||
|
||||
# ---------------------------------------------------------- messages
|
||||
def test_messages_jsonl(self):
|
||||
code, out, err = self.run_cli("messages", "--limit", "3", "--jsonl")
|
||||
self.assertEqual(code, 0, err)
|
||||
lines = [line for line in out.splitlines() if line.strip()]
|
||||
self.assertEqual(len(lines), 3)
|
||||
for line in lines:
|
||||
json.loads(line)
|
||||
|
||||
# --------------------------------------------------------------- auth
|
||||
def test_bad_token_returns_1(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
code = cli.main(["--url", self.server.url, "--token", "wrong", "probe"])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("401", stderr.getvalue())
|
||||
|
||||
def test_missing_config_returns_2(self):
|
||||
saved = {key: os.environ.pop(key, None) for key in (
|
||||
"GRAYLOG_URL", "GRAYLOG_TOKEN", "GRAYLOG_USER", "GRAYLOG_PASSWORD")}
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
# Run from an empty directory: a real ./graylog-ops.json in the
|
||||
# project root must not leak into this test.
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
os.chdir(directory)
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
code = cli.main(["system"])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("配置错误", stderr.getvalue())
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
for key, value in saved.items():
|
||||
if value is not None:
|
||||
os.environ[key] = value
|
||||
|
||||
# --------------------------------------------------------------- collect
|
||||
def test_collect_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
code, out, err = self.run_cli("collect", "--dir", directory, "--range", "3600")
|
||||
self.assertEqual(code, 0, err)
|
||||
for name in ("probe.json", "discover.json", "judge.json",
|
||||
"profile.json", "SUMMARY.txt"):
|
||||
self.assertTrue(os.path.exists(os.path.join(directory, name)), name)
|
||||
self.assertTrue(os.path.isdir(os.path.join(directory, "snapshot")))
|
||||
data = json.loads(out)
|
||||
self.assertTrue(all(step["ok"] for step in data["steps"]), data["steps"])
|
||||
self.assertEqual(data["recommendation"]["field"], "client_ip")
|
||||
|
||||
# ---------------------------------------------------------------- out
|
||||
def test_out_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "ips.json")
|
||||
code, out, err = self.run_cli("top-ips", "--field", "client_ip", "--out", path)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
self.assertEqual(payload["field"], "client_ip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from graylog_ops.geo import GeoIP, build_geo_db, country_name, flag, region_name
|
||||
|
||||
|
||||
class GeoIPTests(unittest.TestCase):
|
||||
def test_build_and_lookup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
csv_path = os.path.join(directory, "city.csv")
|
||||
with open(csv_path, "w", encoding="utf-8") as fh:
|
||||
fh.write("1.0.0.0,1.0.0.255,OC,AU,Queensland,\"South Brisbane\",0,0\n")
|
||||
fh.write("1.0.1.0,1.0.3.255,AS,CN,Fujian,Wenquan,0,0\n")
|
||||
fh.write("8.8.8.0,8.8.8.255,NA,US,California,\"Mountain View\",0,0\n")
|
||||
fh.write("2001:db8::,2001:db8::ffff,NA,US,California,X,0,0\n")
|
||||
db_path = os.path.join(directory, "geo.db")
|
||||
count = build_geo_db(csv_path, db_path)
|
||||
self.assertEqual(count, 3) # IPv6 range skipped
|
||||
|
||||
geo = GeoIP(db_path)
|
||||
try:
|
||||
cn = geo.lookup("1.0.2.1")
|
||||
self.assertEqual(cn["country_code"], "CN")
|
||||
self.assertEqual(cn["country_name"], "中国")
|
||||
self.assertEqual(cn["region"], "福建")
|
||||
self.assertEqual(geo.lookup("8.8.8.8")["country_name"], "美国")
|
||||
self.assertIsNone(geo.lookup("9.9.9.9"))
|
||||
self.assertIsNone(geo.lookup("not-an-ip"))
|
||||
finally:
|
||||
geo.close()
|
||||
|
||||
def test_helpers(self):
|
||||
self.assertEqual(flag("CN"), "\U0001F1E8\U0001F1F3")
|
||||
self.assertEqual(flag("ZZ"), "")
|
||||
self.assertEqual(flag(""), "")
|
||||
self.assertEqual(country_name("CN"), "中国")
|
||||
self.assertEqual(country_name("US"), "美国")
|
||||
self.assertEqual(country_name("XX"), "XX")
|
||||
self.assertEqual(region_name("CN", "Guangdong"), "广东")
|
||||
self.assertEqual(region_name("US", "California"), "California")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for the judgement layer (ban candidate scoring)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from fake_graylog import JUDGE_MESSAGES, FakeGraylog # noqa: E402
|
||||
from graylog_ops import cli # noqa: E402
|
||||
|
||||
|
||||
class JudgeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = FakeGraylog(messages=JUDGE_MESSAGES).start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.stop()
|
||||
|
||||
def run_cli(self, *argv, json_mode=True):
|
||||
args = ["--url", self.server.url, "--token", "testtoken"]
|
||||
if json_mode:
|
||||
args.append("--json")
|
||||
args.extend(argv)
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
code = cli.main(args)
|
||||
return code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def judged(self, *argv):
|
||||
code, out, err = self.run_cli("judge", "--range", "300", *argv)
|
||||
self.assertEqual(code, 0, err)
|
||||
return json.loads(out)
|
||||
|
||||
# ------------------------------------------------------------ behaviour
|
||||
def test_bruteforce_client_is_recommended_for_ban(self):
|
||||
data = self.judged()
|
||||
rows = {row["ip"]: row for row in data["candidates"]}
|
||||
self.assertIn("203.0.113.66", rows)
|
||||
row = rows["203.0.113.66"]
|
||||
self.assertEqual(row["action"], "ban_1h")
|
||||
self.assertGreaterEqual(row["score"], 75)
|
||||
self.assertIn("auth_fail", row["signals"])
|
||||
self.assertTrue(any("失败请求" in reason for reason in row["reasons"]))
|
||||
|
||||
def test_scanner_client_is_recommended_for_ban(self):
|
||||
data = self.judged()
|
||||
row = {item["ip"]: item for item in data["candidates"]}["192.0.2.99"]
|
||||
self.assertEqual(row["action"], "ban_1h")
|
||||
self.assertIn("scanner_ua", row["signals"])
|
||||
self.assertIn("sensitive_path", row["signals"])
|
||||
|
||||
def test_busy_but_legitimate_client_is_not_banned(self):
|
||||
data = self.judged("--all")
|
||||
row = {item["ip"]: item for item in data["candidates"]}["198.51.100.200"]
|
||||
self.assertIn(row["action"], ("ignore", "observe"))
|
||||
self.assertFalse(row["evidence"])
|
||||
|
||||
def test_internal_addresses_are_excluded_by_default(self):
|
||||
data = self.judged()
|
||||
reasons = {row["ip"]: row["reason"] for row in data["excluded"]}
|
||||
self.assertIn("10.180.5.5", reasons)
|
||||
self.assertIn("内部", reasons["10.180.5.5"])
|
||||
self.assertNotIn("10.180.5.5", {row["ip"] for row in data["candidates"]})
|
||||
|
||||
def test_include_internal_flag(self):
|
||||
data = self.judged("--include-internal", "--all")
|
||||
ips = {row["ip"] for row in data["candidates"]}
|
||||
self.assertIn("10.180.5.5", ips)
|
||||
|
||||
def test_allowlist_file(self):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle:
|
||||
handle.write("# 深信服 AD / VIP\n10.180.254.17\n203.0.113.66\n")
|
||||
path = handle.name
|
||||
try:
|
||||
data = self.judged("--allow", path)
|
||||
excluded = {row["ip"]: row["reason"] for row in data["excluded"]}
|
||||
self.assertIn("203.0.113.66", excluded)
|
||||
self.assertIn("白名单", excluded["203.0.113.66"])
|
||||
self.assertNotIn("203.0.113.66", {row["ip"] for row in data["candidates"]})
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_explain_attaches_samples(self):
|
||||
data = self.judged("--explain", "2")
|
||||
sampled = [row for row in data["candidates"] if row.get("samples")]
|
||||
self.assertTrue(sampled)
|
||||
self.assertLessEqual(len(sampled[0]["samples"]), 3)
|
||||
|
||||
def test_verify_downgrades_automation_client(self):
|
||||
"""A steady single-endpoint poller with 403s must not be auto-banned."""
|
||||
automation = [{
|
||||
"timestamp": "2026-10-17T15:%02d:%02d.000Z" % (index // 60, index % 60),
|
||||
"source": "10.180.254.17",
|
||||
"client_ip": "203.0.113.77",
|
||||
"status": 403,
|
||||
"request_method": "POST",
|
||||
"request_uri": "/prod-api/biz/liveCourseChapter/msgSubscription",
|
||||
"http_user_agent": "okhttp/4.9",
|
||||
"message": '203.0.113.77 - "POST /prod-api/biz/x" 403',
|
||||
} for index in range(40)]
|
||||
server = FakeGraylog(messages=list(JUDGE_MESSAGES) + automation).start()
|
||||
try:
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
code = cli.main(["--url", server.url, "--token", "testtoken",
|
||||
"--json", "judge", "--range", "300",
|
||||
"--verify", "10", "--all"])
|
||||
self.assertEqual(code, 0, stderr.getvalue())
|
||||
data = json.loads(stdout.getvalue())
|
||||
row = {item["ip"]: item for item in data["candidates"]}["203.0.113.77"]
|
||||
self.assertEqual(row["action"], "observe")
|
||||
self.assertTrue(row["verify"]["automation_suspected"])
|
||||
self.assertFalse(row["verify"]["login_like"])
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_verify_keeps_login_bruteforce(self):
|
||||
"""Failures against a login path stay bannable even if concentrated."""
|
||||
data = self.judged("--verify", "5")
|
||||
row = {item["ip"]: item for item in data["candidates"]}["203.0.113.66"]
|
||||
self.assertEqual(row["action"], "ban_1h")
|
||||
self.assertTrue(row["verify"]["login_like"])
|
||||
|
||||
def test_profile_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
profile = os.path.join(directory, "profile.json")
|
||||
code, _, err = self.run_cli("discover", "--range", "3600",
|
||||
"--save-profile", profile)
|
||||
self.assertEqual(code, 0, err)
|
||||
with open(profile, "r", encoding="utf-8") as handle:
|
||||
saved = json.load(handle)
|
||||
self.assertEqual(saved["fields"]["client_ip"], "client_ip")
|
||||
self.assertEqual(saved["fields"]["status"], "status")
|
||||
self.assertEqual(saved["fields"]["uri"], "request_uri")
|
||||
|
||||
data = self.judged("--profile", profile)
|
||||
self.assertTrue(data["candidates"])
|
||||
self.assertEqual(data["field"], "client_ip")
|
||||
|
||||
# -------------------------------------------------------------- output
|
||||
def test_human_output(self):
|
||||
code, out, err = self.run_cli("judge", json_mode=False)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("信号命中", out)
|
||||
self.assertIn("本命令只读", out)
|
||||
self.assertIn("ban_1h", out)
|
||||
|
||||
def test_signal_queries_are_reported(self):
|
||||
data = self.judged()
|
||||
names = {stat["name"]: stat for stat in data["signal_stats"]}
|
||||
self.assertIn("auth_fail", names)
|
||||
self.assertGreater(names["auth_fail"]["ips_matched"], 0)
|
||||
self.assertGreater(names["scanner_ua"]["ips_matched"], 0)
|
||||
self.assertIn("query", names["auth_fail"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,209 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
for path in (ROOT, HERE):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from graylog_ops import watch # noqa: E402
|
||||
from graylog_ops.behavior import notify_fused # noqa: E402
|
||||
from graylog_ops.notify import Notifier, load_notifier # noqa: E402
|
||||
from test_watch import _StubClient # noqa: E402
|
||||
|
||||
|
||||
class _CaptureNotifier:
|
||||
def __init__(self):
|
||||
self.batches = []
|
||||
self.posts = []
|
||||
|
||||
def send(self, entries):
|
||||
self.batches.append(entries)
|
||||
|
||||
def link_for(self, ip):
|
||||
return ""
|
||||
|
||||
def post(self, text):
|
||||
self.posts.append(text)
|
||||
|
||||
|
||||
class _Receiver(BaseHTTPRequestHandler):
|
||||
received = []
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length).decode("utf-8")
|
||||
_Receiver.received.append(body)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"errcode":0}')
|
||||
|
||||
|
||||
class NotifierTests(unittest.TestCase):
|
||||
def _config(self, kind="generic", url="http://127.0.0.1:1/hook"):
|
||||
return {"type": kind, "url": url, "link_prefix": "http://example/panel/"}
|
||||
|
||||
def test_payload_per_kind(self):
|
||||
text = "**新增恶意 IP**\n\n1.2.3.4"
|
||||
self.assertEqual(
|
||||
Notifier(self._config("wecom")).payload(text),
|
||||
{"msgtype": "markdown", "markdown": {"content": text}})
|
||||
self.assertEqual(
|
||||
Notifier(self._config("dingtalk")).payload(text)["msgtype"], "markdown")
|
||||
self.assertEqual(
|
||||
Notifier(self._config("feishu")).payload(text)["msg_type"], "text")
|
||||
self.assertEqual(Notifier(self._config("slack")).payload(text), {"text": text})
|
||||
generic = Notifier(self._config("generic")).payload(text)
|
||||
self.assertEqual(generic["text"], text)
|
||||
self.assertEqual(generic["title"], "新增恶意 IP")
|
||||
|
||||
def test_render_includes_geo_rules_and_link(self):
|
||||
notifier = Notifier(self._config())
|
||||
text = notifier.render([{
|
||||
"ip": "203.0.113.5", "country_name": "美国", "region": "California",
|
||||
"score": 91.0, "total": 120, "first_seen": "2026-10-17T16:00:00Z",
|
||||
"rules_text": "Spring/中间件探测 4 次",
|
||||
}])
|
||||
self.assertIn("203.0.113.5", text)
|
||||
self.assertIn("美国 · California", text)
|
||||
self.assertIn("Spring/中间件探测", text)
|
||||
self.assertIn("http://example/panel/#/ip/203.0.113.5", text)
|
||||
|
||||
def test_send_to_http_endpoint(self):
|
||||
_Receiver.received = []
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _Receiver)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = "http://127.0.0.1:%d/hook" % server.server_address[1]
|
||||
notifier = Notifier(self._config("generic", url))
|
||||
notifier.send_test()
|
||||
self.assertEqual(len(_Receiver.received), 1)
|
||||
payload = json.loads(_Receiver.received[0])
|
||||
self.assertIn("测试推送", payload["text"])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
def test_dingtalk_signing(self):
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import urllib.parse
|
||||
secret = "SECtest"
|
||||
url = "https://oapi.dingtalk.com/robot/send?access_token=abc"
|
||||
notifier = Notifier({"type": "dingtalk", "url": url, "secret": secret})
|
||||
stamp = 1700000000000
|
||||
expected = base64.b64encode(hmac.new(
|
||||
secret.encode(), ("%d\n%s" % (stamp, secret)).encode(),
|
||||
hashlib.sha256).digest()).decode()
|
||||
signed = notifier.signed_url(stamp)
|
||||
self.assertIn("timestamp=1700000000000", signed)
|
||||
self.assertIn("sign=" + urllib.parse.quote_plus(expected), signed)
|
||||
self.assertTrue(signed.startswith(url + "&"))
|
||||
|
||||
def test_dingtalk_without_secret_keeps_url(self):
|
||||
url = "https://oapi.dingtalk.com/robot/send?access_token=abc"
|
||||
self.assertEqual(Notifier({"type": "dingtalk", "url": url}).signed_url(1), url)
|
||||
|
||||
def test_dingtalk_signature(self):
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import urllib.parse
|
||||
|
||||
notifier = Notifier({
|
||||
"type": "dingtalk",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=abc",
|
||||
"secret": "SECtest-secret",
|
||||
})
|
||||
url = notifier.signed_url()
|
||||
self.assertIn("timestamp=", url)
|
||||
self.assertIn("sign=", url)
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||
timestamp = query["timestamp"][0]
|
||||
expected = base64.b64encode(hmac.new(
|
||||
b"SECtest-secret",
|
||||
("%s\nSECtest-secret" % timestamp).encode("utf-8"),
|
||||
hashlib.sha256).digest()).decode("ascii")
|
||||
self.assertEqual(query["sign"][0], expected)
|
||||
|
||||
unsigned = Notifier({"type": "dingtalk",
|
||||
"url": "https://example/hook?access_token=abc"})
|
||||
self.assertEqual(unsigned.signed_url(),
|
||||
"https://example/hook?access_token=abc")
|
||||
|
||||
def test_load_notifier(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "notify.json")
|
||||
json.dump(self._config("wecom"), open(path, "w", encoding="utf-8"))
|
||||
notifier = load_notifier(path)
|
||||
self.assertEqual(notifier.kind, "wecom")
|
||||
|
||||
|
||||
class UpsertStatusTests(unittest.TestCase):
|
||||
def test_upsert_returns_new_upgraded_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
base = {"ip": "203.0.113.9", "total": 10, "rules": {},
|
||||
"reasons": []}
|
||||
self.assertEqual(store.upsert(dict(base, tier=watch.TIER_OBSERVE,
|
||||
score=5.0),
|
||||
"2026-10-17T16:00:00Z"), "new")
|
||||
self.assertEqual(store.upsert(dict(base, tier=watch.TIER_OBSERVE,
|
||||
score=5.0),
|
||||
"2026-10-17T16:01:00Z"), "unchanged")
|
||||
self.assertEqual(store.upsert(dict(base, tier=watch.TIER_MALICIOUS,
|
||||
score=80.0),
|
||||
"2026-10-17T16:02:00Z"), "upgraded")
|
||||
self.assertEqual(store.upsert(dict(base, tier=watch.TIER_MALICIOUS,
|
||||
score=80.0),
|
||||
"2026-10-17T16:03:00Z"), "unchanged")
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class DetectorNotificationTests(unittest.TestCase):
|
||||
def test_new_malicious_notifies_once(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
probe = watch.Detector(None, store, dict(watch.DEFAULT_FIELDS))
|
||||
queries = {rule["name"]: rule["query"] for rule in probe.rules}
|
||||
by_query = {"*": [("203.0.113.5", 40)],
|
||||
queries["probe_java"]: [("203.0.113.5", 40)]}
|
||||
for name, query in queries.items():
|
||||
by_query.setdefault(query, [])
|
||||
capture = _CaptureNotifier()
|
||||
detector = watch.Detector(_StubClient(by_query), store,
|
||||
dict(watch.DEFAULT_FIELDS), window=300)
|
||||
detector.run_once()
|
||||
store.sync_fused()
|
||||
# first sync suppresses historical rows; make it a fresh detection
|
||||
with store.lock, store.conn:
|
||||
store.conn.execute(
|
||||
"UPDATE ip_fused SET last_pushed_at=NULL")
|
||||
notify_fused(store, capture)
|
||||
self.assertEqual(len(capture.posts), 1)
|
||||
self.assertIn("203.0.113.5", capture.posts[0])
|
||||
detector.run_once()
|
||||
store.sync_fused()
|
||||
notify_fused(store, capture)
|
||||
self.assertEqual(len(capture.posts), 1) # 24h cooldown
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Tests for the continuous detector, the SQLite store and the dashboard."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from graylog_ops import watch # noqa: E402
|
||||
|
||||
|
||||
class ClassifyTests(unittest.TestCase):
|
||||
def test_sensitive_probe_is_malicious(self):
|
||||
tier, score, reasons = watch.classify(44, {"probe_java": 44})
|
||||
self.assertEqual(tier, watch.TIER_MALICIOUS)
|
||||
self.assertGreaterEqual(score, 60)
|
||||
self.assertTrue(any("探测" in reason for reason in reasons))
|
||||
|
||||
def test_single_sensitive_hit_is_enough(self):
|
||||
tier, score, _ = watch.classify(18, {"probe_java": 18})
|
||||
self.assertEqual(tier, watch.TIER_MALICIOUS)
|
||||
|
||||
def test_dotbot_crawler_is_malicious(self):
|
||||
tier, score, reasons = watch.classify(352, {"bot_ua": 352, "not_found": 176})
|
||||
self.assertEqual(tier, watch.TIER_MALICIOUS)
|
||||
self.assertTrue(any("404" in reason for reason in reasons))
|
||||
|
||||
def test_login_burst_alone_is_only_observed(self):
|
||||
"""Students logging in repeatedly must not look suspicious."""
|
||||
tier, score, _ = watch.classify(2253, {"login_burst": 660})
|
||||
self.assertEqual(tier, watch.TIER_OBSERVE)
|
||||
|
||||
def test_login_attack_plus_burst_is_malicious(self):
|
||||
tier, score, _ = watch.classify(666, {"login_attack": 333, "login_burst": 666})
|
||||
self.assertEqual(tier, watch.TIER_MALICIOUS)
|
||||
|
||||
def test_busy_clean_ip_scores_zero(self):
|
||||
tier, score, reasons = watch.classify(5000, {})
|
||||
self.assertEqual(score, 0.0)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_404_ratio_needs_volume(self):
|
||||
_, score, _ = watch.classify(10, {"not_found": 5})
|
||||
self.assertEqual(score, 0.0)
|
||||
|
||||
|
||||
class _StubClient:
|
||||
"""Returns canned counts keyed by the exact query string."""
|
||||
|
||||
def __init__(self, by_query, totals=None):
|
||||
self.by_query = by_query
|
||||
self.totals = totals or {}
|
||||
|
||||
def terms(self, field, query, size, range_seconds, filter_str=None):
|
||||
if query.startswith("client_ip:("):
|
||||
ips = [part.strip().strip('"')
|
||||
for part in query[len("client_ip:("):-1].split(" OR ")]
|
||||
items = [(ip, self.totals[ip]) for ip in ips if ip in self.totals]
|
||||
else:
|
||||
items = self.by_query.get(query, [])
|
||||
return {"terms": [{"term": ip, "count": count} for ip, count in items],
|
||||
"total": sum(count for _, count in items)}
|
||||
|
||||
def search_views_messages(self, query="*", range_seconds=300, limit=5, offset=0,
|
||||
filter_str=None, from_ts=None, to_ts=None,
|
||||
timeout_ms=60000, sort_order="DESC"):
|
||||
return []
|
||||
|
||||
|
||||
class StoreTests(unittest.TestCase):
|
||||
def test_upsert_list_forget(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
record = {"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 88.0, "total": 120,
|
||||
"rules": {"sensitive_probe": 4}, "reasons": ["敏感路径探测 4 次"]}
|
||||
store.upsert(record, "2026-10-17T16:00:00Z")
|
||||
store.upsert(dict(record, score=90.0), "2026-10-17T16:01:00Z")
|
||||
rows = store.list()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["score"], 90.0)
|
||||
self.assertEqual(rows[0]["hits"], 2)
|
||||
self.assertEqual(rows[0]["rules"]["sensitive_probe"], 4)
|
||||
|
||||
self.assertEqual(store.stats()["by_tier"][watch.TIER_MALICIOUS], 1)
|
||||
self.assertEqual(store.stats()["events"], 1)
|
||||
|
||||
store.upsert({"ip": "198.51.100.7", "tier": watch.TIER_SUSPICIOUS,
|
||||
"score": 40.0, "total": 10, "rules": {}, "reasons": []},
|
||||
"2026-10-17T16:02:00Z")
|
||||
self.assertEqual(len(store.list(tier=watch.TIER_SUSPICIOUS)), 1)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class PermanentRetentionTests(unittest.TestCase):
|
||||
def _store(self, directory):
|
||||
return watch.Store(os.path.join(directory, "ips.db"))
|
||||
|
||||
def test_ever_malicious_survives_downgrade(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = self._store(directory)
|
||||
try:
|
||||
base = {"ip": "203.0.113.9", "total": 10, "rules": {}, "reasons": []}
|
||||
store.upsert(dict(base, tier=watch.TIER_MALICIOUS, score=80.0),
|
||||
"2026-10-17T16:00:00Z")
|
||||
store.upsert(dict(base, tier=watch.TIER_OBSERVE, score=5.0),
|
||||
"2026-10-17T16:01:00Z")
|
||||
row = store.get("203.0.113.9")
|
||||
self.assertEqual(row["tier"], watch.TIER_OBSERVE)
|
||||
self.assertEqual(row["ever_malicious"], 1)
|
||||
self.assertEqual(row["last_malicious_at"], "2026-10-17T16:00:00Z")
|
||||
core = store.list(scope="core")
|
||||
self.assertEqual([item["ip"] for item in core], ["203.0.113.9"])
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_forget_keeps_convicted_ips(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = self._store(directory)
|
||||
try:
|
||||
store.upsert({"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 80.0, "total": 10, "rules": {},
|
||||
"reasons": []}, "2026-10-01T00:00:00Z")
|
||||
store.upsert({"ip": "198.51.100.7", "tier": watch.TIER_OBSERVE,
|
||||
"score": 5.0, "total": 10, "rules": {},
|
||||
"reasons": []}, "2026-10-01T00:00:00Z")
|
||||
store.forget(3600, time.time())
|
||||
self.assertIsNotNone(store.get("203.0.113.9"))
|
||||
self.assertIsNone(store.get("198.51.100.7"))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_sorting_modes(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = self._store(directory)
|
||||
try:
|
||||
store.upsert({"ip": "203.0.113.2", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 70.0, "total": 10, "rules": {},
|
||||
"reasons": []}, "2026-10-17T16:00:00Z")
|
||||
store.upsert({"ip": "203.0.113.1", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 90.0, "total": 5, "rules": {},
|
||||
"reasons": []}, "2026-10-17T17:00:00Z")
|
||||
first = [row["ip"] for row in store.list(sort="first_seen", order="asc")]
|
||||
self.assertEqual(first, ["203.0.113.2", "203.0.113.1"])
|
||||
recent = [row["ip"] for row in store.list(sort="last_malicious",
|
||||
order="desc")]
|
||||
self.assertEqual(recent, ["203.0.113.1", "203.0.113.2"])
|
||||
score = [row["ip"] for row in store.list(sort="score", order="desc")]
|
||||
self.assertEqual(score, ["203.0.113.1", "203.0.113.2"])
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class TextSanitisingTests(unittest.TestCase):
|
||||
def test_strips_control_and_replacement_chars(self):
|
||||
self.assertEqual(watch._clean_text("a\x00b\ufffdc"), "a·b·c")
|
||||
|
||||
def test_scrubs_binary_header_field(self):
|
||||
self.assertEqual(watch._scrub_binary("\ufffd\ufffdk\ufffdU\ufffd\ufffd"),
|
||||
"[非 UTF-8 数据]")
|
||||
self.assertEqual(watch._scrub_binary("GET"), "GET")
|
||||
|
||||
|
||||
class DetectorTests(unittest.TestCase):
|
||||
def _detector(self, by_query, store):
|
||||
fields = dict(watch.DEFAULT_FIELDS)
|
||||
probe = watch.Detector(None, store, fields)
|
||||
queries = {rule["name"]: rule["query"] for rule in probe.rules}
|
||||
mapping = dict(by_query)
|
||||
mapping.setdefault(queries["not_found"], [])
|
||||
return watch.Detector(_StubClient(mapping), store, fields, window=300)
|
||||
|
||||
def test_automation_is_downgraded(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
probe = watch.Detector(None, store, dict(watch.DEFAULT_FIELDS))
|
||||
q = {rule["name"]: rule["query"] for rule in probe.rules}
|
||||
by_query = {
|
||||
"*": [("124.70.2.111", 666)],
|
||||
q["login_attack"]: [("124.70.2.111", 333)],
|
||||
q["login_burst"]: [("124.70.2.111", 666)],
|
||||
q["not_found"]: [],
|
||||
q["probe_java"]: [],
|
||||
q["bad_ua"]: [],
|
||||
q["bot_ua"]: [],
|
||||
q["probe_config"]: [],
|
||||
q["probe_cms"]: [],
|
||||
q["probe_iot"]: [],
|
||||
q["probe_devops"]: [],
|
||||
q["probe_webshell"]: [],
|
||||
q["exploit_payload"]: [],
|
||||
'client_ip:"124.70.2.111"': [
|
||||
("/prod-api/biz/liveCourseChapter/msgSubscription", 666)],
|
||||
}
|
||||
detector = watch.Detector(_StubClient(by_query), store,
|
||||
dict(watch.DEFAULT_FIELDS), window=300)
|
||||
result = detector.run_once()
|
||||
self.assertEqual(result["automation"], 1)
|
||||
row = store.get("124.70.2.111")
|
||||
self.assertEqual(row["tier"], watch.TIER_AUTOMATION)
|
||||
self.assertTrue(any("单一接口" in r for r in row["reasons"]))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_low_volume_scanner_is_still_caught(self):
|
||||
"""A scanner with 18 requests must not need a top-volume slot."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
probe = watch.Detector(None, store, dict(watch.DEFAULT_FIELDS))
|
||||
q = {rule["name"]: rule["query"] for rule in probe.rules}
|
||||
by_query = {
|
||||
"*": [("10.185.9.41", 5000), ("202.201.16.87", 4000)],
|
||||
q["probe_java"]: [("5.188.86.234", 18)],
|
||||
}
|
||||
detector = watch.Detector(
|
||||
_StubClient(by_query, totals={"5.188.86.234": 18}), store,
|
||||
dict(watch.DEFAULT_FIELDS), window=300)
|
||||
result = detector.run_once()
|
||||
self.assertEqual(result["malicious"], 1)
|
||||
self.assertEqual(store.get("5.188.86.234")["tier"], watch.TIER_MALICIOUS)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_internal_and_allowlisted_are_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
try:
|
||||
probe = watch.Detector(None, store, dict(watch.DEFAULT_FIELDS))
|
||||
q = {rule["name"]: rule["query"] for rule in probe.rules}
|
||||
by_query = {
|
||||
"*": [("10.185.9.41", 5000), ("203.0.113.5", 30), ("198.51.100.9", 30)],
|
||||
q["probe_java"]: [("203.0.113.5", 30), ("198.51.100.9", 30)],
|
||||
}
|
||||
detector = watch.Detector(
|
||||
_StubClient(by_query), store, dict(watch.DEFAULT_FIELDS),
|
||||
window=300, allowlist=lambda ip: ip == "198.51.100.9")
|
||||
detector.run_once()
|
||||
self.assertIsNone(store.get("10.185.9.41"))
|
||||
self.assertIsNone(store.get("198.51.100.9"))
|
||||
self.assertEqual(store.get("203.0.113.5")["tier"], watch.TIER_MALICIOUS)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class SampleTests(unittest.TestCase):
|
||||
def test_sample_keeps_all_source_fields(self):
|
||||
message = {
|
||||
"timestamp": "2026-10-17T05:08:58.046Z",
|
||||
"client_ip": "51.158.145.102", "client_port": 46990,
|
||||
"server_ip": "10.185.9.90", "server_port": 80,
|
||||
"host": "61.178.20.46:443", "bigip": "www.gsedu.edu.cn_2_4600",
|
||||
"vs": "/Common/SmartEDU_https", "method": "GET",
|
||||
"uri": "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
|
||||
"status": "403", "ua": "libredtail-http",
|
||||
"client_ip_country_code": "FR", "rt": "0.031",
|
||||
"message": 'event_type="req",client_ip="51.158.145.102"',
|
||||
}
|
||||
sample = watch._sample(message)
|
||||
for key in ("client_port", "server_port", "bigip", "vs", "rt",
|
||||
"client_ip_country_code", "message"):
|
||||
self.assertIn(key, sample)
|
||||
self.assertEqual(sample["client_port"], "46990")
|
||||
|
||||
def test_sample_drops_empty_and_na_values(self):
|
||||
sample = watch._sample({"client_ip": "1.2.3.4",
|
||||
"client_ip_city_name": "N/A",
|
||||
"referer": ""})
|
||||
self.assertEqual(sample, {"client_ip": "1.2.3.4"})
|
||||
|
||||
|
||||
class SnapshotTests(unittest.TestCase):
|
||||
def test_compact_evidence_is_bounded(self):
|
||||
evidence = {
|
||||
"window": 89600,
|
||||
"uris": [{"term": "/probe/%d" % i, "count": i} for i in range(30)],
|
||||
"statuses": [{"term": str(code), "count": 1} for code in range(10)],
|
||||
"methods": [{"term": "GET", "count": 1}],
|
||||
"hosts": [{"term": "h", "count": 1}],
|
||||
"server_ips": [{"term": "10.0.0.1", "count": 1}],
|
||||
"vips": [{"term": "vip", "count": 1}],
|
||||
"uas": [{"term": "ua", "count": 1}],
|
||||
"samples": [{} for _ in range(5)],
|
||||
"timeline": [{"timestamp": "t%d" % i, "count": 1} for i in range(200)],
|
||||
"peak": {"timestamp": "t", "count": 3},
|
||||
}
|
||||
compact = watch._compact_evidence(evidence)
|
||||
self.assertTrue(compact["compact"])
|
||||
self.assertLessEqual(len(compact["uris"]), 10)
|
||||
self.assertLessEqual(len(compact["timeline"]), 72)
|
||||
self.assertEqual(compact["sample_count"], 5)
|
||||
self.assertEqual(compact["window"], 89600)
|
||||
|
||||
|
||||
class PathPrefixTests(unittest.TestCase):
|
||||
def test_root_is_hidden_and_prefix_works(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
store.upsert({"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 91.0, "total": 120, "window": 3600,
|
||||
"rules": {"probe_java": 4},
|
||||
"reasons": ["探测 4 次"]}, "2026-10-17T16:00:00Z")
|
||||
store.sync_fused()
|
||||
server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0),
|
||||
watch.make_handler(store, 3600, 60, None, "/s/secret123"))
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = "http://127.0.0.1:%d" % server.server_address[1]
|
||||
try:
|
||||
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
||||
urllib.request.urlopen(base + "/", timeout=5)
|
||||
self.assertEqual(ctx.exception.code, 404)
|
||||
|
||||
with urllib.request.urlopen(base + "/s/secret123/", timeout=5) as response:
|
||||
page = response.read().decode("utf-8")
|
||||
self.assertIn('const BASE = "/s/secret123"', page)
|
||||
|
||||
with urllib.request.urlopen(base + "/s/secret123/api/ips", timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(payload["count"], 1)
|
||||
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
urllib.request.urlopen(base + "/api/ips", timeout=5)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
store.close()
|
||||
|
||||
|
||||
class StatusIndicatorTests(unittest.TestCase):
|
||||
def test_status_endpoint(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
|
||||
class DetectorStub:
|
||||
last_success = time.time() - 5
|
||||
last_error = None
|
||||
last_run = "2026-10-17T16:00:00Z"
|
||||
|
||||
server = ThreadingHTTPServer(
|
||||
("127.0.0.1", 0),
|
||||
watch.make_handler(store, 3600, 60, DetectorStub()))
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
base = "http://127.0.0.1:%d" % server.server_address[1]
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/api/status", timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(payload["graylog"], "ok")
|
||||
|
||||
DetectorStub.last_error = "connection refused"
|
||||
DetectorStub.last_success = None
|
||||
with urllib.request.urlopen(base + "/api/status", timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(payload["graylog"], "error")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
store.close()
|
||||
|
||||
|
||||
class DashboardTests(unittest.TestCase):
|
||||
def test_api_and_html(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||
store.upsert({"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 91.0, "total": 120, "window": 3600,
|
||||
"rules": {"sensitive_probe": 4},
|
||||
"reasons": ["敏感路径探测 4 次"]}, "2026-10-17T16:00:00Z")
|
||||
store.upsert({"ip": "198.51.100.77", "tier": watch.TIER_MALICIOUS,
|
||||
"score": 70.0, "total": 653344, "window": 3600,
|
||||
"risk": "shared_egress",
|
||||
"rules": {"sensitive_probe": 4},
|
||||
"reasons": ["疑似共享出口"]}, "2026-10-17T16:00:00Z")
|
||||
store.sync_fused()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0),
|
||||
watch.make_handler(store, 3600, 60))
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = "http://127.0.0.1:%d" % server.server_address[1]
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/", timeout=5) as response:
|
||||
page = response.read().decode("utf-8")
|
||||
self.assertIn("恶意 IP 清单", page)
|
||||
self.assertIn("/api/ips", page)
|
||||
|
||||
with urllib.request.urlopen(base + "/api/ips", timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(payload["count"], 2)
|
||||
self.assertEqual({item["ip"] for item in payload["items"]},
|
||||
{"203.0.113.9", "198.51.100.77"})
|
||||
self.assertTrue(all(item["sources"] == "feature"
|
||||
for item in payload["items"]))
|
||||
|
||||
with urllib.request.urlopen(base + "/api/stats", timeout=5) as response:
|
||||
stats = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(stats["core"], 2)
|
||||
self.assertNotIn("review", stats)
|
||||
|
||||
with urllib.request.urlopen(base + "/api/ip/203.0.113.9", timeout=5) as response:
|
||||
detail = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(detail["item"]["tier"], watch.TIER_MALICIOUS)
|
||||
self.assertFalse(detail["evidence"]["available"])
|
||||
|
||||
with urllib.request.urlopen(base + "/api/export.csv", timeout=5) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertIn("text/csv", response.headers.get("Content-Type", ""))
|
||||
self.assertIn("ip,tier,score", body)
|
||||
self.assertIn("203.0.113.9", body)
|
||||
self.assertIn("198.51.100.77", body)
|
||||
|
||||
with urllib.request.urlopen(base + "/api/export.txt", timeout=5) as response:
|
||||
text = response.read().decode("utf-8")
|
||||
self.assertEqual(set(text.split()),
|
||||
{"203.0.113.9", "198.51.100.77"})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user