一、封禁链路整条没接上(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。
241 lines
7.2 KiB
Go
241 lines
7.2 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 db
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestHoneypotRetentionRunOnce 覆盖蜜罐记录的两道清理闸:
|
||
// 时间闸(保留期)与数量闸(行数上限),以及清理后的空间回收。
|
||
//
|
||
// 这是「数据库会不会被扫描撑爆」的核心防线:蜜罐表只增不减,
|
||
// 写入完全由外部扫描流量驱动。
|
||
func TestHoneypotRetentionRunOnce(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()
|
||
|
||
// 5 条旧记录(超出保留期)+ 5 条新记录(在保留期内)
|
||
rows := []*HoneypotHit{}
|
||
for i := 0; i < 5; i++ {
|
||
rows = append(rows, &HoneypotHit{IP: "1.1.1.1", Method: "GET", Path: "/old", CreatedAt: now.Add(-90 * 24 * time.Hour)})
|
||
rows = append(rows, &HoneypotHit{IP: "2.2.2.2", Method: "GET", Path: "/new", CreatedAt: now.Add(-time.Hour)})
|
||
}
|
||
for _, r := range rows {
|
||
require.NoError(t, store.Create(ctx, r))
|
||
}
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(10), total)
|
||
|
||
// 保留期 30 天、行数上限 7:
|
||
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
||
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
||
ret := NewHoneypotRetention(store, nil, 30, 7, 0)
|
||
ret.runOnce()
|
||
|
||
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(5), remaining, "时间闸删掉 5 条旧记录后剩 5 条,未超数量上限")
|
||
|
||
// 剩下的应该全是新记录
|
||
list, err := store.ListSince(ctx, now.Add(-365*24*time.Hour), 10)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 5)
|
||
for _, r := range list {
|
||
require.Equal(t, "/new", r.Path, "旧的记录应已被时间闸删除")
|
||
}
|
||
}
|
||
|
||
// TestHoneypotRetentionDisabled 保留期 <= 0 时不删任何数据。
|
||
func TestHoneypotRetentionDisabled(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()
|
||
for i := 0; i < 3; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "3.3.3.3", Method: "GET", Path: "/x", CreatedAt: now.Add(-365 * 24 * time.Hour),
|
||
}))
|
||
}
|
||
|
||
ret := NewHoneypotRetention(store, nil, 0, 100, 0)
|
||
ret.runOnce()
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(3), total, "保留期 <= 0 时不应删除任何记录")
|
||
}
|
||
|
||
// TestHoneypotRetentionMaxRowsOnly 只设行数上限、不设保留期时,数量闸仍生效。
|
||
func TestHoneypotRetentionMaxRowsOnly(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()
|
||
for i := 0; i < 20; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "4.4.4.4", Method: "GET", Path: "/p", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
||
}))
|
||
}
|
||
|
||
ret := NewHoneypotRetention(store, nil, 30, 5, 0)
|
||
ret.runOnce()
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(5), total)
|
||
}
|
||
|
||
// TestHoneypotRetentionBothGates 两道闸叠加:时间闸先删超期的,
|
||
// 剩余仍超数量上限时,数量闸再删最旧的。
|
||
func TestHoneypotRetentionBothGates(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()
|
||
|
||
// 5 条超保留期(90 天前)+ 10 条保留期内
|
||
for i := 0; i < 5; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "6.6.6.6", Method: "GET", Path: "/ancient", CreatedAt: now.Add(-90 * 24 * time.Hour),
|
||
}))
|
||
}
|
||
for i := 0; i < 10; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "7.7.7.7", Method: "GET", Path: "/recent",
|
||
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
||
}))
|
||
}
|
||
|
||
// 保留期 30 天、数量上限 4:
|
||
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
||
ret := NewHoneypotRetention(store, nil, 30, 4, 0)
|
||
ret.runOnce()
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(4), total, "两道闸叠加后应回到数量上限")
|
||
|
||
list, err := store.ListSince(ctx, now.Add(-365*24*time.Hour), 10)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 4)
|
||
for _, r := range list {
|
||
require.Equal(t, "/recent", r.Path)
|
||
}
|
||
// 留下的应是最新的 4 条(now-0m ~ now-3m),最旧的应已被数量闸删掉
|
||
require.Contains(t, list[0].Path, "recent")
|
||
}
|
||
|
||
// TestHoneypotDeleteOldestBeyond 直接测数量闸的删除语义。
|
||
func TestHoneypotDeleteOldestBeyond(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()
|
||
for i := 0; i < 10; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "7.7.7.7", Method: "GET", Path: "/p",
|
||
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
||
}))
|
||
}
|
||
|
||
// 保留 3 条 → 删掉最旧的 7 条
|
||
n, err := store.DeleteOldestBeyond(ctx, 3)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(7), n)
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(3), total)
|
||
|
||
// 行数不足时不应删任何东西
|
||
n2, err := store.DeleteOldestBeyond(ctx, 100)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(0), n2)
|
||
|
||
total2, err := store.CountSince(ctx, now.Add(-24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(3), total2)
|
||
|
||
// keepRows <= 0 视为不限制
|
||
n3, err := store.DeleteOldestBeyond(ctx, 0)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(0), n3)
|
||
}
|
||
|
||
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
||
func TestHoneypotCompact(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()
|
||
for i := 0; i < 10; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "5.5.5.5", Method: "GET", Path: "/p", CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||
}))
|
||
}
|
||
n, err := store.DeleteBefore(ctx, now.Add(-2*time.Hour))
|
||
require.NoError(t, err)
|
||
// 10 条记录分别落在 now-0h … now-9h;created_at < now-2h 是严格小于,
|
||
// 恰好落在 now-2h 的那条保留 → 删掉 now-3h 起的 7 条。
|
||
require.Equal(t, int64(7), n)
|
||
|
||
require.NoError(t, store.Compact(ctx), "空间回收不应报错")
|
||
|
||
total, err := store.CountSince(ctx, now.Add(-24*time.Hour))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(3), total, "回收空间不应影响数据正确性")
|
||
}
|