Files
tamabox/internal/security/honeypot/credstore.go
T
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00

344 lines
9.8 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 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 是精确客户端地址;封禁用的 /64 聚合键不会写进来(见 db.HoneypotHit.IP)。
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
}