问题:honeypot_hits 是只追加审计表,写入完全由外部扫描流量驱动(不可预期、 不可节流),而原实现只在手动跑 CLI prune 时才清理,站点运行期表体积只增不减。 实测取证(本地 PostgreSQL 建同构表灌 10.5 万行真实数据): - 典型行 244 字节(含索引),10.5 万行 24 MB - 保留期清理对照(行数恒定 10 万,每轮删 25% 插 25%): 有 VACUUM 22→24MB 收敛;无 VACUUM 22→52MB 持续膨胀 - VACUUM 不缩小文件,只让空间可复用;二次实测(5 万行、连续 4 轮删插) 行数恒定 5 万、文件稳定 15MB 不再增长 改动: - 新增 HoneypotRetention 后台清理器(每小时一轮):时间闸 honeypot_retain_days + 数量闸 honeypot_max_rows,清理后调 Compact 回收空间 - db: 新增 Compact()(PG 执行 VACUUM (ANALYZE),走 GORM Exec 非事务路径)、 DeleteOldestBeyond()(跨方言通用)、ListSinceByIP()、Size() - 去掉 honeypot_hits.provider 无用索引(10 万行占 648kB,全代码库零查询使用), 并在启动时显式清理既有库上的旧索引(GORM 去 tag 不会自动删) - 修 CLI hits --ip 过滤 bug:原先取回最近 N 条再在 Go 侧筛,导致指定 IP 的 历史命中基本查不出来 → 改到 SQL 层过滤 - 新增 CLI honeypot size 子命令:报告行数、磁盘占用、时间跨度,并与两道闸对照 - 新增配置 honeypot_auto_clean / honeypot_max_rows
130 lines
4.1 KiB
Go
130 lines
4.1 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 db
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestHoneypotStore 覆盖蜜罐命中记录的写入、检索与聚合。
|
||
//
|
||
// 重点验证「只追加」的审计语义:DeleteBefore 只按时间删,不做软删除;
|
||
// 以及 TopCredentials 能把攻击者正在用的字典聚合出来。
|
||
func TestHoneypotStore(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()
|
||
|
||
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)},
|
||
}
|
||
for _, r := range rows {
|
||
require.NoError(t, store.Create(ctx, r))
|
||
}
|
||
|
||
since := now.Add(-4 * time.Hour)
|
||
|
||
total, err := store.CountSince(ctx, since)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(4), total)
|
||
|
||
list, err := store.ListSince(ctx, since, 10)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 4)
|
||
// 倒序返回
|
||
require.Equal(t, "5.6.7.8", list[0].IP)
|
||
|
||
sources, err := store.TopSources(ctx, since, 10)
|
||
require.NoError(t, err)
|
||
require.Len(t, sources, 2)
|
||
require.Equal(t, "1.2.3.4", sources[0].IP)
|
||
require.Equal(t, int64(3), sources[0].Count)
|
||
require.Equal(t, "Azure", sources[0].Provider)
|
||
|
||
paths, err := store.TopPaths(ctx, since, 10)
|
||
require.NoError(t, err)
|
||
require.Len(t, paths, 3)
|
||
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)
|
||
require.Equal(t, int64(2), deleted) // 3 小时前、2 小时前两条被清
|
||
|
||
total, err = store.CountSince(ctx, since)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(2), total)
|
||
}
|
||
|
||
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
||
//
|
||
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
||
func TestHoneypotSize(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()
|
||
|
||
for i := 0; i < 3; i++ {
|
||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
||
IP: "9.9.9.9",
|
||
Method: "POST",
|
||
Path: "/wp-login.php",
|
||
Username: "admin",
|
||
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||
}))
|
||
}
|
||
|
||
info, err := store.Size(ctx)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(3), info.Rows)
|
||
require.NotNil(t, info.Oldest)
|
||
require.NotNil(t, info.Newest)
|
||
// 最早的一条是 2 小时前,最新的一条是刚刚。
|
||
require.True(t, info.Newest.After(*info.Oldest))
|
||
// 磁盘占用在 PostgreSQL 下应当可查且不为零(表刚建,至少有元数据开销)。
|
||
require.Greater(t, info.Bytes, int64(0))
|
||
|
||
// 清理后行数应随之下降,但磁盘占用不保证立刻变小
|
||
// (PostgreSQL 的 DELETE 只标记死元组,需 VACUUM 才回收)。
|
||
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(1), deleted)
|
||
|
||
info2, err := store.Size(ctx)
|
||
require.NoError(t, err)
|
||
require.Equal(t, int64(2), info2.Rows)
|
||
}
|