Files
tamabox/internal/security/honeypot/honeypot_test.go
T
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

216 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package honeypot
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
func TestMatch(t *testing.T) {
h := New(Options{Enabled: true})
hit := []string{
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
"/wp-admin/../wp-content/x.php",
// 后缀匹配:备份/配置/密钥泄露扫描
"/backup.zip", "/www.zip", "/site.tar.gz", "/db.sql", "/dump.sql",
"/x.bak", "/config.php.bak", "/.env.bak", "/id_rsa.pem", "/private.key",
"/server.jks", "/data.ovpn",
}
for _, p := range hit {
if !h.Match(p) {
t.Errorf("Match(%q) = false, want true", p)
}
}
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
miss := []string{
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
"/explore", "/i18n/en",
// 业务 URL 空间里不存在这些后缀
"/_/admin.ini", "/api/v1/questions.json", "/user/settings.yaml",
}
for _, p := range miss {
if h.Match(p) {
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
}
}
}
func TestMatchDisabled(t *testing.T) {
h := New(Options{Enabled: false})
if h.Match("/wp-admin") {
t.Error("蜜罐未启用时不应匹配任何路径")
}
}
func TestMatchEncoded(t *testing.T) {
h := New(Options{Enabled: true})
// %2f 编码绕过
if !h.Match("/wp-admin%2f..%2fetc") {
t.Error("编码后的 wp-admin 路径应被匹配")
}
// 大小写
if !h.Match("/WP-ADMIN") {
t.Error("大小写不同的 WP-ADMIN 应被匹配")
}
// 查询串不应影响判定
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
t.Error("带查询串的 wp-login.php 应被匹配")
}
}
func TestExtraPaths(t *testing.T) {
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
if !h.Match("/secret-backdoor") {
t.Error("自定义诱饵路径应被匹配")
}
if !h.Match("/secret-backdoor/x.php") {
t.Error("自定义诱饵路径的子路径应被匹配")
}
}
func TestExtractCredentialsForm(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin", creds.Username)
}
if creds.Password != "admin123" {
t.Errorf("Password = %q, want admin123", creds.Password)
}
if !creds.HadFields {
t.Error("HadFields = false, want true")
}
}
func TestExtractCredentialsJSON(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
req.Header.Set("Content-Type", "application/json")
creds := h.ExtractCredentials(req)
if creds.Username != "root" {
t.Errorf("Username = %q, want root", creds.Username)
}
if creds.Password != "toor" {
t.Errorf("Password = %q, want toor", creds.Password)
}
}
func TestExtractCredentialsQuery(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "secret" {
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
}
}
func TestExtractCredentialsBasicAuth(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
req.SetBasicAuth("admin", "hunter2")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "hunter2" {
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
}
}
func TestExtractCredentialsEmpty(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
creds := h.ExtractCredentials(req)
if creds.HadFields {
t.Error("空请求不应标记为携带凭据")
}
}
func TestExtractCredentialsMalformed(t *testing.T) {
h := New(Options{Enabled: true})
// 畸形 body 不应 panic
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("%zz&log=admin&&=="))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
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)
}
}