生成器(dev/theme-builder): - paletteVars 的 text-secondary/text-muted 从「往底色混 36%/55%」改成 mixToContrast(5.5/4.5):二分找最淡且达标的位置,文字本身不达标就原样返回 - 半透明卡片先 flatten 压平到底色再算对比度;参考背景取「页面底色 + 压平后的卡片」中更不利的 - 配色面板加读数(正文/次要/淡文字/主色),低于 4.5 变橙;默认 primary #4a7fc1(3.69) → #3d6fac(4.63) - 新增 ?dump=palette 四种配色回归;README 补配色一节 主题/base(含用户本轮 base.css 改动): - sakura muted #a08c94(2.8:1) → #78696e(4.61)、shiroko #8794a7(2.76:1) → #656e7a(4.63):主题覆盖令牌,基座的 AA 修复到不了主题页 - 深色顶栏链接补 secondary 覆盖:亮色块已是次要文字色,深色块原来 !important 定成正文色,同一顶栏权重跳一档 - _scaffold.css 令牌清单 23 → 29(补 fs 阶梯与 page-position),muted 说明 AA 下限 - StaticVersion 20261021a → 20261021b
448 lines
36 KiB
Markdown
448 lines
36 KiB
Markdown
# 2026-10-21
|
||
|
||
## 扫描者处置方案设计(未实施,仅设计文档)
|
||
|
||
- 触发:线上频繁收到「疑似批量扫描已自动封禁」告警,命中路径以 wp 探测 + `/.ssh/**` 为主,来源多 Azure 云 IP 池。
|
||
- **实测纠正一处错误认知**:`internal/route/route.go:432` 的注释称「flamego 的 NotFound 会先写 404 状态再 Redirect」——
|
||
**错的**。flamego v1.9.4 的 `NotFound` 只执行 handler,不写状态码;`ctx.Redirect("/")` 实测输出 **302 → "/"`。
|
||
最小复现验证:`/wp-admin/includes/...` 与 `/.ssh/id_rsa` 均得 302 + Location:/。
|
||
后果:扫描器跟随重定向 → 每个探测路径都触发一次首页渲染(1C1G 纯浪费),且被判为「站点有价值」。
|
||
同时告警邮件文案「产生大量 404」与实际 302 不符。
|
||
- 方案分五层(L0 CDN 路径/UA 规则 → L1 真 404 分流 → L2 蜜罐假文件/假凭据 → L3 tarpit + 递进封禁 → L4 上游 abuse 举报):
|
||
- 核心判断:**对云厂商僵尸网络做 IP 封禁无效**,有效的是按**路径**在 CDN 拦 + 蜜罐取证 + 上游举报。
|
||
- 明确排除反向攻击/DoS/回打(法律风险 + Azure 共享出口伤及无辜 + 招致 CDN 封站)。
|
||
- 蜜罐:命中 `/.ssh/id_rsa`、`/.env`、`/wp-login.php` 等诱饵路径 → 短路返回假内容(含 canary 标记)+ 永久封禁 + 留证。
|
||
- 封禁需落库(当前内存 map,重启清零)+ 时长递进 15m→1h→6h→24h→7d→永久 + 多维(IP / IPv6 /64 / UA 家族)。
|
||
- tarpit 必须带全局并发上限 + 单连接超时,否则 1C1G 自身变 DoS 入口。
|
||
- 设计文档:`outputs/scanner-defense-design-2026-10-21.md`(含分级变更清单、文件级改动点、验收指标)。
|
||
- 未动任何生产代码,等用户决定实施范围。
|
||
|
||
## 蜜罐(虚假后台登录界面 + 破解凭据记录)已实施
|
||
|
||
用户明确需求原文:「能不能把这些页面指向一个简单的虚假后台登录界面,然后记录这些破解的用户名密码等信息」。
|
||
|
||
### 落点决策(关键)
|
||
- **蜜罐不改写成新中间件,而是改写 `internal/route/route.go` 的 `f.NotFound`**:
|
||
- NotFound 只在「路由表完全落空」时触发 → 与 `/login` `/register` `/_/{domain}` `/user/**` `/api/v1/**` 天然零交集;
|
||
- 位于全局 `f.Use` 链之后 → `requestBodyLimitMiddleware` 已给 body 加 12 MiB 上限;
|
||
- 组级中间件(含 CSRF)只挂具体路由,不作用于 NotFound → POST 表单可安全解析;
|
||
- flamego `context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即安全短路。
|
||
- **双重计数坑**:autoban 中间件对敏感路径已 `Record` 一次,蜜罐再记一次会让阈值实际减半。
|
||
解决:`autobanMiddleware(b, hp)` 传入蜜罐匹配器,蜜罐命中的路径由蜜罐自己计数。
|
||
|
||
### 新增文件
|
||
- `internal/security/honeypot/honeypot.go` — 匹配器(段前缀 + 片段子串 + 双重保险 `forbiddenPrefixs` 排除业务路由)、
|
||
凭据提取(表单 / JSON / BasicAuth / 查询串)、`CanaryToken = "tb-hp-7f3a1c9e"`。
|
||
- `internal/security/honeypot/page.go` — 仿 WordPress 假登录页(经典字段 `log`/`pwd`/`redirect_to`),内联 CSS 不依赖站点静态资源。
|
||
- `internal/db/honeypot.go` — `HoneypotHit`(**不嵌 dbutil.Model**,审计证据只追加)+ `HoneypotStore`。
|
||
- `internal/route/honeypot.go` — 处理器:GET 假登录页 / POST 假失败响应 / 落库 / 复用 autoban 封禁 / 每 IP 告警一次。
|
||
- `internal/cmd/honeypot.go` — `TamaBox honeypot hits|stats|prune`,密码默认脱敏(`--show-password` 显示明文)。
|
||
- `templates/mail/honeypot-alert.html` — 告警邮件模板(`mail/` 已在 embed 清单,无需改 embed.go)。
|
||
|
||
### 改动文件
|
||
- `internal/route/route.go`(CRLF):NotFound 分流 + 蜜罐实例装配 + `hph.sendAlert`。
|
||
- `internal/route/autoban.go`(LF):`autobanMiddleware(b, hp)`,蜜罐路径跳过自身 Record。
|
||
- `internal/db/db.go`(LF):`AllTables` 加 `&HoneypotHit{}`,`Init()` 加 `Honeypots = NewHoneypotStore(db)`。
|
||
- `internal/conf/static.go`(CRLF):`enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`。
|
||
- `conf/app.sample.ini`(CRLF):对应说明段。
|
||
- `internal/mail/mail.go`(LF):`SendHoneypotAlertMail` + `HoneypotAlert`。
|
||
- `cmd/tamabox.go` + 根 `tamabox.go`:注册 `cmd.Honeypot`(两处必须同步)。
|
||
|
||
### 踩坑
|
||
- `normalize()` 里 `for len(p)>1 { TrimSuffix(p,"/") }` **无限循环**(路径不含尾斜杠时字符串不变)→ 改成 `HasSuffix` 判断。
|
||
- `url.ParseQuery` 遇非法百分号编码返回 error 但仍给出部分字段 → 蜜罐要「尽可能多拿凭据」,忽略 error 继续用部分结果。
|
||
- JSON 凭据提取:手写解析器又脏又易错 → 改用 `jsonPairRe` 正则扫描扁平字段。
|
||
- `templates/mail/` 已在 embed 清单内 → 新增邮件模板**不需要**改 `templates/embed.go`(顶层文件才需要)。
|
||
|
||
### 验证
|
||
- `go build ./...` 通过;`go test ./...` 全绿(含 DB 测试,postgres 门控)。
|
||
- 新增测试:`internal/security/honeypot/honeypot_test.go`(匹配/业务路由零交集/编码绕过/凭据提取)、
|
||
`internal/route/honeypot_test.go`(假页含 canary、业务路由不被劫持、关闭时回落 302、POST 返回失败页)、
|
||
`internal/db/honeypot_test.go`(写入/倒序/三张聚合表/保留期清理)。
|
||
|
||
## 测试站黑盒验证 + P0 缺陷修复(蜜罐封禁被 enable_auto_ban 连坐)
|
||
|
||
测试站:`https://box-dev-api.337845818.best`(Cloudflare Tunnel + CF 边缘,`cf-cache-status: DYNAMIC`、无 `Age` → 不缓存,请求确实回源)。
|
||
生产站 `https://box.tama.guru` 强制 301 → www,本机不可达,无法做对照。
|
||
|
||
### 黑盒验证结果(全部通过)
|
||
- 诱饵路径全部 200 + `text/html`:`/wp-login.php` 2529B、`/wp-admin/` 2517B、`/.ssh/id_rsa` 2525B、`/.env` 2497B、`/administrator` 2533B。
|
||
- 响应体含 canary `tb-hp-7f3a1c9e`(2 处)、`<title>Log In ‹ WordPress</title>`、`name="log"`/`name="pwd"`/`name="redirect_to"`。
|
||
- 响应头:`Cache-Control: no-store, no-cache, must-revalidate`、`x-robots-tag: noindex, nofollow`、`x-frame-options: DENY`。
|
||
- POST 凭据提交 → 200 + `id="login_error">ERROR: Invalid username or password.`。
|
||
- 路径变体全命中:`/WP-ADMIN`(大小写)、`/wp-admin%2f..%2fetc`(编码绕过)、`//wp-admin//x.php`(双斜杠)、`/.git/config`、`/.aws/credentials`、`/phpmyadmin/`、`/actuator/env`、`/xmlrpc.php`、`/vendor/phpunit/x.php`、`/console`、`/cgi-bin/test.cgi`、`/wp-content/plugins/x.php`、`/.env.bak`、`/wp-json/wp/v2/users`。
|
||
- 凭据来源全覆盖:表单 POST、JSON body、BasicAuth、查询串、POST 到 `/.ssh/id_rsa`。
|
||
- **业务路由零交集(红线)**:`/` 34192B、`/login` 23420B、`/register` 25194B、`/_/admin6` 39358B、`/user` 302、`/api/v1/questions` 302、`/robots.txt` 145B;非诱饵随机路径 `/nonexistent-random-xyz` 仍 302→`/`。
|
||
|
||
### P0 缺陷:蜜罐命中 70+ 次但站点零封禁
|
||
- 根因:`autoban.Record()` 第一行 `if !b.enabled { return }`,`b.enabled` 来自 `enable_auto_ban`。
|
||
运维为避免共享出口误伤而关掉 `enable_auto_ban` → **蜜罐被另一个开关连坐**,只落库不封禁。
|
||
- 对照组证明非蜜罐专属:`/index.php`(autoban 敏感、蜜罐不匹配)连打 40 次 + 30 并发同样零封禁。
|
||
- 排除项:CF 缓存(DYNAMIC/无 Age)、flamego NotFound 链(源码确认全局 Use 链生效)、蜜罐匹配器、阈值配置。
|
||
|
||
**修复(已实施,测试全绿)**:
|
||
- `autoban.Options` 新增 `HoneypotEnabled` / `HoneypotThreshold`(默认 1)/ `HoneypotDuration`(默认复用常规)。
|
||
- 新增 `Banner.RecordHoneypot(ip, path) bool`:独立计数、独立阈值,**不受 `enabled` 约束**;键与 `Record` 一致(同样 `shared.LimitKey` 归一化),否则 `IsBanned` 判不出来。
|
||
- `IsBanned()` 移除 `!b.enabled` 早退 —— `enabled` 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
|
||
- `internal/route/honeypot.go`:`banned = h.ab.RecordHoneypot(ip, path)`。
|
||
- `internal/route/route.go`(CRLF)装配三个蜜罐字段;`conf/static.go` + `conf/app.sample.ini`(CRLF)新增 `honeypot_threshold` / `honeypot_ban_minutes`。
|
||
- 告警:仍走既有 `sendAlert`(「已封禁」)+ 蜜罐自己的告警邮件(「捕获了什么凭据」),互补。
|
||
|
||
### 后缀匹配补缺
|
||
- `/backup.zip`、`/db.sql`、`/id_rsa.pem` 这类泄露扫描文件名主体随机,**只有后缀稳定**。
|
||
- 原 `honeypotFragments` 用 `"/.zip"` 子串匹配 → `.zip` 前是 `k` 不是 `/`,全漏网。
|
||
- 新增 `honeypotSuffixes`(`.zip .tar .tar.gz .tgz .gz .rar .7z .bz2 .sql .sql.gz .dump .bak .old .orig .save .swp .env .env.bak .env.local .pem .key .p12 .pfx .jks .kdbx .ovpn`),按 `path.Base()` 结尾匹配。
|
||
|
||
### 踩坑
|
||
- ⚠️ **测试里 `Duration: 60` = 60 纳秒**,封禁瞬间过期 → 表现为「时序巧合的 200/200/403」,测试假失败。一律写 `60 * time.Minute`。
|
||
- ⚠️ 路由测试只注册路由 + NotFound 会被绕过:执行 `IsBanned` → 403 的是 **`autobanMiddleware`**,测试服务器必须 `f.Use(autobanMiddleware(ab, hp))`。
|
||
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;`grep | cat -A` 不显示 `^M`,极易误判为 LF。Python 脚本按 `\n` 锚点替换会静默失败 → 用 `\r\n` 锚点 + 写后统计 `b'\r\n'` 复核。
|
||
- 假页纯静态 HTML(内联 CSS,不落盘)、告警走 SMTP → **取证数据只在 `honeypot_hits` 一张表**,服务器无文件副本;autoban 封禁表是内存态(重启清零)。
|
||
- 测试阶段产生的噪声凭据(`testadmin/S3cr3tP@ss` 等)可用 `TamaBox honeypot prune` 清理。
|
||
|
||
### 遗留(需用户侧)
|
||
- 本轮修复需**重新部署**才在测试站生效;部署后 `curl` 一次 `/wp-login.php` 应 200、再一次 403。
|
||
- 需确认测试环境 `conf/app.ini` 的 `[security] enable_auto_ban`(零封禁直接原因)+ `enable_honeypot = true`。
|
||
- 无法 SSH(`100.79.185.45:19999` 全部账号 Permission denied),落库验证需用户执行 `TamaBox honeypot hits --since 24h --show-password`。
|
||
|
||
---
|
||
|
||
## 蜜罐表体积实测 + 自动清理(commit 63e851d,已 push)
|
||
|
||
用户问「这些记录会不会导致数据库很臃肿」。**不靠推断,本地 PostgreSQL 建同构表灌仿真数据实测。**
|
||
|
||
### 实测数字
|
||
- 典型行 **244 字节**(heap 201 + 索引 43),10.5 万行 = 24 MB;常规 UA 下实测 281 B/行。
|
||
- 字段均值:path 13B、user_agent 111B(单行最大头)、fields 3B、raw_body 3B。
|
||
- `raw_body` 被 `maxCredentialFieldLen = 512` 截断,故字段数量随之受限(200 字段场景 rawBody 仍 512B)。
|
||
- 最坏情况(手工构造 50 字段 × 500B)约 2375 B/行。
|
||
- **UA 截短收益**:截到 100 省 1129 kB / 截到 64 省 4824 kB(10 万行)→ 仅 5–20%,
|
||
远小于自动清理的确定性上界且损失取证信息 → **决定不做**。
|
||
|
||
### PostgreSQL 死元组语义(本轮最关键认知)
|
||
- `DELETE` 只标记死元组,**表文件不会变小**;空间需 VACUUM 后才可被后续 INSERT 复用。
|
||
- 对照(10 万行,每轮删 25% 插 25%,行数恒定):有 VACUUM 22→24 MB **收敛**;无 VACUUM 22→52 MB **膨胀**。
|
||
- ⚠️ **VACUUM 不缩小文件,只让空间可复用**。二次实测(5 万行、连续 4 轮删 12000 插 12000):
|
||
行数恒定 5 万、文件稳定 15 MB 不再增长 —— 这才是要的效果(止住增长,不是让文件变小)。
|
||
想让文件立刻变小只有 `VACUUM FULL`(重建整表 + 锁表),蜜罐表万级行没必要。
|
||
- ⚠️ `VACUUM` **不能在事务块内执行**(SQLSTATE 25001),也不能在 PL/pgSQL 函数体内执行(VACUUM 无法从函数执行)。
|
||
GORM `db.Exec()` 走 `callbacks.Raw()` 处理器、**不包事务** → `WithContext(ctx).Exec("VACUUM ...")` 可用(已实测)。
|
||
- PG18 已移除 `pg_stat_user_tables.n_tup_vacuum`,改用 `last_vacuum` 验证。
|
||
|
||
### 索引实测(EXPLAIN 驱动决策)
|
||
- `idx_honeypot_hits_provider`:10 万行 648 kB,**全代码库零查询使用**(TopSources 用 max(provider) 随 group by 顺带取)→ 已删。
|
||
- `idx_honeypot_hits_created_at`:EXPLAIN 显示 `Index Scan Backward`,被 `ListSince` 实际使用 → 保留。
|
||
- `idx_honeypot_hits_ip`:4.7 MB(最大),但 GROUP BY ip 走 Seq Scan + HashAggregate;**保留**(`ListSinceByIP` 需它)。
|
||
- ⚠️ **GORM 去掉 struct tag 的 `index` 不会删除既有库上的旧索引** → 需显式 `HasIndex` + `DropIndex`(已加 `dropUnusedHoneypotIndexes`,启动时跑,失败仅告警)。
|
||
|
||
### 落地改动
|
||
- `internal/db/honeypot_retention.go`(新):`HoneypotRetention` 后台清理器,每小时一轮,两道闸
|
||
(时间 `honeypot_retain_days` + 数量 `honeypot_max_rows`)→ 清理后调 `Compact()`。
|
||
保留期 <=0 视为不自动清理(只告警);**`runOnce` 开头必须再挡一次 `retainDays<=0`**,
|
||
否则 before=now 会把历史全删光(「保留 0 天」被误解释成「只留此刻」)。
|
||
- `internal/db/honeypot.go`(新方法):`Compact()`(PG `VACUUM (ANALYZE)`)、`DeleteOldestBeyond()`
|
||
(跨方言通用,子查询按 id 倒序取 keepRows 边界)、`ListSinceByIP()`、`Size()`。
|
||
- `internal/cmd/web.go`:接入清理器,`stopRetention` 用 defer close。
|
||
- `internal/cmd/honeypot.go`:新增 `honeypot size` 子命令(行数/磁盘占用/时间跨度 + 与两道闸对照)。
|
||
- 修 CLI `hits --ip` 真 bug:原先取回最近 N 条再在 Go 侧筛 → 指定 IP 的历史命中基本查不出来 → 改 SQL 层过滤。
|
||
- 新配置:`honeypot_auto_clean`(默认 true)、`honeypot_max_rows`(默认 200000 ≈ 53 MB)。
|
||
|
||
### 踩坑
|
||
- ⚠️ 测试预期别自己推算错:`DeleteBefore` 是**严格小于**(`created_at < before`),边界那条不删。
|
||
- ⚠️ Python 补丁脚本里写 Go struct tag 的反引号 → bash 命令替换报错,**必须写成 .py 文件执行**(不能 `python -c`)。
|
||
- ⚠️ `psql` 传中文 SQL 报编码错 → 用英文输出串或 `PGCLIENTENCODING=UTF8`。
|
||
- ⚠️ 库名带连字符要用双引号:`CREATE DATABASE "tamabox-cli-test"`。
|
||
- ⚠️ Edit 工具改 CRLF 文件会保持 CRLF,但**改写时我打的字会转码损坏**(出现过「实测指事/程定/不图」)→ 改完必须逐行读回复核。
|
||
- 仓库内统一存 LF,工作区 CRLF 由 `core.autocrlf=true` 自动转换 —— 提交时 warning 属正常,不是行尾被改。
|
||
|
||
---
|
||
|
||
## 配置键名静默失效的根治(commit d300167,已 push)
|
||
|
||
### 现场故障
|
||
用户配置 `honeypot_retention_days <= 30`,日志报「蜜罐自动清理未启用:honeypot_retention_days <= 0」。
|
||
|
||
### 根因(探针实测 ini v1.67.0,不是读源码猜的)
|
||
1. **键名多一个 t**(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0。
|
||
2. **`<= 30` 这种写法**让 ini 把键名解析成 `"honeypot_retention_days <"`(带尾随比较符),
|
||
实测输出:`key="honeypot_retention_days <" value="30"` —— 更匹配不上。
|
||
3. **我之前那条警告文案里写的也是错键名** `honeypot_retention_days`,
|
||
等于在误导运维往错的键上配 —— 教训:警告文案里的键名必须与代码读的键名逐字一致。
|
||
|
||
### 实测到的 ini 行为(重要,写 ini 相关代码前必读)
|
||
- `MapTo` 在**键缺失或值非法时不覆盖**目标字段 → 可以安全预置缺省值。
|
||
- 键名**大小写敏感**(`Honeypot_Auto_Clean` 不生效)。
|
||
- `HasKey` 能区分「缺失」与「显式 0」。
|
||
- 段不存在时 `MapTo` 返回 nil 且不修改目标。
|
||
- bool 的解析是「有键才赋值」→ **未配置与显式 false 无法用零值区分**,必须靠 HasKey。
|
||
|
||
### 修复三层
|
||
- `internal/conf/keynorm.go`(新):键名归一化(修剪空白、剥尾随 `<` `>` `=`、别名表纠错)。
|
||
⚠️ 归一化结果必须命中该段**合法键名白名单**才采用 —— 不认识的键不猜,交给未知键告警。
|
||
- `internal/conf/unknown.go`(新):启动逐段比对键名,未知键 WARN + 给出最接近的合法键名。
|
||
合法键名由**反射从结构体 ini tag 得出**(单一事实来源,新增配置项自动纳入)。
|
||
- `internal/conf/honeypot_defaults.go`(新):蜜罐缺省值语义修正
|
||
(保留期缺省 30、auto_clean 缺省 true 用 HasKey 判、max_rows 缺省 200000、threshold 缺省 1)。
|
||
- 归一化必须在 `MapTo` **之前**做 —— 等 MapTo 跑完,错拼的键早被丢掉了。
|
||
|
||
### 顺带清理
|
||
`app.sample.ini` 里两条死配置:`app.title`(代码从未读,站点标题走 i18n `site.title`)、
|
||
`server.xsrf_expire`(无对应字段)。留着会让新检测对官方模板误报。
|
||
|
||
### 踩坑
|
||
- ⚠️ **Python 脚本里写 Go struct tag 的反引号** → bash 命令替换报错。
|
||
改用 Edit 工具逐处改(Edit 对 CRLF 文件保持 CRLF,但**我打的字可能转码损坏** → 改完逐行读回复核)。
|
||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、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;用户要部署测试环境验证后再决定推送。
|
||
|
||
### 测试环境实测反馈(同日晚,已修并推送)
|
||
现象:purge-creds 删 4 列成功、GET 200、POST 后 GET 403、文件已生成。
|
||
但凭据文件里 `"path":" /wp-login.php"`(前导空格)、`"ip":"127.0.0.1"`。
|
||
|
||
- **path 脏字符**:反代链路(CF → nginx → 源站)传出的 `req.URL.Path` 带前导空白。
|
||
`Match` 内部 normalize 会 TrimSpace 所以仍命中,但落库与假表单 action 用原始值
|
||
→ 取证数据脏、爆破脚本可能提交到错误路径。本地 httptest 复现不出来,是环境相关。
|
||
修复:`cleanPath()` 在 Serve 入口 trim + 补前导斜杠(不改大小写)。
|
||
- **IP = 127.0.0.1**:说明 `trust_proxy` 未开。后果不只是取证失真 ——
|
||
**一次蜜罐封禁会封掉 127.0.0.1,等于封掉整站所有访客**(封禁是内存态,重启即恢复)。
|
||
修复:命中回环地址且 `!trust_proxy` 时打一次性 WARN(24h 冷却),不改封禁行为。
|
||
sample.ini 的 trust_proxy 段补了蜜罐相关说明。
|
||
- `846bb8a` 已推送(`5e8deca..846bb8a`)。
|
||
|
||
### 收尾:path 脏字符最终定位(同日晚)
|
||
用户重启进程后,`od -c` 显示新记录的 `"path":"/wp-login.php"` —— 干净。
|
||
说明之前那条带空格的是**旧进程**写的(16:07 那次 `./tamabox web` 并没有真正替换掉在跑的实例)。
|
||
期间补了两处加固并推送:
|
||
- `74312fb`:`cleanPath` 改用 `TrimFunc` 覆盖 U+200B/200C/200D/FEFF
|
||
(`TrimSpace` 只认 White_Space,零宽字符会漏,且会因首字符不是 `/` 多补斜杠);
|
||
清洗前后不一致时 WARN 打出 `%q` 原始值,不可见字符靠日志才定位得到。
|
||
- 教训:`strings` 不认 UTF-8 中文(已记入 topics/build.md);替换二进制后必须重启进程。
|
||
|
||
### 文档(`3d45374`,已推送)
|
||
- 新增 `docs/honeypot.md`:生效范围、配置、**两条封禁路径**(GET 走常规阈值 /
|
||
提交凭据才即封)、凭据文件格式与不入库的理由、`trust_proxy` 部署必读、
|
||
旧列清理、排障表、设计取舍。
|
||
- `docs/cli.md`:§0 补「替换二进制必须重启」「strings 用 grep -ac」;
|
||
命令总览加 honeypot;新增 §7 honeypot 命令(原 7/8/9 顺延为 8/9/10);
|
||
排错表加 5 条。
|
||
# 2026-10-21
|
||
|
||
## 新旧前端对比(正式 vs 测试环境),只测量未改码
|
||
|
||
**方法**:curl 抓两环境 4 个页面 → 本地注入脚本取计算样式 / 资源体积 / 对比度 / 点按区;
|
||
无头 Chrome 截图(`--headless=old`)后用 Pillow 算像素指标。
|
||
⚠️ 本模型读不了图片,所以结论全部来自数值与结构,不靠"看截图"。
|
||
|
||
**踩坑**:file:// 打开抓下来的页面时,`href="/static/css/base.css"` 会解析成 `file:///static/...` → CSS 不加载,
|
||
于是 dev 页量出来全是 UIkit 旧样式(圆角 0、旧阴影)。改法:抓下来后把 `/static/` 重写成 dev 域名绝对地址,
|
||
再重新采集。**以后本地复现线上页面必须先重写站内绝对路径,否则测的是"无 CSS 的裸 HTML"。**
|
||
另:`body.scrollWidth` 会被隐藏面板撑大(dev explore 1499),判断横向溢出要看 `documentElement.scrollWidth`(实测 1249 = 无溢出)。
|
||
|
||
**实测差异(1280 宽)**
|
||
| 指标 | 正式(旧) | 测试(新) |
|
||
|---|---|---|
|
||
| 首页 HTML | 42.9KB | 34.2KB |
|
||
| 探索页 HTML | 77.4KB | 53.4KB |
|
||
| `<style>` 块 | 9–10 | 2 |
|
||
| 行内 style 属性 | 8–16 | 3–5 |
|
||
| 卡片圆角 / 阴影 | 0px / 单层硬阴影 | 12px / 双层柔阴影 |
|
||
| 圆角取值种类 | 2px×73、50%、8、6、4、9 | 6、8、12、14、5、50% |
|
||
| 正文色 | #333/#666/#999 混用 | 令牌 #1f2329/#5c6370/#8a919c |
|
||
| 字体栈 | UIkit 默认 | system-ui + 苹方/雅黑 |
|
||
| 图标 | emoji(🌙☀️) | 内联 SVG |
|
||
| 首页截图像素:色彩数 / 边缘密度 | 2206 / 10.3% | 1174 / 5.0% |
|
||
|
||
**共同短板(新旧都没解决)**
|
||
- `--tb-text-muted #8a919c` 在 #fff 上只有 **3.18:1**(AA 需 4.5);旧版 #999 更低(2.85)。
|
||
建议改 #6b7280(4.83:1);暗色 muted #8b93a1 在 #242830 上 4.77 已达标。
|
||
- 顶栏导航链接(UIkit rgba(0,0,0,.5))2.68–2.85:1,同样是低对比。
|
||
- 移动端点按区:两版都有大量 19px 高的纯文字链接(不满足 44px 建议值)。
|
||
|
||
**新版自身的毛病(我写的,待修)**
|
||
- `templates/home.html` 字号用了 0.75/0.78/0.86/0.9/0.95/0.98 rem → 7 个肉眼难辨的档位;
|
||
`static/css/base.css` 里还有 0.78/0.8/0.82/0.84/0.88/0.95。**排版层级比旧版更碎**,应收敛到 4 档。
|
||
- 残留圆角 5px(header 验证码框、sponsor)、9px(profile-tabs,list.html/explore.html 各一处)。
|
||
|
||
## 按上轮结论改代码(字号收敛 / 对比度 / 圆角收敛)
|
||
|
||
先确认过:本地 `static/css/base.css` 与 dev 部署版 **diff 0 行**,本地就是新版基线。
|
||
|
||
1. **新增字号阶梯令牌**(base.css `:root`):`--tb-fs-xs/md/lg/xl/hero` = 12/14/16/24/28px。
|
||
`lg` 取 **1rem 而非 17px** —— UIkit 的 `.uk-card-title`/h4 就是 16px,顺着它才能让自定义标题
|
||
与组件标题同档,否则会留下 16 与 17 差 1px 的两档并存(第一版就是这么写的,实测发现后改回)。
|
||
2. **替换范围**:home.html 全部 7 档 → 5 档;explore / question-list / question-list(list.html) 三页
|
||
的 0.7/0.72/0.82/0.84/0.85/0.9/0.98/1/1.15/1.2 全换令牌。实测 explore 由 **9 档 → 5 档**。
|
||
3. **`--tb-text-muted` #8a919c → #6b7280**(#fff 上 4.83:1)。**关键坑**:
|
||
UIkit 自带 `.uk-text-muted{color:#999!important}`,我原先的普通声明**从未生效**,全站仍是 #999(2.85:1)。
|
||
必须 `!important` 才能压过。改完 box 页 fails 15→11、explore 14→9。
|
||
4. **顶栏导航**:UIkit 默认 rgba(0,0,0,.5) = 2.68:1 → 改 `var(--tb-text-secondary)` = **6.05:1**,
|
||
hover/active 走主色;暗色分支原有 `!important` 规则自然接管(实测 10.75:1)。
|
||
5. **圆角全量收敛**:9px/5px/4px/2px/7px/3px 以及等值的 6/8/10/12/14px 全部换成
|
||
`var(--tb-radius[-sm|-xs])`。全站(排除邮件模板)现在只剩 3 处 `999px`(胶囊,语义正确)。
|
||
6. `conf.StaticVersion` 20261020g → **20261021a**(base.css 改了必须 +1)。
|
||
|
||
**验证**:go vet / go test(templates+i18n+conf)/ go build 全绿;预览页内联本地 base.css 后用无头 Chrome
|
||
量计算样式,首页 12/14/16/24/28、explore 12/14/16/24/32,nav 6.05:1。
|
||
|
||
**遗留(未改,属设计取舍)**:主色链接 `#1e87f0` 在纯白上 3.37–3.42:1(<4.5)。改深会影响品牌观感,
|
||
需用户决定;Waline 第三方赞数绿色 2.87 不在本站控制内。
|
||
|
||
**注意**:工作区有 4 个**非本次改动**的删除(`static/css/themes/test01.css`、`static/images/shiroko-cutout.png`、
|
||
`static/images/themes/test01/*.webp`),是之前就存在的未提交删除,提交时勿一并 add。
|
||
|
||
## 17:4x 用户前端设计令牌化 → 主题/生成器的连带影响
|
||
|
||
用户改了 12 个模板 + base.css(未提交):`--tb-text-muted` #8a919c→#6b7280(AA 4.83:1)、
|
||
新增 `--tb-fs-xs/md/lg/xl/hero` 阶梯、圆角改走 `--tb-radius(-sm/-xs)`、顶栏链接改 `--tb-text-secondary`、
|
||
`.uk-text-muted` 加 `!important`(压 UIkit 的 `#999!important`)。
|
||
|
||
**关键结论:主题会把这套修复盖回原样。** 主题 css 覆盖同名令牌 → 页面用主题的值,不是 base.css 的。
|
||
实测(worse of bg / 压平后的 surface):
|
||
- default.css 不定义文字色 → 跟随 base ✓;sakura muted `#a08c94` **2.80:1**、shiroko `#8794a7` **2.76:1**(都看不见)。
|
||
- 已调到 AA 下限:sakura `#78696e`(4.61)、shiroko `#656e7a`(4.63),同色相只压深;深色块两个主题本来就 ≥4.7,不动。
|
||
- 主色仍不达标(未改,等用户定):base/shiroko `#4a7fc1` 3.69、base `#1e87f0` 3.26、sakura `#e5678f` 2.80。
|
||
候选:`#3d6fac`(4.63)、`#0f6ac6`(4.83)、`#b53d61`(4.91)。
|
||
|
||
**生成器的必修项**:`paletteVars` 原来 `text-muted = mix(text,bg,0.55)` 硬算 → 浅底掉到 2.9:1,
|
||
等于每导一次主题就把 base.css 的修复覆盖掉。改成 `mixToContrast(text, [bg, flatten(surface,bg)], 4.5)`:
|
||
二分找「最淡但达标」的比例;文字本身不达标时原样返回(宁可不够淡,不能看不见)。
|
||
- 半透明卡片(`rgba(255,255,255,0.86)`)必须**压平到底色**再算,否则按纯白算会偏低。
|
||
- 次要 5.5 / 弱化 4.5 两档,保证层级不倒挂。
|
||
- 配色面板加读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,<4.5 变橙(手填主色只提醒不拦)。
|
||
- 默认 primary 由 `#4a7fc1`(3.69) 压到 `#3d6fac`(4.63) —— 默认值不该一上手就踩线。
|
||
- 新增 `?dump=palette`(四种配色:默认亮/默认深/低对比手填/白卡彩底),`ok:true`。
|
||
|
||
**顺手补的一致性**:深色块顶栏原来 `!important` 定成 `--tb-text`(正文色),亮色块新的 secondary 规则
|
||
让同一顶栏在深浅两模式权重跳一档 → 加 `html[data-theme="dark"] .uk-navbar-nav > li > a { secondary !important }`。
|
||
实测计算值:无主题 nav #5c6370 / muted #6b7280;shiroko #51607a / #656e7a;sakura #6b545c / #78696e。
|
||
|
||
**⚠️ 用户删了 test01(css + 2 张图)与 `shiroko-cutout.png`,未提交,我没动。** 若测试站 `[theme] available`
|
||
还登记着 test01,启动日志会打「主题文件不存在,已从可用列表忽略」,设置页不再出现,箱主已选 test01 的会静默回退站点默认。
|
||
`shiroko-cutout.png` 只在 `docs/theme-shiroko.md` 里作参考图引用,不参与 embed。
|
||
|
||
**令牌化的残留**(只提醒,没改):`templates/user/profile.html` 还有 16 处 `font-size: 0.XXrem`;
|
||
`base.css` 自身 11 处(0.78/0.82/0.88/0.95/1.1 —— 正是被点名要消掉的那档);圆角只剩 50%/999px(语义正确)。
|
||
`--tb-fs-*` 在 base.css 里只用了 3 处(主题格、chip),阶梯主要落在模板层。
|
||
`StaticVersion` 20261021a → **20261021b**(主题 css 内容变了,必须换键)。
|