Files
tamabox/internal/db/honeypot.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

346 lines
13 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.
// 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"
"time"
"gorm.io/gorm"
)
// HoneypotHit 是一次蜜罐命中记录(审计证据)。
//
// 刻意不嵌 dbutil.Model:审计证据不做软删除、不做 Update/Delete,
// 只追加与查询。理由与 ContentReport 一致 —— 证据链一旦被软删除过滤,
// 事后追责就断了。
type HoneypotHit struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
IP string `gorm:"size:64;index" json:"ip"`
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
Method string `gorm:"size:16" json:"method"`
Path string `gorm:"size:512" json:"path"`
// Query 保存原始查询串(截断)。
Query string `gorm:"size:1024" json:"query"`
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
//
// ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。
// 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认
// data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见
// internal/security/honeypot/credstore.go 的 CredRecord 注释:
// 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、
// 以及任何有只读权限的报表工具,事后无法彻底清除。
HadFields bool `json:"had_fields"`
// UserAgent / Referer / ContentType 用于扫描器画像。
UserAgent string `gorm:"size:512" json:"user_agent"`
Referer string `gorm:"size:512" json:"referer"`
ContentType string `gorm:"size:256" json:"content_type"`
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
//
// ⚠️ 刻意不加索引:全代码库没有任何按 provider 过滤或排序的查询
// (TopSources 用 max(provider) 随 group by 顺带取,不需要独立索引)。
// 实测该索引占 10 万行约 648 kB,纯属写放大。既有库上的旧索引由
// dropUnusedHoneypotIndexes 在启动时清掉。
Provider string `gorm:"size:64" json:"provider"`
Proxied bool `json:"proxied"`
// Banned 为命中时是否触发了封禁。
Banned bool `json:"banned"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (HoneypotHit) TableName() string { return "honeypot_hits" }
// Honeypots 蜜罐命中记录全局 store,由 db.Init 装配。
var Honeypots HoneypotStore
// HoneypotStore 蜜罐命中记录的读写。
type HoneypotStore interface {
// Create 追加一条命中记录。
Create(ctx context.Context, hit *HoneypotHit) error
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
// CountSince 统计 since 之后的命中总数。
CountSince(ctx context.Context, since time.Time) (int64, error)
// TopSources 返回 since 之后命中次数最多的来源 IP。
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
// TopPaths 返回 since 之后被命中次数最多的路径。
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
//
// 这是「行数上限」兜底:保留期给的是时间维度上界,但攻击者可以用海量
// 源 IP 疯狂扫描,把表在保留期内就撑爆。行数上限提供第二道闸。
DeleteOldestBeyond(ctx context.Context, keepRows int) (int64, error)
// Compact 回收 DeleteBefore 释放出的物理空间。
//
// 为什么需要:PostgreSQL 的 DELETE 只是把行标记为死元组,磁盘文件不会变小。
// 实测删掉 10.5 万行后表仍占 32MB,VACUUM 后才降到 10MB。
// MySQL/InnoDB 删除后空间留在表内可复用,无需额外操作。
Compact(ctx context.Context) error
// Size 报告蜜罐表的行数与实际磁盘占用。
//
// 存在的意义:把「会不会把数据库撑臃肿」这个判断交给运维自己随时能查,
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
LegacyCredentialColumns(ctx context.Context) ([]string, error)
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error)
}
// HoneypotSize 蜜罐表的体积报告。
type HoneypotSize struct {
// Rows 逻辑行数(存活记录)。
Rows int64
// Bytes 关系总大小(含索引),单位字节;无法取得时为 0。
Bytes int64
// Oldest / Newest 是最早与最新的记录时间,用于判断增长是否正常。
Oldest *time.Time
Newest *time.Time
}
// HoneypotSourceStat 来源 IP 聚合行。
type HoneypotSourceStat struct {
IP string
Count int64
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
Count int64
}
// LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。
//
// 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** ——
// 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行
// `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。
var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"}
// LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。
func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) {
var present []string
for _, col := range LegacyHoneypotCredentialColumns {
if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) {
present = append(present, col)
}
}
return present, nil
}
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
//
// 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是
// 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的
// 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。
func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) {
present, err := h.LegacyCredentialColumns(ctx)
if err != nil {
return nil, err
}
dropped := make([]string, 0, len(present))
for _, col := range present {
if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil {
return dropped, err
}
dropped = append(dropped, col)
}
return dropped, nil
}
type honeypots struct {
db *gorm.DB
}
// NewHoneypotStore 构造蜜罐记录 store。
func NewHoneypotStore(db *gorm.DB) HoneypotStore {
return &honeypots{db: db}
}
func (h *honeypots) Create(ctx context.Context, hit *HoneypotHit) error {
if hit == nil {
return nil
}
return h.db.WithContext(ctx).Create(hit).Error
}
func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
var rows []*HoneypotHit
err := h.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Where("created_at >= ?", since).
Count(&n).Error
return n, err
}
func (h *honeypots) TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotSourceStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("ip AS ip, count(*) AS count, max(provider) AS provider").
Where("created_at >= ?", since).
Group("ip").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ?", since).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
}
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录。
//
// 用子查询按 id 倒序取要保留的最小 id,再删掉比它更旧的行。
// 跨 PostgreSQL / MySQL 通用(只用了标准 SQL + 子查询,无方言特性)。
func (h *honeypots) DeleteOldestBeyond(ctx context.Context, keepRows int) (int64, error) {
if keepRows <= 0 {
return 0, nil
}
var keepID uint
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Select("id").
Order("id DESC").
Offset(keepRows - 1).
Limit(1).
Scan(&keepID).Error
if err != nil {
return 0, err
}
if keepID == 0 {
// 行数不足 keepRows,无需删除。
return 0, nil
}
res := h.db.WithContext(ctx).Where("id < ?", keepID).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
}
// Size 报告行数与磁盘占用。
//
// 大小取整个关系的总大小(pg_total_relation_size / information_schema),
// 含索引 —— 磁盘压力主要来自索引,只看堆表会低估。
func (h *honeypots) Size(ctx context.Context) (HoneypotSize, error) {
var out HoneypotSize
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).Count(&out.Rows).Error; err != nil {
return out, err
}
// 最早/最新记录时间:判断增长是否正常、清理是否在起作用。
var minRow struct{ CreatedAt *time.Time }
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Select("MIN(created_at) AS created_at").Scan(&minRow).Error; err == nil {
out.Oldest = minRow.CreatedAt
}
var maxRow struct{ CreatedAt *time.Time }
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Select("MAX(created_at) AS created_at").Scan(&maxRow).Error; err == nil {
out.Newest = maxRow.CreatedAt
}
// 大小查询按方言分派:PostgreSQL 与 MySQL 的元数据接口完全不同。
if h.db.Dialector != nil {
switch h.db.Dialector.Name() {
case "postgres":
var b int64
err := h.db.WithContext(ctx).
Raw("SELECT pg_total_relation_size('honeypot_hits')").Scan(&b).Error
if err == nil {
out.Bytes = b
}
case "mysql":
var b int64
err := h.db.WithContext(ctx).
Raw("SELECT COALESCE(SUM(data_length + index_length), 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = 'honeypot_hits'").
Scan(&b).Error
if err == nil {
out.Bytes = b
}
}
}
return out, nil
}
// Compact 回收删除记录释放出的物理空间。
//
// PostgreSQL:DELETE 只把行标记为死元组,表文件不会变小。
// 实测(10 万行、每轮删 25% 插 25%、行数恒定):
//
// 有 VACUUM:22 MB → 24 MB 后收敛,不再增长;
// 无 VACUUM:22 MB → 52 MB,持续膨胀。
//
// 即:没有 VACUUM,「保留期清理」只是让表逻辑变小,物理文件继续单调增长 ——
// 而蜜罐表是持续写入的,物理文件不缩就等于磁盘单调增长。
//
// ⚠️ 注意 VACUUM 不缩小文件,只是把死元组的空间标记为「可被后续插入复用」。
// 二次实测(5 万行、每轮删 12000 插 12000、连续 4 轮):
//
// 行数恒定 50000,文件稳定在 15 MB 不再增长。
//
// 这正是我们要的效果:磁盘占用止住,而不是「删了之后文件变小」。
// 想要文件本身立刻变小只有 VACUUM FULL(重建整表 + 锁表),代价大得多,
// 对蜜罐表(万级行)没有必要。
//
// MySQL/InnoDB:删除后的空间留在表内可被后续插入复用,无需额外操作。
func (h *honeypots) Compact(ctx context.Context) error {
if h.db.Dialector == nil {
return nil
}
switch h.db.Dialector.Name() {
case "postgres":
// VACUUM 不能在事务块内执行。用 Exec 走连接池的裸连接,
// 并显式关闭 GORM 的默认事务包装。
return h.db.WithContext(ctx).Exec("VACUUM (ANALYZE) honeypot_hits").Error
case "mysql":
// OPTIMIZE TABLE 会重建整表并短暂锁表,蜜罐表数据量小(实测万级行),
// 代价可接受;但放在自动清理里没必要 —— InnoDB 会复用空洞空间。
return nil
default:
return nil
}
}