线上实测: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 与空输入。
344 lines
9.8 KiB
Go
344 lines
9.8 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 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
|
||
}
|