一、封禁链路整条没接上(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。
102 lines
3.4 KiB
Go
102 lines
3.4 KiB
Go
// 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 时不应产生封禁记录")
|
||
}
|
||
}
|