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。
This commit is contained in:
tamakyi
2026-10-21 15:43:25 +08:00
parent 566b13eedd
commit c29e1d071f
21 changed files with 1573 additions and 268 deletions
+342
View File
@@ -0,0 +1,342 @@
// 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 (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// CredRecord 是一条凭据取证记录。
//
// 为什么单独落文件、绝不进数据库:这些是外部攻击者提交的**真实账号密码**
// (多是从别处泄露后复用的),一旦入库就会跟着进备份、进主从复制、进任何
// 能连库的报表/BI 工具,事后想彻底清除几乎做不到。文件可以:
// - 用 0600 权限、目录 0700,与数据库账号的权限边界分开;
// - 按天分文件,到期直接删文件(DB 的 DELETE 只是标记死元组);
// - 目录可以单独挂 tmpfs / 独立卷,甚至不参与任何备份。
//
// 数据库里只保留「发生过一次带凭据的提交」这个事实(HadFields 等元数据)。
type CredRecord struct {
Time time.Time `json:"time"`
IP string `json:"ip"`
Method string `json:"method"`
Path string `json:"path"`
Query string `json:"query,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
RawBody string `json:"raw_body,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Banned bool `json:"banned"`
}
// CredStat 是一条「用户名/密码组合」的命中次数。
type CredStat struct {
Username string
Password string
Count int64
}
// CredStore 是凭据取证记录的存储。
//
// 刻意只有「追加 / 读取 / 按天清理」三类操作:取证记录不做修改,
// 也就不需要 Update/Delete 单条(清理只按天删文件)。
type CredStore interface {
// Append 追加一条记录,按记录时间落到当天的文件。
Append(rec CredRecord) error
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
// ip 非空时只返回该来源的记录(在读取阶段过滤)。
ListSince(since time.Time, ip string, limit int) ([]CredRecord, error)
// TopCredentials 返回 since 之后最常被尝试的凭据组合。
TopCredentials(since time.Time, limit int) ([]CredStat, error)
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
PurgeBefore(before time.Time) (int, error)
// Size 报告文件数与总字节数。
Size() (files int, bytes int64, err error)
// Dir 返回存储目录,供 CLI 展示。
Dir() string
}
const (
credFilePrefix = "creds-"
credFileSuffix = ".jsonl"
credDateLayout = "2006-01-02"
// maxCredScanRecords 单次聚合扫描的记录上限。
// TopCredentials 必须扫全量才能排出「最常用凭据」,这里给个硬上限
// 防止目录被刷爆时把 CLI 拖死 —— 超出部分只是不参与排行,不影响取证。
maxCredScanRecords = 200000
)
type fileCredStore struct {
dir string
mu sync.Mutex
}
// NewFileCredStore 打开(必要时创建)凭据目录。
//
// 目录权限强制 0700:记录里是明文账号密码,同机其他用户不该能读。
// 出错时由调用方决定降级策略(蜜罐是安全增强功能,不能因为它启动失败)。
func NewFileCredStore(dir string) (CredStore, error) {
if strings.TrimSpace(dir) == "" {
return nil, fmt.Errorf("honeypot credential dir is empty")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
// MkdirAll 对已存在的目录不改动权限,显式收紧一次,避免沿用 0755。
if err := os.Chmod(dir, 0o700); err != nil {
return nil, err
}
return &fileCredStore{dir: dir}, nil
}
// Dir 返回存储目录。
func (s *fileCredStore) Dir() string { return s.dir }
func credFileName(t time.Time) string {
return credFilePrefix + t.Format(credDateLayout) + credFileSuffix
}
// credFileDate 从文件名解析出日期(本地时区)。
func credFileDate(path string) (time.Time, bool) {
base := filepath.Base(path)
base = strings.TrimPrefix(base, credFilePrefix)
base = strings.TrimSuffix(base, credFileSuffix)
d, err := time.ParseInLocation(credDateLayout, base, time.Local)
if err != nil {
return time.Time{}, false
}
return d, true
}
// Append 追加一条记录。
func (s *fileCredStore) Append(rec CredRecord) error {
if rec.Time.IsZero() {
rec.Time = time.Now()
}
b, err := json.Marshal(rec)
if err != nil {
return err
}
// 一行一条,便于按行流式读取与人工 grep。
b = append(b, '\n')
s.mu.Lock()
defer s.mu.Unlock()
name := filepath.Join(s.dir, credFileName(rec.Time))
f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
// O_APPEND + 单次 Write:同一进程内另有互斥锁串行化;
// 多进程场景下 <= PIPE_BUF(Linux 4096 字节)的写是原子的,
// 单条记录通常远小于该值(超长 UA/表单已在上游截断)。
_, err = f.Write(b)
return err
}
// filesSince 返回可能包含 since 之后记录的文件(按时间升序)。
func (s *fileCredStore) filesSince(since time.Time) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(s.dir, credFilePrefix+"*"+credFileSuffix))
if err != nil {
return nil, err
}
sort.Strings(matches) // 文件名内嵌日期,字典序即时间序
if since.IsZero() {
return matches, nil
}
out := make([]string, 0, len(matches))
for _, p := range matches {
d, ok := credFileDate(p)
if !ok {
continue // 不认识的文件名,不碰
}
// 该文件覆盖 [d, d+1day),只有末尾晚于 since 才有内容。
if d.AddDate(0, 0, 1).After(since) {
out = append(out, p)
}
}
return out, nil
}
// readCredFile 按行解析一个文件。
//
// 坏行(例如进程在写入中途被杀导致的半条 JSON)直接跳过:
// 取证文件的价值在于「尽可能多读到」,不能因为一行损坏就整天的数据报废。
func readCredFile(path string) ([]CredRecord, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
out := make([]CredRecord, 0, 64)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var rec CredRecord
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
out = append(out, rec)
}
return out, sc.Err()
}
// ListSince 返回 since 之后的记录(时间倒序)。
func (s *fileCredStore) ListSince(since time.Time, ip string, limit int) ([]CredRecord, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
files, err := s.filesSince(since)
if err != nil {
return nil, err
}
out := make([]CredRecord, 0, limit)
// 从最新的文件往回读,凑够 limit 就停,避免把 30 天全读进内存。
for i := len(files) - 1; i >= 0; i-- {
recs, err := readCredFile(files[i])
if err != nil {
return out, err
}
for j := len(recs) - 1; j >= 0; j-- {
rec := recs[j]
if rec.Time.Before(since) {
continue
}
if ip != "" && rec.IP != ip {
continue
}
out = append(out, rec)
if len(out) >= limit {
return out, nil
}
}
}
return out, nil
}
// TopCredentials 统计 since 之后最常被尝试的凭据组合。
func (s *fileCredStore) TopCredentials(since time.Time, limit int) ([]CredStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
files, err := s.filesSince(since)
if err != nil {
return nil, err
}
type agg struct {
username string
password string
count int64
}
counts := make(map[string]*agg, 128)
scanned := 0
scan:
for _, p := range files {
recs, err := readCredFile(p)
if err != nil {
continue // 单个文件读失败不影响其余日期
}
for _, rec := range recs {
if rec.Time.Before(since) {
continue
}
if rec.Username == "" && rec.Password == "" {
continue
}
key := rec.Username + "\x00" + rec.Password
if a, ok := counts[key]; ok {
a.count++
} else {
counts[key] = &agg{username: rec.Username, password: rec.Password, count: 1}
}
scanned++
if scanned >= maxCredScanRecords {
break scan
}
}
}
out := make([]CredStat, 0, len(counts))
for _, a := range counts {
out = append(out, CredStat{Username: a.username, Password: a.password, Count: a.count})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Username < out[j].Username
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// PurgeBefore 删除早于 before 的整天文件。
//
// 只删「整天的日期都早于 before」的文件:边界当天的文件里可能还有
// before 之后的记录,不能整文件丢。与 Append 共用互斥锁,
// 避免删到刚打开准备写入的文件。
func (s *fileCredStore) PurgeBefore(before time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
files, err := s.filesSince(time.Time{})
if err != nil {
return 0, err
}
removed := 0
for _, p := range files {
d, ok := credFileDate(p)
if !ok {
continue
}
if !d.Before(before) {
continue
}
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
continue
}
return removed, err
}
removed++
}
return removed, nil
}
// Size 报告文件数与总字节数。
func (s *fileCredStore) Size() (int, int64, error) {
matches, err := filepath.Glob(filepath.Join(s.dir, credFilePrefix+"*"+credFileSuffix))
if err != nil {
return 0, 0, err
}
var total int64
for _, p := range matches {
if fi, err := os.Stat(p); err == nil {
total += fi.Size()
}
}
return len(matches), total, nil
}