From c29e1d071f5f318cb9c375c3695a411066d7a306 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 21 Oct 2026 15:43:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(honeypot):=20=E8=A1=A5=E9=BD=90=E5=B0=81?= =?UTF-8?q?=E7=A6=81=E8=A3=85=E9=85=8D=E4=B8=8E=E8=AF=B1=E6=8D=95=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E5=87=AD=E6=8D=AE=E5=A4=96=E7=BD=AE=E4=B8=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8D=E5=86=8D=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、封禁链路整条没接上(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。 --- .gitignore | 5 + .workbuddy/memory/2026-10-21.md | 115 +++++++ internal/cmd/honeypot.go | 167 +++++++-- internal/cmd/web.go | 13 +- internal/conf/honeypot_defaults.go | 35 +- internal/conf/static.go | 22 +- internal/db/db.go | 10 + internal/db/honeypot.go | 101 +++--- internal/db/honeypot_retention.go | 27 +- internal/db/honeypot_retention_test.go | 46 +-- internal/db/honeypot_test.go | 54 ++- internal/route/banner_config_test.go | 101 ++++++ internal/route/honeypot.go | 272 ++++++++++----- internal/route/honeypot_test.go | 92 ++++- internal/route/route.go | 54 ++- internal/security/autoban/autoban.go | 6 +- internal/security/honeypot/credstore.go | 342 +++++++++++++++++++ internal/security/honeypot/credstore_test.go | 203 +++++++++++ internal/security/honeypot/honeypot.go | 91 ++++- internal/security/honeypot/honeypot_test.go | 65 ++++ internal/security/honeypot/page.go | 20 +- 21 files changed, 1573 insertions(+), 268 deletions(-) create mode 100644 internal/route/banner_config_test.go create mode 100644 internal/security/honeypot/credstore.go create mode 100644 internal/security/honeypot/credstore_test.go 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")