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。
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestCredStore(t *testing.T) CredStore {
|
||||
t.Helper()
|
||||
s, err := NewFileCredStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileCredStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestFileCredStoreAppendListFilter 覆盖追加、时间倒序、IP 过滤。
|
||||
func TestFileCredStoreAppendListFilter(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
|
||||
if err := s.Append(CredRecord{Time: now.Add(-2 * time.Hour), IP: "1.2.3.4", Username: "admin", Password: "a"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: now, IP: "5.6.7.8", Username: "root", Password: "b"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
recs, err := s.ListSince(now.Add(-3*time.Hour), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("记录数 = %d, want 2", len(recs))
|
||||
}
|
||||
if recs[0].IP != "5.6.7.8" {
|
||||
t.Errorf("首条 IP = %q, want 5.6.7.8(应按时间倒序)", recs[0].IP)
|
||||
}
|
||||
|
||||
// IP 过滤
|
||||
only, err := s.ListSince(now.Add(-3*time.Hour), "1.2.3.4", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince(ip): %v", err)
|
||||
}
|
||||
if len(only) != 1 || only[0].Username != "admin" {
|
||||
t.Fatalf("IP 过滤结果 = %+v, want 仅 1 条 admin", only)
|
||||
}
|
||||
|
||||
// 时间窗过滤
|
||||
recent, err := s.ListSince(now.Add(-time.Minute), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince(since): %v", err)
|
||||
}
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("近 1 分钟记录数 = %d, want 1", len(recent))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreSkipsCorruptLines 确保半行/坏行不会让整天数据报废。
|
||||
func TestFileCredStoreSkipsCorruptLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileCredStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileCredStore: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
name := filepath.Join(dir, credFileName(now))
|
||||
content := `{"time":"` + now.Format(time.RFC3339Nano) + `","ip":"1.1.1.1","username":"a"}` + "\n" +
|
||||
`{这行不是合法 JSON` + "\n" +
|
||||
"\n" +
|
||||
`{"time":"` + now.Add(time.Second).Format(time.RFC3339Nano) + `","ip":"2.2.2.2","username":"b"}` + "\n"
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recs, err := s.ListSince(now.Add(-time.Minute), "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSince: %v", err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("记录数 = %d, want 2(坏行与空行应被跳过)", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreTopCredentials 覆盖凭据排行榜的计数与排序。
|
||||
func TestFileCredStoreTopCredentials(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.Append(CredRecord{Time: now, IP: "1.1.1.1", Username: "admin", Password: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: now, IP: "2.2.2.2", Username: "root", Password: "b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := s.TopCredentials(now.Add(-time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("TopCredentials: %v", err)
|
||||
}
|
||||
if len(stats) != 2 {
|
||||
t.Fatalf("组合数 = %d, want 2", len(stats))
|
||||
}
|
||||
if stats[0].Username != "admin" || stats[0].Password != "a" || stats[0].Count != 3 {
|
||||
t.Errorf("首位 = %+v, want admin/a ×3", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStorePurgeBefore 覆盖按天删文件,且不碰边界当天。
|
||||
func TestFileCredStorePurgeBefore(t *testing.T) {
|
||||
s := newTestCredStore(t)
|
||||
now := time.Now()
|
||||
for _, d := range []int{-10, -5, 0} {
|
||||
if err := s.Append(CredRecord{Time: now.AddDate(0, 0, d), IP: "1.1.1.1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files, _, err := s.Size()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 3 {
|
||||
t.Fatalf("文件数 = %d, want 3(按天分文件)", files)
|
||||
}
|
||||
|
||||
removed, err := s.PurgeBefore(now.AddDate(0, 0, -7))
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeBefore: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("删除文件数 = %d, want 1(只应删 10 天前那份)", removed)
|
||||
}
|
||||
|
||||
files, _, err = s.Size()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 2 {
|
||||
t.Errorf("剩余文件数 = %d, want 2", files)
|
||||
}
|
||||
|
||||
// 剩下的记录仍可读
|
||||
recs, err := s.ListSince(now.AddDate(0, 0, -20), "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Errorf("剩余记录数 = %d, want 2", len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStorePermissions 验证目录 0700 / 文件 0600。
|
||||
//
|
||||
// 记录里是明文账号密码,同机其他用户不该能读。Windows 上 os.Chmod 语义不同,
|
||||
// 跳过该用例(部署环境是 Linux)。
|
||||
func TestFileCredStorePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows 不支持 POSIX 权限位")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "creds")
|
||||
s, err := NewFileCredStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(CredRecord{Time: time.Now(), IP: "1.1.1.1", Username: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
di, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := di.Mode().Perm(); perm != 0o700 {
|
||||
t.Errorf("目录权限 = %#o, want 0700", perm)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, credFilePrefix+"*"+credFileSuffix))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("凭据文件数 = %d, want 1(%v)", len(files), err)
|
||||
}
|
||||
fi, err := os.Stat(files[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("文件权限 = %#o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredStoreEmptyDir 验证空目录配置会被明确拒绝。
|
||||
func TestFileCredStoreEmptyDir(t *testing.T) {
|
||||
if _, err := NewFileCredStore(" "); err == nil {
|
||||
t.Fatal("空目录应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -25,6 +24,26 @@ const CanaryToken = "tb-hp-7f3a1c9e"
|
||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||
const maxCredentialFieldLen = 512
|
||||
|
||||
// clipRunes 按字符(rune)截断字符串。
|
||||
//
|
||||
// 为什么不能用 s[:n]:按字节切会把多字节 UTF-8 字符劈成半个序列,得到非法
|
||||
// UTF-8。凭据是直接落库的,PostgreSQL 会以 `invalid byte sequence for encoding
|
||||
// "UTF8"` 拒绝整条插入 —— 取证记录就这么丢了,而且只在日志里留一行 warn。
|
||||
//
|
||||
// 攻击者可以随便发一个 600 字节的中文密码来稳定触发这个问题,所以这里是
|
||||
// 可达路径,不是理论风险。
|
||||
func clipRunes(s string, n int) string {
|
||||
// 字节数已不超过 n,字符数必然也不超过,无需转换。
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||
var honeypotPrefixes = []string{
|
||||
@@ -77,6 +96,40 @@ var forbiddenPrefixes = []string{
|
||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
||||
}
|
||||
|
||||
// SplitPaths 解析配置中的自定义诱饵路径(逗号 / 分号 / 空白 / 换行分隔)。
|
||||
//
|
||||
// 放在本包而不是 route:CLI 的 `honeypot paths` 也要用同一份解析规则,
|
||||
// 两边各写一遍迟早漂移(一处补了分号、另一处没补),而配置解析不一致
|
||||
// 会让「我配了但没生效」这类问题更难查。
|
||||
func SplitPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
// 在这里就补前导斜杠:CLI 会把这个结果直接打给运维看,
|
||||
// 显示 "foo" 而实际生效 "/foo" 会让人以为是两个不同的东西
|
||||
// (New 里的 normalize 是幂等的,重复补不会出问题)。
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
// 拒绝根路径:"/" 会让**所有** 404 都变成假登录页 —— 正常访客输错
|
||||
// 网址也看到 WordPress 后台,是灾难级的配置失误,几乎不可能是本意。
|
||||
if f == "/" {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Options 是蜜罐行为配置。
|
||||
type Options struct {
|
||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||
@@ -212,11 +265,15 @@ type Credentials struct {
|
||||
}
|
||||
|
||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||
//
|
||||
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
|
||||
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
|
||||
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
|
||||
var credentialKeys = map[string]bool{
|
||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true,
|
||||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "token": true,
|
||||
}
|
||||
|
||||
// ExtractCredentials 从请求中提取破解凭据。
|
||||
@@ -240,7 +297,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
return
|
||||
}
|
||||
if len(v) > limit {
|
||||
v = v[:limit]
|
||||
v = clipRunes(v, limit)
|
||||
}
|
||||
if _, exists := out.Fields[k]; !exists {
|
||||
out.Fields[k] = v
|
||||
@@ -293,7 +350,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
}
|
||||
raw := string(buf)
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
raw = clipRunes(raw, limit)
|
||||
}
|
||||
out.RawBody = raw
|
||||
|
||||
@@ -390,15 +447,25 @@ func parseJSONCredentials(raw string, put func(k, v string)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
||||
func (h *Honeypot) Describer() string {
|
||||
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
|
||||
//
|
||||
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
|
||||
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
|
||||
// 调用方自己决定怎么展示。
|
||||
func (h *Honeypot) Prefixes() []string {
|
||||
if h == nil || !h.enabled {
|
||||
return "disabled"
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(h.prefix))
|
||||
out := make([]string, 0, len(h.prefix))
|
||||
for k := range h.prefix {
|
||||
keys = append(keys, k)
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return path.Join(keys...)
|
||||
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
|
||||
// 排序依赖渗透进整个包的 import 列表。
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
@@ -148,3 +149,67 @@ func TestExtractCredentialsMalformed(t *testing.T) {
|
||||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClipRunesKeepsValidUTF8 守住「按字节截断」的回归。
|
||||
//
|
||||
// 凭据是直接落库的,非法 UTF-8 会让 PostgreSQL 以
|
||||
// `invalid byte sequence for encoding "UTF8"` 拒绝整条插入 —— 取证记录静默丢失。
|
||||
// 攻击者只要发一个超长中文密码就能稳定触发,所以这是可达路径。
|
||||
func TestClipRunesKeepsValidUTF8(t *testing.T) {
|
||||
s := strings.Repeat("密码", 400) // 2400 字节 / 800 字符
|
||||
got := clipRunes(s, 512)
|
||||
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("截断结果不是合法 UTF-8(说明退回了字节截断)")
|
||||
}
|
||||
if n := len([]rune(got)); n != 512 {
|
||||
t.Errorf("截断后字符数 = %d, want 512", n)
|
||||
}
|
||||
// 确认这个用例真的能区分两种实现:字节截断在 512 处必然切在字符中间。
|
||||
if utf8.ValidString(s[:512]) {
|
||||
t.Fatal("测试素材本身能被字节安全截断,用例失去区分力")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCredentialsIgnoresCommonJSONFields 守住误报回归。
|
||||
//
|
||||
// "id" / "name" 是任意 JSON 里最常见的字段。把它们当凭据会让
|
||||
// 「扫描器 POST 了一个无关探测」被记成破解提交,并触发假的告警邮件。
|
||||
func TestExtractCredentialsIgnoresCommonJSONFields(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/thing",
|
||||
strings.NewReader(`{"id":1,"name":"probe","status":"ok"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.HadFields {
|
||||
t.Errorf("普通 JSON 探测不应被判定为凭据提交(Username=%q Password=%q)",
|
||||
creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitPaths 覆盖自定义诱饵路径的解析与归一化。
|
||||
func TestSplitPaths(t *testing.T) {
|
||||
raw := " /admin, foo;bar\n/baz "
|
||||
got := SplitPaths(raw)
|
||||
want := []string{"/admin", "/foo", "/bar", "/baz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("SplitPaths() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// 未写前导斜杠的项也应在 New 里被归一化,否则永远匹配不上。
|
||||
h := New(Options{Enabled: true, ExtraPaths: SplitPaths("foo")})
|
||||
if !h.Match("/foo/bar") {
|
||||
t.Error("自定义路径 foo 归一化后应能匹配 /foo/bar")
|
||||
}
|
||||
|
||||
// 根路径会让所有 404 都变成假登录页,必须拒绝。
|
||||
if got := SplitPaths("/"); len(got) != 0 {
|
||||
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,29 @@ type LoginPageVars struct {
|
||||
Redir string
|
||||
}
|
||||
|
||||
// LoginPageTitle 是假登录页的统一标题(与真实 WordPress 登录页一致)。
|
||||
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
|
||||
// 已经出现漂移。
|
||||
const LoginPageTitle = "Log In ‹ WordPress"
|
||||
|
||||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||||
//
|
||||
// 页面刻意包含:
|
||||
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
|
||||
// - 隐藏 canary 标记(HTML 注释),便于日后溯源;
|
||||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||||
// 我们就能拿到最干净的用户名与密码对;
|
||||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||||
//
|
||||
// ⚠️ canary 只放在 HTML 注释里,不放进 <meta name="generator">。
|
||||
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
|
||||
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
|
||||
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
|
||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
action := html.EscapeString(vars.Action)
|
||||
title := vars.Title
|
||||
if title == "" {
|
||||
title = "Log In ‹ WordPress"
|
||||
title = LoginPageTitle
|
||||
}
|
||||
heading := vars.Heading
|
||||
if heading == "" {
|
||||
@@ -46,12 +56,14 @@ func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
var b strings.Builder
|
||||
b.Grow(4096)
|
||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||||
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
|
||||
// canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
|
||||
b.WriteString(`<!-- build: ` + CanaryToken + ` -->` + "\n")
|
||||
b.WriteString(`<html lang="en-US">` + "\n")
|
||||
b.WriteString(`<head>` + "\n")
|
||||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||||
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
|
||||
// generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
|
||||
b.WriteString(`<meta name="generator" content="WordPress 6.4.3" />` + "\n")
|
||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||
|
||||
Reference in New Issue
Block a user