一、封禁链路整条没接上(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。
216 lines
7.2 KiB
Go
216 lines
7.2 KiB
Go
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)
|
||
}
|
||
}
|