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:
tamakyi
2026-10-21 15:43:25 +08:00
parent 566b13eedd
commit c29e1d071f
21 changed files with 1573 additions and 268 deletions
+87 -5
View File
@@ -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)
}