一、封禁链路整条没接上(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。
343 lines
9.7 KiB
Go
343 lines
9.7 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 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
|
||
}
|