fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库

一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
  既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
  honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
This commit is contained in:
tamakyi
2026-10-21 15:43:25 +08:00
parent 566b13eedd
commit c29e1d071f
21 changed files with 1573 additions and 268 deletions
+115
View File
@@ -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` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
和「这是 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 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
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)通过。
### 遗留
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
- 邮件告警仍会带明文用户名/密码(用户未要求改)。