一、封禁链路整条没接上(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。
25 KiB
2026-10-21
扫描者处置方案设计(未实施,仅设计文档)
- 触发:线上频繁收到「疑似批量扫描已自动封禁」告警,命中路径以 wp 探测 +
/.ssh/**为主,来源多 Azure 云 IP 池。 - 实测纠正一处错误认知:
internal/route/route.go:432的注释称「flamego 的 NotFound 会先写 404 状态再 Redirect」—— 错的。flamego v1.9.4 的NotFound只执行 handler,不写状态码;ctx.Redirect("/")实测输出 **302 → "/"。 最小复现验证:/wp-admin/includes/...与/.ssh/id_rsa` 均得 302 + Location:/。 后果:扫描器跟随重定向 → 每个探测路径都触发一次首页渲染(1C1G 纯浪费),且被判为「站点有价值」。 同时告警邮件文案「产生大量 404」与实际 302 不符。 - 方案分五层(L0 CDN 路径/UA 规则 → L1 真 404 分流 → L2 蜜罐假文件/假凭据 → L3 tarpit + 递进封禁 → L4 上游 abuse 举报):
- 核心判断:对云厂商僵尸网络做 IP 封禁无效,有效的是按路径在 CDN 拦 + 蜜罐取证 + 上游举报。
- 明确排除反向攻击/DoS/回打(法律风险 + Azure 共享出口伤及无辜 + 招致 CDN 封站)。
- 蜜罐:命中
/.ssh/id_rsa、/.env、/wp-login.php等诱饵路径 → 短路返回假内容(含 canary 标记)+ 永久封禁 + 留证。 - 封禁需落库(当前内存 map,重启清零)+ 时长递进 15m→1h→6h→24h→7d→永久 + 多维(IP / IPv6 /64 / UA 家族)。
- tarpit 必须带全局并发上限 + 单连接超时,否则 1C1G 自身变 DoS 入口。
- 设计文档:
outputs/scanner-defense-design-2026-10-21.md(含分级变更清单、文件级改动点、验收指标)。 - 未动任何生产代码,等用户决定实施范围。
蜜罐(虚假后台登录界面 + 破解凭据记录)已实施
用户明确需求原文:「能不能把这些页面指向一个简单的虚假后台登录界面,然后记录这些破解的用户名密码等信息」。
落点决策(关键)
- 蜜罐不改写成新中间件,而是改写
internal/route/route.go的f.NotFound:- NotFound 只在「路由表完全落空」时触发 → 与
/login/register/_/{domain}/user/**/api/v1/**天然零交集; - 位于全局
f.Use链之后 →requestBodyLimitMiddleware已给 body 加 12 MiB 上限; - 组级中间件(含 CSRF)只挂具体路由,不作用于 NotFound → POST 表单可安全解析;
- flamego
context.run()在ResponseWriter().Written()为真时 return → 写完响应不调ctx.Next()即安全短路。
- NotFound 只在「路由表完全落空」时触发 → 与
- 双重计数坑:autoban 中间件对敏感路径已
Record一次,蜜罐再记一次会让阈值实际减半。 解决:autobanMiddleware(b, hp)传入蜜罐匹配器,蜜罐命中的路径由蜜罐自己计数。
新增文件
internal/security/honeypot/honeypot.go— 匹配器(段前缀 + 片段子串 + 双重保险forbiddenPrefixs排除业务路由)、 凭据提取(表单 / JSON / BasicAuth / 查询串)、CanaryToken = "tb-hp-7f3a1c9e"。internal/security/honeypot/page.go— 仿 WordPress 假登录页(经典字段log/pwd/redirect_to),内联 CSS 不依赖站点静态资源。internal/db/honeypot.go—HoneypotHit(不嵌 dbutil.Model,审计证据只追加)+HoneypotStore。internal/route/honeypot.go— 处理器:GET 假登录页 / POST 假失败响应 / 落库 / 复用 autoban 封禁 / 每 IP 告警一次。internal/cmd/honeypot.go—TamaBox honeypot hits|stats|prune,密码默认脱敏(--show-password显示明文)。templates/mail/honeypot-alert.html— 告警邮件模板(mail/已在 embed 清单,无需改 embed.go)。
改动文件
internal/route/route.go(CRLF):NotFound 分流 + 蜜罐实例装配 +hph.sendAlert。internal/route/autoban.go(LF):autobanMiddleware(b, hp),蜜罐路径跳过自身 Record。internal/db/db.go(LF):AllTables加&HoneypotHit{},Init()加Honeypots = NewHoneypotStore(db)。internal/conf/static.go(CRLF):enable_honeypot/honeypot_paths/honeypot_retain_days。conf/app.sample.ini(CRLF):对应说明段。internal/mail/mail.go(LF):SendHoneypotAlertMail+HoneypotAlert。cmd/tamabox.go+ 根tamabox.go:注册cmd.Honeypot(两处必须同步)。
踩坑
normalize()里for len(p)>1 { TrimSuffix(p,"/") }无限循环(路径不含尾斜杠时字符串不变)→ 改成HasSuffix判断。url.ParseQuery遇非法百分号编码返回 error 但仍给出部分字段 → 蜜罐要「尽可能多拿凭据」,忽略 error 继续用部分结果。- JSON 凭据提取:手写解析器又脏又易错 → 改用
jsonPairRe正则扫描扁平字段。 templates/mail/已在 embed 清单内 → 新增邮件模板不需要改templates/embed.go(顶层文件才需要)。
验证
go build ./...通过;go test ./...全绿(含 DB 测试,postgres 门控)。- 新增测试:
internal/security/honeypot/honeypot_test.go(匹配/业务路由零交集/编码绕过/凭据提取)、internal/route/honeypot_test.go(假页含 canary、业务路由不被劫持、关闭时回落 302、POST 返回失败页)、internal/db/honeypot_test.go(写入/倒序/三张聚合表/保留期清理)。
测试站黑盒验证 + P0 缺陷修复(蜜罐封禁被 enable_auto_ban 连坐)
测试站:https://box-dev-api.337845818.best(Cloudflare Tunnel + CF 边缘,cf-cache-status: DYNAMIC、无 Age → 不缓存,请求确实回源)。
生产站 https://box.tama.guru 强制 301 → www,本机不可达,无法做对照。
黑盒验证结果(全部通过)
- 诱饵路径全部 200 +
text/html:/wp-login.php2529B、/wp-admin/2517B、/.ssh/id_rsa2525B、/.env2497B、/administrator2533B。 - 响应体含 canary
tb-hp-7f3a1c9e(2 处)、<title>Log In ‹ WordPress</title>、name="log"/name="pwd"/name="redirect_to"。 - 响应头:
Cache-Control: no-store, no-cache, must-revalidate、x-robots-tag: noindex, nofollow、x-frame-options: DENY。 - POST 凭据提交 → 200 +
id="login_error">ERROR: Invalid username or password.。 - 路径变体全命中:
/WP-ADMIN(大小写)、/wp-admin%2f..%2fetc(编码绕过)、//wp-admin//x.php(双斜杠)、/.git/config、/.aws/credentials、/phpmyadmin/、/actuator/env、/xmlrpc.php、/vendor/phpunit/x.php、/console、/cgi-bin/test.cgi、/wp-content/plugins/x.php、/.env.bak、/wp-json/wp/v2/users。 - 凭据来源全覆盖:表单 POST、JSON body、BasicAuth、查询串、POST 到
/.ssh/id_rsa。 - 业务路由零交集(红线):
/34192B、/login23420B、/register25194B、/_/admin639358B、/user302、/api/v1/questions302、/robots.txt145B;非诱饵随机路径/nonexistent-random-xyz仍 302→/。
P0 缺陷:蜜罐命中 70+ 次但站点零封禁
- 根因:
autoban.Record()第一行if !b.enabled { return },b.enabled来自enable_auto_ban。 运维为避免共享出口误伤而关掉enable_auto_ban→ 蜜罐被另一个开关连坐,只落库不封禁。 - 对照组证明非蜜罐专属:
/index.php(autoban 敏感、蜜罐不匹配)连打 40 次 + 30 并发同样零封禁。 - 排除项:CF 缓存(DYNAMIC/无 Age)、flamego NotFound 链(源码确认全局 Use 链生效)、蜜罐匹配器、阈值配置。
修复(已实施,测试全绿):
autoban.Options新增HoneypotEnabled/HoneypotThreshold(默认 1)/HoneypotDuration(默认复用常规)。- 新增
Banner.RecordHoneypot(ip, path) bool:独立计数、独立阈值,不受enabled约束;键与Record一致(同样shared.LimitKey归一化),否则IsBanned判不出来。 IsBanned()移除!b.enabled早退 ——enabled只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。internal/route/honeypot.go:banned = h.ab.RecordHoneypot(ip, path)。internal/route/route.go(CRLF)装配三个蜜罐字段;conf/static.go+conf/app.sample.ini(CRLF)新增honeypot_threshold/honeypot_ban_minutes。- 告警:仍走既有
sendAlert(「已封禁」)+ 蜜罐自己的告警邮件(「捕获了什么凭据」),互补。
后缀匹配补缺
/backup.zip、/db.sql、/id_rsa.pem这类泄露扫描文件名主体随机,只有后缀稳定。- 原
honeypotFragments用"/.zip"子串匹配 →.zip前是k不是/,全漏网。 - 新增
honeypotSuffixes(.zip .tar .tar.gz .tgz .gz .rar .7z .bz2 .sql .sql.gz .dump .bak .old .orig .save .swp .env .env.bak .env.local .pem .key .p12 .pfx .jks .kdbx .ovpn),按path.Base()结尾匹配。
踩坑
- ⚠️ 测试里
Duration: 60= 60 纳秒,封禁瞬间过期 → 表现为「时序巧合的 200/200/403」,测试假失败。一律写60 * time.Minute。 - ⚠️ 路由测试只注册路由 + NotFound 会被绕过:执行
IsBanned→ 403 的是autobanMiddleware,测试服务器必须f.Use(autobanMiddleware(ab, hp))。 - ⚠️
internal/route/route.go、internal/conf/static.go、conf/app.sample.ini实际是全 CRLF;grep | cat -A不显示^M,极易误判为 LF。Python 脚本按\n锚点替换会静默失败 → 用\r\n锚点 + 写后统计b'\r\n'复核。 - 假页纯静态 HTML(内联 CSS,不落盘)、告警走 SMTP → 取证数据只在
honeypot_hits一张表,服务器无文件副本;autoban 封禁表是内存态(重启清零)。 - 测试阶段产生的噪声凭据(
testadmin/S3cr3tP@ss等)可用TamaBox honeypot prune清理。
遗留(需用户侧)
- 本轮修复需重新部署才在测试站生效;部署后
curl一次/wp-login.php应 200、再一次 403。 - 需确认测试环境
conf/app.ini的[security] enable_auto_ban(零封禁直接原因)+enable_honeypot = true。 - 无法 SSH(
100.79.185.45:19999全部账号 Permission denied),落库验证需用户执行TamaBox honeypot hits --since 24h --show-password。
蜜罐表体积实测 + 自动清理(commit 63e851d,已 push)
用户问「这些记录会不会导致数据库很臃肿」。不靠推断,本地 PostgreSQL 建同构表灌仿真数据实测。
实测数字
- 典型行 244 字节(heap 201 + 索引 43),10.5 万行 = 24 MB;常规 UA 下实测 281 B/行。
- 字段均值:path 13B、user_agent 111B(单行最大头)、fields 3B、raw_body 3B。
raw_body被maxCredentialFieldLen = 512截断,故字段数量随之受限(200 字段场景 rawBody 仍 512B)。- 最坏情况(手工构造 50 字段 × 500B)约 2375 B/行。
- UA 截短收益:截到 100 省 1129 kB / 截到 64 省 4824 kB(10 万行)→ 仅 5–20%, 远小于自动清理的确定性上界且损失取证信息 → 决定不做。
PostgreSQL 死元组语义(本轮最关键认知)
DELETE只标记死元组,表文件不会变小;空间需 VACUUM 后才可被后续 INSERT 复用。- 对照(10 万行,每轮删 25% 插 25%,行数恒定):有 VACUUM 22→24 MB 收敛;无 VACUUM 22→52 MB 膨胀。
- ⚠️ VACUUM 不缩小文件,只让空间可复用。二次实测(5 万行、连续 4 轮删 12000 插 12000):
行数恒定 5 万、文件稳定 15 MB 不再增长 —— 这才是要的效果(止住增长,不是让文件变小)。
想让文件立刻变小只有
VACUUM FULL(重建整表 + 锁表),蜜罐表万级行没必要。 - ⚠️
VACUUM不能在事务块内执行(SQLSTATE 25001),也不能在 PL/pgSQL 函数体内执行(VACUUM 无法从函数执行)。 GORMdb.Exec()走callbacks.Raw()处理器、不包事务 →WithContext(ctx).Exec("VACUUM ...")可用(已实测)。 - PG18 已移除
pg_stat_user_tables.n_tup_vacuum,改用last_vacuum验证。
索引实测(EXPLAIN 驱动决策)
idx_honeypot_hits_provider:10 万行 648 kB,全代码库零查询使用(TopSources 用 max(provider) 随 group by 顺带取)→ 已删。idx_honeypot_hits_created_at:EXPLAIN 显示Index Scan Backward,被ListSince实际使用 → 保留。idx_honeypot_hits_ip:4.7 MB(最大),但 GROUP BY ip 走 Seq Scan + HashAggregate;保留(ListSinceByIP需它)。- ⚠️ GORM 去掉 struct tag 的
index不会删除既有库上的旧索引 → 需显式HasIndex+DropIndex(已加dropUnusedHoneypotIndexes,启动时跑,失败仅告警)。
落地改动
internal/db/honeypot_retention.go(新):HoneypotRetention后台清理器,每小时一轮,两道闸 (时间honeypot_retain_days+ 数量honeypot_max_rows)→ 清理后调Compact()。 保留期 <=0 视为不自动清理(只告警);runOnce开头必须再挡一次retainDays<=0, 否则 before=now 会把历史全删光(「保留 0 天」被误解释成「只留此刻」)。internal/db/honeypot.go(新方法):Compact()(PGVACUUM (ANALYZE))、DeleteOldestBeyond()(跨方言通用,子查询按 id 倒序取 keepRows 边界)、ListSinceByIP()、Size()。internal/cmd/web.go:接入清理器,stopRetention用 defer close。internal/cmd/honeypot.go:新增honeypot size子命令(行数/磁盘占用/时间跨度 + 与两道闸对照)。- 修 CLI
hits --ip真 bug:原先取回最近 N 条再在 Go 侧筛 → 指定 IP 的历史命中基本查不出来 → 改 SQL 层过滤。 - 新配置:
honeypot_auto_clean(默认 true)、honeypot_max_rows(默认 200000 ≈ 53 MB)。
踩坑
- ⚠️ 测试预期别自己推算错:
DeleteBefore是严格小于(created_at < before),边界那条不删。 - ⚠️ Python 补丁脚本里写 Go struct tag 的反引号 → bash 命令替换报错,必须写成 .py 文件执行(不能
python -c)。 - ⚠️
psql传中文 SQL 报编码错 → 用英文输出串或PGCLIENTENCODING=UTF8。 - ⚠️ 库名带连字符要用双引号:
CREATE DATABASE "tamabox-cli-test"。 - ⚠️ Edit 工具改 CRLF 文件会保持 CRLF,但改写时我打的字会转码损坏(出现过「实测指事/程定/不图」)→ 改完必须逐行读回复核。
- 仓库内统一存 LF,工作区 CRLF 由
core.autocrlf=true自动转换 —— 提交时 warning 属正常,不是行尾被改。
配置键名静默失效的根治(commit d300167,已 push)
现场故障
用户配置 honeypot_retention_days <= 30,日志报「蜜罐自动清理未启用:honeypot_retention_days <= 0」。
根因(探针实测 ini v1.67.0,不是读源码猜的)
- 键名多一个 t(retention vs retain)→ ini 对未知键静默忽略,保留期成 0。
<= 30这种写法让 ini 把键名解析成"honeypot_retention_days <"(带尾随比较符), 实测输出:key="honeypot_retention_days <" value="30"—— 更匹配不上。- 我之前那条警告文案里写的也是错键名
honeypot_retention_days, 等于在误导运维往错的键上配 —— 教训:警告文案里的键名必须与代码读的键名逐字一致。
实测到的 ini 行为(重要,写 ini 相关代码前必读)
MapTo在键缺失或值非法时不覆盖目标字段 → 可以安全预置缺省值。- 键名大小写敏感(
Honeypot_Auto_Clean不生效)。 HasKey能区分「缺失」与「显式 0」。- 段不存在时
MapTo返回 nil 且不修改目标。 - bool 的解析是「有键才赋值」→ 未配置与显式 false 无法用零值区分,必须靠 HasKey。
修复三层
internal/conf/keynorm.go(新):键名归一化(修剪空白、剥尾随<>=、别名表纠错)。 ⚠️ 归一化结果必须命中该段合法键名白名单才采用 —— 不认识的键不猜,交给未知键告警。internal/conf/unknown.go(新):启动逐段比对键名,未知键 WARN + 给出最接近的合法键名。 合法键名由反射从结构体 ini tag 得出(单一事实来源,新增配置项自动纳入)。internal/conf/honeypot_defaults.go(新):蜜罐缺省值语义修正 (保留期缺省 30、auto_clean 缺省 true 用 HasKey 判、max_rows 缺省 200000、threshold 缺省 1)。- 归一化必须在
MapTo之前做 —— 等 MapTo 跑完,错拼的键早被丢掉了。
顺带清理
app.sample.ini 里两条死配置:app.title(代码从未读,站点标题走 i18n site.title)、
server.xsrf_expire(无对应字段)。留着会让新检测对官方模板误报。
踩坑
- ⚠️ Python 脚本里写 Go struct tag 的反引号 → bash 命令替换报错。 改用 Edit 工具逐处改(Edit 对 CRLF 文件保持 CRLF,但我打的字可能转码损坏 → 改完逐行读回复核)。
- ⚠️
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 → PGSQLSTATE 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()并接到新 CLITamaBox 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)通过。
遗留
- 历史备份/主从复制里可能仍有旧明文凭据,代码无法处理,需运维自行清理。
- 邮件告警仍会带明文用户名/密码(用户未要求改)。