Compare commits
3
Commits
bfdc5089c5
...
5e8decab8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8decab8f | ||
|
|
c29e1d071f | ||
|
|
566b13eedd |
@@ -32,3 +32,8 @@ go.work.sum
|
|||||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||||
# 每次跑都会变,跟版本库无关。
|
# 每次跑都会变,跟版本库无关。
|
||||||
/outputs/
|
/outputs/
|
||||||
|
|
||||||
|
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
|
||||||
|
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||||
|
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||||
|
/data/
|
||||||
|
|||||||
@@ -195,3 +195,123 @@
|
|||||||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||||||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
||||||
**测试失败要先怀疑自己的预期**,别急着改产品代码。
|
**测试失败要先怀疑自己的预期**,别急着改产品代码。
|
||||||
|
|
||||||
|
## 蜜罐实现审计(第三方 agent 写完,我复审)
|
||||||
|
|
||||||
|
### P0:蜜罐封禁整条链路未接线(已实测复现)
|
||||||
|
`internal/route/route.go:85` 的 `autoban.New(...)` **没传** `HoneypotEnabled / HoneypotThreshold /
|
||||||
|
HoneypotDuration`,而 `autoban.RecordHoneypot` 首行就是 `if !b.honeypotEnabled { return false }`
|
||||||
|
→ 蜜罐命中永不封禁、落库 `banned` 恒 false、`IsBanned` 永不为真。
|
||||||
|
同一提交又把 `autobanMiddleware` 里蜜罐路径的**常规计数也跳过**了
|
||||||
|
(`route/autoban.go:38 if hp == nil || !hp.Match(path)`,理由「蜜罐处理器自己计数」)
|
||||||
|
→ 净效果:蜜罐路径从「10 次封禁」退化为**零计数零封禁**,是 regression。
|
||||||
|
`honeypot_threshold` / `honeypot_ban_minutes` 两个配置项全代码库**零读取**(死配置,样例 ini 里却写了)。
|
||||||
|
测试为什么绿:`route/honeypot_test.go` 自建 banner 时显式传了 `HoneypotEnabled`,
|
||||||
|
注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。
|
||||||
|
|
||||||
|
### P1
|
||||||
|
- 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」,
|
||||||
|
且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
|
||||||
|
- `honeypotHandler.alerted`(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长;
|
||||||
|
autoban 的同名 map 也是(既有问题,蜜罐放大)。
|
||||||
|
|
||||||
|
### P2
|
||||||
|
- `Path` 原样落库但列是 `size:512` → 超长路径直接 PG 报错丢证据;`Query` 用 `query[:1000]`
|
||||||
|
字节截断,`honeypot.go:243/296` 的 `v[:limit]`、`raw[:limit]` 同样是字节截断
|
||||||
|
→ 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 `truncate()` 却没用于这些点。
|
||||||
|
- `credentialKeys` 含 `id` / `name` / `token` → 任意 JSON 探测带 `{"id":1}` 就会被当成「破解凭据」
|
||||||
|
触发告警邮件 + 记为 HadFields,污染取证与告警。
|
||||||
|
- `page.go:54` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
|
||||||
|
和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
|
||||||
|
- `serveLoginFailure` 手抄了一遍 `WriteLoginPage` 的响应头逻辑(没复用),已开始漂移
|
||||||
|
(Title 一处写 `‹`、一处写 `\u2039`)。
|
||||||
|
- `Honeypot.Describer()` 零调用死代码,且 `path.Join(keys...)` 语义错误(会把前缀拼成一条路径)。
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确;
|
||||||
|
问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。
|
||||||
|
|
||||||
|
## 蜜罐审计问题的修复实施(已改完,未提交)
|
||||||
|
|
||||||
|
### 1. P0 装配(route.go)
|
||||||
|
抽 `newBanner()` 作为唯一装配入口,补 `HoneypotEnabled / HoneypotThreshold / HoneypotDuration`。
|
||||||
|
新增 `internal/route/banner_config_test.go` 四个用例,全部经由 `newBanner()` 断言。
|
||||||
|
**变异验证**:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。
|
||||||
|
|
||||||
|
### 2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)
|
||||||
|
原实现 `RecordHoneypot` 对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
|
||||||
|
永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。
|
||||||
|
改为:`creds.HadFields` → RecordHoneypot(立即封);纯探测 → 常规 `Record`(阈值 10)。
|
||||||
|
新增 `TestHoneypotProbeDoesNotBanImmediately`,同上做了变异验证。
|
||||||
|
连带把 `honeypot_threshold` 的语义在 static.go / defaults / autoban / sample.ini 统一为
|
||||||
|
「提交凭据次数」。
|
||||||
|
|
||||||
|
### 3. 落库边界(实测取证)
|
||||||
|
- `Path` 原样落库、列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
|
||||||
|
实测 600 字符失败、500 字符成功。
|
||||||
|
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL 可稳定触发)。
|
||||||
|
全部改为 rune 截断(`clipRunes` / route 的 `truncate`),并按列宽逐个对齐。
|
||||||
|
|
||||||
|
### 4. 资源与噪音
|
||||||
|
- `honeypotHandler.alerted`(无界 map)→ 换成带 TTL 的 `deduper`(容量上限 2 万,
|
||||||
|
过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
|
||||||
|
- `credentialKeys` 去掉 `id` / `name`(普通 JSON 探测不再被当成凭据提交触发假告警)。
|
||||||
|
- canary 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
|
||||||
|
generator 伪装成 `WordPress 6.4.3`;不再向扫描器自曝「TamaBox 蜜罐」。
|
||||||
|
- `serveLoginFailure` 不再手抄响应头,统一走 `WriteLoginPage`;`LoginPageTitle` 抽常量。
|
||||||
|
- `Describer()` 死代码 → 改为 `Prefixes()` 并接到新 CLI `TamaBox honeypot paths`。
|
||||||
|
- `honeypot_paths` 解析挪到 `honeypot.SplitPaths`(CLI 与 route 共用),补前导斜杠、拒绝 `/`。
|
||||||
|
|
||||||
|
### 5. 新发现的配置陷阱(影响面比蜜罐大)
|
||||||
|
`conf.Init` 用 `ini.LoadOptions{IgnoreInlineComment: true}`,该选项语义是
|
||||||
|
**不剥离行尾注释**(实测:`port = 1440 ; 注释` → 值 `"1440 ; 注释"`,int 解析失败,
|
||||||
|
`MapTo` 却返回 nil error)。后果:
|
||||||
|
- `app.sample.ini` 照抄**无法启动**(`text_censor_source` 值带注释被 validate 拒绝);
|
||||||
|
- 所有带行尾注释的 int/bool 静默回退零值 —— `enable_auto_ban`、`honeypot_max_rows`、
|
||||||
|
`honeypot_auto_clean` 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。
|
||||||
|
已把 sample 的**蜜罐段**改成「注释独占一行 + 裸值」,并在段首写明原因。
|
||||||
|
其余段落仍是老写法,未动(需用户决定是否全面清理)。
|
||||||
|
|
||||||
|
### 待办
|
||||||
|
- 用户确认是否提交这批改动。
|
||||||
|
- `app.sample.ini` 其余段落的行尾注释问题未修,建议后续统一。
|
||||||
|
- 蜜罐 GET 探测目前受 `enable_auto_ban` 约束(关了就不计数),这是有意的:
|
||||||
|
纯探测无害,不该在运维关掉自动封禁后仍凭空封人。
|
||||||
|
|
||||||
|
## 后续:sample 注释清理 + 凭据外置(同日晚)
|
||||||
|
|
||||||
|
### 1. app.sample.ini 行尾注释全量清理
|
||||||
|
17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF。
|
||||||
|
新增 `TestSampleIniValuesHaveNoInlineComment`:遍历所有段的键,断言 `Value()` 不含 `;`。
|
||||||
|
变异验证:把 `ban_window_seconds = 60` 改成带行尾注释 → 测试按预期失败。
|
||||||
|
清理后 `conf.Init` 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」,
|
||||||
|
说明解析路径已通(必填项留空是模板的预期行为)。
|
||||||
|
|
||||||
|
### 2. 凭据改为外部文件,数据库不再存用户名/密码
|
||||||
|
- `honeypot.CredRecord` + `CredStore`(`internal/security/honeypot/credstore.go`):
|
||||||
|
按天分文件 `creds-YYYY-MM-DD.jsonl`,一行一条 JSON;目录 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(DB 挂了也能查),`stats` 的凭据排行读文件,新增 `purge-creds`。
|
||||||
|
- 配置 `[security] honeypot_cred_dir`,缺省 `data/honeypot-creds`;`.gitignore` 加 `/data/`。
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
`credstore_test.go`(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
|
||||||
|
`TestHoneypotLegacyCredentialColumns`(ADD COLUMN 模拟老库 → 检测 → DROP)、
|
||||||
|
`TestHoneypotWritesCredentialsToFile`(POST 后文件有记录)。
|
||||||
|
CLI 端到端手动验证:`honeypot hits` / `--ip` / `--show-password` 输出正确。
|
||||||
|
全量 `go test ./...`(含 PostgreSQL)通过。
|
||||||
|
|
||||||
|
### 遗留
|
||||||
|
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
|
||||||
|
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
|
||||||
|
|
||||||
|
### 已提交(2026-10-21,未推送)
|
||||||
|
- `566b13e` fix(conf): 样例配置去掉行尾注释(2 文件)
|
||||||
|
- `c29e1d0` fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
|
||||||
|
- `main` ahead of `origin/main` 2;用户要部署测试环境验证后再决定推送。
|
||||||
|
|||||||
+89
-44
@@ -6,22 +6,27 @@
|
|||||||
|
|
||||||
[app]
|
[app]
|
||||||
production = true
|
production = true
|
||||||
external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
||||||
|
external_url = "https://box.shiroko.one"
|
||||||
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
||||||
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
||||||
icp = ""
|
icp = ""
|
||||||
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||||
|
default_lang = ""
|
||||||
uptrace_dsn = ""
|
uptrace_dsn = ""
|
||||||
qiniu_access_key = ""
|
qiniu_access_key = ""
|
||||||
qiniu_access_secret = ""
|
qiniu_access_secret = ""
|
||||||
aliyun_access_key = ""
|
aliyun_access_key = ""
|
||||||
aliyun_access_key_secret = ""
|
aliyun_access_key_secret = ""
|
||||||
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
|
; 默认分享图背景图链接(1280x720)
|
||||||
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
|
share_background = "https://your-default-background.jpg"
|
||||||
|
; 中文字体文件路径(相对项目根目录)
|
||||||
|
font_path = "static/fonts/NotoSansSC-Regular.otf"
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
enable_text_censor = true
|
enable_text_censor = true
|
||||||
text_censor_source = auto ; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
||||||
|
text_censor_source = auto
|
||||||
; 留空等同于 auto,即引入该配置前的历史行为。
|
; 留空等同于 auto,即引入该配置前的历史行为。
|
||||||
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
||||||
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
||||||
@@ -40,9 +45,12 @@ text_censor_source = auto ; 文本审核服务通道:qiniu(七牛
|
|||||||
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
||||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
||||||
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
||||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
ip_blacklist =
|
||||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
; 黑名单IP访问时强制跳转到该URL
|
||||||
|
ip_blacklist_redirect_url =
|
||||||
|
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||||
|
trust_proxy = false
|
||||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||||
@@ -65,47 +73,83 @@ trusted_proxies =
|
|||||||
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
||||||
; EO-Client-IP(需源站已锁定)。
|
; EO-Client-IP(需源站已锁定)。
|
||||||
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
||||||
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
||||||
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
enable_auto_ban = true
|
||||||
|
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||||
|
ban_track_notfound = false
|
||||||
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||||
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||||
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
ban_threshold_sensitive = 10
|
||||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
ban_threshold_404 = 20
|
||||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
; 统计窗口时长(秒)
|
||||||
|
ban_window_seconds = 60
|
||||||
|
; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||||
|
ban_duration_minutes = 15
|
||||||
|
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
||||||
|
security_alert_email =
|
||||||
|
|
||||||
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
||||||
enable_honeypot = false ; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)
|
; ⚠️ 本段一律「注释独占一行、值单独一行」。
|
||||||
; 时不再返回 302/404,而是返回一个仿 WordPress 的假登录页,
|
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
|
||||||
; 并把其提交的用户名/密码记录入库供取证。
|
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
|
||||||
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集;
|
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
|
||||||
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
|
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
|
||||||
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
|
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
|
||||||
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
|
||||||
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
|
||||||
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
|
||||||
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
|
||||||
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
|
||||||
; 可用 TamaBox honeypot size 查看行数与磁盘占用。
|
enable_honeypot = false
|
||||||
honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
|
||||||
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量
|
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
|
||||||
; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。
|
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
|
||||||
; 缺省为 true(开启)。
|
honeypot_paths =
|
||||||
honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。缺省 200000。
|
|
||||||
; 显式写 0 = 不限(会告警提醒表只增不减)。
|
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
||||||
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内
|
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
||||||
; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
||||||
; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长
|
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
||||||
; (VACUUM 让空间可复用,不保证文件缩小)。
|
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||||
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
|
honeypot_retain_days = 30
|
||||||
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
|
||||||
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
|
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
||||||
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
|
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||||
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
|
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
|
||||||
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
honeypot_auto_clean = true
|
||||||
|
|
||||||
|
; 蜜罐命中记录的行数上限(第二道闸)。显式写 0 = 不限(会告警提醒表只增不减)。
|
||||||
|
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内把表撑爆。
|
||||||
|
; 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
|
honeypot_max_rows = 200000
|
||||||
|
|
||||||
|
; 「提交凭据」多少次即封禁。默认 1(一次即封):往假后台里填账号密码是证据最强
|
||||||
|
; 的行为,抓到一次就足够。⚠️ 不作用于纯 GET 探测 —— 那类请求走 ban_threshold_sensitive,
|
||||||
|
; 否则扫描器看一眼假登录页就被封,永远走不到提交凭据那一步。
|
||||||
|
honeypot_threshold = 1
|
||||||
|
|
||||||
|
; 蜜罐封禁时长(分钟)。缺省 1440(24 小时),刻意远长于 ban_duration_minutes:
|
||||||
|
; 命中蜜罐意味着对方已在提交账号密码,证据强度远高于单纯路径探测。
|
||||||
|
; 显式写 0 = 回退 ban_duration_minutes。
|
||||||
|
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
|
||||||
|
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
|
||||||
|
honeypot_ban_minutes = 1440
|
||||||
|
|
||||||
|
; 凭据取证文件的存放目录。
|
||||||
|
; ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||||
|
; (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||||
|
; 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||||
|
; 目录会被强制为 0700、文件 0600;相对路径以进程工作目录为基准。
|
||||||
|
; 目录不可写时只降级为「不保存凭据」,不影响站点启动。
|
||||||
|
; 建议单独挂一个卷,并让它不参与备份。
|
||||||
|
honeypot_cred_dir = data/honeypot-creds
|
||||||
|
|
||||||
|
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
||||||
|
default_moderation_strictness =
|
||||||
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
||||||
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
||||||
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
||||||
@@ -139,7 +183,8 @@ addr = "127.0.0.1:6379"
|
|||||||
password = ""
|
password = ""
|
||||||
|
|
||||||
[recaptcha]
|
[recaptcha]
|
||||||
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||||
|
captcha_type = recaptcha
|
||||||
domain = "https://www.recaptcha.net"
|
domain = "https://www.recaptcha.net"
|
||||||
site_key = ""
|
site_key = ""
|
||||||
server_key = ""
|
server_key = ""
|
||||||
|
|||||||
+144
-23
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||||
@@ -53,7 +54,7 @@ var Honeypot = &cli.Command{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "prune",
|
Name: "prune",
|
||||||
Usage: "Delete honeypot records older than the retention period",
|
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
||||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||||
@@ -65,6 +66,19 @@ var Honeypot = &cli.Command{
|
|||||||
Usage: "Report honeypot table row count and disk usage",
|
Usage: "Report honeypot table row count and disk usage",
|
||||||
Action: runHoneypotSize,
|
Action: runHoneypotSize,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "paths",
|
||||||
|
Usage: "List active decoy paths (built-in + honeypot_paths)",
|
||||||
|
Action: runHoneypotPaths,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "purge-creds",
|
||||||
|
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||||
|
},
|
||||||
|
Action: runHoneypotPurgeCreds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,28 +110,40 @@ func parseSince(s string) (time.Duration, error) {
|
|||||||
return time.Duration(n) * mult, nil
|
return time.Duration(n) * mult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// credStoreFromConfig 用已加载的配置打开凭据取证目录。
|
||||||
|
//
|
||||||
|
// 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的
|
||||||
|
// 命令都走这里,而不是 db.HoneypotStore。
|
||||||
|
func credStoreFromConfig() (honeypot.CredStore, error) {
|
||||||
|
return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||||
|
}
|
||||||
|
|
||||||
func runHoneypotHits(ctx *cli.Context) error {
|
func runHoneypotHits(ctx *cli.Context) error {
|
||||||
dur, err := parseSince(ctx.String("since"))
|
dur, err := parseSince(ctx.String("since"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
database, err := openDatabase()
|
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
|
||||||
if err != nil {
|
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
|
||||||
return err
|
if err := conf.Init(); err != nil {
|
||||||
|
return errors.Wrap(err, "load configuration")
|
||||||
|
}
|
||||||
|
store, err := credStoreFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "open honeypot credential store")
|
||||||
}
|
}
|
||||||
store := db.NewHoneypotStore(database)
|
|
||||||
since := time.Now().Add(-dur)
|
since := time.Now().Add(-dur)
|
||||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||||
showPwd := ctx.Bool("show-password")
|
showPwd := ctx.Bool("show-password")
|
||||||
|
|
||||||
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
|
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
|
||||||
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
|
// 指定 IP 的历史记录被其他 IP 挤出去)。
|
||||||
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
|
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query honeypot hits")
|
return errors.Wrap(err, "read honeypot credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
|
||||||
fmt.Println(strings.Repeat("-", 72))
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
@@ -134,11 +160,8 @@ func runHoneypotHits(ctx *cli.Context) error {
|
|||||||
if user == "" {
|
if user == "" {
|
||||||
user = "(空)"
|
user = "(空)"
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %-8s %-6s %s\n", r.CreatedAt.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||||
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
||||||
if r.Provider != "" {
|
|
||||||
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
|
|
||||||
}
|
|
||||||
if r.UserAgent != "" {
|
if r.UserAgent != "" {
|
||||||
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
||||||
}
|
}
|
||||||
@@ -178,7 +201,11 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top paths")
|
return errors.Wrap(err, "query top paths")
|
||||||
}
|
}
|
||||||
creds, err := store.TopCredentials(cctx, since, ctx.Int("limit"))
|
credStore, err := credStoreFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "open honeypot credential store")
|
||||||
|
}
|
||||||
|
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top credentials")
|
return errors.Wrap(err, "query top credentials")
|
||||||
}
|
}
|
||||||
@@ -204,7 +231,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("最常被尝试的凭据组合:")
|
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
|
||||||
if len(creds) == 0 {
|
if len(creds) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
}
|
}
|
||||||
@@ -264,6 +291,16 @@ func runHoneypotPrune(ctx *cli.Context) error {
|
|||||||
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。
|
||||||
|
// 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。
|
||||||
|
if cs, err := credStoreFromConfig(); err != nil {
|
||||||
|
fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err)
|
||||||
|
} else if m, err := cs.PurgeBefore(before); err != nil {
|
||||||
|
fmt.Printf("提示:凭据文件清理失败(%v)。\n", err)
|
||||||
|
} else if m > 0 {
|
||||||
|
fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +351,97 @@ func runHoneypotSize(ctx *cli.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 凭据与元数据是两套存储,体积必须分开看:
|
||||||
|
// 数据库里只有元数据,明文账号密码在文件里。
|
||||||
|
fmt.Println()
|
||||||
|
if cs, err := credStoreFromConfig(); err == nil {
|
||||||
|
if n, size, err := cs.Size(); err == nil {
|
||||||
|
fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size))
|
||||||
|
fmt.Printf(" 目录:%s\n", cs.Dir())
|
||||||
|
fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHoneypotPaths 列出当前生效的诱饵路径。
|
||||||
|
//
|
||||||
|
// 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠)
|
||||||
|
// 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。
|
||||||
|
func runHoneypotPaths(ctx *cli.Context) error {
|
||||||
|
// 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条
|
||||||
|
// 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值,
|
||||||
|
// 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。
|
||||||
|
if err := conf.Init(); err != nil {
|
||||||
|
return errors.Wrap(err, "load configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := honeypot.SplitPaths(conf.Security.HoneypotPaths)
|
||||||
|
builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes()
|
||||||
|
active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes()
|
||||||
|
|
||||||
|
fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot))
|
||||||
|
if !conf.Security.EnableHoneypot {
|
||||||
|
fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。")
|
||||||
|
}
|
||||||
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
|
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
|
||||||
|
len(active), len(builtin), len(extra))
|
||||||
|
for _, p := range active {
|
||||||
|
fmt.Println(" " + p)
|
||||||
|
}
|
||||||
|
if len(extra) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("自定义路径(honeypot_paths):")
|
||||||
|
for _, p := range extra {
|
||||||
|
fmt.Println(" " + p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。
|
||||||
|
//
|
||||||
|
// 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**,
|
||||||
|
// 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。
|
||||||
|
// DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行
|
||||||
|
// (启动时只会 WARN 提示)。
|
||||||
|
func runHoneypotPurgeCreds(ctx *cli.Context) error {
|
||||||
|
database, err := openDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store := db.NewHoneypotStore(database)
|
||||||
|
|
||||||
|
cols, err := store.LegacyCredentialColumns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "check legacy credential columns")
|
||||||
|
}
|
||||||
|
if len(cols) == 0 {
|
||||||
|
fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):")
|
||||||
|
for _, c := range cols {
|
||||||
|
fmt.Println(" " + c)
|
||||||
|
}
|
||||||
|
fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。")
|
||||||
|
if !ctx.Bool("yes") {
|
||||||
|
fmt.Print("确认继续?输入 yes 继续:")
|
||||||
|
var answer string
|
||||||
|
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
|
||||||
|
fmt.Println("已取消。")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped, err := store.PurgeLegacyCredentialColumns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped)
|
||||||
|
}
|
||||||
|
fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", "))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,10 +495,3 @@ func maskSecret(s string) string {
|
|||||||
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func proxiedSuffix(proxied bool) string {
|
|
||||||
if proxied {
|
|
||||||
return "(CDN/代理出口)"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-1
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/route"
|
"github.com/tamakyi/TamaBox/internal/route"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||||
)
|
)
|
||||||
@@ -75,9 +76,19 @@ func runWeb(ctx *cli.Context) error {
|
|||||||
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
||||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||||
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||||
|
//
|
||||||
|
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
|
||||||
|
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
|
||||||
|
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
|
||||||
stopRetention := make(chan struct{})
|
stopRetention := make(chan struct{})
|
||||||
if conf.Security.HoneypotAutoClean {
|
if conf.Security.HoneypotAutoClean {
|
||||||
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
var credPurge db.CredPurgeable
|
||||||
|
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
|
||||||
|
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
|
||||||
|
} else {
|
||||||
|
credPurge = cs
|
||||||
|
}
|
||||||
|
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||||
defer close(stopRetention)
|
defer close(stopRetention)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,9 +28,21 @@ const (
|
|||||||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||||||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
DefaultHoneypotMaxRows = 200000
|
DefaultHoneypotMaxRows = 200000
|
||||||
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
|
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
|
||||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
|
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||||
|
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
|
||||||
DefaultHoneypotThreshold = 1
|
DefaultHoneypotThreshold = 1
|
||||||
|
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
|
||||||
|
//
|
||||||
|
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
|
||||||
|
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
|
||||||
|
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
|
||||||
|
// 24 小时能覆盖一轮扫描活动周期。
|
||||||
|
DefaultHoneypotBanMinutes = 1440
|
||||||
|
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
|
||||||
|
//
|
||||||
|
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
|
||||||
|
DefaultHoneypotCredDir = "data/honeypot-creds"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||||||
@@ -78,4 +92,21 @@ func sanitizeHoneypot() {
|
|||||||
if !sec.HasKey("honeypot_threshold") {
|
if !sec.HasKey("honeypot_threshold") {
|
||||||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
|
||||||
|
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
|
||||||
|
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
|
||||||
|
if !sec.HasKey("honeypot_ban_minutes") {
|
||||||
|
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
|
||||||
|
} else if Security.HoneypotBanMinutes <= 0 {
|
||||||
|
logrus.WithField("fallback_key", "ban_duration_minutes").
|
||||||
|
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 凭据文件目录:未配置或留空都用默认值。
|
||||||
|
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
|
||||||
|
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
|
||||||
|
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
|
||||||
|
Security.HoneypotCredDir = DefaultHoneypotCredDir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows int
|
wantMaxRows int
|
||||||
wantAutoClean bool
|
wantAutoClean bool
|
||||||
wantThreshold int
|
wantThreshold int
|
||||||
|
wantBan int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "全未配置时回退安全默认值",
|
name: "全未配置时回退安全默认值",
|
||||||
@@ -244,6 +245,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "显式关闭自动清理被尊重",
|
name: "显式关闭自动清理被尊重",
|
||||||
@@ -252,6 +254,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: false,
|
wantAutoClean: false,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "显式保留期被采用",
|
name: "显式保留期被采用",
|
||||||
@@ -260,6 +263,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "保留期写成 0 回退默认而非永不清理",
|
name: "保留期写成 0 回退默认而非永不清理",
|
||||||
@@ -268,6 +272,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "行数上限显式 0 表示不限",
|
name: "行数上限显式 0 表示不限",
|
||||||
@@ -276,6 +281,27 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: 0,
|
wantMaxRows: 0,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "显式封禁时长被采用",
|
||||||
|
src: "[security]\nhoneypot_ban_minutes = 90\n",
|
||||||
|
wantRetain: DefaultHoneypotRetainDays,
|
||||||
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
|
wantAutoClean: true,
|
||||||
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
|
||||||
|
// sanitize 不做覆盖(只告警),故期望值仍是 0。
|
||||||
|
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
|
||||||
|
src: "[security]\nhoneypot_ban_minutes = 0\n",
|
||||||
|
wantRetain: DefaultHoneypotRetainDays,
|
||||||
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
|
wantAutoClean: true,
|
||||||
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +316,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
Security.HoneypotMaxRows = 0
|
Security.HoneypotMaxRows = 0
|
||||||
Security.HoneypotAutoClean = false
|
Security.HoneypotAutoClean = false
|
||||||
Security.HoneypotThreshold = 0
|
Security.HoneypotThreshold = 0
|
||||||
|
Security.HoneypotBanMinutes = 0
|
||||||
File = f
|
File = f
|
||||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||||
t.Fatalf("map security: %v", err)
|
t.Fatalf("map security: %v", err)
|
||||||
@@ -308,6 +335,9 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
if Security.HoneypotThreshold != c.wantThreshold {
|
if Security.HoneypotThreshold != c.wantThreshold {
|
||||||
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
||||||
}
|
}
|
||||||
|
if Security.HoneypotBanMinutes != c.wantBan {
|
||||||
|
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +388,34 @@ func TestSampleIniHasNoUnknownKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
|
||||||
|
//
|
||||||
|
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
|
||||||
|
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
|
||||||
|
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
|
||||||
|
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
|
||||||
|
//
|
||||||
|
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
|
||||||
|
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
|
||||||
|
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
|
||||||
|
//
|
||||||
|
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
|
||||||
|
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||||||
|
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||||||
|
}
|
||||||
|
for _, sec := range f.Sections() {
|
||||||
|
for _, k := range sec.Keys() {
|
||||||
|
if strings.Contains(k.Value(), ";") {
|
||||||
|
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
|
||||||
|
sec.Name(), k.Name(), k.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
||||||
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
||||||
//
|
//
|
||||||
|
|||||||
+18
-4
@@ -90,7 +90,7 @@ var (
|
|||||||
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
||||||
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
||||||
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||||
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||||||
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||||
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||||||
@@ -103,12 +103,26 @@ var (
|
|||||||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||||
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
|
||||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||||
|
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
|
||||||
|
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
|
||||||
|
// POST 提交凭据那一步,蜜罐也就拿不到字典。
|
||||||
// 缺省回退 1(见 sanitizeHoneypot)。
|
// 缺省回退 1(见 sanitizeHoneypot)。
|
||||||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
|
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
|
||||||
|
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
|
||||||
|
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
|
||||||
|
// 显式 <= 0 表示回退 ban_duration_minutes。
|
||||||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
||||||
|
// HoneypotCredDir 凭据取证文件的存放目录。
|
||||||
|
//
|
||||||
|
// ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||||
|
// (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||||
|
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||||
|
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
|
||||||
|
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
|
||||||
|
HoneypotCredDir string `ini:"honeypot_cred_dir"`
|
||||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -127,6 +128,15 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
Reports = NewReportsStore(db)
|
Reports = NewReportsStore(db)
|
||||||
Honeypots = NewHoneypotStore(db)
|
Honeypots = NewHoneypotStore(db)
|
||||||
|
|
||||||
|
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||||
|
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||||
|
// 这里只告警不自动删:DROP COLUMN 不可逆,必须由运维确认后执行
|
||||||
|
// `TamaBox honeypot purge-creds`。
|
||||||
|
if cols, err := Honeypots.LegacyCredentialColumns(context.Background()); err == nil && len(cols) > 0 {
|
||||||
|
logrus.WithField("columns", cols).
|
||||||
|
Warn("honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除")
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.Use(otelgorm.NewPlugin(
|
if err := db.Use(otelgorm.NewPlugin(
|
||||||
otelgorm.WithDBName(conf.Database.Name),
|
otelgorm.WithDBName(conf.Database.Name),
|
||||||
)); err != nil {
|
)); err != nil {
|
||||||
|
|||||||
+47
-54
@@ -28,15 +28,14 @@ type HoneypotHit struct {
|
|||||||
// Query 保存原始查询串(截断)。
|
// Query 保存原始查询串(截断)。
|
||||||
Query string `gorm:"size:1024" json:"query"`
|
Query string `gorm:"size:1024" json:"query"`
|
||||||
|
|
||||||
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
|
|
||||||
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
|
|
||||||
Username string `gorm:"size:256" json:"username"`
|
|
||||||
Password string `gorm:"size:256" json:"password"`
|
|
||||||
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
|
|
||||||
Fields string `gorm:"type:text" json:"fields"`
|
|
||||||
// RawBody 保存截断后的原始 body。
|
|
||||||
RawBody string `gorm:"type:text" json:"raw_body"`
|
|
||||||
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。
|
||||||
|
// 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认
|
||||||
|
// data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见
|
||||||
|
// internal/security/honeypot/credstore.go 的 CredRecord 注释:
|
||||||
|
// 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、
|
||||||
|
// 以及任何有只读权限的报表工具,事后无法彻底清除。
|
||||||
HadFields bool `json:"had_fields"`
|
HadFields bool `json:"had_fields"`
|
||||||
|
|
||||||
// UserAgent / Referer / ContentType 用于扫描器画像。
|
// UserAgent / Referer / ContentType 用于扫描器画像。
|
||||||
@@ -69,19 +68,12 @@ type HoneypotStore interface {
|
|||||||
Create(ctx context.Context, hit *HoneypotHit) error
|
Create(ctx context.Context, hit *HoneypotHit) error
|
||||||
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
||||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
||||||
// ListSinceByIP 返回 since 之后、来自指定 IP 的命中记录,按时间倒序。
|
|
||||||
//
|
|
||||||
// 刻意在 SQL 层过滤而不是取回后在 Go 侧过滤:后者只能看到「最近 limit 条」
|
|
||||||
// 里恰好落在窗口内的该 IP 记录,指定 IP 的历史命中基本查不出来。
|
|
||||||
ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error)
|
|
||||||
// CountSince 统计 since 之后的命中总数。
|
// CountSince 统计 since 之后的命中总数。
|
||||||
CountSince(ctx context.Context, since time.Time) (int64, error)
|
CountSince(ctx context.Context, since time.Time) (int64, error)
|
||||||
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
||||||
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
||||||
// TopPaths 返回 since 之后被命中次数最多的路径。
|
// TopPaths 返回 since 之后被命中次数最多的路径。
|
||||||
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||||
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
|
|
||||||
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
|
||||||
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
||||||
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
||||||
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
|
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
|
||||||
@@ -101,6 +93,10 @@ type HoneypotStore interface {
|
|||||||
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
||||||
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
||||||
Size(ctx context.Context) (HoneypotSize, error)
|
Size(ctx context.Context) (HoneypotSize, error)
|
||||||
|
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
|
||||||
|
LegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||||
|
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||||
|
PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HoneypotSize 蜜罐表的体积报告。
|
// HoneypotSize 蜜罐表的体积报告。
|
||||||
@@ -127,11 +123,42 @@ type HoneypotPathStat struct {
|
|||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// HoneypotCredentialStat 凭据组合聚合行。
|
// LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。
|
||||||
type HoneypotCredentialStat struct {
|
//
|
||||||
Username string
|
// 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** ——
|
||||||
Password string
|
// 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行
|
||||||
Count int64
|
// `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。
|
||||||
|
var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"}
|
||||||
|
|
||||||
|
// LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。
|
||||||
|
func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||||
|
var present []string
|
||||||
|
for _, col := range LegacyHoneypotCredentialColumns {
|
||||||
|
if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) {
|
||||||
|
present = append(present, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return present, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||||
|
//
|
||||||
|
// 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是
|
||||||
|
// 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的
|
||||||
|
// 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。
|
||||||
|
func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||||
|
present, err := h.LegacyCredentialColumns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dropped := make([]string, 0, len(present))
|
||||||
|
for _, col := range present {
|
||||||
|
if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil {
|
||||||
|
return dropped, err
|
||||||
|
}
|
||||||
|
dropped = append(dropped, col)
|
||||||
|
}
|
||||||
|
return dropped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type honeypots struct {
|
type honeypots struct {
|
||||||
@@ -163,23 +190,6 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *honeypots) ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error) {
|
|
||||||
if limit <= 0 || limit > 1000 {
|
|
||||||
limit = 200
|
|
||||||
}
|
|
||||||
if ip == "" {
|
|
||||||
return h.ListSince(ctx, since, limit)
|
|
||||||
}
|
|
||||||
var rows []*HoneypotHit
|
|
||||||
err := h.db.WithContext(ctx).
|
|
||||||
Where("created_at >= ?", since).
|
|
||||||
Where("ip = ?", ip).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
||||||
@@ -220,23 +230,6 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
var rows []*HoneypotCredentialStat
|
|
||||||
err := h.db.WithContext(ctx).
|
|
||||||
Table("honeypot_hits").
|
|
||||||
Select("username AS username, password AS password, count(*) AS count").
|
|
||||||
Where("created_at >= ?", since).
|
|
||||||
Where("username <> '' OR password <> ''").
|
|
||||||
Group("username, password").
|
|
||||||
Order("count DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Scan(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||||
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||||
return res.RowsAffected, res.Error
|
return res.RowsAffected, res.Error
|
||||||
|
|||||||
@@ -34,8 +34,19 @@ import (
|
|||||||
// 此时只记一条告警日志,不删任何数据。
|
// 此时只记一条告警日志,不删任何数据。
|
||||||
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
|
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
|
||||||
// 不能因为它把站点搞挂。
|
// 不能因为它把站点搞挂。
|
||||||
|
//
|
||||||
|
// CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。
|
||||||
|
//
|
||||||
|
// 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层,
|
||||||
|
// 也能让清理器在测试里用假实现。
|
||||||
|
type CredPurgeable interface {
|
||||||
|
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
|
||||||
|
PurgeBefore(before time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
type HoneypotRetention struct {
|
type HoneypotRetention struct {
|
||||||
store HoneypotStore
|
store HoneypotStore
|
||||||
|
creds CredPurgeable
|
||||||
retainDays int
|
retainDays int
|
||||||
maxRows int
|
maxRows int
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
@@ -44,13 +55,14 @@ type HoneypotRetention struct {
|
|||||||
// NewHoneypotRetention 构造保留期清理器。
|
// NewHoneypotRetention 构造保留期清理器。
|
||||||
//
|
//
|
||||||
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
|
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
|
||||||
// interval <= 0 时使用默认 1 小时。
|
// interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。
|
||||||
func NewHoneypotRetention(store HoneypotStore, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = time.Hour
|
interval = time.Hour
|
||||||
}
|
}
|
||||||
return &HoneypotRetention{
|
return &HoneypotRetention{
|
||||||
store: store,
|
store: store,
|
||||||
|
creds: creds,
|
||||||
retainDays: retainDays,
|
retainDays: retainDays,
|
||||||
maxRows: maxRows,
|
maxRows: maxRows,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
@@ -126,6 +138,17 @@ func (r *HoneypotRetention) runOnce() {
|
|||||||
total += m
|
total += m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。
|
||||||
|
// 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。
|
||||||
|
if r.creds != nil {
|
||||||
|
if n, err := r.creds.PurgeBefore(before); err != nil {
|
||||||
|
logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)")
|
||||||
|
} else if n > 0 {
|
||||||
|
logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}).
|
||||||
|
Info("已清理过期的蜜罐凭据文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestHoneypotRetentionRunOnce(t *testing.T) {
|
|||||||
// 保留期 30 天、行数上限 7:
|
// 保留期 30 天、行数上限 7:
|
||||||
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
||||||
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
||||||
ret := NewHoneypotRetention(store, 30, 7, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 7, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -82,7 +82,7 @@ func TestHoneypotRetentionDisabled(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := NewHoneypotRetention(store, 0, 100, 0)
|
ret := NewHoneypotRetention(store, nil, 0, 100, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -109,7 +109,7 @@ func TestHoneypotRetentionMaxRowsOnly(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := NewHoneypotRetention(store, 30, 5, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 5, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -147,7 +147,7 @@ func TestHoneypotRetentionBothGates(t *testing.T) {
|
|||||||
|
|
||||||
// 保留期 30 天、数量上限 4:
|
// 保留期 30 天、数量上限 4:
|
||||||
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
||||||
ret := NewHoneypotRetention(store, 30, 4, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 4, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -208,44 +208,6 @@ func TestHoneypotDeleteOldestBeyond(t *testing.T) {
|
|||||||
require.Equal(t, int64(0), n3)
|
require.Equal(t, int64(0), n3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHoneypotListSinceByIP 验证按 IP 过滤在 SQL 层生效。
|
|
||||||
//
|
|
||||||
// 背景:原先 CLI 的 --ip 过滤是在取回最近 N 条后在内存里筛,
|
|
||||||
// 导致「指定 IP 的历史命中」基本查不出来(被其他 IP 的更近记录挤出窗口)。
|
|
||||||
func TestHoneypotListSinceByIP(t *testing.T) {
|
|
||||||
database, cleanup := newTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := cleanup("honeypot_hits"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
store := NewHoneypotStore(database)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// 目标 IP 只有 1 条很老的记录;其余 20 条都是别的 IP 且更新
|
|
||||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
|
||||||
IP: "9.9.9.9", Method: "GET", Path: "/target", CreatedAt: now.Add(-10 * time.Hour),
|
|
||||||
}))
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
|
||||||
IP: "8.8.8.8", Method: "GET", Path: "/other", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "9.9.9.9", 200)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, rows, 1, "指定 IP 的老记录应能被查出(不能被其他 IP 的新记录挤出窗口)")
|
|
||||||
require.Equal(t, "/target", rows[0].Path)
|
|
||||||
|
|
||||||
// 空 IP 退化为不过滤
|
|
||||||
all, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "", 200)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, all, 21)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
||||||
func TestHoneypotCompact(t *testing.T) {
|
func TestHoneypotCompact(t *testing.T) {
|
||||||
database, cleanup := newTestDB(t)
|
database, cleanup := newTestDB(t)
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
|
|
||||||
rows := []*HoneypotHit{
|
rows := []*HoneypotHit{
|
||||||
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
||||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
||||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
||||||
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
require.NoError(t, store.Create(ctx, r))
|
require.NoError(t, store.Create(ctx, r))
|
||||||
@@ -64,13 +64,6 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
require.Equal(t, "/wp-login.php", paths[0].Path)
|
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||||
require.Equal(t, int64(2), paths[0].Count)
|
require.Equal(t, int64(2), paths[0].Count)
|
||||||
|
|
||||||
creds, err := store.TopCredentials(ctx, since, 10)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, creds, 2)
|
|
||||||
require.Equal(t, "admin", creds[0].Username)
|
|
||||||
require.Equal(t, "admin123", creds[0].Password)
|
|
||||||
require.Equal(t, int64(2), creds[0].Count)
|
|
||||||
|
|
||||||
// 保留期清理
|
// 保留期清理
|
||||||
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -81,6 +74,45 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
require.Equal(t, int64(2), total)
|
require.Equal(t, int64(2), total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotLegacyCredentialColumns 覆盖「旧版凭据列」的检测与清除。
|
||||||
|
//
|
||||||
|
// 背景:凭据已改为只落文件(见 honeypot.CredRecord),结构体也删掉了
|
||||||
|
// username/password/fields/raw_body 四个字段。但 GORM 的 AutoMigrate
|
||||||
|
// 不会删除既有列,老库升级上来后历史明文仍然留在表里。
|
||||||
|
// 这个测试用 ADD COLUMN 模拟老库,验证检测与 DROP COLUMN 都真的生效。
|
||||||
|
func TestHoneypotLegacyCredentialColumns(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("honeypot_hits"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewHoneypotStore(database)
|
||||||
|
|
||||||
|
cols, err := store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cols, "新建的表不应有旧版凭据列")
|
||||||
|
|
||||||
|
// 模拟旧版本留下的列。
|
||||||
|
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN username varchar(256)").Error)
|
||||||
|
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN password varchar(256)").Error)
|
||||||
|
|
||||||
|
cols, err = store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, []string{"username", "password"}, cols)
|
||||||
|
|
||||||
|
dropped, err := store.PurgeLegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, []string{"username", "password"}, dropped)
|
||||||
|
|
||||||
|
cols, err = store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cols, "清除后不应再检测到旧版凭据列")
|
||||||
|
}
|
||||||
|
|
||||||
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
||||||
//
|
//
|
||||||
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
||||||
@@ -102,7 +134,7 @@ func TestHoneypotSize(t *testing.T) {
|
|||||||
IP: "9.9.9.9",
|
IP: "9.9.9.9",
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
Path: "/wp-login.php",
|
Path: "/wp-login.php",
|
||||||
Username: "admin",
|
HadFields: true,
|
||||||
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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 route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 这组测试守的是「装配层」而不是「逻辑层」。
|
||||||
|
//
|
||||||
|
// 背景:蜜罐的封禁通道参数(HoneypotEnabled / HoneypotThreshold /
|
||||||
|
// HoneypotDuration)曾经在 route.New 里漏传,而 autoban.RecordHoneypot 第一行
|
||||||
|
// 就是 `if !b.honeypotEnabled { return false }` —— 表现为蜜罐命中永不封禁、
|
||||||
|
// 落库 banned 恒为 false,但所有测试全绿(它们都自建 Banner 并显式传了这些字段)。
|
||||||
|
//
|
||||||
|
// 因此这里的每个用例都必须经由 newBanner()(生产唯一装配入口),
|
||||||
|
// 绝不能自己构造 autoban.Options。
|
||||||
|
|
||||||
|
// swapSecurity 在测试期间覆盖 conf.Security,结束后恢复。
|
||||||
|
func swapSecurity(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
old := conf.Security
|
||||||
|
t.Cleanup(func() { conf.Security = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBannerWiresHoneypotChannel 是这次漏接线的直接回归测试。
|
||||||
|
func TestNewBannerWiresHoneypotChannel(t *testing.T) {
|
||||||
|
swapSecurity(t)
|
||||||
|
conf.Security.EnableHoneypot = true
|
||||||
|
conf.Security.HoneypotThreshold = 1
|
||||||
|
conf.Security.HoneypotBanMinutes = 1440
|
||||||
|
|
||||||
|
b := newBanner()
|
||||||
|
if !b.RecordHoneypot("203.0.113.9", "/wp-login.php") {
|
||||||
|
t.Fatal("蜜罐开启时 RecordHoneypot 应判定封禁:newBanner 很可能漏传了 HoneypotEnabled")
|
||||||
|
}
|
||||||
|
if !b.IsBanned("203.0.113.9") {
|
||||||
|
t.Fatal("蜜罐命中后 IsBanned 应为 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBannerHonorsHoneypotThreshold 验证阈值真的传进去了。
|
||||||
|
//
|
||||||
|
// 只断言「能封禁」是不够的:漏传 HoneypotThreshold 时 autoban 会兜底成 1,
|
||||||
|
// 一次就封,看起来一切正常。设成 3 才能区分「传了」与「用了默认值」。
|
||||||
|
func TestNewBannerHonorsHoneypotThreshold(t *testing.T) {
|
||||||
|
swapSecurity(t)
|
||||||
|
conf.Security.EnableHoneypot = true
|
||||||
|
conf.Security.HoneypotThreshold = 3
|
||||||
|
conf.Security.HoneypotBanMinutes = 60
|
||||||
|
|
||||||
|
b := newBanner()
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
if b.RecordHoneypot("198.51.100.7", "/admin") {
|
||||||
|
t.Fatalf("第 %d 次命中不应封禁(阈值 3)——阈值未接线时会退化为默认 1", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !b.RecordHoneypot("198.51.100.7", "/admin") {
|
||||||
|
t.Fatal("第 3 次命中应封禁(阈值 3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBannerHoneypotIndependentOfAutoBan 守住「两个开关互不连坐」。
|
||||||
|
//
|
||||||
|
// 线上触发过的组合:运维嫌自动封禁误伤多,关掉 enable_auto_ban;
|
||||||
|
// 结果蜜罐跟着变成只记录不封禁的旁观者。
|
||||||
|
func TestNewBannerHoneypotIndependentOfAutoBan(t *testing.T) {
|
||||||
|
swapSecurity(t)
|
||||||
|
conf.Security.EnableAutoBan = false
|
||||||
|
conf.Security.EnableHoneypot = true
|
||||||
|
conf.Security.HoneypotThreshold = 1
|
||||||
|
conf.Security.HoneypotBanMinutes = 60
|
||||||
|
|
||||||
|
b := newBanner()
|
||||||
|
if !b.RecordHoneypot("192.0.2.5", "/.env") {
|
||||||
|
t.Fatal("enable_auto_ban=false 不应让蜜罐封禁失效")
|
||||||
|
}
|
||||||
|
if !b.IsBanned("192.0.2.5") {
|
||||||
|
t.Fatal("蜜罐封禁应在 enable_auto_ban=false 时依然生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBannerHoneypotDisabled 守住反向:关掉蜜罐就不该凭空封人。
|
||||||
|
func TestNewBannerHoneypotDisabled(t *testing.T) {
|
||||||
|
swapSecurity(t)
|
||||||
|
conf.Security.EnableHoneypot = false
|
||||||
|
conf.Security.HoneypotThreshold = 1
|
||||||
|
conf.Security.HoneypotBanMinutes = 1440
|
||||||
|
|
||||||
|
b := newBanner()
|
||||||
|
if b.RecordHoneypot("192.0.2.9", "/wp-admin") {
|
||||||
|
t.Fatal("enable_honeypot=false 时不应封禁")
|
||||||
|
}
|
||||||
|
if b.IsBanned("192.0.2.9") {
|
||||||
|
t.Fatal("enable_honeypot=false 时不应产生封禁记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
-93
@@ -6,9 +6,7 @@ package route
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,28 +30,98 @@ import (
|
|||||||
// 行为:
|
// 行为:
|
||||||
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
|
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
|
||||||
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
|
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
|
||||||
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
|
// - 提交了凭据 → 立即封禁(RecordHoneypot,不受 enable_auto_ban 约束)+ 告警邮件;
|
||||||
|
// - 纯路径探测 → 只计入常规敏感路径阈值,不立即封禁。必须留出「咬钩」的机会:
|
||||||
|
// 爆破脚本先 GET 探表单再 POST 提交,GET 就把人封了等于永远拿不到凭据;
|
||||||
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
|
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
|
||||||
|
//
|
||||||
|
// 蜜罐取证的去重/采样参数。
|
||||||
|
const (
|
||||||
|
// alertDedupTTL 同一个 IP 的告警冷却时间。旧实现是「永久只发一次」,
|
||||||
|
// 直接让 map 随源 IP 数量无界增长;24 小时既能压住噪音,也允许
|
||||||
|
// 同一 IP 在长期攻击中再次提醒。
|
||||||
|
alertDedupTTL = 24 * time.Hour
|
||||||
|
// probeSampleWindow 纯路径探测(无凭据)的落库采样窗口:
|
||||||
|
// 同一 IP 在窗口内最多落一行,保留「谁扫了哪些路径」的分布,
|
||||||
|
// 但单个 IP 的洪泛不会变成等量 INSERT。
|
||||||
|
probeSampleWindow = time.Minute
|
||||||
|
// maxDedupEntries 去重表容量上限,超出后先清过期项、仍超则按容量硬裁。
|
||||||
|
maxDedupEntries = 20000
|
||||||
|
)
|
||||||
|
|
||||||
|
// deduper 是一个带过期时间的 best-effort 去重器。
|
||||||
|
//
|
||||||
|
// 用「带 TTL 的 map」而不是永不过期的 map[string]bool:
|
||||||
|
// 蜜罐的键完全由外部流量决定(源 IP),后者等于把内存交给攻击者控制。
|
||||||
|
type deduper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeduper() *deduper {
|
||||||
|
return &deduper{seen: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// first 报告 key 是否在窗口内首次出现;首次会登记,过期时间为 now+ttl。
|
||||||
|
// ttl <= 0 表示永不过期(仍受容量上限约束)。
|
||||||
|
func (d *deduper) first(key string, ttl time.Duration) bool {
|
||||||
|
if key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if until, ok := d.seen[key]; ok {
|
||||||
|
if ttl <= 0 || until.After(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.seen[key] = now.Add(ttl)
|
||||||
|
if len(d.seen) > maxDedupEntries {
|
||||||
|
d.pruneLocked(now)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocked 先清过期项;仍超上限时按容量硬裁到一半。
|
||||||
|
// 硬裁是 best-effort:宁可偶尔漏一次去重,也不能让内存无界增长。
|
||||||
|
func (d *deduper) pruneLocked(now time.Time) {
|
||||||
|
for k, until := range d.seen {
|
||||||
|
if !until.IsZero() && !until.After(now) {
|
||||||
|
delete(d.seen, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limit := maxDedupEntries / 2
|
||||||
|
for k := range d.seen {
|
||||||
|
if len(d.seen) <= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(d.seen, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type honeypotHandler struct {
|
type honeypotHandler struct {
|
||||||
hp *honeypot.Honeypot
|
hp *honeypot.Honeypot
|
||||||
ab *autoban.Banner
|
ab *autoban.Banner
|
||||||
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
|
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
|
||||||
store db.HoneypotStore
|
store db.HoneypotStore
|
||||||
|
// cstore 是凭据取证文件的存储,允许为 nil(目录不可写时降级)。
|
||||||
|
// 用户名/密码只走这里,绝不写进 store(数据库)。
|
||||||
|
cstore honeypot.CredStore
|
||||||
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
|
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
|
||||||
sendAlert func(mail.HoneypotAlert) error
|
sendAlert func(mail.HoneypotAlert) error
|
||||||
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
|
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
|
||||||
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
|
dedup *deduper
|
||||||
alerted map[string]bool
|
|
||||||
alertedMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newHoneypotHandler 构造蜜罐处理器。
|
// newHoneypotHandler 构造蜜罐处理器。
|
||||||
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
|
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
|
||||||
return &honeypotHandler{
|
return &honeypotHandler{
|
||||||
hp: hp,
|
hp: hp,
|
||||||
ab: ab,
|
ab: ab,
|
||||||
store: store,
|
store: store,
|
||||||
alerted: map[string]bool{},
|
cstore: cstore,
|
||||||
|
dedup: newDeduper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,26 +131,35 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
||||||
path := req.URL.Path
|
path := req.URL.Path
|
||||||
|
|
||||||
// 封禁:走 autoban 的蜜罐独立通道(RecordHoneypot)。
|
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
|
||||||
|
creds := h.hp.ExtractCredentials(req)
|
||||||
|
|
||||||
|
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
|
||||||
//
|
//
|
||||||
// 刻意不用常规的 Record:它受 enable_auto_ban 开关约束,
|
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
|
||||||
// 而蜜罐是显式开启的功能 —— 开了就该封禁,不该被另一个开关连坐。
|
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
|
||||||
// 蜜罐路径与业务路由零交集,正常访客不可能命中,阈值取 1(一次即封)。
|
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
|
||||||
|
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
|
||||||
|
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
|
||||||
|
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
|
||||||
|
//
|
||||||
|
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
|
||||||
banned := false
|
banned := false
|
||||||
if h.ab != nil && ip != "" {
|
if h.ab != nil && ip != "" {
|
||||||
banned = h.ab.RecordHoneypot(ip, path)
|
if creds.HadFields {
|
||||||
|
banned = h.ab.RecordHoneypot(ip, path)
|
||||||
|
} else {
|
||||||
|
h.ab.Record(ip, path, autoban.KindSensitive)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
|
|
||||||
creds := h.hp.ExtractCredentials(req)
|
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case http.MethodGet, http.MethodHead:
|
case http.MethodGet, http.MethodHead:
|
||||||
h.serveLoginPage(ctx, path)
|
h.serveLoginPage(ctx, path)
|
||||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||||
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
|
h.serveLoginFailure(ctx, path)
|
||||||
default:
|
default:
|
||||||
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
|
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
|
||||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,71 +169,66 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
|
|
||||||
// serveLoginPage 返回假登录页。
|
// serveLoginPage 返回假登录页。
|
||||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||||
redir := ""
|
h.writeLogin(ctx, path, "")
|
||||||
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
|
|
||||||
redir = q
|
|
||||||
}
|
|
||||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
|
||||||
Action: path,
|
|
||||||
Title: "Log In ‹ WordPress",
|
|
||||||
Heading: "WordPress",
|
|
||||||
Redir: redir,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveLoginFailure 返回「登录失败」的假响应。
|
// serveLoginFailure 返回「登录失败」的假响应。
|
||||||
//
|
//
|
||||||
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
||||||
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
||||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
|
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) {
|
||||||
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
|
h.writeLogin(ctx, path, honeypot.LoginFailureNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLogin 是两种响应形态的唯一出口。
|
||||||
|
//
|
||||||
|
// 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type /
|
||||||
|
// Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、
|
||||||
|
// 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。
|
||||||
|
func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
|
||||||
|
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||||
Action: path,
|
Action: path,
|
||||||
Title: "Log In \u2039 WordPress",
|
Title: honeypot.LoginPageTitle,
|
||||||
Heading: "WordPress",
|
Heading: "WordPress",
|
||||||
Notice: notice,
|
Notice: notice,
|
||||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||||
})
|
})
|
||||||
w := ctx.ResponseWriter()
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
||||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// record 追加一条命中记录。
|
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
||||||
|
//
|
||||||
|
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
|
||||||
|
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
|
||||||
|
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
|
||||||
|
// 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。
|
||||||
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||||
h.recordCredentials(ip, req, path, creds, banned)
|
if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) {
|
||||||
}
|
return
|
||||||
|
|
||||||
// recordCredentials 把一次命中写入数据库。
|
|
||||||
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
|
||||||
provider, proxied := ipinfo.Lookup(ip)
|
|
||||||
|
|
||||||
fieldsJSON := ""
|
|
||||||
if len(creds.Fields) > 0 {
|
|
||||||
if b, err := json.Marshal(creds.Fields); err == nil {
|
|
||||||
fieldsJSON = string(b)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query := ""
|
query := ""
|
||||||
if req.URL != nil {
|
if req.URL != nil {
|
||||||
query = req.URL.RawQuery
|
query = req.URL.RawQuery
|
||||||
if len(query) > 1000 {
|
|
||||||
query = query[:1000]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
|
||||||
|
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
|
||||||
|
if creds.HadFields {
|
||||||
|
h.saveCredential(ip, req, path, query, creds, banned)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, proxied := ipinfo.Lookup(ip)
|
||||||
|
|
||||||
|
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
|
||||||
|
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
|
||||||
|
// 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上
|
||||||
|
// 以 `value too long for type character varying(N)` 整条插入失败 ——
|
||||||
|
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
|
||||||
hit := &db.HoneypotHit{
|
hit := &db.HoneypotHit{
|
||||||
IP: ip,
|
IP: truncate(ip, 64),
|
||||||
Method: req.Method,
|
Method: truncate(req.Method, 16),
|
||||||
Path: path,
|
Path: truncate(path, 500),
|
||||||
Query: query,
|
Query: truncate(query, 1000),
|
||||||
Username: creds.Username,
|
|
||||||
Password: creds.Password,
|
|
||||||
Fields: fieldsJSON,
|
|
||||||
RawBody: creds.RawBody,
|
|
||||||
HadFields: creds.HadFields,
|
HadFields: creds.HadFields,
|
||||||
UserAgent: truncate(req.UserAgent(), 500),
|
UserAgent: truncate(req.UserAgent(), 500),
|
||||||
Referer: truncate(req.Referer(), 500),
|
Referer: truncate(req.Referer(), 500),
|
||||||
@@ -195,20 +267,56 @@ func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveCredential 把一次凭据提交追加到取证文件。
|
||||||
|
//
|
||||||
|
// 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库,
|
||||||
|
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||||
|
// 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证,
|
||||||
|
// 也不能消除「凭据以某种形式留在库里」这个事实。
|
||||||
|
//
|
||||||
|
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
|
||||||
|
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
|
||||||
|
func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
|
||||||
|
if h.cstore == nil {
|
||||||
|
logrus.WithField("ip", ip).
|
||||||
|
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec := honeypot.CredRecord{
|
||||||
|
Time: time.Now(),
|
||||||
|
IP: truncate(ip, 64),
|
||||||
|
Method: truncate(req.Method, 16),
|
||||||
|
Path: truncate(path, 500),
|
||||||
|
Query: truncate(query, 1000),
|
||||||
|
Username: truncate(creds.Username, 512),
|
||||||
|
Password: truncate(creds.Password, 512),
|
||||||
|
Fields: creds.Fields,
|
||||||
|
RawBody: creds.RawBody,
|
||||||
|
UserAgent: truncate(req.UserAgent(), 500),
|
||||||
|
Banned: banned,
|
||||||
|
}
|
||||||
|
if err := h.cstore.Append(rec); err != nil {
|
||||||
|
logrus.WithError(err).WithField("ip", ip).Warn("蜜罐凭据写入文件失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
||||||
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
||||||
if h.sendAlert == nil || ip == "" {
|
if h.sendAlert == nil || ip == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.alertedMu.Lock()
|
// 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
|
||||||
if h.alerted[ip] {
|
// 不去重会把邮箱打爆(且每个源 IP 都是一封)。
|
||||||
h.alertedMu.Unlock()
|
if !h.dedup.first("alert:"+ip, alertDedupTTL) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.alerted[ip] = true
|
|
||||||
h.alertedMu.Unlock()
|
|
||||||
|
|
||||||
durationMin := conf.Security.BanDurationMinutes
|
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
|
||||||
|
// 否则运维按邮件判断「什么时候解封」会错。
|
||||||
|
durationMin := conf.Security.HoneypotBanMinutes
|
||||||
|
if durationMin <= 0 {
|
||||||
|
durationMin = conf.Security.BanDurationMinutes
|
||||||
|
}
|
||||||
if durationMin <= 0 {
|
if durationMin <= 0 {
|
||||||
durationMin = 15
|
durationMin = 15
|
||||||
}
|
}
|
||||||
@@ -239,25 +347,3 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return string(r[:n])
|
return string(r[:n])
|
||||||
}
|
}
|
||||||
|
|
||||||
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
|
|
||||||
func honeypotExtraPaths(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
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(f, "/") {
|
|
||||||
f = "/" + f
|
|
||||||
}
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
|
|||||||
// newHoneypotTestServerWith 可分别控制「蜜罐开关」与「常规自动封禁开关」,
|
// newHoneypotTestServerWith 可分别控制「蜜罐开关」与「常规自动封禁开关」,
|
||||||
// 用于验证两者相互独立(见 autoban.Options.HoneypotEnabled)。
|
// 用于验证两者相互独立(见 autoban.Options.HoneypotEnabled)。
|
||||||
func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Server {
|
func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return newHoneypotTestServerFull(t, enabled, autoBan, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHoneypotTestServerFull 额外允许注入凭据文件存储(nil 表示不落凭据)。
|
||||||
|
func newHoneypotTestServerFull(t *testing.T, enabled, autoBan bool, cs honeypot.CredStore) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f := flamego.New()
|
f := flamego.New()
|
||||||
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
||||||
@@ -40,7 +46,7 @@ func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Se
|
|||||||
Window: 60 * time.Second,
|
Window: 60 * time.Second,
|
||||||
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
|
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
|
||||||
})
|
})
|
||||||
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
|
hph := newHoneypotHandler(hp, ab, nil, cs) // store=nil → 元数据不落库,只走日志
|
||||||
|
|
||||||
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
|
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
|
||||||
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
|
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
|
||||||
@@ -159,6 +165,38 @@ func TestHoneypotCapturesCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotWritesCredentialsToFile 验证凭据落的是文件、不是数据库。
|
||||||
|
//
|
||||||
|
// 这是「明文账号密码不入库」的落地检查:POST 提交后凭据必须能从 CredStore
|
||||||
|
// 读到(说明真的写了),而 db.HoneypotHit 已经从类型上不允许携带凭据字段
|
||||||
|
// (结构体里没有 Username/Password,编译期即保证入不了库)。
|
||||||
|
func TestHoneypotWritesCredentialsToFile(t *testing.T) {
|
||||||
|
cs, err := honeypot.NewFileCredStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开凭据目录失败:%v", err)
|
||||||
|
}
|
||||||
|
ts := newHoneypotTestServerFull(t, true, true, cs)
|
||||||
|
|
||||||
|
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||||
|
strings.NewReader("log=admin&pwd=hunter2"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取凭据文件失败:%v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
|
||||||
|
}
|
||||||
|
if recs[0].Username != "admin" || recs[0].Password != "hunter2" {
|
||||||
|
t.Errorf("凭据 = %q/%q, want admin/hunter2", recs[0].Username, recs[0].Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
|
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
|
||||||
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
|
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
|
||||||
ts := newHoneypotTestServer(t, true)
|
ts := newHoneypotTestServer(t, true)
|
||||||
@@ -190,14 +228,16 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
|||||||
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
|
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
|
||||||
ts := newHoneypotTestServerWith(t, true, false)
|
ts := newHoneypotTestServerWith(t, true, false)
|
||||||
|
|
||||||
// 第一次命中蜜罐
|
// 第一次:提交凭据(这才是触发蜜罐封禁的行为;纯 GET 探测故意不立即封,
|
||||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
// 见 TestHoneypotProbeDoesNotBanImmediately)
|
||||||
|
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||||
|
strings.NewReader("log=admin&pwd=admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("首次命中 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
|
t.Fatalf("首次提交 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二次:应已被蜜罐封禁 → 403
|
// 第二次:应已被蜜罐封禁 → 403
|
||||||
@@ -215,11 +255,53 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotProbeDoesNotBanImmediately 守住「诱捕能力」。
|
||||||
|
//
|
||||||
|
// 若纯 GET 也按「一次即封」处理,扫描器看一眼假登录页就吃 403,永远走不到
|
||||||
|
// POST 提交凭据那一步 —— 蜜罐退化成一次性 403 陷阱,而它的核心价值恰恰是
|
||||||
|
// 拿到对方正在用的账号密码字典。
|
||||||
|
func TestHoneypotProbeDoesNotBanImmediately(t *testing.T) {
|
||||||
|
ts := newHoneypotTestServerWith(t, true, false) // 常规自动封禁关闭,只看蜜罐通道
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
resp, err := http.Get(ts.URL + "/wp-login.php")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("第 %d 次探测 status = %d, want 200(纯探测不应立即封禁)", i, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), honeypot.CanaryToken) {
|
||||||
|
t.Fatalf("第 %d 次探测没有返回假登录页", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一旦提交凭据,必须当场封。
|
||||||
|
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||||
|
strings.NewReader("log=admin&pwd=admin"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
resp2, err := http.Get(ts.URL + "/wp-login.php")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("提交凭据后 status = %d, want 403", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
|
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
|
||||||
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
|
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
|
||||||
ts := newHoneypotTestServerWith(t, true, false)
|
ts := newHoneypotTestServerWith(t, true, false)
|
||||||
|
|
||||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
|
||||||
|
strings.NewReader("log=admin&pwd=admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-12
@@ -73,16 +73,18 @@ func securityHeaders() flamego.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *flamego.Flame {
|
// newBanner 按 conf.Security 装配自动封禁器。
|
||||||
f := flamego.Classic()
|
//
|
||||||
if conf.App.Production {
|
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
|
||||||
flamego.SetEnv(flamego.EnvTypeProd)
|
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
|
||||||
}
|
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
|
||||||
|
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
|
||||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
|
||||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
//
|
||||||
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
|
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
|
||||||
ab := autoban.New(autoban.Options{
|
// 下次再漏传会直接测试失败。
|
||||||
|
func newBanner() *autoban.Banner {
|
||||||
|
return autoban.New(autoban.Options{
|
||||||
Enabled: conf.Security.EnableAutoBan,
|
Enabled: conf.Security.EnableAutoBan,
|
||||||
TrackNotFound: conf.Security.BanTrackNotFound,
|
TrackNotFound: conf.Security.BanTrackNotFound,
|
||||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||||
@@ -96,15 +98,43 @@ func New() *flamego.Flame {
|
|||||||
provider, proxied := ipinfo.Lookup(ip)
|
provider, proxied := ipinfo.Lookup(ip)
|
||||||
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
|
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
|
||||||
|
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
|
||||||
|
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
|
||||||
|
HoneypotEnabled: conf.Security.EnableHoneypot,
|
||||||
|
HoneypotThreshold: conf.Security.HoneypotThreshold,
|
||||||
|
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *flamego.Flame {
|
||||||
|
f := flamego.Classic()
|
||||||
|
if conf.App.Production {
|
||||||
|
flamego.SetEnv(flamego.EnvTypeProd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||||
|
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||||
|
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
|
||||||
|
ab := newBanner()
|
||||||
|
|
||||||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
||||||
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
||||||
hp := honeypot.New(honeypot.Options{
|
hp := honeypot.New(honeypot.Options{
|
||||||
Enabled: conf.Security.EnableHoneypot,
|
Enabled: conf.Security.EnableHoneypot,
|
||||||
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
|
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
|
||||||
})
|
})
|
||||||
hph := newHoneypotHandler(hp, ab, db.Honeypots)
|
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
|
||||||
|
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
|
||||||
|
// 绝不让取证功能把站点拖挂。
|
||||||
|
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
|
||||||
|
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
|
||||||
|
credStore = nil
|
||||||
|
}
|
||||||
|
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
|
||||||
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
||||||
// 收件人复用 security_alert_email,不新增配置项。
|
// 收件人复用 security_alert_email,不新增配置项。
|
||||||
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ type Options struct {
|
|||||||
//
|
//
|
||||||
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
|
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
|
||||||
HoneypotEnabled bool
|
HoneypotEnabled bool
|
||||||
// HoneypotThreshold 蜜罐独立阈值:窗口内蜜罐命中次数达到即封禁。
|
// HoneypotThreshold 蜜罐「提交凭据」达到多少次即封禁。
|
||||||
// 默认 1(一次即封)—— 蜜罐路径与业务路由零交集,正常访客不可能命中,
|
// 默认 1 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
|
||||||
// 一次命中就足以判定为扫描器,无需等 autoban 的 10 次阈值。
|
// 纯路径探测不要走这个通道(否则对方 GET 一次就被封,走不到提交凭据那步)。
|
||||||
// 留空或 <= 0 时兜底为 1。
|
// 留空或 <= 0 时兜底为 1。
|
||||||
HoneypotThreshold int
|
HoneypotThreshold int
|
||||||
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
|
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
|
||||||
|
|||||||
@@ -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"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -25,6 +24,26 @@ const CanaryToken = "tb-hp-7f3a1c9e"
|
|||||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||||
const maxCredentialFieldLen = 512
|
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 的语义,
|
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||||
var honeypotPrefixes = []string{
|
var honeypotPrefixes = []string{
|
||||||
@@ -77,6 +96,40 @@ var forbiddenPrefixes = []string{
|
|||||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
"/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 是蜜罐行为配置。
|
// Options 是蜜罐行为配置。
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||||
@@ -212,11 +265,15 @@ type Credentials struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||||
|
//
|
||||||
|
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
|
||||||
|
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
|
||||||
|
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
|
||||||
var credentialKeys = map[string]bool{
|
var credentialKeys = map[string]bool{
|
||||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
"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,
|
"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 从请求中提取破解凭据。
|
// ExtractCredentials 从请求中提取破解凭据。
|
||||||
@@ -240,7 +297,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(v) > limit {
|
if len(v) > limit {
|
||||||
v = v[:limit]
|
v = clipRunes(v, limit)
|
||||||
}
|
}
|
||||||
if _, exists := out.Fields[k]; !exists {
|
if _, exists := out.Fields[k]; !exists {
|
||||||
out.Fields[k] = v
|
out.Fields[k] = v
|
||||||
@@ -293,7 +350,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
|||||||
}
|
}
|
||||||
raw := string(buf)
|
raw := string(buf)
|
||||||
if len(raw) > limit {
|
if len(raw) > limit {
|
||||||
raw = raw[:limit]
|
raw = clipRunes(raw, limit)
|
||||||
}
|
}
|
||||||
out.RawBody = raw
|
out.RawBody = raw
|
||||||
|
|
||||||
@@ -390,15 +447,25 @@ func parseJSONCredentials(raw string, put func(k, v string)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
|
||||||
func (h *Honeypot) Describer() string {
|
//
|
||||||
|
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
|
||||||
|
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
|
||||||
|
// 调用方自己决定怎么展示。
|
||||||
|
func (h *Honeypot) Prefixes() []string {
|
||||||
if h == nil || !h.enabled {
|
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 {
|
for k := range h.prefix {
|
||||||
keys = append(keys, k)
|
out = append(out, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
|
||||||
return path.Join(keys...)
|
// 排序依赖渗透进整个包的 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"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatch(t *testing.T) {
|
func TestMatch(t *testing.T) {
|
||||||
@@ -148,3 +149,67 @@ func TestExtractCredentialsMalformed(t *testing.T) {
|
|||||||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
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
|
Redir string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginPageTitle 是假登录页的统一标题(与真实 WordPress 登录页一致)。
|
||||||
|
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
|
||||||
|
// 已经出现漂移。
|
||||||
|
const LoginPageTitle = "Log In ‹ WordPress"
|
||||||
|
|
||||||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||||||
//
|
//
|
||||||
// 页面刻意包含:
|
// 页面刻意包含:
|
||||||
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
|
// - 隐藏 canary 标记(HTML 注释),便于日后溯源;
|
||||||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||||||
// 我们就能拿到最干净的用户名与密码对;
|
// 我们就能拿到最干净的用户名与密码对;
|
||||||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||||||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||||||
|
//
|
||||||
|
// ⚠️ canary 只放在 HTML 注释里,不放进 <meta name="generator">。
|
||||||
|
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
|
||||||
|
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
|
||||||
|
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
|
||||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||||
action := html.EscapeString(vars.Action)
|
action := html.EscapeString(vars.Action)
|
||||||
title := vars.Title
|
title := vars.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Log In ‹ WordPress"
|
title = LoginPageTitle
|
||||||
}
|
}
|
||||||
heading := vars.Heading
|
heading := vars.Heading
|
||||||
if heading == "" {
|
if heading == "" {
|
||||||
@@ -46,12 +56,14 @@ func RenderLoginPage(vars LoginPageVars) []byte {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.Grow(4096)
|
b.Grow(4096)
|
||||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
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(`<html lang="en-US">` + "\n")
|
||||||
b.WriteString(`<head>` + "\n")
|
b.WriteString(`<head>` + "\n")
|
||||||
b.WriteString(`<meta charset="UTF-8" />` + "\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="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(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user