// 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 }