fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。
二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。
三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
可稳定触发)。统一改 rune 截断并按列宽逐项对齐。
四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。
五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。
验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
This commit is contained in:
@@ -32,3 +32,8 @@ go.work.sum
|
||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||
# 每次跑都会变,跟版本库无关。
|
||||
/outputs/
|
||||
|
||||
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
|
||||
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||
/data/
|
||||
|
||||
@@ -195,3 +195,118 @@
|
||||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
||||
**测试失败要先怀疑自己的预期**,别急着改产品代码。
|
||||
|
||||
## 蜜罐实现审计(第三方 agent 写完,我复审)
|
||||
|
||||
### P0:蜜罐封禁整条链路未接线(已实测复现)
|
||||
`internal/route/route.go:85` 的 `autoban.New(...)` **没传** `HoneypotEnabled / HoneypotThreshold /
|
||||
HoneypotDuration`,而 `autoban.RecordHoneypot` 首行就是 `if !b.honeypotEnabled { return false }`
|
||||
→ 蜜罐命中永不封禁、落库 `banned` 恒 false、`IsBanned` 永不为真。
|
||||
同一提交又把 `autobanMiddleware` 里蜜罐路径的**常规计数也跳过**了
|
||||
(`route/autoban.go:38 if hp == nil || !hp.Match(path)`,理由「蜜罐处理器自己计数」)
|
||||
→ 净效果:蜜罐路径从「10 次封禁」退化为**零计数零封禁**,是 regression。
|
||||
`honeypot_threshold` / `honeypot_ban_minutes` 两个配置项全代码库**零读取**(死配置,样例 ini 里却写了)。
|
||||
测试为什么绿:`route/honeypot_test.go` 自建 banner 时显式传了 `HoneypotEnabled`,
|
||||
注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。
|
||||
|
||||
### P1
|
||||
- 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」,
|
||||
且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
|
||||
- `honeypotHandler.alerted`(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长;
|
||||
autoban 的同名 map 也是(既有问题,蜜罐放大)。
|
||||
|
||||
### P2
|
||||
- `Path` 原样落库但列是 `size:512` → 超长路径直接 PG 报错丢证据;`Query` 用 `query[:1000]`
|
||||
字节截断,`honeypot.go:243/296` 的 `v[:limit]`、`raw[:limit]` 同样是字节截断
|
||||
→ 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 `truncate()` 却没用于这些点。
|
||||
- `credentialKeys` 含 `id` / `name` / `token` → 任意 JSON 探测带 `{"id":1}` 就会被当成「破解凭据」
|
||||
触发告警邮件 + 记为 HadFields,污染取证与告警。
|
||||
- `page.go:54` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
|
||||
和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
|
||||
- `serveLoginFailure` 手抄了一遍 `WriteLoginPage` 的响应头逻辑(没复用),已开始漂移
|
||||
(Title 一处写 `‹`、一处写 `\u2039`)。
|
||||
- `Honeypot.Describer()` 零调用死代码,且 `path.Join(keys...)` 语义错误(会把前缀拼成一条路径)。
|
||||
|
||||
### 结论
|
||||
总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确;
|
||||
问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。
|
||||
|
||||
## 蜜罐审计问题的修复实施(已改完,未提交)
|
||||
|
||||
### 1. P0 装配(route.go)
|
||||
抽 `newBanner()` 作为唯一装配入口,补 `HoneypotEnabled / HoneypotThreshold / HoneypotDuration`。
|
||||
新增 `internal/route/banner_config_test.go` 四个用例,全部经由 `newBanner()` 断言。
|
||||
**变异验证**:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。
|
||||
|
||||
### 2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)
|
||||
原实现 `RecordHoneypot` 对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
|
||||
永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。
|
||||
改为:`creds.HadFields` → RecordHoneypot(立即封);纯探测 → 常规 `Record`(阈值 10)。
|
||||
新增 `TestHoneypotProbeDoesNotBanImmediately`,同上做了变异验证。
|
||||
连带把 `honeypot_threshold` 的语义在 static.go / defaults / autoban / sample.ini 统一为
|
||||
「提交凭据次数」。
|
||||
|
||||
### 3. 落库边界(实测取证)
|
||||
- `Path` 原样落库、列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
|
||||
实测 600 字符失败、500 字符成功。
|
||||
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL 可稳定触发)。
|
||||
全部改为 rune 截断(`clipRunes` / route 的 `truncate`),并按列宽逐个对齐。
|
||||
|
||||
### 4. 资源与噪音
|
||||
- `honeypotHandler.alerted`(无界 map)→ 换成带 TTL 的 `deduper`(容量上限 2 万,
|
||||
过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
|
||||
- `credentialKeys` 去掉 `id` / `name`(普通 JSON 探测不再被当成凭据提交触发假告警)。
|
||||
- canary 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
|
||||
generator 伪装成 `WordPress 6.4.3`;不再向扫描器自曝「TamaBox 蜜罐」。
|
||||
- `serveLoginFailure` 不再手抄响应头,统一走 `WriteLoginPage`;`LoginPageTitle` 抽常量。
|
||||
- `Describer()` 死代码 → 改为 `Prefixes()` 并接到新 CLI `TamaBox honeypot paths`。
|
||||
- `honeypot_paths` 解析挪到 `honeypot.SplitPaths`(CLI 与 route 共用),补前导斜杠、拒绝 `/`。
|
||||
|
||||
### 5. 新发现的配置陷阱(影响面比蜜罐大)
|
||||
`conf.Init` 用 `ini.LoadOptions{IgnoreInlineComment: true}`,该选项语义是
|
||||
**不剥离行尾注释**(实测:`port = 1440 ; 注释` → 值 `"1440 ; 注释"`,int 解析失败,
|
||||
`MapTo` 却返回 nil error)。后果:
|
||||
- `app.sample.ini` 照抄**无法启动**(`text_censor_source` 值带注释被 validate 拒绝);
|
||||
- 所有带行尾注释的 int/bool 静默回退零值 —— `enable_auto_ban`、`honeypot_max_rows`、
|
||||
`honeypot_auto_clean` 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。
|
||||
已把 sample 的**蜜罐段**改成「注释独占一行 + 裸值」,并在段首写明原因。
|
||||
其余段落仍是老写法,未动(需用户决定是否全面清理)。
|
||||
|
||||
### 待办
|
||||
- 用户确认是否提交这批改动。
|
||||
- `app.sample.ini` 其余段落的行尾注释问题未修,建议后续统一。
|
||||
- 蜜罐 GET 探测目前受 `enable_auto_ban` 约束(关了就不计数),这是有意的:
|
||||
纯探测无害,不该在运维关掉自动封禁后仍凭空封人。
|
||||
|
||||
## 后续:sample 注释清理 + 凭据外置(同日晚)
|
||||
|
||||
### 1. app.sample.ini 行尾注释全量清理
|
||||
17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF。
|
||||
新增 `TestSampleIniValuesHaveNoInlineComment`:遍历所有段的键,断言 `Value()` 不含 `;`。
|
||||
变异验证:把 `ban_window_seconds = 60` 改成带行尾注释 → 测试按预期失败。
|
||||
清理后 `conf.Init` 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」,
|
||||
说明解析路径已通(必填项留空是模板的预期行为)。
|
||||
|
||||
### 2. 凭据改为外部文件,数据库不再存用户名/密码
|
||||
- `honeypot.CredRecord` + `CredStore`(`internal/security/honeypot/credstore.go`):
|
||||
按天分文件 `creds-YYYY-MM-DD.jsonl`,一行一条 JSON;目录 0700、文件 0600;
|
||||
坏行跳过;`PurgeBefore` 只删「整天都早于保留期」的文件。
|
||||
- `db.HoneypotHit` 删掉 `Username/Password/Fields/RawBody`,只留 `HadFields` 元数据。
|
||||
- `HoneypotStore` 删掉 `TopCredentials` / `ListSinceByIP`(CLI 已改读文件,零调用)。
|
||||
- 新增 `LegacyCredentialColumns` / `PurgeLegacyCredentialColumns`:
|
||||
AutoMigrate 不删既有列,老库会残留历史明文 → 启动时 WARN,`honeypot purge-creds` 显式 DROP。
|
||||
- 清理器 `HoneypotRetention` 加 `CredPurgeable` 参数,与 DB 记录共用保留期;
|
||||
`honeypot prune` 也一并删过期凭据文件。
|
||||
- CLI:`hits` 不连 DB(DB 挂了也能查),`stats` 的凭据排行读文件,新增 `purge-creds`。
|
||||
- 配置 `[security] honeypot_cred_dir`,缺省 `data/honeypot-creds`;`.gitignore` 加 `/data/`。
|
||||
|
||||
### 测试
|
||||
`credstore_test.go`(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
|
||||
`TestHoneypotLegacyCredentialColumns`(ADD COLUMN 模拟老库 → 检测 → DROP)、
|
||||
`TestHoneypotWritesCredentialsToFile`(POST 后文件有记录)。
|
||||
CLI 端到端手动验证:`honeypot hits` / `--ip` / `--show-password` 输出正确。
|
||||
全量 `go test ./...`(含 PostgreSQL)通过。
|
||||
|
||||
### 遗留
|
||||
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
|
||||
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
|
||||
|
||||
+144
-23
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
)
|
||||
|
||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||
@@ -53,7 +54,7 @@ var Honeypot = &cli.Command{
|
||||
},
|
||||
{
|
||||
Name: "prune",
|
||||
Usage: "Delete honeypot records older than the retention period",
|
||||
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
@@ -65,6 +66,19 @@ var Honeypot = &cli.Command{
|
||||
Usage: "Report honeypot table row count and disk usage",
|
||||
Action: runHoneypotSize,
|
||||
},
|
||||
{
|
||||
Name: "paths",
|
||||
Usage: "List active decoy paths (built-in + honeypot_paths)",
|
||||
Action: runHoneypotPaths,
|
||||
},
|
||||
{
|
||||
Name: "purge-creds",
|
||||
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHoneypotPurgeCreds,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,28 +110,40 @@ func parseSince(s string) (time.Duration, error) {
|
||||
return time.Duration(n) * mult, nil
|
||||
}
|
||||
|
||||
// credStoreFromConfig 用已加载的配置打开凭据取证目录。
|
||||
//
|
||||
// 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的
|
||||
// 命令都走这里,而不是 db.HoneypotStore。
|
||||
func credStoreFromConfig() (honeypot.CredStore, error) {
|
||||
return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||
}
|
||||
|
||||
func runHoneypotHits(ctx *cli.Context) error {
|
||||
dur, err := parseSince(ctx.String("since"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
|
||||
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
store, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
since := time.Now().Add(-dur)
|
||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||
showPwd := ctx.Bool("show-password")
|
||||
|
||||
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
|
||||
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
|
||||
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
|
||||
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
|
||||
// 指定 IP 的历史记录被其他 IP 挤出去)。
|
||||
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query honeypot hits")
|
||||
return errors.Wrap(err, "read honeypot credentials")
|
||||
}
|
||||
|
||||
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
||||
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
if len(rows) == 0 {
|
||||
fmt.Println(" 无")
|
||||
@@ -134,11 +160,8 @@ func runHoneypotHits(ctx *cli.Context) error {
|
||||
if user == "" {
|
||||
user = "(空)"
|
||||
}
|
||||
fmt.Printf("%s %-8s %-6s %s\n", r.CreatedAt.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
||||
if r.Provider != "" {
|
||||
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
|
||||
}
|
||||
if r.UserAgent != "" {
|
||||
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
||||
}
|
||||
@@ -178,7 +201,11 @@ func runHoneypotStats(ctx *cli.Context) error {
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top paths")
|
||||
}
|
||||
creds, err := store.TopCredentials(cctx, since, ctx.Int("limit"))
|
||||
credStore, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top credentials")
|
||||
}
|
||||
@@ -204,7 +231,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("最常被尝试的凭据组合:")
|
||||
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
|
||||
if len(creds) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
@@ -264,6 +291,16 @@ func runHoneypotPrune(ctx *cli.Context) error {
|
||||
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。
|
||||
// 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。
|
||||
if cs, err := credStoreFromConfig(); err != nil {
|
||||
fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err)
|
||||
} else if m, err := cs.PurgeBefore(before); err != nil {
|
||||
fmt.Printf("提示:凭据文件清理失败(%v)。\n", err)
|
||||
} else if m > 0 {
|
||||
fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -314,6 +351,97 @@ func runHoneypotSize(ctx *cli.Context) error {
|
||||
} else {
|
||||
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
||||
}
|
||||
|
||||
// 凭据与元数据是两套存储,体积必须分开看:
|
||||
// 数据库里只有元数据,明文账号密码在文件里。
|
||||
fmt.Println()
|
||||
if cs, err := credStoreFromConfig(); err == nil {
|
||||
if n, size, err := cs.Size(); err == nil {
|
||||
fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size))
|
||||
fmt.Printf(" 目录:%s\n", cs.Dir())
|
||||
fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPaths 列出当前生效的诱饵路径。
|
||||
//
|
||||
// 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠)
|
||||
// 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。
|
||||
func runHoneypotPaths(ctx *cli.Context) error {
|
||||
// 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条
|
||||
// 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值,
|
||||
// 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
extra := honeypot.SplitPaths(conf.Security.HoneypotPaths)
|
||||
builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes()
|
||||
active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes()
|
||||
|
||||
fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot))
|
||||
if !conf.Security.EnableHoneypot {
|
||||
fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。")
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
|
||||
len(active), len(builtin), len(extra))
|
||||
for _, p := range active {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("自定义路径(honeypot_paths):")
|
||||
for _, p := range extra {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。
|
||||
//
|
||||
// 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**,
|
||||
// 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。
|
||||
// DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行
|
||||
// (启动时只会 WARN 提示)。
|
||||
func runHoneypotPurgeCreds(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
|
||||
cols, err := store.LegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "check legacy credential columns")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):")
|
||||
for _, c := range cols {
|
||||
fmt.Println(" " + c)
|
||||
}
|
||||
fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。")
|
||||
if !ctx.Bool("yes") {
|
||||
fmt.Print("确认继续?输入 yes 继续:")
|
||||
var answer string
|
||||
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
|
||||
fmt.Println("已取消。")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dropped, err := store.PurgeLegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped)
|
||||
}
|
||||
fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -367,10 +495,3 @@ func maskSecret(s string) string {
|
||||
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func proxiedSuffix(proxied bool) string {
|
||||
if proxied {
|
||||
return "(CDN/代理出口)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+12
-1
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
)
|
||||
@@ -75,9 +76,19 @@ func runWeb(ctx *cli.Context) error {
|
||||
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||
//
|
||||
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
|
||||
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
|
||||
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
|
||||
stopRetention := make(chan struct{})
|
||||
if conf.Security.HoneypotAutoClean {
|
||||
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||
var credPurge db.CredPurgeable
|
||||
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
|
||||
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
|
||||
} else {
|
||||
credPurge = cs
|
||||
}
|
||||
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||
defer close(stopRetention)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -26,9 +28,21 @@ const (
|
||||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
DefaultHoneypotMaxRows = 200000
|
||||
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
|
||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
|
||||
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
|
||||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
|
||||
DefaultHoneypotThreshold = 1
|
||||
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
|
||||
//
|
||||
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
|
||||
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
|
||||
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
|
||||
// 24 小时能覆盖一轮扫描活动周期。
|
||||
DefaultHoneypotBanMinutes = 1440
|
||||
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
|
||||
//
|
||||
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
|
||||
DefaultHoneypotCredDir = "data/honeypot-creds"
|
||||
)
|
||||
|
||||
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||||
@@ -78,4 +92,21 @@ func sanitizeHoneypot() {
|
||||
if !sec.HasKey("honeypot_threshold") {
|
||||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
||||
}
|
||||
|
||||
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
|
||||
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
|
||||
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
|
||||
if !sec.HasKey("honeypot_ban_minutes") {
|
||||
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
|
||||
} else if Security.HoneypotBanMinutes <= 0 {
|
||||
logrus.WithField("fallback_key", "ban_duration_minutes").
|
||||
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
|
||||
}
|
||||
|
||||
// 凭据文件目录:未配置或留空都用默认值。
|
||||
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
|
||||
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
|
||||
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
|
||||
Security.HoneypotCredDir = DefaultHoneypotCredDir
|
||||
}
|
||||
}
|
||||
|
||||
+18
-4
@@ -90,7 +90,7 @@ var (
|
||||
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
||||
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
||||
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||||
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||||
@@ -103,12 +103,26 @@ var (
|
||||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
||||
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
|
||||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
|
||||
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
|
||||
// POST 提交凭据那一步,蜜罐也就拿不到字典。
|
||||
// 缺省回退 1(见 sanitizeHoneypot)。
|
||||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
|
||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
|
||||
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
|
||||
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
|
||||
// 显式 <= 0 表示回退 ban_duration_minutes。
|
||||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
||||
// HoneypotCredDir 凭据取证文件的存放目录。
|
||||
//
|
||||
// ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||
// (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
|
||||
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
|
||||
HoneypotCredDir string `ini:"honeypot_cred_dir"`
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -127,6 +128,15 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
Reports = NewReportsStore(db)
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
|
||||
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
// 这里只告警不自动删:DROP COLUMN 不可逆,必须由运维确认后执行
|
||||
// `TamaBox honeypot purge-creds`。
|
||||
if cols, err := Honeypots.LegacyCredentialColumns(context.Background()); err == nil && len(cols) > 0 {
|
||||
logrus.WithField("columns", cols).
|
||||
Warn("honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除")
|
||||
}
|
||||
|
||||
if err := db.Use(otelgorm.NewPlugin(
|
||||
otelgorm.WithDBName(conf.Database.Name),
|
||||
)); err != nil {
|
||||
|
||||
+47
-54
@@ -28,15 +28,14 @@ type HoneypotHit struct {
|
||||
// Query 保存原始查询串(截断)。
|
||||
Query string `gorm:"size:1024" json:"query"`
|
||||
|
||||
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
|
||||
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
|
||||
Username string `gorm:"size:256" json:"username"`
|
||||
Password string `gorm:"size:256" json:"password"`
|
||||
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
|
||||
Fields string `gorm:"type:text" json:"fields"`
|
||||
// RawBody 保存截断后的原始 body。
|
||||
RawBody string `gorm:"type:text" json:"raw_body"`
|
||||
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
||||
//
|
||||
// ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。
|
||||
// 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认
|
||||
// data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见
|
||||
// internal/security/honeypot/credstore.go 的 CredRecord 注释:
|
||||
// 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、
|
||||
// 以及任何有只读权限的报表工具,事后无法彻底清除。
|
||||
HadFields bool `json:"had_fields"`
|
||||
|
||||
// UserAgent / Referer / ContentType 用于扫描器画像。
|
||||
@@ -69,19 +68,12 @@ type HoneypotStore interface {
|
||||
Create(ctx context.Context, hit *HoneypotHit) error
|
||||
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
||||
// ListSinceByIP 返回 since 之后、来自指定 IP 的命中记录,按时间倒序。
|
||||
//
|
||||
// 刻意在 SQL 层过滤而不是取回后在 Go 侧过滤:后者只能看到「最近 limit 条」
|
||||
// 里恰好落在窗口内的该 IP 记录,指定 IP 的历史命中基本查不出来。
|
||||
ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error)
|
||||
// CountSince 统计 since 之后的命中总数。
|
||||
CountSince(ctx context.Context, since time.Time) (int64, error)
|
||||
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
||||
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
||||
// TopPaths 返回 since 之后被命中次数最多的路径。
|
||||
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
|
||||
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
||||
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
||||
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
|
||||
@@ -101,6 +93,10 @@ type HoneypotStore interface {
|
||||
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
||||
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
||||
Size(ctx context.Context) (HoneypotSize, error)
|
||||
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
|
||||
LegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||
PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// HoneypotSize 蜜罐表的体积报告。
|
||||
@@ -127,11 +123,42 @@ type HoneypotPathStat struct {
|
||||
Count int64
|
||||
}
|
||||
|
||||
// HoneypotCredentialStat 凭据组合聚合行。
|
||||
type HoneypotCredentialStat struct {
|
||||
Username string
|
||||
Password string
|
||||
Count int64
|
||||
// LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。
|
||||
//
|
||||
// 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** ——
|
||||
// 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行
|
||||
// `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。
|
||||
var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"}
|
||||
|
||||
// LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。
|
||||
func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||
var present []string
|
||||
for _, col := range LegacyHoneypotCredentialColumns {
|
||||
if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) {
|
||||
present = append(present, col)
|
||||
}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||
//
|
||||
// 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是
|
||||
// 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的
|
||||
// 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。
|
||||
func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||
present, err := h.LegacyCredentialColumns(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dropped := make([]string, 0, len(present))
|
||||
for _, col := range present {
|
||||
if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil {
|
||||
return dropped, err
|
||||
}
|
||||
dropped = append(dropped, col)
|
||||
}
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
type honeypots struct {
|
||||
@@ -163,23 +190,6 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
if ip == "" {
|
||||
return h.ListSince(ctx, since, limit)
|
||||
}
|
||||
var rows []*HoneypotHit
|
||||
err := h.db.WithContext(ctx).
|
||||
Where("created_at >= ?", since).
|
||||
Where("ip = ?", ip).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
||||
@@ -220,23 +230,6 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []*HoneypotCredentialStat
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
Select("username AS username, password AS password, count(*) AS count").
|
||||
Where("created_at >= ?", since).
|
||||
Where("username <> '' OR password <> ''").
|
||||
Group("username, password").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||
return res.RowsAffected, res.Error
|
||||
|
||||
@@ -34,8 +34,19 @@ import (
|
||||
// 此时只记一条告警日志,不删任何数据。
|
||||
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
|
||||
// 不能因为它把站点搞挂。
|
||||
//
|
||||
// CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。
|
||||
//
|
||||
// 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层,
|
||||
// 也能让清理器在测试里用假实现。
|
||||
type CredPurgeable interface {
|
||||
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
|
||||
PurgeBefore(before time.Time) (int, error)
|
||||
}
|
||||
|
||||
type HoneypotRetention struct {
|
||||
store HoneypotStore
|
||||
creds CredPurgeable
|
||||
retainDays int
|
||||
maxRows int
|
||||
interval time.Duration
|
||||
@@ -44,13 +55,14 @@ type HoneypotRetention struct {
|
||||
// NewHoneypotRetention 构造保留期清理器。
|
||||
//
|
||||
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
|
||||
// interval <= 0 时使用默认 1 小时。
|
||||
func NewHoneypotRetention(store HoneypotStore, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
||||
// interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。
|
||||
func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
return &HoneypotRetention{
|
||||
store: store,
|
||||
creds: creds,
|
||||
retainDays: retainDays,
|
||||
maxRows: maxRows,
|
||||
interval: interval,
|
||||
@@ -126,6 +138,17 @@ func (r *HoneypotRetention) runOnce() {
|
||||
total += m
|
||||
}
|
||||
|
||||
// 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。
|
||||
// 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。
|
||||
if r.creds != nil {
|
||||
if n, err := r.creds.PurgeBefore(before); err != nil {
|
||||
logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)")
|
||||
} else if n > 0 {
|
||||
logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}).
|
||||
Info("已清理过期的蜜罐凭据文件")
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestHoneypotRetentionRunOnce(t *testing.T) {
|
||||
// 保留期 30 天、行数上限 7:
|
||||
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
||||
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
||||
ret := NewHoneypotRetention(store, 30, 7, 0)
|
||||
ret := NewHoneypotRetention(store, nil, 30, 7, 0)
|
||||
ret.runOnce()
|
||||
|
||||
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||
@@ -82,7 +82,7 @@ func TestHoneypotRetentionDisabled(t *testing.T) {
|
||||
}))
|
||||
}
|
||||
|
||||
ret := NewHoneypotRetention(store, 0, 100, 0)
|
||||
ret := NewHoneypotRetention(store, nil, 0, 100, 0)
|
||||
ret.runOnce()
|
||||
|
||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||
@@ -109,7 +109,7 @@ func TestHoneypotRetentionMaxRowsOnly(t *testing.T) {
|
||||
}))
|
||||
}
|
||||
|
||||
ret := NewHoneypotRetention(store, 30, 5, 0)
|
||||
ret := NewHoneypotRetention(store, nil, 30, 5, 0)
|
||||
ret.runOnce()
|
||||
|
||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||
@@ -147,7 +147,7 @@ func TestHoneypotRetentionBothGates(t *testing.T) {
|
||||
|
||||
// 保留期 30 天、数量上限 4:
|
||||
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
||||
ret := NewHoneypotRetention(store, 30, 4, 0)
|
||||
ret := NewHoneypotRetention(store, nil, 30, 4, 0)
|
||||
ret.runOnce()
|
||||
|
||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||
@@ -208,44 +208,6 @@ func TestHoneypotDeleteOldestBeyond(t *testing.T) {
|
||||
require.Equal(t, int64(0), n3)
|
||||
}
|
||||
|
||||
// TestHoneypotListSinceByIP 验证按 IP 过滤在 SQL 层生效。
|
||||
//
|
||||
// 背景:原先 CLI 的 --ip 过滤是在取回最近 N 条后在内存里筛,
|
||||
// 导致「指定 IP 的历史命中」基本查不出来(被其他 IP 的更近记录挤出窗口)。
|
||||
func TestHoneypotListSinceByIP(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("honeypot_hits"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewHoneypotStore(database)
|
||||
now := time.Now()
|
||||
|
||||
// 目标 IP 只有 1 条很老的记录;其余 20 条都是别的 IP 且更新
|
||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||||
IP: "9.9.9.9", Method: "GET", Path: "/target", CreatedAt: now.Add(-10 * time.Hour),
|
||||
}))
|
||||
for i := 0; i < 20; i++ {
|
||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||||
IP: "8.8.8.8", Method: "GET", Path: "/other", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
||||
}))
|
||||
}
|
||||
|
||||
rows, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "9.9.9.9", 200)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1, "指定 IP 的老记录应能被查出(不能被其他 IP 的新记录挤出窗口)")
|
||||
require.Equal(t, "/target", rows[0].Path)
|
||||
|
||||
// 空 IP 退化为不过滤
|
||||
all, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "", 200)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 21)
|
||||
}
|
||||
|
||||
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
||||
func TestHoneypotCompact(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
|
||||
@@ -31,9 +31,9 @@ func TestHoneypotStore(t *testing.T) {
|
||||
|
||||
rows := []*HoneypotHit{
|
||||
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
||||
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
||||
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, store.Create(ctx, r))
|
||||
@@ -64,13 +64,6 @@ func TestHoneypotStore(t *testing.T) {
|
||||
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||
require.Equal(t, int64(2), paths[0].Count)
|
||||
|
||||
creds, err := store.TopCredentials(ctx, since, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, creds, 2)
|
||||
require.Equal(t, "admin", creds[0].Username)
|
||||
require.Equal(t, "admin123", creds[0].Password)
|
||||
require.Equal(t, int64(2), creds[0].Count)
|
||||
|
||||
// 保留期清理
|
||||
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||||
require.NoError(t, err)
|
||||
@@ -81,6 +74,45 @@ func TestHoneypotStore(t *testing.T) {
|
||||
require.Equal(t, int64(2), total)
|
||||
}
|
||||
|
||||
// TestHoneypotLegacyCredentialColumns 覆盖「旧版凭据列」的检测与清除。
|
||||
//
|
||||
// 背景:凭据已改为只落文件(见 honeypot.CredRecord),结构体也删掉了
|
||||
// username/password/fields/raw_body 四个字段。但 GORM 的 AutoMigrate
|
||||
// 不会删除既有列,老库升级上来后历史明文仍然留在表里。
|
||||
// 这个测试用 ADD COLUMN 模拟老库,验证检测与 DROP COLUMN 都真的生效。
|
||||
func TestHoneypotLegacyCredentialColumns(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("honeypot_hits"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewHoneypotStore(database)
|
||||
|
||||
cols, err := store.LegacyCredentialColumns(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cols, "新建的表不应有旧版凭据列")
|
||||
|
||||
// 模拟旧版本留下的列。
|
||||
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN username varchar(256)").Error)
|
||||
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN password varchar(256)").Error)
|
||||
|
||||
cols, err = store.LegacyCredentialColumns(ctx)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{"username", "password"}, cols)
|
||||
|
||||
dropped, err := store.PurgeLegacyCredentialColumns(ctx)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{"username", "password"}, dropped)
|
||||
|
||||
cols, err = store.LegacyCredentialColumns(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cols, "清除后不应再检测到旧版凭据列")
|
||||
}
|
||||
|
||||
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
||||
//
|
||||
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
||||
@@ -102,7 +134,7 @@ func TestHoneypotSize(t *testing.T) {
|
||||
IP: "9.9.9.9",
|
||||
Method: "POST",
|
||||
Path: "/wp-login.php",
|
||||
Username: "admin",
|
||||
HadFields: true,
|
||||
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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 route
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// 这组测试守的是「装配层」而不是「逻辑层」。
|
||||
//
|
||||
// 背景:蜜罐的封禁通道参数(HoneypotEnabled / HoneypotThreshold /
|
||||
// HoneypotDuration)曾经在 route.New 里漏传,而 autoban.RecordHoneypot 第一行
|
||||
// 就是 `if !b.honeypotEnabled { return false }` —— 表现为蜜罐命中永不封禁、
|
||||
// 落库 banned 恒为 false,但所有测试全绿(它们都自建 Banner 并显式传了这些字段)。
|
||||
//
|
||||
// 因此这里的每个用例都必须经由 newBanner()(生产唯一装配入口),
|
||||
// 绝不能自己构造 autoban.Options。
|
||||
|
||||
// swapSecurity 在测试期间覆盖 conf.Security,结束后恢复。
|
||||
func swapSecurity(t *testing.T) {
|
||||
t.Helper()
|
||||
old := conf.Security
|
||||
t.Cleanup(func() { conf.Security = old })
|
||||
}
|
||||
|
||||
// TestNewBannerWiresHoneypotChannel 是这次漏接线的直接回归测试。
|
||||
func TestNewBannerWiresHoneypotChannel(t *testing.T) {
|
||||
swapSecurity(t)
|
||||
conf.Security.EnableHoneypot = true
|
||||
conf.Security.HoneypotThreshold = 1
|
||||
conf.Security.HoneypotBanMinutes = 1440
|
||||
|
||||
b := newBanner()
|
||||
if !b.RecordHoneypot("203.0.113.9", "/wp-login.php") {
|
||||
t.Fatal("蜜罐开启时 RecordHoneypot 应判定封禁:newBanner 很可能漏传了 HoneypotEnabled")
|
||||
}
|
||||
if !b.IsBanned("203.0.113.9") {
|
||||
t.Fatal("蜜罐命中后 IsBanned 应为 true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewBannerHonorsHoneypotThreshold 验证阈值真的传进去了。
|
||||
//
|
||||
// 只断言「能封禁」是不够的:漏传 HoneypotThreshold 时 autoban 会兜底成 1,
|
||||
// 一次就封,看起来一切正常。设成 3 才能区分「传了」与「用了默认值」。
|
||||
func TestNewBannerHonorsHoneypotThreshold(t *testing.T) {
|
||||
swapSecurity(t)
|
||||
conf.Security.EnableHoneypot = true
|
||||
conf.Security.HoneypotThreshold = 3
|
||||
conf.Security.HoneypotBanMinutes = 60
|
||||
|
||||
b := newBanner()
|
||||
for i := 1; i <= 2; i++ {
|
||||
if b.RecordHoneypot("198.51.100.7", "/admin") {
|
||||
t.Fatalf("第 %d 次命中不应封禁(阈值 3)——阈值未接线时会退化为默认 1", i)
|
||||
}
|
||||
}
|
||||
if !b.RecordHoneypot("198.51.100.7", "/admin") {
|
||||
t.Fatal("第 3 次命中应封禁(阈值 3)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewBannerHoneypotIndependentOfAutoBan 守住「两个开关互不连坐」。
|
||||
//
|
||||
// 线上触发过的组合:运维嫌自动封禁误伤多,关掉 enable_auto_ban;
|
||||
// 结果蜜罐跟着变成只记录不封禁的旁观者。
|
||||
func TestNewBannerHoneypotIndependentOfAutoBan(t *testing.T) {
|
||||
swapSecurity(t)
|
||||
conf.Security.EnableAutoBan = false
|
||||
conf.Security.EnableHoneypot = true
|
||||
conf.Security.HoneypotThreshold = 1
|
||||
conf.Security.HoneypotBanMinutes = 60
|
||||
|
||||
b := newBanner()
|
||||
if !b.RecordHoneypot("192.0.2.5", "/.env") {
|
||||
t.Fatal("enable_auto_ban=false 不应让蜜罐封禁失效")
|
||||
}
|
||||
if !b.IsBanned("192.0.2.5") {
|
||||
t.Fatal("蜜罐封禁应在 enable_auto_ban=false 时依然生效")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewBannerHoneypotDisabled 守住反向:关掉蜜罐就不该凭空封人。
|
||||
func TestNewBannerHoneypotDisabled(t *testing.T) {
|
||||
swapSecurity(t)
|
||||
conf.Security.EnableHoneypot = false
|
||||
conf.Security.HoneypotThreshold = 1
|
||||
conf.Security.HoneypotBanMinutes = 1440
|
||||
|
||||
b := newBanner()
|
||||
if b.RecordHoneypot("192.0.2.9", "/wp-admin") {
|
||||
t.Fatal("enable_honeypot=false 时不应封禁")
|
||||
}
|
||||
if b.IsBanned("192.0.2.9") {
|
||||
t.Fatal("enable_honeypot=false 时不应产生封禁记录")
|
||||
}
|
||||
}
|
||||
+179
-93
@@ -6,9 +6,7 @@ package route
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -32,28 +30,98 @@ import (
|
||||
// 行为:
|
||||
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
|
||||
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
|
||||
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
|
||||
// - 提交了凭据 → 立即封禁(RecordHoneypot,不受 enable_auto_ban 约束)+ 告警邮件;
|
||||
// - 纯路径探测 → 只计入常规敏感路径阈值,不立即封禁。必须留出「咬钩」的机会:
|
||||
// 爆破脚本先 GET 探表单再 POST 提交,GET 就把人封了等于永远拿不到凭据;
|
||||
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
|
||||
//
|
||||
// 蜜罐取证的去重/采样参数。
|
||||
const (
|
||||
// alertDedupTTL 同一个 IP 的告警冷却时间。旧实现是「永久只发一次」,
|
||||
// 直接让 map 随源 IP 数量无界增长;24 小时既能压住噪音,也允许
|
||||
// 同一 IP 在长期攻击中再次提醒。
|
||||
alertDedupTTL = 24 * time.Hour
|
||||
// probeSampleWindow 纯路径探测(无凭据)的落库采样窗口:
|
||||
// 同一 IP 在窗口内最多落一行,保留「谁扫了哪些路径」的分布,
|
||||
// 但单个 IP 的洪泛不会变成等量 INSERT。
|
||||
probeSampleWindow = time.Minute
|
||||
// maxDedupEntries 去重表容量上限,超出后先清过期项、仍超则按容量硬裁。
|
||||
maxDedupEntries = 20000
|
||||
)
|
||||
|
||||
// deduper 是一个带过期时间的 best-effort 去重器。
|
||||
//
|
||||
// 用「带 TTL 的 map」而不是永不过期的 map[string]bool:
|
||||
// 蜜罐的键完全由外部流量决定(源 IP),后者等于把内存交给攻击者控制。
|
||||
type deduper struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]time.Time
|
||||
}
|
||||
|
||||
func newDeduper() *deduper {
|
||||
return &deduper{seen: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
// first 报告 key 是否在窗口内首次出现;首次会登记,过期时间为 now+ttl。
|
||||
// ttl <= 0 表示永不过期(仍受容量上限约束)。
|
||||
func (d *deduper) first(key string, ttl time.Duration) bool {
|
||||
if key == "" {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if until, ok := d.seen[key]; ok {
|
||||
if ttl <= 0 || until.After(now) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
d.seen[key] = now.Add(ttl)
|
||||
if len(d.seen) > maxDedupEntries {
|
||||
d.pruneLocked(now)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// pruneLocked 先清过期项;仍超上限时按容量硬裁到一半。
|
||||
// 硬裁是 best-effort:宁可偶尔漏一次去重,也不能让内存无界增长。
|
||||
func (d *deduper) pruneLocked(now time.Time) {
|
||||
for k, until := range d.seen {
|
||||
if !until.IsZero() && !until.After(now) {
|
||||
delete(d.seen, k)
|
||||
}
|
||||
}
|
||||
limit := maxDedupEntries / 2
|
||||
for k := range d.seen {
|
||||
if len(d.seen) <= limit {
|
||||
break
|
||||
}
|
||||
delete(d.seen, k)
|
||||
}
|
||||
}
|
||||
|
||||
type honeypotHandler struct {
|
||||
hp *honeypot.Honeypot
|
||||
ab *autoban.Banner
|
||||
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
|
||||
store db.HoneypotStore
|
||||
// cstore 是凭据取证文件的存储,允许为 nil(目录不可写时降级)。
|
||||
// 用户名/密码只走这里,绝不写进 store(数据库)。
|
||||
cstore honeypot.CredStore
|
||||
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
|
||||
sendAlert func(mail.HoneypotAlert) error
|
||||
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
|
||||
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
|
||||
alerted map[string]bool
|
||||
alertedMu sync.Mutex
|
||||
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
|
||||
dedup *deduper
|
||||
}
|
||||
|
||||
// newHoneypotHandler 构造蜜罐处理器。
|
||||
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
|
||||
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
|
||||
return &honeypotHandler{
|
||||
hp: hp,
|
||||
ab: ab,
|
||||
store: store,
|
||||
alerted: map[string]bool{},
|
||||
hp: hp,
|
||||
ab: ab,
|
||||
store: store,
|
||||
cstore: cstore,
|
||||
dedup: newDeduper(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,26 +131,35 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
||||
path := req.URL.Path
|
||||
|
||||
// 封禁:走 autoban 的蜜罐独立通道(RecordHoneypot)。
|
||||
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
|
||||
creds := h.hp.ExtractCredentials(req)
|
||||
|
||||
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
|
||||
//
|
||||
// 刻意不用常规的 Record:它受 enable_auto_ban 开关约束,
|
||||
// 而蜜罐是显式开启的功能 —— 开了就该封禁,不该被另一个开关连坐。
|
||||
// 蜜罐路径与业务路由零交集,正常访客不可能命中,阈值取 1(一次即封)。
|
||||
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
|
||||
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
|
||||
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
|
||||
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
|
||||
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
|
||||
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
|
||||
//
|
||||
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
|
||||
banned := false
|
||||
if h.ab != nil && ip != "" {
|
||||
banned = h.ab.RecordHoneypot(ip, path)
|
||||
if creds.HadFields {
|
||||
banned = h.ab.RecordHoneypot(ip, path)
|
||||
} else {
|
||||
h.ab.Record(ip, path, autoban.KindSensitive)
|
||||
}
|
||||
}
|
||||
|
||||
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
|
||||
creds := h.hp.ExtractCredentials(req)
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
h.serveLoginPage(ctx, path)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
|
||||
h.serveLoginFailure(ctx, path)
|
||||
default:
|
||||
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
|
||||
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -92,71 +169,66 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
|
||||
// serveLoginPage 返回假登录页。
|
||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||
redir := ""
|
||||
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
|
||||
redir = q
|
||||
}
|
||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In ‹ WordPress",
|
||||
Heading: "WordPress",
|
||||
Redir: redir,
|
||||
})
|
||||
h.writeLogin(ctx, path, "")
|
||||
}
|
||||
|
||||
// serveLoginFailure 返回「登录失败」的假响应。
|
||||
//
|
||||
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
||||
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
|
||||
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
|
||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) {
|
||||
h.writeLogin(ctx, path, honeypot.LoginFailureNotice)
|
||||
}
|
||||
|
||||
// writeLogin 是两种响应形态的唯一出口。
|
||||
//
|
||||
// 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type /
|
||||
// Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、
|
||||
// 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。
|
||||
func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
|
||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In \u2039 WordPress",
|
||||
Title: honeypot.LoginPageTitle,
|
||||
Heading: "WordPress",
|
||||
Notice: notice,
|
||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||
})
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// record 追加一条命中记录。
|
||||
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
||||
//
|
||||
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
|
||||
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
|
||||
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
|
||||
// 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。
|
||||
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
h.recordCredentials(ip, req, path, creds, banned)
|
||||
}
|
||||
|
||||
// recordCredentials 把一次命中写入数据库。
|
||||
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
|
||||
fieldsJSON := ""
|
||||
if len(creds.Fields) > 0 {
|
||||
if b, err := json.Marshal(creds.Fields); err == nil {
|
||||
fieldsJSON = string(b)
|
||||
}
|
||||
if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
query := ""
|
||||
if req.URL != nil {
|
||||
query = req.URL.RawQuery
|
||||
if len(query) > 1000 {
|
||||
query = query[:1000]
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
|
||||
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
|
||||
if creds.HadFields {
|
||||
h.saveCredential(ip, req, path, query, creds, banned)
|
||||
}
|
||||
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
|
||||
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
|
||||
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
|
||||
// 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上
|
||||
// 以 `value too long for type character varying(N)` 整条插入失败 ——
|
||||
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
|
||||
hit := &db.HoneypotHit{
|
||||
IP: ip,
|
||||
Method: req.Method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Username: creds.Username,
|
||||
Password: creds.Password,
|
||||
Fields: fieldsJSON,
|
||||
RawBody: creds.RawBody,
|
||||
IP: truncate(ip, 64),
|
||||
Method: truncate(req.Method, 16),
|
||||
Path: truncate(path, 500),
|
||||
Query: truncate(query, 1000),
|
||||
HadFields: creds.HadFields,
|
||||
UserAgent: truncate(req.UserAgent(), 500),
|
||||
Referer: truncate(req.Referer(), 500),
|
||||
@@ -195,20 +267,56 @@ func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path s
|
||||
}
|
||||
}
|
||||
|
||||
// saveCredential 把一次凭据提交追加到取证文件。
|
||||
//
|
||||
// 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库,
|
||||
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||
// 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证,
|
||||
// 也不能消除「凭据以某种形式留在库里」这个事实。
|
||||
//
|
||||
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
|
||||
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
|
||||
func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
|
||||
if h.cstore == nil {
|
||||
logrus.WithField("ip", ip).
|
||||
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
|
||||
return
|
||||
}
|
||||
rec := honeypot.CredRecord{
|
||||
Time: time.Now(),
|
||||
IP: truncate(ip, 64),
|
||||
Method: truncate(req.Method, 16),
|
||||
Path: truncate(path, 500),
|
||||
Query: truncate(query, 1000),
|
||||
Username: truncate(creds.Username, 512),
|
||||
Password: truncate(creds.Password, 512),
|
||||
Fields: creds.Fields,
|
||||
RawBody: creds.RawBody,
|
||||
UserAgent: truncate(req.UserAgent(), 500),
|
||||
Banned: banned,
|
||||
}
|
||||
if err := h.cstore.Append(rec); err != nil {
|
||||
logrus.WithError(err).WithField("ip", ip).Warn("蜜罐凭据写入文件失败")
|
||||
}
|
||||
}
|
||||
|
||||
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
||||
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
||||
if h.sendAlert == nil || ip == "" {
|
||||
return
|
||||
}
|
||||
h.alertedMu.Lock()
|
||||
if h.alerted[ip] {
|
||||
h.alertedMu.Unlock()
|
||||
// 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
|
||||
// 不去重会把邮箱打爆(且每个源 IP 都是一封)。
|
||||
if !h.dedup.first("alert:"+ip, alertDedupTTL) {
|
||||
return
|
||||
}
|
||||
h.alerted[ip] = true
|
||||
h.alertedMu.Unlock()
|
||||
|
||||
durationMin := conf.Security.BanDurationMinutes
|
||||
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
|
||||
// 否则运维按邮件判断「什么时候解封」会错。
|
||||
durationMin := conf.Security.HoneypotBanMinutes
|
||||
if durationMin <= 0 {
|
||||
durationMin = conf.Security.BanDurationMinutes
|
||||
}
|
||||
if durationMin <= 0 {
|
||||
durationMin = 15
|
||||
}
|
||||
@@ -239,25 +347,3 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
|
||||
func honeypotExtraPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
|
||||
// newHoneypotTestServerWith 可分别控制「蜜罐开关」与「常规自动封禁开关」,
|
||||
// 用于验证两者相互独立(见 autoban.Options.HoneypotEnabled)。
|
||||
func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return newHoneypotTestServerFull(t, enabled, autoBan, nil)
|
||||
}
|
||||
|
||||
// newHoneypotTestServerFull 额外允许注入凭据文件存储(nil 表示不落凭据)。
|
||||
func newHoneypotTestServerFull(t *testing.T, enabled, autoBan bool, cs honeypot.CredStore) *httptest.Server {
|
||||
t.Helper()
|
||||
f := flamego.New()
|
||||
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
||||
@@ -40,7 +46,7 @@ func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Se
|
||||
Window: 60 * time.Second,
|
||||
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
|
||||
hph := newHoneypotHandler(hp, ab, nil, cs) // store=nil → 元数据不落库,只走日志
|
||||
|
||||
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
|
||||
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
|
||||
@@ -159,6 +165,38 @@ func TestHoneypotCapturesCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotWritesCredentialsToFile 验证凭据落的是文件、不是数据库。
|
||||
//
|
||||
// 这是「明文账号密码不入库」的落地检查:POST 提交后凭据必须能从 CredStore
|
||||
// 读到(说明真的写了),而 db.HoneypotHit 已经从类型上不允许携带凭据字段
|
||||
// (结构体里没有 Username/Password,编译期即保证入不了库)。
|
||||
func TestHoneypotWritesCredentialsToFile(t *testing.T) {
|
||||
cs, err := honeypot.NewFileCredStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开凭据目录失败:%v", err)
|
||||
}
|
||||
ts := newHoneypotTestServerFull(t, true, true, cs)
|
||||
|
||||
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||
strings.NewReader("log=admin&pwd=hunter2"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
|
||||
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("读取凭据文件失败:%v", err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
|
||||
}
|
||||
if recs[0].Username != "admin" || recs[0].Password != "hunter2" {
|
||||
t.Errorf("凭据 = %q/%q, want admin/hunter2", recs[0].Username, recs[0].Password)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
|
||||
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
|
||||
ts := newHoneypotTestServer(t, true)
|
||||
@@ -190,14 +228,16 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
||||
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
|
||||
ts := newHoneypotTestServerWith(t, true, false)
|
||||
|
||||
// 第一次命中蜜罐
|
||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
||||
// 第一次:提交凭据(这才是触发蜜罐封禁的行为;纯 GET 探测故意不立即封,
|
||||
// 见 TestHoneypotProbeDoesNotBanImmediately)
|
||||
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||
strings.NewReader("log=admin&pwd=admin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("首次命中 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
|
||||
t.Fatalf("首次提交 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 第二次:应已被蜜罐封禁 → 403
|
||||
@@ -215,11 +255,53 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotProbeDoesNotBanImmediately 守住「诱捕能力」。
|
||||
//
|
||||
// 若纯 GET 也按「一次即封」处理,扫描器看一眼假登录页就吃 403,永远走不到
|
||||
// POST 提交凭据那一步 —— 蜜罐退化成一次性 403 陷阱,而它的核心价值恰恰是
|
||||
// 拿到对方正在用的账号密码字典。
|
||||
func TestHoneypotProbeDoesNotBanImmediately(t *testing.T) {
|
||||
ts := newHoneypotTestServerWith(t, true, false) // 常规自动封禁关闭,只看蜜罐通道
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("第 %d 次探测 status = %d, want 200(纯探测不应立即封禁)", i, resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), honeypot.CanaryToken) {
|
||||
t.Fatalf("第 %d 次探测没有返回假登录页", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 一旦提交凭据,必须当场封。
|
||||
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||
strings.NewReader("log=admin&pwd=admin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp2, err := http.Get(ts.URL + "/wp-login.php")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("提交凭据后 status = %d, want 403", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
|
||||
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
|
||||
ts := newHoneypotTestServerWith(t, true, false)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
||||
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||
strings.NewReader("log=admin&pwd=admin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+42
-12
@@ -73,16 +73,18 @@ func securityHeaders() flamego.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
|
||||
ab := autoban.New(autoban.Options{
|
||||
// newBanner 按 conf.Security 装配自动封禁器。
|
||||
//
|
||||
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
|
||||
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
|
||||
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
|
||||
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
|
||||
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
|
||||
//
|
||||
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
|
||||
// 下次再漏传会直接测试失败。
|
||||
func newBanner() *autoban.Banner {
|
||||
return autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
TrackNotFound: conf.Security.BanTrackNotFound,
|
||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||
@@ -96,15 +98,43 @@ func New() *flamego.Flame {
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
|
||||
},
|
||||
|
||||
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
|
||||
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
|
||||
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
|
||||
HoneypotEnabled: conf.Security.EnableHoneypot,
|
||||
HoneypotThreshold: conf.Security.HoneypotThreshold,
|
||||
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
|
||||
})
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
|
||||
ab := newBanner()
|
||||
|
||||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
||||
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
||||
hp := honeypot.New(honeypot.Options{
|
||||
Enabled: conf.Security.EnableHoneypot,
|
||||
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
|
||||
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots)
|
||||
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
|
||||
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
|
||||
// 绝不让取证功能把站点拖挂。
|
||||
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||
if err != nil {
|
||||
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
|
||||
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
|
||||
credStore = nil
|
||||
}
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
|
||||
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
||||
// 收件人复用 security_alert_email,不新增配置项。
|
||||
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
||||
|
||||
@@ -48,9 +48,9 @@ type Options struct {
|
||||
//
|
||||
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
|
||||
HoneypotEnabled bool
|
||||
// HoneypotThreshold 蜜罐独立阈值:窗口内蜜罐命中次数达到即封禁。
|
||||
// 默认 1(一次即封)—— 蜜罐路径与业务路由零交集,正常访客不可能命中,
|
||||
// 一次命中就足以判定为扫描器,无需等 autoban 的 10 次阈值。
|
||||
// HoneypotThreshold 蜜罐「提交凭据」达到多少次即封禁。
|
||||
// 默认 1 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
|
||||
// 纯路径探测不要走这个通道(否则对方 GET 一次就被封,走不到提交凭据那步)。
|
||||
// 留空或 <= 0 时兜底为 1。
|
||||
HoneypotThreshold int
|
||||
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// 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 honeypot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CredRecord 是一条凭据取证记录。
|
||||
//
|
||||
// 为什么单独落文件、绝不进数据库:这些是外部攻击者提交的**真实账号密码**
|
||||
// (多是从别处泄露后复用的),一旦入库就会跟着进备份、进主从复制、进任何
|
||||
// 能连库的报表/BI 工具,事后想彻底清除几乎做不到。文件可以:
|
||||
// - 用 0600 权限、目录 0700,与数据库账号的权限边界分开;
|
||||
// - 按天分文件,到期直接删文件(DB 的 DELETE 只是标记死元组);
|
||||
// - 目录可以单独挂 tmpfs / 独立卷,甚至不参与任何备份。
|
||||
//
|
||||
// 数据库里只保留「发生过一次带凭据的提交」这个事实(HadFields 等元数据)。
|
||||
type CredRecord struct {
|
||||
Time time.Time `json:"time"`
|
||||
IP string `json:"ip"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
RawBody string `json:"raw_body,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
|
||||
// CredStat 是一条「用户名/密码组合」的命中次数。
|
||||
type CredStat struct {
|
||||
Username string
|
||||
Password string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// CredStore 是凭据取证记录的存储。
|
||||
//
|
||||
// 刻意只有「追加 / 读取 / 按天清理」三类操作:取证记录不做修改,
|
||||
// 也就不需要 Update/Delete 单条(清理只按天删文件)。
|
||||
type CredStore interface {
|
||||
// Append 追加一条记录,按记录时间落到当天的文件。
|
||||
Append(rec CredRecord) error
|
||||
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
|
||||
// ip 非空时只返回该来源的记录(在读取阶段过滤)。
|
||||
ListSince(since time.Time, ip string, limit int) ([]CredRecord, error)
|
||||
// TopCredentials 返回 since 之后最常被尝试的凭据组合。
|
||||
TopCredentials(since time.Time, limit int) ([]CredStat, error)
|
||||
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
|
||||
PurgeBefore(before time.Time) (int, error)
|
||||
// Size 报告文件数与总字节数。
|
||||
Size() (files int, bytes int64, err error)
|
||||
// Dir 返回存储目录,供 CLI 展示。
|
||||
Dir() string
|
||||
}
|
||||
|
||||
const (
|
||||
credFilePrefix = "creds-"
|
||||
credFileSuffix = ".jsonl"
|
||||
credDateLayout = "2006-01-02"
|
||||
// maxCredScanRecords 单次聚合扫描的记录上限。
|
||||
// TopCredentials 必须扫全量才能排出「最常用凭据」,这里给个硬上限
|
||||
// 防止目录被刷爆时把 CLI 拖死 —— 超出部分只是不参与排行,不影响取证。
|
||||
maxCredScanRecords = 200000
|
||||
)
|
||||
|
||||
type fileCredStore struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileCredStore 打开(必要时创建)凭据目录。
|
||||
//
|
||||
// 目录权限强制 0700:记录里是明文账号密码,同机其他用户不该能读。
|
||||
// 出错时由调用方决定降级策略(蜜罐是安全增强功能,不能因为它启动失败)。
|
||||
func NewFileCredStore(dir string) (CredStore, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, fmt.Errorf("honeypot credential dir is empty")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MkdirAll 对已存在的目录不改动权限,显式收紧一次,避免沿用 0755。
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileCredStore{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Dir 返回存储目录。
|
||||
func (s *fileCredStore) Dir() string { return s.dir }
|
||||
|
||||
func credFileName(t time.Time) string {
|
||||
return credFilePrefix + t.Format(credDateLayout) + credFileSuffix
|
||||
}
|
||||
|
||||
// credFileDate 从文件名解析出日期(本地时区)。
|
||||
func credFileDate(path string) (time.Time, bool) {
|
||||
base := filepath.Base(path)
|
||||
base = strings.TrimPrefix(base, credFilePrefix)
|
||||
base = strings.TrimSuffix(base, credFileSuffix)
|
||||
d, err := time.ParseInLocation(credDateLayout, base, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// Append 追加一条记录。
|
||||
func (s *fileCredStore) Append(rec CredRecord) error {
|
||||
if rec.Time.IsZero() {
|
||||
rec.Time = time.Now()
|
||||
}
|
||||
b, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 一行一条,便于按行流式读取与人工 grep。
|
||||
b = append(b, '\n')
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
name := filepath.Join(s.dir, credFileName(rec.Time))
|
||||
f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
// O_APPEND + 单次 Write:同一进程内另有互斥锁串行化;
|
||||
// 多进程场景下 <= PIPE_BUF(Linux 4096 字节)的写是原子的,
|
||||
// 单条记录通常远小于该值(超长 UA/表单已在上游截断)。
|
||||
_, err = f.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// filesSince 返回可能包含 since 之后记录的文件(按时间升序)。
|
||||
func (s *fileCredStore) filesSince(since time.Time) ([]string, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(s.dir, credFilePrefix+"*"+credFileSuffix))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches) // 文件名内嵌日期,字典序即时间序
|
||||
if since.IsZero() {
|
||||
return matches, nil
|
||||
}
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, p := range matches {
|
||||
d, ok := credFileDate(p)
|
||||
if !ok {
|
||||
continue // 不认识的文件名,不碰
|
||||
}
|
||||
// 该文件覆盖 [d, d+1day),只有末尾晚于 since 才有内容。
|
||||
if d.AddDate(0, 0, 1).After(since) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readCredFile 按行解析一个文件。
|
||||
//
|
||||
// 坏行(例如进程在写入中途被杀导致的半条 JSON)直接跳过:
|
||||
// 取证文件的价值在于「尽可能多读到」,不能因为一行损坏就整天的数据报废。
|
||||
func readCredFile(path string) ([]CredRecord, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := make([]CredRecord, 0, 64)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec CredRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// ListSince 返回 since 之后的记录(时间倒序)。
|
||||
func (s *fileCredStore) ListSince(since time.Time, ip string, limit int) ([]CredRecord, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
files, err := s.filesSince(since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CredRecord, 0, limit)
|
||||
// 从最新的文件往回读,凑够 limit 就停,避免把 30 天全读进内存。
|
||||
for i := len(files) - 1; i >= 0; i-- {
|
||||
recs, err := readCredFile(files[i])
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for j := len(recs) - 1; j >= 0; j-- {
|
||||
rec := recs[j]
|
||||
if rec.Time.Before(since) {
|
||||
continue
|
||||
}
|
||||
if ip != "" && rec.IP != ip {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
if len(out) >= limit {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TopCredentials 统计 since 之后最常被尝试的凭据组合。
|
||||
func (s *fileCredStore) TopCredentials(since time.Time, limit int) ([]CredStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
files, err := s.filesSince(since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type agg struct {
|
||||
username string
|
||||
password string
|
||||
count int64
|
||||
}
|
||||
counts := make(map[string]*agg, 128)
|
||||
scanned := 0
|
||||
|
||||
scan:
|
||||
for _, p := range files {
|
||||
recs, err := readCredFile(p)
|
||||
if err != nil {
|
||||
continue // 单个文件读失败不影响其余日期
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.Time.Before(since) {
|
||||
continue
|
||||
}
|
||||
if rec.Username == "" && rec.Password == "" {
|
||||
continue
|
||||
}
|
||||
key := rec.Username + "\x00" + rec.Password
|
||||
if a, ok := counts[key]; ok {
|
||||
a.count++
|
||||
} else {
|
||||
counts[key] = &agg{username: rec.Username, password: rec.Password, count: 1}
|
||||
}
|
||||
scanned++
|
||||
if scanned >= maxCredScanRecords {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]CredStat, 0, len(counts))
|
||||
for _, a := range counts {
|
||||
out = append(out, CredStat{Username: a.username, Password: a.password, Count: a.count})
|
||||
}
|
||||
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].Username < out[j].Username
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PurgeBefore 删除早于 before 的整天文件。
|
||||
//
|
||||
// 只删「整天的日期都早于 before」的文件:边界当天的文件里可能还有
|
||||
// before 之后的记录,不能整文件丢。与 Append 共用互斥锁,
|
||||
// 避免删到刚打开准备写入的文件。
|
||||
func (s *fileCredStore) PurgeBefore(before time.Time) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
files, err := s.filesSince(time.Time{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
removed := 0
|
||||
for _, p := range files {
|
||||
d, ok := credFileDate(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !d.Before(before) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return removed, err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// Size 报告文件数与总字节数。
|
||||
func (s *fileCredStore) Size() (int, int64, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(s.dir, credFilePrefix+"*"+credFileSuffix))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, p := range matches {
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
total += fi.Size()
|
||||
}
|
||||
}
|
||||
return len(matches), total, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 honeypot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestCredStore(t *testing.T) CredStore {
|
||||
t.Helper()
|
||||
s, err := NewFileCredStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileCredStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestFileCredStoreAppendListFilter 覆盖追加、时间倒序、IP 过滤。
|
||||
func TestFileCredStoreAppendListFilter(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
|
||||
if err := s.Append(CredRecord{Time: now.Add(-2 * time.Hour), IP: "1.2.3.4", Username: "admin", Password: "a"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: now, IP: "5.6.7.8", Username: "root", Password: "b"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
recs, err := s.ListSince(now.Add(-3*time.Hour), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("记录数 = %d, want 2", len(recs))
|
||||
}
|
||||
if recs[0].IP != "5.6.7.8" {
|
||||
t.Errorf("首条 IP = %q, want 5.6.7.8(应按时间倒序)", recs[0].IP)
|
||||
}
|
||||
|
||||
// IP 过滤
|
||||
only, err := s.ListSince(now.Add(-3*time.Hour), "1.2.3.4", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince(ip): %v", err)
|
||||
}
|
||||
if len(only) != 1 || only[0].Username != "admin" {
|
||||
t.Fatalf("IP 过滤结果 = %+v, want 仅 1 条 admin", only)
|
||||
}
|
||||
|
||||
// 时间窗过滤
|
||||
recent, err := s.ListSince(now.Add(-time.Minute), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince(since): %v", err)
|
||||
}
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("近 1 分钟记录数 = %d, want 1", len(recent))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreSkipsCorruptLines 确保半行/坏行不会让整天数据报废。
|
||||
func TestFileCredStoreSkipsCorruptLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileCredStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileCredStore: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
name := filepath.Join(dir, credFileName(now))
|
||||
content := `{"time":"` + now.Format(time.RFC3339Nano) + `","ip":"1.1.1.1","username":"a"}` + "\n" +
|
||||
`{这行不是合法 JSON` + "\n" +
|
||||
"\n" +
|
||||
`{"time":"` + now.Add(time.Second).Format(time.RFC3339Nano) + `","ip":"2.2.2.2","username":"b"}` + "\n"
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recs, err := s.ListSince(now.Add(-time.Minute), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("记录数 = %d, want 2(坏行与空行应被跳过)", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreTopCredentials 覆盖凭据排行榜的计数与排序。
|
||||
func TestFileCredStoreTopCredentials(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.Append(CredRecord{Time: now, IP: "1.1.1.1", Username: "admin", Password: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: now, IP: "2.2.2.2", Username: "root", Password: "b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := s.TopCredentials(now.Add(-time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("TopCredentials: %v", err)
|
||||
}
|
||||
if len(stats) != 2 {
|
||||
t.Fatalf("组合数 = %d, want 2", len(stats))
|
||||
}
|
||||
if stats[0].Username != "admin" || stats[0].Password != "a" || stats[0].Count != 3 {
|
||||
t.Errorf("首位 = %+v, want admin/a ×3", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStorePurgeBefore 覆盖按天删文件,且不碰边界当天。
|
||||
func TestFileCredStorePurgeBefore(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
for _, d := range []int{-10, -5, 0} {
|
||||
if err := s.Append(CredRecord{Time: now.AddDate(0, 0, d), IP: "1.1.1.1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files, _, err := s.Size()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 3 {
|
||||
t.Fatalf("文件数 = %d, want 3(按天分文件)", files)
|
||||
}
|
||||
|
||||
removed, err := s.PurgeBefore(now.AddDate(0, 0, -7))
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeBefore: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("删除文件数 = %d, want 1(只应删 10 天前那份)", removed)
|
||||
}
|
||||
|
||||
files, _, err = s.Size()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 2 {
|
||||
t.Errorf("剩余文件数 = %d, want 2", files)
|
||||
}
|
||||
|
||||
// 剩下的记录仍可读
|
||||
recs, err := s.ListSince(now.AddDate(0, 0, -20), "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Errorf("剩余记录数 = %d, want 2", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStorePermissions 验证目录 0700 / 文件 0600。
|
||||
//
|
||||
// 记录里是明文账号密码,同机其他用户不该能读。Windows 上 os.Chmod 语义不同,
|
||||
// 跳过该用例(部署环境是 Linux)。
|
||||
func TestFileCredStorePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows 不支持 POSIX 权限位")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "creds")
|
||||
s, err := NewFileCredStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: time.Now(), IP: "1.1.1.1", Username: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
di, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := di.Mode().Perm(); perm != 0o700 {
|
||||
t.Errorf("目录权限 = %#o, want 0700", perm)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, credFilePrefix+"*"+credFileSuffix))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("凭据文件数 = %d, want 1(%v)", len(files), err)
|
||||
}
|
||||
fi, err := os.Stat(files[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("文件权限 = %#o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreEmptyDir 验证空目录配置会被明确拒绝。
|
||||
func TestFileCredStoreEmptyDir(t *testing.T) {
|
||||
if _, err := NewFileCredStore(" "); err == nil {
|
||||
t.Fatal("空目录应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -25,6 +24,26 @@ const CanaryToken = "tb-hp-7f3a1c9e"
|
||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||
const maxCredentialFieldLen = 512
|
||||
|
||||
// clipRunes 按字符(rune)截断字符串。
|
||||
//
|
||||
// 为什么不能用 s[:n]:按字节切会把多字节 UTF-8 字符劈成半个序列,得到非法
|
||||
// UTF-8。凭据是直接落库的,PostgreSQL 会以 `invalid byte sequence for encoding
|
||||
// "UTF8"` 拒绝整条插入 —— 取证记录就这么丢了,而且只在日志里留一行 warn。
|
||||
//
|
||||
// 攻击者可以随便发一个 600 字节的中文密码来稳定触发这个问题,所以这里是
|
||||
// 可达路径,不是理论风险。
|
||||
func clipRunes(s string, n int) string {
|
||||
// 字节数已不超过 n,字符数必然也不超过,无需转换。
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||
var honeypotPrefixes = []string{
|
||||
@@ -77,6 +96,40 @@ var forbiddenPrefixes = []string{
|
||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
||||
}
|
||||
|
||||
// SplitPaths 解析配置中的自定义诱饵路径(逗号 / 分号 / 空白 / 换行分隔)。
|
||||
//
|
||||
// 放在本包而不是 route:CLI 的 `honeypot paths` 也要用同一份解析规则,
|
||||
// 两边各写一遍迟早漂移(一处补了分号、另一处没补),而配置解析不一致
|
||||
// 会让「我配了但没生效」这类问题更难查。
|
||||
func SplitPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
// 在这里就补前导斜杠:CLI 会把这个结果直接打给运维看,
|
||||
// 显示 "foo" 而实际生效 "/foo" 会让人以为是两个不同的东西
|
||||
// (New 里的 normalize 是幂等的,重复补不会出问题)。
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
// 拒绝根路径:"/" 会让**所有** 404 都变成假登录页 —— 正常访客输错
|
||||
// 网址也看到 WordPress 后台,是灾难级的配置失误,几乎不可能是本意。
|
||||
if f == "/" {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Options 是蜜罐行为配置。
|
||||
type Options struct {
|
||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||
@@ -212,11 +265,15 @@ type Credentials struct {
|
||||
}
|
||||
|
||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||
//
|
||||
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
|
||||
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
|
||||
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
|
||||
var credentialKeys = map[string]bool{
|
||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true,
|
||||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "token": true,
|
||||
}
|
||||
|
||||
// ExtractCredentials 从请求中提取破解凭据。
|
||||
@@ -240,7 +297,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
return
|
||||
}
|
||||
if len(v) > limit {
|
||||
v = v[:limit]
|
||||
v = clipRunes(v, limit)
|
||||
}
|
||||
if _, exists := out.Fields[k]; !exists {
|
||||
out.Fields[k] = v
|
||||
@@ -293,7 +350,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
}
|
||||
raw := string(buf)
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
raw = clipRunes(raw, limit)
|
||||
}
|
||||
out.RawBody = raw
|
||||
|
||||
@@ -390,15 +447,25 @@ func parseJSONCredentials(raw string, put func(k, v string)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
||||
func (h *Honeypot) Describer() string {
|
||||
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
|
||||
//
|
||||
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
|
||||
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
|
||||
// 调用方自己决定怎么展示。
|
||||
func (h *Honeypot) Prefixes() []string {
|
||||
if h == nil || !h.enabled {
|
||||
return "disabled"
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(h.prefix))
|
||||
out := make([]string, 0, len(h.prefix))
|
||||
for k := range h.prefix {
|
||||
keys = append(keys, k)
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return path.Join(keys...)
|
||||
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
|
||||
// 排序依赖渗透进整个包的 import 列表。
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
@@ -148,3 +149,67 @@ func TestExtractCredentialsMalformed(t *testing.T) {
|
||||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClipRunesKeepsValidUTF8 守住「按字节截断」的回归。
|
||||
//
|
||||
// 凭据是直接落库的,非法 UTF-8 会让 PostgreSQL 以
|
||||
// `invalid byte sequence for encoding "UTF8"` 拒绝整条插入 —— 取证记录静默丢失。
|
||||
// 攻击者只要发一个超长中文密码就能稳定触发,所以这是可达路径。
|
||||
func TestClipRunesKeepsValidUTF8(t *testing.T) {
|
||||
s := strings.Repeat("密码", 400) // 2400 字节 / 800 字符
|
||||
got := clipRunes(s, 512)
|
||||
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("截断结果不是合法 UTF-8(说明退回了字节截断)")
|
||||
}
|
||||
if n := len([]rune(got)); n != 512 {
|
||||
t.Errorf("截断后字符数 = %d, want 512", n)
|
||||
}
|
||||
// 确认这个用例真的能区分两种实现:字节截断在 512 处必然切在字符中间。
|
||||
if utf8.ValidString(s[:512]) {
|
||||
t.Fatal("测试素材本身能被字节安全截断,用例失去区分力")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCredentialsIgnoresCommonJSONFields 守住误报回归。
|
||||
//
|
||||
// "id" / "name" 是任意 JSON 里最常见的字段。把它们当凭据会让
|
||||
// 「扫描器 POST 了一个无关探测」被记成破解提交,并触发假的告警邮件。
|
||||
func TestExtractCredentialsIgnoresCommonJSONFields(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/thing",
|
||||
strings.NewReader(`{"id":1,"name":"probe","status":"ok"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.HadFields {
|
||||
t.Errorf("普通 JSON 探测不应被判定为凭据提交(Username=%q Password=%q)",
|
||||
creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitPaths 覆盖自定义诱饵路径的解析与归一化。
|
||||
func TestSplitPaths(t *testing.T) {
|
||||
raw := " /admin, foo;bar\n/baz "
|
||||
got := SplitPaths(raw)
|
||||
want := []string{"/admin", "/foo", "/bar", "/baz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("SplitPaths() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// 未写前导斜杠的项也应在 New 里被归一化,否则永远匹配不上。
|
||||
h := New(Options{Enabled: true, ExtraPaths: SplitPaths("foo")})
|
||||
if !h.Match("/foo/bar") {
|
||||
t.Error("自定义路径 foo 归一化后应能匹配 /foo/bar")
|
||||
}
|
||||
|
||||
// 根路径会让所有 404 都变成假登录页,必须拒绝。
|
||||
if got := SplitPaths("/"); len(got) != 0 {
|
||||
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,29 @@ type LoginPageVars struct {
|
||||
Redir string
|
||||
}
|
||||
|
||||
// LoginPageTitle 是假登录页的统一标题(与真实 WordPress 登录页一致)。
|
||||
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
|
||||
// 已经出现漂移。
|
||||
const LoginPageTitle = "Log In ‹ WordPress"
|
||||
|
||||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||||
//
|
||||
// 页面刻意包含:
|
||||
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
|
||||
// - 隐藏 canary 标记(HTML 注释),便于日后溯源;
|
||||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||||
// 我们就能拿到最干净的用户名与密码对;
|
||||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||||
//
|
||||
// ⚠️ canary 只放在 HTML 注释里,不放进 <meta name="generator">。
|
||||
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
|
||||
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
|
||||
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
|
||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
action := html.EscapeString(vars.Action)
|
||||
title := vars.Title
|
||||
if title == "" {
|
||||
title = "Log In ‹ WordPress"
|
||||
title = LoginPageTitle
|
||||
}
|
||||
heading := vars.Heading
|
||||
if heading == "" {
|
||||
@@ -46,12 +56,14 @@ func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
var b strings.Builder
|
||||
b.Grow(4096)
|
||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||||
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
|
||||
// canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
|
||||
b.WriteString(`<!-- build: ` + CanaryToken + ` -->` + "\n")
|
||||
b.WriteString(`<html lang="en-US">` + "\n")
|
||||
b.WriteString(`<head>` + "\n")
|
||||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||||
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
|
||||
// generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
|
||||
b.WriteString(`<meta name="generator" content="WordPress 6.4.3" />` + "\n")
|
||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||
|
||||
Reference in New Issue
Block a user