diff --git a/.gitignore b/.gitignore
index 4f716b8..46de1c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,8 @@ go.work.sum
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
# 每次跑都会变,跟版本库无关。
/outputs/
+
+# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
+# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
+# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
+/data/
diff --git a/.workbuddy/memory/2026-10-21.md b/.workbuddy/memory/2026-10-21.md
index fe895a2..ee25ba2 100644
--- a/.workbuddy/memory/2026-10-21.md
+++ b/.workbuddy/memory/2026-10-21.md
@@ -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` `` 把「这是蜜罐」
+ 和「这是 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 从 `` 移到 HTML 注释(``),
+ 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)通过。
+
+### 遗留
+- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
+- 邮件告警仍会带明文用户名/密码(用户未要求改)。
diff --git a/internal/cmd/honeypot.go b/internal/cmd/honeypot.go
index a835062..31de856 100644
--- a/internal/cmd/honeypot.go
+++ b/internal/cmd/honeypot.go
@@ -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 ""
-}
diff --git a/internal/cmd/web.go b/internal/cmd/web.go
index d1969f3..09bf774 100644
--- a/internal/cmd/web.go
+++ b/internal/cmd/web.go
@@ -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)
}
diff --git a/internal/conf/honeypot_defaults.go b/internal/conf/honeypot_defaults.go
index eb44da7..b273139 100644
--- a/internal/conf/honeypot_defaults.go
+++ b/internal/conf/honeypot_defaults.go
@@ -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
+ }
}
diff --git a/internal/conf/static.go b/internal/conf/static.go
index 1c58760..76c1f23 100644
--- a/internal/conf/static.go
+++ b/internal/conf/static.go
@@ -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"` // 触发封禁时告警收件人(留空不发送)
}
diff --git a/internal/db/db.go b/internal/db/db.go
index 8270835..5de835e 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -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 {
diff --git a/internal/db/honeypot.go b/internal/db/honeypot.go
index d984be7..fc28360 100644
--- a/internal/db/honeypot.go
+++ b/internal/db/honeypot.go
@@ -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
diff --git a/internal/db/honeypot_retention.go b/internal/db/honeypot_retention.go
index 784f240..57c699a 100644
--- a/internal/db/honeypot_retention.go
+++ b/internal/db/honeypot_retention.go
@@ -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
}
diff --git a/internal/db/honeypot_retention_test.go b/internal/db/honeypot_retention_test.go
index a4ef9b6..0e9dfaa 100644
--- a/internal/db/honeypot_retention_test.go
+++ b/internal/db/honeypot_retention_test.go
@@ -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)
diff --git a/internal/db/honeypot_test.go b/internal/db/honeypot_test.go
index 6a1de3f..d8bc4a6 100644
--- a/internal/db/honeypot_test.go
+++ b/internal/db/honeypot_test.go
@@ -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),
}))
}
diff --git a/internal/route/banner_config_test.go b/internal/route/banner_config_test.go
new file mode 100644
index 0000000..bba2e4b
--- /dev/null
+++ b/internal/route/banner_config_test.go
@@ -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 时不应产生封禁记录")
+ }
+}
diff --git a/internal/route/honeypot.go b/internal/route/honeypot.go
index aa68082..e5325ce 100644
--- a/internal/route/honeypot.go
+++ b/internal/route/honeypot.go
@@ -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
-}
diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go
index 4794adb..5a66f58 100644
--- a/internal/route/honeypot_test.go
+++ b/internal/route/honeypot_test.go
@@ -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)
}
diff --git a/internal/route/route.go b/internal/route/route.go
index 473edcf..ec2f8f4 100644
--- a/internal/route/route.go
+++ b/internal/route/route.go
@@ -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 {
diff --git a/internal/security/autoban/autoban.go b/internal/security/autoban/autoban.go
index a5a2bd8..3e683c6 100644
--- a/internal/security/autoban/autoban.go
+++ b/internal/security/autoban/autoban.go
@@ -48,9 +48,9 @@ type Options struct {
//
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
HoneypotEnabled bool
- // HoneypotThreshold 蜜罐独立阈值:窗口内蜜罐命中次数达到即封禁。
- // 默认 1(一次即封)—— 蜜罐路径与业务路由零交集,正常访客不可能命中,
- // 一次命中就足以判定为扫描器,无需等 autoban 的 10 次阈值。
+ // HoneypotThreshold 蜜罐「提交凭据」达到多少次即封禁。
+ // 默认 1 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
+ // 纯路径探测不要走这个通道(否则对方 GET 一次就被封,走不到提交凭据那步)。
// 留空或 <= 0 时兜底为 1。
HoneypotThreshold int
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
diff --git a/internal/security/honeypot/credstore.go b/internal/security/honeypot/credstore.go
new file mode 100644
index 0000000..ecde6c3
--- /dev/null
+++ b/internal/security/honeypot/credstore.go
@@ -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
+}
diff --git a/internal/security/honeypot/credstore_test.go b/internal/security/honeypot/credstore_test.go
new file mode 100644
index 0000000..9979816
--- /dev/null
+++ b/internal/security/honeypot/credstore_test.go
@@ -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("空目录应返回错误")
+ }
+}
diff --git a/internal/security/honeypot/honeypot.go b/internal/security/honeypot/honeypot.go
index cca071b..3fe0304 100644
--- a/internal/security/honeypot/honeypot.go
+++ b/internal/security/honeypot/honeypot.go
@@ -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
}
diff --git a/internal/security/honeypot/honeypot_test.go b/internal/security/honeypot/honeypot_test.go
index 6a07a1d..c6587b7 100644
--- a/internal/security/honeypot/honeypot_test.go
+++ b/internal/security/honeypot/honeypot_test.go
@@ -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)
+ }
+}
diff --git a/internal/security/honeypot/page.go b/internal/security/honeypot/page.go
index 4cf36d3..c2345c4 100644
--- a/internal/security/honeypot/page.go
+++ b/internal/security/honeypot/page.go
@@ -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 注释里,不放进 。
+// 上一版把 "TamaBox honeypot " 写进了 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(`` + "\n")
- b.WriteString(`` + "\n")
+ // canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
+ b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
+ // generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
+ b.WriteString(`` + "\n")
b.WriteString(`` + html.EscapeString(title) + `` + "\n")
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
b.WriteString(`` + "\n")