Compare commits

...
3 Commits
Author SHA1 Message Date
tamakyi 5e8decab8f docs(memory): 记录蜜罐审计修复、凭据外置与提交状态 2026-10-21 15:47:13 +08:00
tamakyi c29e1d071f 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。
2026-10-21 15:43:25 +08:00
tamakyi 566b13eedd fix(conf): 样例配置去掉行尾注释(会被并入值导致静默失效)
conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
**不剥离**行尾注释:`port = 1440 ; 注释` 解析出来的值是 "1440 ; 注释",
int 解析失败,而 MapTo 仍然返回 nil error —— 配置项静默回退零值。

实测照抄 app.sample.ini 的后果:
- text_censor_source 的值带注释,被 validateTextCensorSource 拒绝,直接起不来;
- ip_blacklist / security_alert_email 变成带注释的垃圾字符串;
- honeypot_max_rows / honeypot_auto_clean 静默失效(后者靠 sanitize 兜底才没出事)。

改动:
- 17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF;
- 蜜罐段补 honeypot_cred_dir,以及「提交凭据才封禁」的语义说明;
- 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言值不含 ';'
  (比逐字段断言彻底,新增配置项自动纳入)。
  变异验证:给 ban_window_seconds 加回行尾注释,测试按预期失败;
- keynorm_test 补 HoneypotBanMinutes 的缺省值断言(缺省 1440 / 显式 0 交给 autoban 回退)。
2026-10-21 15:43:13 +08:00
23 changed files with 1725 additions and 312 deletions
+5
View File
@@ -32,3 +32,8 @@ go.work.sum
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
# 每次跑都会变,跟版本库无关。
/outputs/
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
/data/
+120
View File
@@ -195,3 +195,123 @@
- ⚠️ `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)通过。
### 遗留
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
### 已提交(2026-10-21,未推送)
- `566b13e` fix(conf): 样例配置去掉行尾注释(2 文件)
- `c29e1d0` fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
- `main` ahead of `origin/main` 2;用户要部署测试环境验证后再决定推送。
+89 -44
View File
@@ -6,22 +6,27 @@
[app]
production = true
external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
external_url = "https://box.shiroko.one"
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
icp = ""
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
default_lang = ""
uptrace_dsn = ""
qiniu_access_key = ""
qiniu_access_secret = ""
aliyun_access_key = ""
aliyun_access_key_secret = ""
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
; 默认分享图背景图链接(1280x720)
share_background = "https://your-default-background.jpg"
; 中文字体文件路径(相对项目根目录)
font_path = "static/fonts/NotoSansSC-Regular.otf"
[security]
enable_text_censor = true
text_censor_source = auto ; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
text_censor_source = auto
; 留空等同于 auto,即引入该配置前的历史行为。
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
@@ -40,9 +45,12 @@ text_censor_source = auto ; 文本审核服务通道:qiniu(七牛
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
ip_blacklist =
; 黑名单IP访问时强制跳转到该URL
ip_blacklist_redirect_url =
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
trust_proxy = false
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
@@ -65,47 +73,83 @@ trusted_proxies =
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
; EO-Client-IP(需源站已锁定)。
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
enable_auto_ban = true
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
ban_track_notfound = false
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
; 如需恢复对纯 404 字典扫描的封禁再置 true。
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
ban_window_seconds = 60 ; 统计窗口时长(秒)
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
ban_threshold_sensitive = 10
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
ban_threshold_404 = 20
; 统计窗口时长(秒)
ban_window_seconds = 60
; 临时封禁时长(分钟),到期自动解除,重启即清零
ban_duration_minutes = 15
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
security_alert_email =
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
enable_honeypot = false ; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)
; 时不再返回 302/404,而是返回一个仿 WordPress 的假登录页,
; 并把其提交的用户名/密码记录入库供取证。
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集;
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
; 要关闭自动清理请用 honeypot_auto_clean = false。
; 可用 TamaBox honeypot size 查看行数与磁盘占用。
honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量
; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。
; 缺省为 true(开启)。
honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。缺省 200000。
; 显式写 0 = 不限(会告警提醒表只增不减)。
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内
; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长
; (VACUUM 让空间可复用,不保证文件缩小)。
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
; ⚠️ 本段一律「注释独占一行、值单独一行」。
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
enable_honeypot = false
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
honeypot_paths =
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
; 要关闭自动清理请用 honeypot_auto_clean = false。
honeypot_retain_days = 30
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
honeypot_auto_clean = true
; 蜜罐命中记录的行数上限(第二道闸)。显式写 0 = 不限(会告警提醒表只增不减)。
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内把表撑爆。
; 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
honeypot_max_rows = 200000
; 「提交凭据」多少次即封禁。默认 1(一次即封):往假后台里填账号密码是证据最强
; 的行为,抓到一次就足够。⚠️ 不作用于纯 GET 探测 —— 那类请求走 ban_threshold_sensitive,
; 否则扫描器看一眼假登录页就被封,永远走不到提交凭据那一步。
honeypot_threshold = 1
; 蜜罐封禁时长(分钟)。缺省 1440(24 小时),刻意远长于 ban_duration_minutes:
; 命中蜜罐意味着对方已在提交账号密码,证据强度远高于单纯路径探测。
; 显式写 0 = 回退 ban_duration_minutes。
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
honeypot_ban_minutes = 1440
; 凭据取证文件的存放目录。
; ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
; (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
; 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
; 目录会被强制为 0700、文件 0600;相对路径以进程工作目录为基准。
; 目录不可写时只降级为「不保存凭据」,不影响站点启动。
; 建议单独挂一个卷,并让它不参与备份。
honeypot_cred_dir = data/honeypot-creds
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
default_moderation_strictness =
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
@@ -139,7 +183,8 @@ addr = "127.0.0.1:6379"
password = ""
[recaptcha]
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
captcha_type = recaptcha
domain = "https://www.recaptcha.net"
site_key = ""
server_key = ""
+144 -23
View File
@@ -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 ""
}
+12 -1
View File
@@ -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)
}
+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
}
}
+58
View File
@@ -236,6 +236,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows int
wantAutoClean bool
wantThreshold int
wantBan int
}{
{
name: "全未配置时回退安全默认值",
@@ -244,6 +245,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式关闭自动清理被尊重",
@@ -252,6 +254,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: false,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式保留期被采用",
@@ -260,6 +263,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "保留期写成 0 回退默认而非永不清理",
@@ -268,6 +272,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "行数上限显式 0 表示不限",
@@ -276,6 +281,27 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: 0,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式封禁时长被采用",
src: "[security]\nhoneypot_ban_minutes = 90\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 90,
},
{
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
// sanitize 不做覆盖(只告警),故期望值仍是 0。
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
src: "[security]\nhoneypot_ban_minutes = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 0,
},
}
@@ -290,6 +316,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
Security.HoneypotMaxRows = 0
Security.HoneypotAutoClean = false
Security.HoneypotThreshold = 0
Security.HoneypotBanMinutes = 0
File = f
if err := f.Section("security").MapTo(&Security); err != nil {
t.Fatalf("map security: %v", err)
@@ -308,6 +335,9 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
if Security.HoneypotThreshold != c.wantThreshold {
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
}
if Security.HoneypotBanMinutes != c.wantBan {
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
}
})
}
}
@@ -358,6 +388,34 @@ func TestSampleIniHasNoUnknownKeys(t *testing.T) {
}
}
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
//
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
//
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
//
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
path := filepath.Join("..", "..", "conf", "app.sample.ini")
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
if err != nil {
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
}
for _, sec := range f.Sections() {
for _, k := range sec.Keys() {
if strings.Contains(k.Value(), ";") {
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
sec.Name(), k.Name(), k.Value())
}
}
}
}
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
//
+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"` // 触发封禁时告警收件人(留空不发送)
}
+10
View File
@@ -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 {
+47 -54
View File
@@ -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
+25 -2
View File
@@ -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
}
+4 -42
View File
@@ -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)
+43 -11
View File
@@ -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),
}))
}
+101
View File
@@ -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 时不应产生封禁记录")
}
}
+179 -93
View File
@@ -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
}
+87 -5
View File
@@ -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)
}
+42 -12
View File
@@ -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 {
+3 -3
View File
@@ -48,9 +48,9 @@ type Options struct {
//
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
HoneypotEnabled bool
// HoneypotThreshold 蜜罐独立阈值:窗口内蜜罐命中次数达到即封禁。
// 默认 1(一次即封)—— 蜜罐路径与业务路由零交集,正常访客不可能命中,
// 一次命中就足以判定为扫描器,无需等 autoban 的 10 次阈值。
// HoneypotThreshold 蜜罐「提交凭据」达到多少次即封禁。
// 默认 1 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
// 纯路径探测不要走这个通道(否则对方 GET 一次就被封,走不到提交凭据那步)。
// 留空或 <= 0 时兜底为 1。
HoneypotThreshold int
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
+342
View File
@@ -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
}
@@ -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("空目录应返回错误")
}
}
+79 -12
View File
@@ -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
}
@@ -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)
}
}
+16 -4
View File
@@ -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 注释里,不放进 <meta name="generator">。
// 上一版把 "TamaBox honeypot <canary>" 写进了 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(`<!DOCTYPE html>` + "\n")
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
// canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
b.WriteString(`<!-- build: ` + CanaryToken + ` -->` + "\n")
b.WriteString(`<html lang="en-US">` + "\n")
b.WriteString(`<head>` + "\n")
b.WriteString(`<meta charset="UTF-8" />` + "\n")
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
// generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
b.WriteString(`<meta name="generator" content="WordPress 6.4.3" />` + "\n")
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")