# 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`。 > 非 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/` - 存储:`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/`、`/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:` | 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` 文件 |