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
+33 -2
View File
@@ -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
}
}
+18 -4
View File
@@ -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"` // 触发封禁时告警收件人(留空不发送)
}