Files
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00

232 lines
8.0 KiB
Go
Raw Permalink 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.
// 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", 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))
}
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)
// 保留期清理
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)
}
// 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 覆盖体积报告:行数、时间跨度、磁盘占用。
//
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
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",
HadFields: true,
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)
}
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
//
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
// - [since, until) 左闭右开,相邻两期不能重叠;
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
func TestHoneypotAggregateRange(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)
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 7)
rows := []*HoneypotHit{
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
// 窗口外(右侧开区间边界):不能算进本期
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
// 窗口外(早于起点)
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
sources, err := store.AggregateSources(ctx, start, end)
require.NoError(t, err)
require.Len(t, sources, 2)
// 按命中数降序:203.0.113.1 在前
require.Equal(t, "203.0.113.1", sources[0].IP)
require.Equal(t, int64(3), sources[0].Hits)
require.Equal(t, int64(3), sources[0].Banned)
require.Equal(t, int64(1), sources[0].Creds)
require.Equal(t, "Azure", sources[0].Provider)
require.Equal(t, "198.51.100.2", sources[1].IP)
require.Equal(t, int64(1), sources[1].Hits)
require.Equal(t, int64(0), sources[1].Banned)
paths, err := store.AggregatePaths(ctx, start, end, 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)
stamps, err := store.TimestampsBetween(ctx, start, end)
require.NoError(t, err)
require.Len(t, stamps, 4, "右边界那条不能算进来")
for _, ts := range stamps {
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
}
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
require.NoError(t, err)
require.Empty(t, empty)
}