一、封禁链路整条没接上(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。
204 lines
5.5 KiB
Go
204 lines
5.5 KiB
Go
// 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("空目录应返回错误")
|
||
}
|
||
}
|