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

343 lines
9.7 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 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
}