Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ddee07db | ||
|
|
90a284a3dd | ||
|
|
818fa74160 |
@@ -41,3 +41,45 @@
|
|||||||
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 `time.Hour`)。
|
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 `time.Hour`)。
|
||||||
新增 `TestShareCardCacheKeysCoverDrawnFields`——逐字段变异断言 key 必须变,
|
新增 `TestShareCardCacheKeysCoverDrawnFields`——逐字段变异断言 key 必须变,
|
||||||
去掉 Domain 立刻 FAIL。**以后改分享卡要加新字段时,先看这张测试。**
|
去掉 Domain 立刻 FAIL。**以后改分享卡要加新字段时,先看这张测试。**
|
||||||
|
|
||||||
|
## 正式环境「蜜罐封了但还能访问」排查(IPv6)
|
||||||
|
|
||||||
|
本地用 IPv6 全程复现了一遍 autoban 链路(CF-Connecting-IP 注入 + trust_proxy=true):
|
||||||
|
封禁前 200 → POST 凭据后 `IsBanned`=true → 同地址 403 → 同 /64 换地址仍 403 →
|
||||||
|
不同 /64 放行 200。**判定逻辑与中间件都正确,不是代码 bug。**
|
||||||
|
|
||||||
|
封禁键 = `shared.LimitKey(ResolveClientIP(req))`:
|
||||||
|
- IPv4 原样
|
||||||
|
- IPv6 折成 `xxxx:xxxx:xxxx:xxxx::/64`
|
||||||
|
写入(honeypot.Serve / route.NotFound)与检查(autobanMiddleware)用的是同一个函数,一致。
|
||||||
|
|
||||||
|
⇒ 「邮件说封了但能访问」只能是**键不一致**或**进程重启(封禁纯内存态)**。
|
||||||
|
三种可能:① 部署后重启过;② trust_proxy 未配 → 键是反代地址(`127.0.0.1` / `::/64`);
|
||||||
|
③ 前后请求地址族或 /64 不同(双栈、IPv6 前缀轮换、多实例)。
|
||||||
|
|
||||||
|
判定方法:`honeypot hits` / `data/honeypot-creds/creds-YYYY-MM-DD.jsonl` 里的 `ip` 字段
|
||||||
|
就是**被封的键**,与本机 `curl -6 ifconfig.co` 比对即可。
|
||||||
|
|
||||||
|
**发现的真实取证缺陷**:`db.HoneypotHit.IP` 与 creds 文件存的是折叠后的 `/64` 键,
|
||||||
|
IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会误以为「这不是我的 IP」。
|
||||||
|
建议:取证记精确 IP,封禁仍用 /64;建封与 403 命中各打一条日志。
|
||||||
|
|
||||||
|
### 据此动手改的三处(精确 IP / 封禁键分离 + 可观测性)
|
||||||
|
|
||||||
|
1. `honeypotHandler.Serve` 拆两个变量:`clientIP = ResolveClientIP`(精确,取证/邮件用)、
|
||||||
|
`banKey = LimitKey(clientIP)`(/64,封禁/采样/告警去重用)。DB 与凭据文件都写 clientIP。
|
||||||
|
2. `autoban.Options.OnBan(key, duration, reason)` 回调(新增 `ReasonProbe` / `ReasonHoneypot`),
|
||||||
|
保持 autoban 纯标准库、不再依赖 logrus。route.newBanner 注入 → 建封打 WARN
|
||||||
|
(ban_key / reason / duration / expires_at)。中间件 403 分支打 Debug。
|
||||||
|
3. 邮件同时给「来源 IP」(精确)与「封禁范围」(ban_key,仅 `ne .ip .ban_key` 时渲染);
|
||||||
|
顺手把模板里过时的「已入库」改成「写入取证文件」(上一轮凭据外置后没同步)。
|
||||||
|
|
||||||
|
副作用修掉:`honeypot stats` 的来源排行改按 `LimitKey` 合并(否则 IPv6 轮换地址会把排行碎成
|
||||||
|
几十条各 1 次)。SQL 取 `limit*4` 再合并,属近似。
|
||||||
|
|
||||||
|
测试:`TestHoneypotRecordsExactIPv6ButBansByPrefix`(精确 IP + /64 键 + store 断言)、
|
||||||
|
`TestOnBanCallback`、`TestHoneypotAlertTemplateRenders`、`TestMergeSourcesByKey`。
|
||||||
|
四处变异验证有效(改回存聚合键 / 封禁用精确地址 / 去掉 notifyBan / 不合并排行)。
|
||||||
|
|
||||||
|
**变异验证的坑**:把 `shared.LimitKey(r.IP)` 改成 `r.IP` 会因 `shared` 未使用而
|
||||||
|
**编译失败**(不是断言失败);变异要保证仍能编译(改成 `LimitKey(r.IP) + r.IP` 才测到逻辑)。
|
||||||
|
|||||||
@@ -27,3 +27,8 @@
|
|||||||
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
|
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
|
||||||
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
|
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
|
||||||
补文件后无需重启。
|
补文件后无需重启。
|
||||||
|
- ⚠️ **`GOSUMDB=off` 会让 toolchain 自动下载失败**:go.mod 要 1.27.3 而 PATH 上的 `go` 是旧版时,
|
||||||
|
会报 `golang.org/toolchain@...: verifying module: checksum database disabled by GOSUMDB=off`
|
||||||
|
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
|
||||||
|
解法:直接用缓存里的 toolchain 并禁用自动切换——
|
||||||
|
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
|
||||||
|
|||||||
+3
-1
@@ -298,7 +298,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
| 子命令 | 作用 | 改数据 |
|
| 子命令 | 作用 | 改数据 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `hits` | 列出捕获到的凭据(时间倒序,密码默认脱敏) | 否 |
|
| `hits` | 列出捕获到的凭据(时间倒序,密码默认脱敏) | 否 |
|
||||||
| `stats` | 来源 IP / 路径排行(库)+ 凭据排行(文件) | 否 |
|
| `stats` | 来源排行(IPv6 按 /64 聚合)+ 路径排行(库)+ 凭据排行(文件) | 否 |
|
||||||
| `paths` | 列出当前生效的诱饵路径 | 否 |
|
| `paths` | 列出当前生效的诱饵路径 | 否 |
|
||||||
| `size` | 表行数、磁盘占用、凭据文件体积 | 否 |
|
| `size` | 表行数、磁盘占用、凭据文件体积 | 否 |
|
||||||
| `prune` | 按保留期清元数据 + 凭据文件 | 是 |
|
| `prune` | 按保留期清元数据 + 凭据文件 | 是 |
|
||||||
@@ -320,6 +320,8 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
(目录 0700、文件 0600),数据库里只有 IP / 路径 / UA / 是否封禁这些元数据。
|
(目录 0700、文件 0600),数据库里只有 IP / 路径 / UA / 是否封禁这些元数据。
|
||||||
所以 `hits` / `paths` **不连数据库**,DB 挂了也能查。
|
所以 `hits` / `paths` **不连数据库**,DB 挂了也能查。
|
||||||
- 密码默认脱敏(保留首尾各 1 位)。终端输出常被贴进工单或截图,`--show-password` 只在确有取证需要时开。
|
- 密码默认脱敏(保留首尾各 1 位)。终端输出常被贴进工单或截图,`--show-password` 只在确有取证需要时开。
|
||||||
|
- `hits` / `stats` 里的 `ip` 是**精确**客户端地址;封禁用的是 `shared.LimitKey` 折叠后的键
|
||||||
|
(IPv6 为 `/64` 前缀)。两者在 IPv6 下不一致是正常的,别拿邮件里的精确地址去对封禁键。
|
||||||
- `purge-creds` 用于清掉早期版本存在 `honeypot_hits` 上的明文列(AutoMigrate 不删既有列)。
|
- `purge-creds` 用于清掉早期版本存在 `honeypot_hits` 上的明文列(AutoMigrate 不删既有列)。
|
||||||
执行前先 `pg_dump`;它只处理当前库,**历史备份与从库里的明文需要自行清理**。
|
执行前先 `pg_dump`;它只处理当前库,**历史备份与从库里的明文需要自行清理**。
|
||||||
- 封禁语义:GET 探测走常规阈值(默认 10 次 / 60 秒),**提交凭据才一次即封**。
|
- 封禁语义:GET 探测走常规阈值(默认 10 次 / 60 秒),**提交凭据才一次即封**。
|
||||||
|
|||||||
+34
-1
@@ -74,6 +74,34 @@ honeypot_cred_dir = data/honeypot-creds
|
|||||||
|
|
||||||
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
|
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
|
||||||
|
|
||||||
|
### 封的是一把「键」,不是一个人
|
||||||
|
|
||||||
|
`autoban` 的表就是内存里的 `map[键]到期时间`,键由 `shared.LimitKey` 生成:
|
||||||
|
|
||||||
|
- IPv4 → 原样(`203.0.113.7`)
|
||||||
|
- IPv6 → 折叠到 `/64`(`2408:8207:1234:5678::/64`),防止在单个 /64 内轮换地址绕过阈值
|
||||||
|
|
||||||
|
所以告警邮件里**可能有两个地址**:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 来源 IP | 精确客户端地址,取证与对账用 |
|
||||||
|
| 封禁范围 | 实际写进封禁表的键;IPv6 下是 /64 前缀,整段一起封(IPv4 下与来源 IP 相同,不重复显示) |
|
||||||
|
|
||||||
|
两个直接后果,排障时先记住:
|
||||||
|
|
||||||
|
1. **封禁只在内存里** —— 重启进程即解除,邮件不会撤回;
|
||||||
|
2. **封的键与请求算出来的键必须一致** —— 换地址族(IPv6 访问 / IPv4 回源)或换 /64 前缀就不会被拦。
|
||||||
|
|
||||||
|
每次建封都会打一条 WARN,键、原因、时长、到期时间都在里面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u tamabox -n 50 | grep "已自动封禁来源"
|
||||||
|
# ban_key=2408:8207:1234:5678::/64 reason=蜜罐提交凭据 duration=24h0m0s expires_at=...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ban_key` 就是被封的键,与邮件里的「封禁范围」一致。若这里没有记录,说明当时根本没建封(邮件可能是别的通道发的);若有记录但仍能访问,就去比对请求算出来的键。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 凭据存在哪里
|
## 4. 凭据存在哪里
|
||||||
@@ -103,6 +131,9 @@ data/honeypot-creds/creds-2026-10-21.jsonl
|
|||||||
|
|
||||||
数据库里只保留「发生过一次带凭据的提交」这个事实(`honeypot_hits.had_fields`),以及 IP / 路径 / UA / 是否封禁这些元数据。
|
数据库里只保留「发生过一次带凭据的提交」这个事实(`honeypot_hits.had_fields`),以及 IP / 路径 / UA / 是否封禁这些元数据。
|
||||||
|
|
||||||
|
> `ip` 两处存的都是**精确客户端地址**(IPv6 保留完整地址),不是封禁用的 `/64` 聚合键 ——
|
||||||
|
> 取证要能定位到具体主机。想知道「封了多大范围」,看告警邮件的「封禁范围」或日志里的 `ban_key`。
|
||||||
|
|
||||||
> ⚠️ 这些是**外部攻击者提交的真实账号密码**,很可能是从别处泄露后复用的。请按敏感数据对待:不要提交进版本库(`/data/` 已在 `.gitignore`)、不要参与备份、不要贴到公开群组。
|
> ⚠️ 这些是**外部攻击者提交的真实账号密码**,很可能是从别处泄露后复用的。请按敏感数据对待:不要提交进版本库(`/data/` 已在 `.gitignore`)、不要参与备份、不要贴到公开群组。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -158,7 +189,7 @@ pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
|||||||
```bash
|
```bash
|
||||||
./tamabox honeypot hits # 最近 24h 的凭据(密码默认脱敏)
|
./tamabox honeypot hits # 最近 24h 的凭据(密码默认脱敏)
|
||||||
./tamabox honeypot hits --ip 1.2.3.4 --show-password
|
./tamabox honeypot hits --ip 1.2.3.4 --show-password
|
||||||
./tamabox honeypot stats --days 7 # 来源 IP / 路径排行(库)+ 凭据排行(文件)
|
./tamabox honeypot stats --days 7 # 来源排行(IPv6 按 /64 聚合)+ 路径排行 + 凭据排行
|
||||||
./tamabox honeypot paths # 当前生效的诱饵路径
|
./tamabox honeypot paths # 当前生效的诱饵路径
|
||||||
./tamabox honeypot size # 表行数 + 磁盘占用 + 凭据文件体积
|
./tamabox honeypot size # 表行数 + 磁盘占用 + 凭据文件体积
|
||||||
./tamabox honeypot prune --days 30 # 手动按保留期清(元数据 + 凭据文件)
|
./tamabox honeypot prune --days 30 # 手动按保留期清(元数据 + 凭据文件)
|
||||||
@@ -172,6 +203,7 @@ pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
|||||||
|
|
||||||
| 现象 | 原因 | 处理 |
|
| 现象 | 原因 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **邮件说封了,但还能正常访问** | ① 进程重启过(封禁是内存态)② 后续请求换了地址族 / 换了 /64 前缀 ③ `trust_proxy` 未配,封的是反代地址 | 看日志里 `已自动封禁来源` 的 `ban_key`,与 `honeypot hits` 里的 IP、以及本机 `curl -6 ifconfig.co` 比对;见 §3「封的是一把键」 |
|
||||||
| `/wp-admin` 还是 302 | `enable_honeypot` 未开 / 配置未重启生效 | 看启动日志;`honeypot paths` 确认开关状态 |
|
| `/wp-admin` 还是 302 | `enable_honeypot` 未开 / 配置未重启生效 | 看启动日志;`honeypot paths` 确认开关状态 |
|
||||||
| 记录里 `ip` 是 `127.0.0.1` | `trust_proxy` 未配 | 见 §5;**注意此时封禁会封全站** |
|
| 记录里 `ip` 是 `127.0.0.1` | `trust_proxy` 未配 | 见 §5;**注意此时封禁会封全站** |
|
||||||
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
|
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
|
||||||
@@ -186,6 +218,7 @@ pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
|||||||
|
|
||||||
- **验证二进制里有没有某个中文字符串,不能用 `strings`** —— 它默认只输出可打印 ASCII,UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。用 `grep -ac "中文字符串" x`。
|
- **验证二进制里有没有某个中文字符串,不能用 `strings`** —— 它默认只输出可打印 ASCII,UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。用 `grep -ac "中文字符串" x`。
|
||||||
- **替换二进制 ≠ 生效**,进程必须重启;前台跑 `./tamabox web` 若立刻返回,通常是端口被旧进程占着。
|
- **替换二进制 ≠ 生效**,进程必须重启;前台跑 `./tamabox web` 若立刻返回,通常是端口被旧进程占着。
|
||||||
|
- **邮件里的「来源 IP」不是封禁键**:IPv6 下前者是精确地址、后者是 /64 前缀,拿前者去查后者永远对不上。判「封没封」只看 `ban_key`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||||
@@ -193,10 +195,18 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "count honeypot hits")
|
return errors.Wrap(err, "count honeypot hits")
|
||||||
}
|
}
|
||||||
sources, err := store.TopSources(cctx, since, ctx.Int("limit"))
|
// 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址:
|
||||||
|
// 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。
|
||||||
|
// 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。
|
||||||
|
limit := ctx.Int("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
sources, err := store.TopSources(cctx, since, limit*4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top sources")
|
return errors.Wrap(err, "query top sources")
|
||||||
}
|
}
|
||||||
|
sources = mergeSourcesByKey(sources, limit)
|
||||||
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
|
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top paths")
|
return errors.Wrap(err, "query top paths")
|
||||||
@@ -213,7 +223,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
|
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
|
||||||
fmt.Println(strings.Repeat("-", 72))
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
|
|
||||||
fmt.Println("来源 IP 排行:")
|
fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):")
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
}
|
}
|
||||||
@@ -495,3 +505,47 @@ func maskSecret(s string) string {
|
|||||||
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。
|
||||||
|
//
|
||||||
|
// 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者
|
||||||
|
// 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录,
|
||||||
|
// 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 ——
|
||||||
|
// 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。
|
||||||
|
//
|
||||||
|
// 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。
|
||||||
|
func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
type agg struct {
|
||||||
|
count int64
|
||||||
|
provider string
|
||||||
|
}
|
||||||
|
merged := make(map[string]*agg, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
key := shared.LimitKey(r.IP)
|
||||||
|
if a, ok := merged[key]; ok {
|
||||||
|
a.count += r.Count
|
||||||
|
if a.provider == "" {
|
||||||
|
a.provider = r.Provider
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged[key] = &agg{count: r.Count, provider: r.Provider}
|
||||||
|
}
|
||||||
|
out := make([]*db.HoneypotSourceStat, 0, len(merged))
|
||||||
|
for key, a := range merged {
|
||||||
|
out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动
|
||||||
|
})
|
||||||
|
if limit > 0 && len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMergeSourcesByKey 验证来源排行按「封禁口径」合并(IPv6 折成 /64)。
|
||||||
|
//
|
||||||
|
// 元数据表存的是精确地址(取证需要),若排行榜也按精确地址分组,
|
||||||
|
// 一个在 /64 内轮换地址的扫描器会碎成几十条各 1 次的记录 ——
|
||||||
|
// 看起来像「很多人在扫」,其实只有一台机器。合并口径必须与 autoban 一致。
|
||||||
|
func TestMergeSourcesByKey(t *testing.T) {
|
||||||
|
rows := []*db.HoneypotSourceStat{
|
||||||
|
{IP: "2408:8207:1234:5678::1", Count: 5, Provider: "联通"},
|
||||||
|
{IP: "2408:8207:1234:5678::2", Count: 3},
|
||||||
|
{IP: "1.2.3.4", Count: 4, Provider: "Azure"},
|
||||||
|
{IP: "2408:8207:9999:0001::1", Count: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := mergeSourcesByKey(rows, 10)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("合并后条目数 = %d, want 3(同 /64 必须合并)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].IP != "2408:8207:1234:5678::/64" || got[0].Count != 8 {
|
||||||
|
t.Errorf("第一名 = %s / %d, want 2408:8207:1234:5678::/64 / 8", got[0].IP, got[0].Count)
|
||||||
|
}
|
||||||
|
if got[0].Provider != "联通" {
|
||||||
|
t.Errorf("合并后应保留首个非空归属,got %q", got[0].Provider)
|
||||||
|
}
|
||||||
|
if got[1].IP != "1.2.3.4" || got[1].Count != 4 {
|
||||||
|
t.Errorf("第二名 = %s / %d, want 1.2.3.4 / 4(IPv4 原样保留)", got[1].IP, got[1].Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if limited := mergeSourcesByKey(rows, 1); len(limited) != 1 {
|
||||||
|
t.Errorf("limit=1 时条目数 = %d, want 1", len(limited))
|
||||||
|
}
|
||||||
|
if out := mergeSourcesByKey(nil, 10); len(out) != 0 {
|
||||||
|
t.Errorf("空输入应返回空,got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,11 @@ type HoneypotHit struct {
|
|||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
|
||||||
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
|
// IP 是发起请求的**精确**客户端地址(IPv6 保留完整地址,最长 45 字符)。
|
||||||
|
//
|
||||||
|
// 刻意不存 shared.LimitKey 折叠后的 /64 聚合键:那张表是取证物,
|
||||||
|
// 「哪台主机」必须留下来才能查其他日志、做 abuse 投诉。聚合键只适合
|
||||||
|
// 当计数器的 key(封禁 / 限流),它进不来这里。
|
||||||
IP string `gorm:"size:64;index" json:"ip"`
|
IP string `gorm:"size:64;index" json:"ip"`
|
||||||
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
|
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
|
||||||
Method string `gorm:"size:16" json:"method"`
|
Method string `gorm:"size:16" json:"method"`
|
||||||
|
|||||||
@@ -115,7 +115,13 @@ func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []stri
|
|||||||
|
|
||||||
// HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。
|
// HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。
|
||||||
type HoneypotAlert struct {
|
type HoneypotAlert struct {
|
||||||
IP string
|
// IP 是精确客户端地址,正文里展示的就是它(人要对得上自己的地址)。
|
||||||
|
IP string
|
||||||
|
// BanKey 是实际写进封禁表的键:IPv4 与 IP 相同,IPv6 是折叠后的 /64 前缀。
|
||||||
|
// 单独给出,是为了让运维能分清「源地址」与「被封的范围」——
|
||||||
|
// 曾经只给键,收到「你的 IP 已被封禁」的人发现邮件里的地址不是自己的,
|
||||||
|
// 排查方向直接被带偏。
|
||||||
|
BanKey string
|
||||||
Provider string
|
Provider string
|
||||||
Proxied bool
|
Proxied bool
|
||||||
Path string
|
Path string
|
||||||
@@ -149,6 +155,7 @@ func SendHoneypotAlertMail(a HoneypotAlert) error {
|
|||||||
}
|
}
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"ip": a.IP,
|
"ip": a.IP,
|
||||||
|
"ban_key": a.BanKey,
|
||||||
"provider": a.Provider,
|
"provider": a.Provider,
|
||||||
"proxy_hint": proxyHint,
|
"proxy_hint": proxyHint,
|
||||||
"path": a.Path,
|
"path": a.Path,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mail
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/templates"
|
"github.com/tamakyi/TamaBox/templates"
|
||||||
@@ -57,3 +58,60 @@ func cloneParams(src map[string]string) map[string]string {
|
|||||||
}
|
}
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotAlertTemplateRenders 保证蜜罐告警模板可解析,并且:
|
||||||
|
// - IPv6(ip ≠ ban_key)时额外渲染「封禁范围」,说明封的是整个 /64 前缀;
|
||||||
|
// - IPv4(ip == ban_key)时不渲染那一行,避免同一串地址出现两次;
|
||||||
|
// - 正文说明的是「写入取证文件」而不是早已过时的「已入库」(凭据不入库)。
|
||||||
|
func TestHoneypotAlertTemplateRenders(t *testing.T) {
|
||||||
|
tmpl, err := template.ParseFS(templates.FS, "mail/honeypot-alert.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := map[string]string{
|
||||||
|
"ip": "2408:8207:1234:5678::abcd",
|
||||||
|
"ban_key": "2408:8207:1234:5678::/64",
|
||||||
|
"provider": "中国联通(住宅宽带)",
|
||||||
|
"path": "/wp-login.php",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "hunter2",
|
||||||
|
"disposition": "已记录并自动临时封禁 1440 分钟",
|
||||||
|
"time": "2026-10-23 10:30:00",
|
||||||
|
"year": "2026",
|
||||||
|
}
|
||||||
|
|
||||||
|
var v6 bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&v6, base); err != nil {
|
||||||
|
t.Fatalf("execute ipv6: %v", err)
|
||||||
|
}
|
||||||
|
out := v6.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"2408:8207:1234:5678::abcd",
|
||||||
|
"封禁范围",
|
||||||
|
"2408:8207:1234:5678::/64",
|
||||||
|
"取证文件",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("IPv6 邮件缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "已入库") {
|
||||||
|
t.Error("邮件仍在说「已入库」:凭据已改为写入文件,不在数据库里")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4:精确地址与封禁键相同,不该重复展示「封禁范围」。
|
||||||
|
v4 := cloneParams(base)
|
||||||
|
v4["ip"] = "203.0.113.7"
|
||||||
|
v4["ban_key"] = "203.0.113.7"
|
||||||
|
var buf4 bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf4, v4); err != nil {
|
||||||
|
t.Fatalf("execute ipv4: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf4.String(), "封禁范围") {
|
||||||
|
t.Error("IPv4 下 ip == ban_key,不应渲染「封禁范围」行")
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf4.String(), "203.0.113.7") {
|
||||||
|
t.Error("IPv4 邮件缺少来源 IP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/flamego/flamego"
|
"github.com/flamego/flamego"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
@@ -25,13 +26,19 @@ func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
path := ctx.Request().URL.Path
|
||||||
if b.IsBanned(ip) {
|
if b.IsBanned(ip) {
|
||||||
|
// Debug 级别:被封住的扫描器会持续请求,Info 会让日志彻底淹没。
|
||||||
|
// 需要现场确认「封禁真的在拦」时把日志级别调到 debug 即可。
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"ban_key": ip,
|
||||||
|
"path": path,
|
||||||
|
}).Debug("拒绝已封禁来源")
|
||||||
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
|
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
|
||||||
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
|
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
path := ctx.Request().URL.Path
|
|
||||||
kind := autoban.KindNone
|
kind := autoban.KindNone
|
||||||
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
|
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
|
||||||
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
|
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
|
||||||
|
|||||||
+57
-30
@@ -131,7 +131,16 @@ func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.Hone
|
|||||||
// Serve 处理一次蜜罐命中。
|
// Serve 处理一次蜜罐命中。
|
||||||
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||||
req := ctx.Request().Request
|
req := ctx.Request().Request
|
||||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
// 两个不同的东西,必须分开用:
|
||||||
|
//
|
||||||
|
// clientIP —— 精确客户端地址(IPv6 保留完整地址),取证与告警展示用;
|
||||||
|
// banKey —— 聚合键(shared.LimitKey,IPv6 折成 /64),封禁与采样去重用。
|
||||||
|
//
|
||||||
|
// 曾经两者混用(都取 banKey),后果是取证只留下 /64 前缀:运维收到
|
||||||
|
// 「你的 IP 已被封禁」时发现邮件里那个地址根本不是自己的,排查方向直接被带偏;
|
||||||
|
// 而且精确地址一丢,拿去查其他日志或做 abuse 投诉就再也对不上了。
|
||||||
|
clientIP := shared.ResolveClientIP(req)
|
||||||
|
banKey := shared.LimitKey(clientIP)
|
||||||
// 反代/网关可能把 URI 带上首尾空白:实测 Cloudflare → nginx → 源站 链路上
|
// 反代/网关可能把 URI 带上首尾空白:实测 Cloudflare → nginx → 源站 链路上
|
||||||
// 出现过 ` /wp-login.php`。Match 内部会 normalize(含 TrimSpace),所以能命中,
|
// 出现过 ` /wp-login.php`。Match 内部会 normalize(含 TrimSpace),所以能命中,
|
||||||
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
|
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
|
||||||
@@ -141,9 +150,10 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
// 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。
|
// 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。
|
||||||
if raw := req.URL.Path; raw != path {
|
if raw := req.URL.Path; raw != path {
|
||||||
logrus.WithFields(logrus.Fields{
|
logrus.WithFields(logrus.Fields{
|
||||||
"ip": ip,
|
"ip": clientIP,
|
||||||
"raw": fmt.Sprintf("%q", raw),
|
"ban_key": banKey,
|
||||||
"used": fmt.Sprintf("%q", path),
|
"raw": fmt.Sprintf("%q", raw),
|
||||||
|
"used": fmt.Sprintf("%q", path),
|
||||||
}).Warn("蜜罐路径含上游注入的空白/零宽字符,已清洗后使用")
|
}).Warn("蜜罐路径含上游注入的空白/零宽字符,已清洗后使用")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +161,7 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
// 典型信号。此时所有访客在源站眼里都是同一个 IP:
|
// 典型信号。此时所有访客在源站眼里都是同一个 IP:
|
||||||
// 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。
|
// 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。
|
||||||
// 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。
|
// 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。
|
||||||
if ip != "" && !conf.Security.TrustProxy && isLoopbackIP(ip) {
|
if clientIP != "" && !conf.Security.TrustProxy && isLoopbackIP(clientIP) {
|
||||||
if h.dedup.first("warn:loopback", 24*time.Hour) {
|
if h.dedup.first("warn:loopback", 24*time.Hour) {
|
||||||
logrus.Warn("蜜罐记录的来源 IP 是回环地址(127.0.0.1 / ::1):站点很可能在反向代理后面," +
|
logrus.Warn("蜜罐记录的来源 IP 是回环地址(127.0.0.1 / ::1):站点很可能在反向代理后面," +
|
||||||
"[security] trust_proxy 未开启。此时所有访客共享同一个 IP —— 取证失去意义," +
|
"[security] trust_proxy 未开启。此时所有访客共享同一个 IP —— 取证失去意义," +
|
||||||
@@ -173,11 +183,11 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
//
|
//
|
||||||
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
|
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
|
||||||
banned := false
|
banned := false
|
||||||
if h.ab != nil && ip != "" {
|
if h.ab != nil && banKey != "" {
|
||||||
if creds.HadFields {
|
if creds.HadFields {
|
||||||
banned = h.ab.RecordHoneypot(ip, path)
|
banned = h.ab.RecordHoneypot(banKey, path)
|
||||||
} else {
|
} else {
|
||||||
h.ab.Record(ip, path, autoban.KindSensitive)
|
h.ab.Record(banKey, path, autoban.KindSensitive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +202,7 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
|
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
|
||||||
h.record(ip, req, path, creds, banned)
|
h.record(clientIP, banKey, req, path, creds, banned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanPath 去掉路径首尾的空白与零宽字符,并保证前导斜杠。
|
// cleanPath 去掉路径首尾的空白与零宽字符,并保证前导斜杠。
|
||||||
@@ -266,12 +276,14 @@ func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
|
|||||||
|
|
||||||
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
||||||
//
|
//
|
||||||
|
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
|
||||||
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
|
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
|
||||||
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
|
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
|
||||||
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
|
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
|
||||||
// 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。
|
// 纯路径探测按来源 + 窗口采样即可 —— 路径分布仍然保留。
|
||||||
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
func (h *honeypotHandler) record(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||||
if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) {
|
// 采样按聚合键:同一 /64 内轮换地址的探测属于同一个来源,不必各存一条。
|
||||||
|
if !creds.HadFields && !h.dedup.first("probe:"+banKey, probeSampleWindow) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +295,10 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred
|
|||||||
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
|
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
|
||||||
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
|
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
|
||||||
if creds.HadFields {
|
if creds.HadFields {
|
||||||
h.saveCredential(ip, req, path, query, creds, banned)
|
h.saveCredential(clientIP, req, path, query, creds, banned)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, proxied := ipinfo.Lookup(ip)
|
provider, proxied := ipinfo.Lookup(clientIP)
|
||||||
|
|
||||||
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
|
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
|
||||||
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
|
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
|
||||||
@@ -294,7 +306,7 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred
|
|||||||
// 以 `value too long for type character varying(N)` 整条插入失败 ——
|
// 以 `value too long for type character varying(N)` 整条插入失败 ——
|
||||||
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
|
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
|
||||||
hit := &db.HoneypotHit{
|
hit := &db.HoneypotHit{
|
||||||
IP: truncate(ip, 64),
|
IP: truncate(clientIP, 64),
|
||||||
Method: truncate(req.Method, 16),
|
Method: truncate(req.Method, 16),
|
||||||
Path: truncate(path, 500),
|
Path: truncate(path, 500),
|
||||||
Query: truncate(query, 1000),
|
Query: truncate(query, 1000),
|
||||||
@@ -311,13 +323,14 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred
|
|||||||
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
|
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
|
||||||
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
|
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
|
||||||
if creds.HadFields {
|
if creds.HadFields {
|
||||||
h.alert(ip, req, path, creds, provider, proxied, banned)
|
h.alert(clientIP, banKey, req, path, creds, provider, proxied, banned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
|
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
|
||||||
if creds.HadFields {
|
if creds.HadFields {
|
||||||
logrus.WithFields(logrus.Fields{
|
logrus.WithFields(logrus.Fields{
|
||||||
"ip": ip,
|
"ip": clientIP,
|
||||||
|
"ban_key": banKey,
|
||||||
"path": path,
|
"path": path,
|
||||||
"username": creds.Username,
|
"username": creds.Username,
|
||||||
"banned": banned,
|
"banned": banned,
|
||||||
@@ -331,7 +344,7 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.store.Create(ctx, hit); err != nil {
|
if err := h.store.Create(ctx, hit); err != nil {
|
||||||
logrus.WithError(err).WithField("ip", ip).WithField("path", path).
|
logrus.WithError(err).WithField("ip", clientIP).WithField("path", path).
|
||||||
Warn("honeypot: failed to persist hit")
|
Warn("honeypot: failed to persist hit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,15 +358,17 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred
|
|||||||
//
|
//
|
||||||
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
|
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
|
||||||
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
|
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
|
||||||
func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
|
// clientIP 必须是精确地址(不是 shared.LimitKey 折叠后的 /64 键):
|
||||||
|
// 这份文件是取证物,IPv6 只留前缀等于把「哪台主机」永久丢掉了。
|
||||||
|
func (h *honeypotHandler) saveCredential(clientIP string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
|
||||||
if h.cstore == nil {
|
if h.cstore == nil {
|
||||||
logrus.WithField("ip", ip).
|
logrus.WithField("ip", clientIP).
|
||||||
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
|
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec := honeypot.CredRecord{
|
rec := honeypot.CredRecord{
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
IP: truncate(ip, 64),
|
IP: truncate(clientIP, 64),
|
||||||
Method: truncate(req.Method, 16),
|
Method: truncate(req.Method, 16),
|
||||||
Path: truncate(path, 500),
|
Path: truncate(path, 500),
|
||||||
Query: truncate(query, 1000),
|
Query: truncate(query, 1000),
|
||||||
@@ -365,18 +380,26 @@ func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, que
|
|||||||
Banned: banned,
|
Banned: banned,
|
||||||
}
|
}
|
||||||
if err := h.cstore.Append(rec); err != nil {
|
if err := h.cstore.Append(rec); err != nil {
|
||||||
logrus.WithError(err).WithField("ip", ip).Warn("蜜罐凭据写入文件失败")
|
logrus.WithError(err).WithField("ip", clientIP).Warn("蜜罐凭据写入文件失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
// alert 发送一次告警邮件(同一来源在冷却期内只发一封)。
|
||||||
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
//
|
||||||
if h.sendAlert == nil || ip == "" {
|
// clientIP 进正文(人要看的是「谁来了」,必须是精确地址);
|
||||||
|
// banKey 用作去重键,同时也让邮件能说清「实际被封的是哪个键」——
|
||||||
|
// IPv6 下两者不同(键是 /64 前缀),邮件只给其中一个都会让人误判。
|
||||||
|
func (h *honeypotHandler) alert(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
||||||
|
if h.sendAlert == nil || clientIP == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
|
dedupKey := banKey
|
||||||
// 不去重会把邮箱打爆(且每个源 IP 都是一封)。
|
if dedupKey == "" {
|
||||||
if !h.dedup.first("alert:"+ip, alertDedupTTL) {
|
dedupKey = clientIP
|
||||||
|
}
|
||||||
|
// 同一来源在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
|
||||||
|
// 不去重会把邮箱打爆。按聚合键去重 —— 同一 /64 内换地址仍是同一个人。
|
||||||
|
if !h.dedup.first("alert:"+dedupKey, alertDedupTTL) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +414,8 @@ func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds
|
|||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := h.sendAlert(mail.HoneypotAlert{
|
if err := h.sendAlert(mail.HoneypotAlert{
|
||||||
IP: ip,
|
IP: clientIP,
|
||||||
|
BanKey: banKey,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Proxied: proxied,
|
Proxied: proxied,
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -400,7 +424,10 @@ func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds
|
|||||||
Banned: banned,
|
Banned: banned,
|
||||||
DurationMin: durationMin,
|
DurationMin: durationMin,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail")
|
logrus.WithError(err).WithFields(logrus.Fields{
|
||||||
|
"ip": clientIP,
|
||||||
|
"ban_key": banKey,
|
||||||
|
}).Warn("honeypot: failed to send alert mail")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
|
|
||||||
"github.com/flamego/flamego"
|
"github.com/flamego/flamego"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
)
|
)
|
||||||
@@ -360,3 +363,147 @@ func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want 403(被封禁的 IP 不应还能访问业务路由)", resp2.StatusCode)
|
t.Errorf("status = %d, want 403(被封禁的 IP 不应还能访问业务路由)", resp2.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotRecordsExactIPv6ButBansByPrefix 验证「取证地址」与「封禁键」是两个东西。
|
||||||
|
//
|
||||||
|
// 线上踩过:用 IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
|
||||||
|
// 2408:…::/64 —— 既不像自己的地址,也说不清到底封了多大范围,
|
||||||
|
// 排查方向直接被带偏。现在取证与邮件给精确地址,封禁仍用 /64 聚合键
|
||||||
|
// (抗「单个 /64 内轮换地址」绕过)。两个断言都必须成立,否则说明又混用了。
|
||||||
|
func TestHoneypotRecordsExactIPv6ButBansByPrefix(t *testing.T) {
|
||||||
|
oldTrust := conf.Security.TrustProxy
|
||||||
|
conf.Security.TrustProxy = true
|
||||||
|
t.Cleanup(func() { conf.Security.TrustProxy = oldTrust })
|
||||||
|
|
||||||
|
cs, err := honeypot.NewFileCredStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 落库路径也要断言:文件与 DB 用的是同一个 clientIP,但任一处写错
|
||||||
|
// 都会让取证「只剩前缀」,而单测只有把它抓在手里才知道。
|
||||||
|
store := &captureStore{}
|
||||||
|
|
||||||
|
f := flamego.New()
|
||||||
|
hp := honeypot.New(honeypot.Options{Enabled: true})
|
||||||
|
ab := autoban.New(autoban.Options{
|
||||||
|
Enabled: true,
|
||||||
|
HoneypotEnabled: true,
|
||||||
|
ThresholdSensitive: 10,
|
||||||
|
Window: time.Minute,
|
||||||
|
Duration: time.Hour,
|
||||||
|
HoneypotThreshold: 1,
|
||||||
|
HoneypotDuration: 24 * time.Hour,
|
||||||
|
})
|
||||||
|
hph := newHoneypotHandler(hp, ab, store, cs)
|
||||||
|
f.Use(autobanMiddleware(ab, hp))
|
||||||
|
f.NotFound(func(ctx flamego.Context) {
|
||||||
|
if hp.Match(ctx.Request().URL.Path) {
|
||||||
|
hph.Serve(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect("/")
|
||||||
|
})
|
||||||
|
ts := httptest.NewServer(f)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
const clientIP = "2408:8207:1234:5678::abcd"
|
||||||
|
const banKey = "2408:8207:1234:5678::/64"
|
||||||
|
|
||||||
|
post, err := http.NewRequest(http.MethodPost, ts.URL+"/wp-login.php",
|
||||||
|
strings.NewReader("log=admin&pwd=hunter2"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
post.Header.Set("CF-Connecting-IP", clientIP)
|
||||||
|
resp, err := http.DefaultClient.Do(post)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
|
||||||
|
}
|
||||||
|
if recs[0].IP != clientIP {
|
||||||
|
t.Errorf("取证 IP = %q, want 精确地址 %q(不能写 /64 聚合键)", recs[0].IP, clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.hits) != 1 {
|
||||||
|
t.Fatalf("落库记录数 = %d, want 1", len(store.hits))
|
||||||
|
}
|
||||||
|
if store.hits[0].IP != clientIP {
|
||||||
|
t.Errorf("落库 IP = %q, want 精确地址 %q(元数据表同样是取证物)", store.hits[0].IP, clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ab.IsBanned(banKey) {
|
||||||
|
t.Errorf("封禁键 %q 未生效:IPv6 必须按 /64 聚合", banKey)
|
||||||
|
}
|
||||||
|
if ab.IsBanned(clientIP) {
|
||||||
|
t.Errorf("精确地址 %q 不应单独成为封禁键:写入与判定必须是同一口径", clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同 /64 内换个地址仍应被拦 —— 这才是按前缀聚合的实际价值。
|
||||||
|
get, err := http.NewRequest(http.MethodGet, ts.URL+"/anything", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get.Header.Set("CF-Connecting-IP", "2408:8207:1234:5678::9999")
|
||||||
|
resp2, err := http.DefaultClient.Do(get)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("同 /64 内换地址 status = %d, want 403", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureStore 是 db.HoneypotStore 的极简实现:只把 Create 的记录留下,
|
||||||
|
// 其余方法返回零值。真库测试有 DB 门控、不适合放进这组结构性用例,
|
||||||
|
// 而「落库的 IP 是不是精确地址」又必须被钉住(混用聚合键正是曾经的缺陷)。
|
||||||
|
type captureStore struct {
|
||||||
|
hits []*db.HoneypotHit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) Create(_ context.Context, hit *db.HoneypotHit) error {
|
||||||
|
c.hits = append(c.hits, hit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) ListSince(context.Context, time.Time, int) ([]*db.HoneypotHit, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||||
|
|
||||||
|
func (c *captureStore) TopSources(context.Context, time.Time, int) ([]*db.HoneypotSourceStat, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) TopPaths(context.Context, time.Time, int) ([]*db.HoneypotPathStat, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) DeleteBefore(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||||
|
|
||||||
|
func (c *captureStore) DeleteOldestBeyond(context.Context, int) (int64, error) { return 0, nil }
|
||||||
|
|
||||||
|
func (c *captureStore) Compact(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (c *captureStore) Size(context.Context) (db.HoneypotSize, error) {
|
||||||
|
return db.HoneypotSize{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) LegacyCredentialColumns(context.Context) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ func newBanner() *autoban.Banner {
|
|||||||
HoneypotEnabled: conf.Security.EnableHoneypot,
|
HoneypotEnabled: conf.Security.EnableHoneypot,
|
||||||
HoneypotThreshold: conf.Security.HoneypotThreshold,
|
HoneypotThreshold: conf.Security.HoneypotThreshold,
|
||||||
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
|
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
|
||||||
|
|
||||||
|
// 建封日志:封禁表是纯内存、外部完全看不见,而线上出现过
|
||||||
|
// 「邮件说封了、实际还能访问」—— 当时没有任何记录能说明封的是哪个键、
|
||||||
|
// 到什么时候解除,排查只能靠猜。键与 IsBanned 的判断口径完全一致
|
||||||
|
// (shared.LimitKey,IPv6 折成 /64),日志与判定不会各说一套。
|
||||||
|
OnBan: func(key string, duration time.Duration, reason string) {
|
||||||
|
reasonText := "敏感路径探测"
|
||||||
|
if reason == autoban.ReasonHoneypot {
|
||||||
|
reasonText = "蜜罐提交凭据"
|
||||||
|
}
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"ban_key": key,
|
||||||
|
"reason": reasonText,
|
||||||
|
"duration": duration.String(),
|
||||||
|
"expires_at": time.Now().Add(duration).Format("2006-01-02 15:04:05"),
|
||||||
|
}).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,27 @@ type Options struct {
|
|||||||
HoneypotThreshold int
|
HoneypotThreshold int
|
||||||
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
|
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
|
||||||
HoneypotDuration time.Duration
|
HoneypotDuration time.Duration
|
||||||
|
|
||||||
|
// OnBan 在「新建一条封禁」时回调,供上层打日志 / 埋点。
|
||||||
|
//
|
||||||
|
// 存在的意义:封禁表是纯内存的,从外部完全看不见 —— 线上出现过
|
||||||
|
// 「邮件说封了、实际没挡住」,而当时没有任何日志能说明封的是哪个键、
|
||||||
|
// 到什么时候解除,排查只能靠猜。有了回调,每一次建封都会留下痕迹。
|
||||||
|
//
|
||||||
|
// ⚠️ 只在真正新建封禁时触发:已在封禁期内再次命中不会重复回调
|
||||||
|
// (与告警去重口径一致)。回调在独立 goroutine 中执行,不要假设顺序,
|
||||||
|
// 也不要在回调里调用本 Banner 的方法。
|
||||||
|
OnBan func(key string, duration time.Duration, reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 封禁原因(传给 Options.OnBan)。
|
||||||
|
const (
|
||||||
|
// ReasonProbe 常规通道:敏感路径 / 404 探测累计到阈值。
|
||||||
|
ReasonProbe = "probe"
|
||||||
|
// ReasonHoneypot 蜜罐通道:往假登录页提交了凭据。
|
||||||
|
ReasonHoneypot = "honeypot"
|
||||||
|
)
|
||||||
|
|
||||||
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,完整段前缀匹配)。
|
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,完整段前缀匹配)。
|
||||||
// 注意:前缀匹配抓不到 //xxx/wp-includes/wlwmanifest.xml 这类"目录枚举变体"(敏感段
|
// 注意:前缀匹配抓不到 //xxx/wp-includes/wlwmanifest.xml 这类"目录枚举变体"(敏感段
|
||||||
// 出现在路径中部),需要与 sensitiveFragments(任意位置子串)配合使用。
|
// 出现在路径中部),需要与 sensitiveFragments(任意位置子串)配合使用。
|
||||||
@@ -114,6 +133,8 @@ type Banner struct {
|
|||||||
honeypotEnabled bool
|
honeypotEnabled bool
|
||||||
honeypotThreshold int
|
honeypotThreshold int
|
||||||
honeypotDuration time.Duration
|
honeypotDuration time.Duration
|
||||||
|
|
||||||
|
onBan func(key string, duration time.Duration, reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 按配置创建封禁器并注册全局清理。
|
// New 按配置创建封禁器并注册全局清理。
|
||||||
@@ -157,6 +178,7 @@ func New(opts Options) *Banner {
|
|||||||
honeypotEnabled: opts.HoneypotEnabled,
|
honeypotEnabled: opts.HoneypotEnabled,
|
||||||
honeypotThreshold: honeypotThreshold,
|
honeypotThreshold: honeypotThreshold,
|
||||||
honeypotDuration: honeypotDuration,
|
honeypotDuration: honeypotDuration,
|
||||||
|
onBan: opts.OnBan,
|
||||||
}
|
}
|
||||||
register(b)
|
register(b)
|
||||||
return b
|
return b
|
||||||
@@ -214,6 +236,7 @@ func (b *Banner) RecordHoneypot(ip, path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
b.banned[ip] = now.Add(b.honeypotDuration)
|
b.banned[ip] = now.Add(b.honeypotDuration)
|
||||||
|
b.notifyBan(ip, b.honeypotDuration, ReasonHoneypot)
|
||||||
|
|
||||||
// 告警(每 IP 仅一次)
|
// 告警(每 IP 仅一次)
|
||||||
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
||||||
@@ -285,6 +308,7 @@ func (b *Banner) Record(ip, path string, kind int) {
|
|||||||
}
|
}
|
||||||
until := now.Add(b.duration)
|
until := now.Add(b.duration)
|
||||||
b.banned[ip] = until
|
b.banned[ip] = until
|
||||||
|
b.notifyBan(ip, b.duration, ReasonProbe)
|
||||||
|
|
||||||
// 告警(每 IP 仅一次)
|
// 告警(每 IP 仅一次)
|
||||||
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
||||||
@@ -297,6 +321,18 @@ func (b *Banner) Record(ip, path string, kind int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyBan 触发 OnBan 回调。
|
||||||
|
//
|
||||||
|
// 调用点都在持锁区间内,因此这里只做「取函数 + 起 goroutine」:
|
||||||
|
// 回调本身可能在写日志、发邮件,放到锁外执行才不会拖慢被判定的请求。
|
||||||
|
func (b *Banner) notifyBan(key string, duration time.Duration, reason string) {
|
||||||
|
fn := b.onBan
|
||||||
|
if fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go fn(key, duration, reason)
|
||||||
|
}
|
||||||
|
|
||||||
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
|
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
|
||||||
//
|
//
|
||||||
// 注意:即使 enabled(enable_auto_ban)关闭,蜜罐通道写入的封禁依然生效 ——
|
// 注意:即使 enabled(enable_auto_ban)关闭,蜜罐通道写入的封禁依然生效 ——
|
||||||
|
|||||||
@@ -155,3 +155,69 @@ func TestIsSensitivePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOnBanCallback 验证建封时会回调上层(键 / 时长 / 原因),
|
||||||
|
// 且「已在封禁期内重复命中」不会重复回调。
|
||||||
|
//
|
||||||
|
// 存在的意义:封禁表是纯内存的,外部完全看不见。线上出现过
|
||||||
|
// 「邮件说封了、实际还能访问」,而当时没有任何记录能说明封的是哪个键、
|
||||||
|
// 到什么时候解除 —— 这个回调就是那条记录的唯一来源。
|
||||||
|
func TestOnBanCallback(t *testing.T) {
|
||||||
|
type banEvent struct {
|
||||||
|
key string
|
||||||
|
duration time.Duration
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
events := make(chan banEvent, 4)
|
||||||
|
b := New(Options{
|
||||||
|
Enabled: true,
|
||||||
|
ThresholdSensitive: 1,
|
||||||
|
TrackNotFound: false,
|
||||||
|
Window: time.Minute,
|
||||||
|
Duration: 10 * time.Minute,
|
||||||
|
HoneypotEnabled: true,
|
||||||
|
HoneypotThreshold: 1,
|
||||||
|
HoneypotDuration: 24 * time.Hour,
|
||||||
|
OnBan: func(key string, duration time.Duration, reason string) {
|
||||||
|
events <- banEvent{key: key, duration: duration, reason: reason}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const key = "2408:8207:1234:5678::/64"
|
||||||
|
if !b.RecordHoneypot(key, "/wp-login.php") {
|
||||||
|
t.Fatal("首次蜜罐命中应触发封禁")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
if e.key != key {
|
||||||
|
t.Errorf("回调键 = %q, want %q", e.key, key)
|
||||||
|
}
|
||||||
|
if e.duration != 24*time.Hour {
|
||||||
|
t.Errorf("回调时长 = %v, want 24h(蜜罐应走 HoneypotDuration)", e.duration)
|
||||||
|
}
|
||||||
|
if e.reason != ReasonHoneypot {
|
||||||
|
t.Errorf("回调原因 = %q, want %q", e.reason, ReasonHoneypot)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("蜜罐封禁没有触发 OnBan 回调")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已在封禁期内再次命中:不新建封禁,也就不应重复回调。
|
||||||
|
b.RecordHoneypot(key, "/wp-login.php")
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
t.Fatalf("重复命中不应重复回调,收到 %+v", e)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常规通道(敏感路径)也要回调,且时长用常规 Duration、原因可区分。
|
||||||
|
b.Record("1.2.3.4", "/admin", KindSensitive)
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
if e.key != "1.2.3.4" || e.reason != ReasonProbe || e.duration != 10*time.Minute {
|
||||||
|
t.Errorf("常规封禁回调 = %+v, want key=1.2.3.4 reason=probe duration=10m", e)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("常规封禁没有触发 OnBan 回调")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import (
|
|||||||
//
|
//
|
||||||
// 数据库里只保留「发生过一次带凭据的提交」这个事实(HadFields 等元数据)。
|
// 数据库里只保留「发生过一次带凭据的提交」这个事实(HadFields 等元数据)。
|
||||||
type CredRecord struct {
|
type CredRecord struct {
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
|
// IP 是精确客户端地址;封禁用的 /64 聚合键不会写进来(见 db.HoneypotHit.IP)。
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
|
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
|
||||||
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fff4e5; border: 1px solid #ffe0b2; margin-bottom: 14px;">
|
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fff4e5; border: 1px solid #ffe0b2; margin-bottom: 14px;">
|
||||||
<div><b>来源 IP:</b>{{.ip}}</div>
|
<div><b>来源 IP:</b>{{.ip}}</div>
|
||||||
|
{{if ne .ip .ban_key}}
|
||||||
|
<div style="margin-top: 4px;"><b>封禁范围:</b>{{.ban_key}}(IPv6 按 /64 前缀聚合,整段一起封)</div>
|
||||||
|
{{end}}
|
||||||
<div style="margin-top: 4px;"><b>网络归属:</b>{{.provider}}</div>
|
<div style="margin-top: 4px;"><b>网络归属:</b>{{.provider}}</div>
|
||||||
<div style="margin-top: 4px;"><b>诱饵路径:</b>{{.path}}</div>
|
<div style="margin-top: 4px;"><b>诱饵路径:</b>{{.path}}</div>
|
||||||
<div style="margin-top: 4px;"><b>处置:</b>{{.disposition}}</div>
|
<div style="margin-top: 4px;"><b>处置:</b>{{.disposition}}</div>
|
||||||
@@ -45,8 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size: 12px; color: rgba(0,0,0,0.54); margin-top: 12px;">
|
<div style="font-size: 12px; color: rgba(0,0,0,0.54); margin-top: 12px;">
|
||||||
攻击者在假登录页上提交了凭据(假页面只返回静态内容,从不校验、也从不返回任何真实信息)。
|
攻击者在假登录页上提交了凭据(假页面只返回静态内容,从不校验、也从不返回任何真实信息)。
|
||||||
完整记录(含 User-Agent、Referer、原始 body)已入库,可用
|
完整记录(含 User-Agent、Referer、原始 body)已写入取证文件,可用
|
||||||
<code>TamaBox honeypot hits</code> 命令查看。
|
<code>TamaBox honeypot hits</code> 命令查看;用户名与密码只存在该文件里,不写入数据库。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user