fix(conf): 配置键名容错 + 未知键告警 + 蜜罐保留期缺省值修正
现场故障:配置写 `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 <"
(带尾随比较符),更加匹配不上;
3. 警告文案里写的也是错键名,等于在误导运维往错的键上配。
修复(三层):
- 键名归一化(internal/conf/keynorm.go):修剪首尾空白、剥掉尾随 `<` `>`
`=` 等比较符残留,并按别名表纠正常见误拼。归一化结果必须命中该段
合法键名白名单才会被采用 —— 不认识的键不猜,交给未知键告警。
- 未知键告警(internal/conf/unknown.go):启动时逐段比对键名,无法识别的
键 WARN 出来并给出最接近的合法键名建议。合法键名由反射从结构体 ini tag
得出(单一事实来源,新增配置项自动纳入,不会脱节)。
- 缺省值语义修正(internal/conf/honeypot_defaults.go):
* 保留期缺省 30 天(原先零值 0 = 永不清理);显式 <= 0 也回退 30。
要关闭自动清理请用 honeypot_auto_clean = false。
* honeypot_auto_clean 缺省 true。原先靠 bool 零值,ini 的 bool 解析是
「有键才赋值」,未配置与显式关闭无法区分 → 老配置升级上来自动清理
实际是关着的。现改用 HasKey 判定。
* honeypot_max_rows 缺省 200000;显式 0 = 不限(会告警提醒只增不减)。
* honeypot_threshold 缺省 1。
实测 ini 行为(探针程序,已删):
- 键缺失或值非法时 MapTo **不覆盖**预置默认值 → 可安全预置缺省值;
- 键名大小写敏感(Honeypot_Auto_Clean 不生效);
- HasKey 能区分「缺失」与「显式 0」;
- 段不存在时 MapTo 返回 nil 且不修改目标。
顺带清理 app.sample.ini 里的两条死配置(app.title / server.xsrf_expire):
代码中从未读取(站点标题走 i18n),留着会让新检测对官方模板误报。
验证:
- 用现场原始配置端到端复现 → 归一化后保留期 30 天、自动清理开启、上限 20 万,
日志明确说明「配置键名已自动纠正」;
- 误拼键(honeypot_autoclean / honeypot_max_row)自动纠正;未知键(totally_bogus)
WARN 告警;
- 显式 honeypot_auto_clean = false 被尊重为关闭;完全未配置时缺省开启;
- 新增 TestSampleIniHasNoUnknownKeys 回归检测:官方样例配置零误报;
- go build ./... + go test -timeout 600s ./... 全绿,go vet 无输出。
This commit is contained in:
@@ -29,6 +29,10 @@ func Init() error {
|
||||
return errors.Wrapf(err, "parse %q", configFile)
|
||||
}
|
||||
|
||||
// 键名归一化必须在 MapTo **之前**做:ini 对未知键是静默忽略的,
|
||||
// 等 MapTo 跑完,错拼的键早就被丢掉了,再也认不出来。
|
||||
normalizeAllSectionKeys(File)
|
||||
|
||||
if err := File.Section("app").MapTo(&App); err != nil {
|
||||
return errors.Wrap(err, "map 'server'")
|
||||
}
|
||||
@@ -42,6 +46,9 @@ func Init() error {
|
||||
return errors.Wrap(err, "map 'security'")
|
||||
}
|
||||
|
||||
// 蜜罐配置归一化:把「未配置」与「显式关闭」区分开,回退安全默认值。
|
||||
sanitizeHoneypot()
|
||||
|
||||
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
|
||||
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 conf
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 蜜罐相关配置的默认值。
|
||||
//
|
||||
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
|
||||
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
|
||||
//
|
||||
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
|
||||
// honeypot_hits 只增不减;
|
||||
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
|
||||
// - HoneypotMaxRows 零值 0 → 无行数上限。
|
||||
//
|
||||
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
|
||||
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
|
||||
const (
|
||||
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
|
||||
DefaultHoneypotRetainDays = 30
|
||||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
DefaultHoneypotMaxRows = 200000
|
||||
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
|
||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
|
||||
DefaultHoneypotThreshold = 1
|
||||
)
|
||||
|
||||
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||||
//
|
||||
// 语义约定(两个开关各管一件事,不再互相牵连):
|
||||
//
|
||||
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
|
||||
// honeypot_retain_days 保留多少天。缺省 30 天。
|
||||
//
|
||||
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
|
||||
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
|
||||
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
|
||||
//
|
||||
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
|
||||
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
|
||||
func sanitizeHoneypot() {
|
||||
sec := File.Section("security")
|
||||
hasRetain := sec.HasKey("honeypot_retain_days")
|
||||
hasMaxRows := sec.HasKey("honeypot_max_rows")
|
||||
|
||||
// 保留期:未配置或显式 <= 0 → 回退默认。
|
||||
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
|
||||
if !hasRetain {
|
||||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||||
} else if Security.HoneypotRetainDays <= 0 {
|
||||
logrus.WithField("default", DefaultHoneypotRetainDays).
|
||||
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
|
||||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||||
}
|
||||
|
||||
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
|
||||
if !hasMaxRows {
|
||||
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
|
||||
} else if Security.HoneypotMaxRows <= 0 {
|
||||
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
|
||||
}
|
||||
|
||||
// 自动清理:缺省开启。
|
||||
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
|
||||
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
|
||||
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
|
||||
if !sec.HasKey("honeypot_auto_clean") {
|
||||
Security.HoneypotAutoClean = true
|
||||
}
|
||||
|
||||
// 封禁阈值:缺省 1。
|
||||
if !sec.HasKey("honeypot_threshold") {
|
||||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// 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 conf
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 配置键名归一化:容忍运维手写时的常见失误,避免「配了但不生效」。
|
||||
//
|
||||
// 背景:ini 库对未知键是**静默忽略**的 —— 写错一个字母,程序照常启动、
|
||||
// 照常跑,但那个配置根本没生效,现场还看不出来。本次就踩了这个坑:
|
||||
// 写 `honeypot_retention_days`(多一个 t)而代码读 `honeypot_retain_days`,
|
||||
// 表现为「自动清理未启用」警告,而根因是键名不匹配。
|
||||
//
|
||||
// 这里做两层兜底:
|
||||
//
|
||||
// 1. 键名归一化(normalizeKeyName):修剪首尾空白、剥掉尾随的
|
||||
// `<` `>` `=` 等比较符号残留(`honeypot_retention_days <= 30` 会被 ini
|
||||
// 解析成键名 "honeypot_retention_days <",不是我们想要的键名)。
|
||||
// 2. 别名表(keyAliases):把已知的常见误拼映射到正确键名。
|
||||
//
|
||||
// 归一化只影响**键名**,不改动值。且归一化后的键名必须在该段的合法键名
|
||||
// 白名单内才会被采用 —— 避免把真正写错、无法识别的键「猜」成某个配置项。
|
||||
|
||||
// keyAliases 常见误拼 → 正确键名。
|
||||
//
|
||||
// 只收录「意图明确、写法唯一」的误拼。收录标准:
|
||||
// - 去掉别名后一定是该段某个合法键名(由调用方用白名单校验);
|
||||
// - 不会与任何合法键名或彼此产生歧义。
|
||||
//
|
||||
// 收录历史教训:honeypot_retain_days 被写成 honeypot_retention_days。
|
||||
var keyAliases = map[string]string{
|
||||
"honeypot_retention_days": "honeypot_retain_days",
|
||||
"honeypot_retain_day": "honeypot_retain_days",
|
||||
"honeypot_retention": "honeypot_retain_days",
|
||||
"honeypot_maxrow": "honeypot_max_rows",
|
||||
"honeypot_max_row": "honeypot_max_rows",
|
||||
"honeypot_maxrow_count": "honeypot_max_rows",
|
||||
"honeypot_autoclean": "honeypot_auto_clean",
|
||||
"honeypot_auto_cleaning": "honeypot_auto_clean",
|
||||
"enable_honeypot_hit": "enable_honeypot",
|
||||
"honeypot_retaindays": "honeypot_retain_days",
|
||||
"honeypot_retentionday": "honeypot_retain_days",
|
||||
"honeypot_keep_days": "honeypot_retain_days",
|
||||
"honeypot_retain_max_days": "honeypot_retain_days",
|
||||
}
|
||||
|
||||
// normalizeKeyName 归一化配置键名。
|
||||
//
|
||||
// 处理两类问题:
|
||||
// 1. 首尾空白与尾随比较符号残留。
|
||||
// `honeypot_retention_days <= 30` 在 ini 里被解析为键名
|
||||
// "honeypot_retention_days <"(值 "30")—— 键名带了尾随的 "<",
|
||||
// 直接与任何配置项都对不上。剥掉这些字符后才是用户真正想写的名字。
|
||||
// 2. 常见误拼别名(见 keyAliases)。
|
||||
//
|
||||
// 大小写不归一:ini 的键名匹配本来就是大小写敏感的,
|
||||
// 顺手转小写反而会让「键名写错大小写」这类问题更难发现。
|
||||
func normalizeKeyName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
// 剥掉尾随的空白与比较符号残留(可能叠加多个,如 "key <=" 或 "key ==")。
|
||||
for len(name) > 0 {
|
||||
last := name[len(name)-1]
|
||||
if last == ' ' || last == '\t' || last == '<' || last == '>' || last == '=' {
|
||||
name = name[:len(name)-1]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// 剥完后可能又暴露出新的尾随空白,继续削。
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// 别名映射(只做一次,不递归,避免别名链产生意外结果)。
|
||||
if canonical, ok := keyAliases[name]; ok {
|
||||
return canonical
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// legalKeysOf 收集一个段在代码中真实存在的合法键名。
|
||||
//
|
||||
// 通过反射读取结构体的 `ini` tag —— 单一事实来源,避免「白名单与代码脱节」
|
||||
// 导致新增配置项后被误报为未知键。
|
||||
//
|
||||
// 返回的键名集合同时包含 ini tag 原文与其归一化形式(归一化形式一般等于原文,
|
||||
// 因为 tag 本身不带空白与比较符号)。
|
||||
func legalKeysOf(v interface{}) map[string]bool {
|
||||
legal := map[string]bool{}
|
||||
add := func(name string) {
|
||||
if name == "" || name == "-" {
|
||||
return
|
||||
}
|
||||
legal[name] = true
|
||||
}
|
||||
collect := func(v interface{}) {
|
||||
rv := reflect.ValueOf(v)
|
||||
for rv.Kind() == reflect.Ptr {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if rv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
rt := rv.Type()
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
tag := field.Tag.Get("ini")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
// ini tag 形如 "name" 或 "name,opt1,opt2",取第一段。
|
||||
name := tag
|
||||
if idx := strings.Index(tag, ","); idx >= 0 {
|
||||
name = tag[:idx]
|
||||
}
|
||||
if name == "" {
|
||||
// 无 ini tag 时 ini 用字段名(小写化后的 snake_case 近似),
|
||||
// 保守起见不登记 —— 未登记只会导致漏报,不会误报。
|
||||
continue
|
||||
}
|
||||
add(name)
|
||||
add(normalizeKeyName(name))
|
||||
}
|
||||
}
|
||||
collect(v)
|
||||
return legal
|
||||
}
|
||||
|
||||
// sectionKeySets 配置文件中各段 → 该段代码里真实存在的合法键名。
|
||||
//
|
||||
// 由反射从结构体 ini tag 得出,是单一事实来源:新增配置项后自动纳入,
|
||||
// 不会出现「白名单与代码脱节」导致的误报。
|
||||
//
|
||||
// 注意:ini 对**无 ini tag** 的字段用字段名做键名,这部分无法通过 tag
|
||||
// 枚举,为避免误报这里不登记(漏报优于误报)。
|
||||
func sectionKeySets() map[string]map[string]bool {
|
||||
return map[string]map[string]bool{
|
||||
"app": legalKeysOf(&App),
|
||||
"theme": legalKeysOf(&Theme),
|
||||
"security": legalKeysOf(&Security),
|
||||
"server": legalKeysOf(&Server),
|
||||
"database": legalKeysOf(&Database),
|
||||
"redis": legalKeysOf(&Redis),
|
||||
"recaptcha": legalKeysOf(&Recaptcha),
|
||||
"pixel": legalKeysOf(&Pixel),
|
||||
"upload": legalKeysOf(&Upload),
|
||||
"limits": legalKeysOf(&Limits),
|
||||
"mail": legalKeysOf(&Mail),
|
||||
"service": legalKeysOf(&Service),
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeAllSectionKeys 归一化配置文件中所有段的键名,并告警未知键。
|
||||
//
|
||||
// 做两件事:
|
||||
// 1. 把可识别的误拼键名(带尾随垃圾字符 / 命中别名表)改写成正确键名,
|
||||
// 改写会记 INFO 日志,便于运维确认「我配的到底生效了没」。
|
||||
// 2. 把无法识别的键名 WARN 出来(附最接近的合法键名建议)。
|
||||
// 这是防住「配了但不生效」的关键 —— ini 本身对未知键完全静默。
|
||||
//
|
||||
// 只告警不报错:未知键不应该阻止服务启动(可能只是运维写了无关的键),
|
||||
// 但必须让人看见。
|
||||
func normalizeAllSectionKeys(f *ini.File) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
legal := sectionKeySets()
|
||||
|
||||
// 1) 键名归一化(改写 ini 文件里的键名,后续 MapTo 才能读到)。
|
||||
for section, keys := range legal {
|
||||
sec := f.Section(section)
|
||||
if sec == nil {
|
||||
continue
|
||||
}
|
||||
renamed := normalizeSectionKeys(sec, keys)
|
||||
for old, new := range renamed {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"section": section,
|
||||
"from": old,
|
||||
"to": new,
|
||||
}).Info("配置键名已自动纠正(该键原本会被静默忽略)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 未知键告警。
|
||||
for _, u := range detectUnknownSections(f, legal) {
|
||||
for _, key := range u.Keys {
|
||||
fields := logrus.Fields{"section": u.Section, "key": key}
|
||||
if s, ok := u.Suggestions[key]; ok {
|
||||
fields["suggestion"] = s
|
||||
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
|
||||
continue
|
||||
}
|
||||
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSectionKeys 归一化一个段内的键名,并把归一化结果写回 ini 文件。
|
||||
//
|
||||
// 行为:
|
||||
// - 遍历段内所有键,计算归一化键名;
|
||||
// - 若归一化键名与原键名不同,且归一化键名在该段白名单内 → 用归一化名替换;
|
||||
// - 若归一化键名是别名且白名单内 → 用正确键名替换;
|
||||
// - 否则保留原样(交给未知键告警处理,不擅自猜测)。
|
||||
//
|
||||
// 返回被改写的键名对(原名 → 新名),供调用方记日志。
|
||||
func normalizeSectionKeys(sec *ini.Section, legal map[string]bool) map[string]string {
|
||||
renamed := map[string]string{}
|
||||
if sec == nil {
|
||||
return renamed
|
||||
}
|
||||
type rename struct {
|
||||
old string
|
||||
new string
|
||||
}
|
||||
var pending []rename
|
||||
for _, key := range sec.Keys() {
|
||||
old := key.Name()
|
||||
norm := normalizeKeyName(old)
|
||||
if norm == "" || norm == old {
|
||||
continue
|
||||
}
|
||||
if !legal[norm] {
|
||||
// 归一化后的名字不是本段的合法键名 —— 可能是真写错了,
|
||||
// 也可能是别段的键。不要擅自猜测,交给未知键告警。
|
||||
continue
|
||||
}
|
||||
// 目标键名已存在时保留原键(显式的正确写法优先于需要猜测的写法)。
|
||||
if sec.HasKey(norm) {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, rename{old: old, new: norm})
|
||||
}
|
||||
for _, r := range pending {
|
||||
val := sec.Key(r.old).Value()
|
||||
sec.DeleteKey(r.old)
|
||||
sec.NewKey(r.new, val)
|
||||
renamed[r.old] = r.new
|
||||
}
|
||||
return renamed
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// 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 conf
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// TestNormalizeKeyName 覆盖键名归一化的各种手写失误。
|
||||
//
|
||||
// 现场背景:运维写成 `honeypot_retention_days`(多一个 t)且写成
|
||||
// `honeypot_retention_days <= 30`,ini 把键名解析成
|
||||
// "honeypot_retention_days <"(带尾随比较符),完全匹配不上任何配置项,
|
||||
// 静默失效 —— 表现为「自动清理未启用」,现场毫无线索。
|
||||
func TestNormalizeKeyName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"正常键名不变", "honeypot_retain_days", "honeypot_retain_days"},
|
||||
{"多一个 t 的误拼", "honeypot_retention_days", "honeypot_retain_days"},
|
||||
{"误拼加尾随比较符", "honeypot_retention_days <", "honeypot_retain_days"},
|
||||
{"尾随 <= ", "honeypot_retain_days<=", "honeypot_retain_days"},
|
||||
{"首尾空白", " honeypot_retain_days ", "honeypot_retain_days"},
|
||||
{"空白加比较符", " honeypot_retain_days < ", "honeypot_retain_days"},
|
||||
{"多个尾随符号", "honeypot_retain_days<< ", "honeypot_retain_days"},
|
||||
{"空串", "", ""},
|
||||
{"纯符号", "<<<", ""},
|
||||
{"大小写不归一", "HONEYPOT_RETAIN_DAYS", "HONEYPOT_RETAIN_DAYS"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := normalizeKeyName(c.in); got != c.want {
|
||||
t.Errorf("normalizeKeyName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeys 验证段内键名改写:错拼的键被纠正到正确名字。
|
||||
func TestNormalizeSectionKeys(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_retention_days <= 30
|
||||
honeypot_auto_clean = true
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
legal := legalKeysOf(&Security)
|
||||
|
||||
renamed := normalizeSectionKeys(sec, legal)
|
||||
if len(renamed) == 0 {
|
||||
t.Fatalf("期望有键名被改写,实际没有")
|
||||
}
|
||||
if !sec.HasKey("honeypot_retain_days") {
|
||||
t.Errorf("改写后应存在 honeypot_retain_days,实际键: %v", sec.KeyStrings())
|
||||
}
|
||||
// 值必须原样保留(30),不能因为改写丢值。
|
||||
if got := sec.Key("honeypot_retain_days").Value(); got != "30" {
|
||||
t.Errorf("改写后值 = %q, want %q", got, "30")
|
||||
}
|
||||
// 归一化后的键名必须真的能被 MapTo 读到 —— 这是整个修复的落点。
|
||||
if err := sec.MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
if Security.HoneypotRetainDays != 30 {
|
||||
t.Errorf("HoneypotRetainDays = %d, want 30", Security.HoneypotRetainDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeysKeepsExplicit 验证正确键名存在时不会被别名的值覆盖。
|
||||
//
|
||||
// 显式写对的键优先级必须高于需要猜测的别名 —— 否则运维改了正确写法反而失效。
|
||||
func TestNormalizeSectionKeysKeepsExplicit(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
honeypot_retain_days = 7
|
||||
honeypot_retention_days = 30
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||||
|
||||
if got := sec.Key("honeypot_retain_days").Value(); got != "7" {
|
||||
t.Errorf("显式键应保留原值, got %q, want %q", got, "7")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeysUnknownNotGuessed 验证无法识别的键不会被「猜」成某个配置项。
|
||||
//
|
||||
// 归一化只能纠正「意图明确」的写法;对真正拼错的键必须保持原样,
|
||||
// 交由未知键告警提示 —— 擅自猜测会让配置静默变成另一个意思,比不猜更危险。
|
||||
func TestNormalizeSectionKeysUnknownNotGuessed(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
totally_bogus_key = 1
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||||
|
||||
if !sec.HasKey("totally_bogus_key") {
|
||||
t.Errorf("无法识别的键应保持原样,不应被改写;实际键: %v", sec.KeyStrings())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectUnknownSections 验证未知键被检出并给出建议。
|
||||
func TestDetectUnknownSections(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_retention_days = 30
|
||||
honeypot_totally_bogus = 1
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
|
||||
var sec *unknownSection
|
||||
for i := range unknown {
|
||||
if unknown[i].Section == "security" {
|
||||
sec = &unknown[i]
|
||||
}
|
||||
}
|
||||
if sec == nil {
|
||||
t.Fatalf("应检出 security 段的未知键,实际: %+v", unknown)
|
||||
}
|
||||
found := false
|
||||
for _, k := range sec.Keys {
|
||||
if k == "honeypot_totally_bogus" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("未知键列表应含 honeypot_totally_bogus,实际: %v", sec.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectUnknownSectionsNoFalsePositive 验证合法键不会被误报为未知。
|
||||
//
|
||||
// 误报比漏报更烦人:每次启动都刷一屏 WARN,运维会麻木。
|
||||
func TestDetectUnknownSectionsNoFalsePositive(t *testing.T) {
|
||||
src := `
|
||||
[app]
|
||||
external_url = "http://127.0.0.1:8080"
|
||||
production = true
|
||||
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_auto_clean = true
|
||||
honeypot_max_rows = 200000
|
||||
honeypot_retain_days = 30
|
||||
honeypot_threshold = 1
|
||||
honeypot_ban_minutes = 15
|
||||
honeypot_paths = "/a,/b"
|
||||
enable_auto_ban = true
|
||||
ban_threshold_sensitive = 5
|
||||
security_alert_email = "[email protected]"
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
salt = "s"
|
||||
xsrf_key = "k"
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
name = "x"
|
||||
schema = "public"
|
||||
|
||||
[limits]
|
||||
max_tags = 5
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
for _, u := range unknown {
|
||||
// [service] 段是子段结构,合法键在前缀段上,不在本段。
|
||||
if u.Section == "service" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("合法键被误报为未知: 段 %s 键 %v", u.Section, u.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClosestKey 验证建议算法能给出有用的近似键名。
|
||||
func TestClosestKey(t *testing.T) {
|
||||
legal := map[string]bool{
|
||||
"honeypot_retain_days": true,
|
||||
"honeypot_max_rows": true,
|
||||
"enable_honeypot": true,
|
||||
}
|
||||
if got := closestKey("honeypot_retention_days", legal); got != "honeypot_retain_days" {
|
||||
t.Errorf("closestKey = %q, want %q", got, "honeypot_retain_days")
|
||||
}
|
||||
if got := closestKey("enable_honeypot_hit", legal); got != "enable_honeypot" {
|
||||
t.Errorf("closestKey = %q, want %q", got, "enable_honeypot")
|
||||
}
|
||||
// 毫无关系的键不应给建议(避免噪音)。
|
||||
if got := closestKey("zzzzzz", legal); got != "" {
|
||||
t.Errorf("closestKey 应返回空, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeHoneypotDefaults 验证蜜罐配置的缺省值与语义修正。
|
||||
//
|
||||
// 核心是三件事:
|
||||
// 1. 未配置保留期 → 回退 30 天(而不是 0 = 永不清理);
|
||||
// 2. 显式 honeypot_auto_clean = false → 尊重为关闭(这是明确的关闭意图);
|
||||
// 3. 未配置自动清理 → 开启(ini 的 bool 零值是 false,若不区分就等于默认关闭)。
|
||||
func TestSanitizeHoneypotDefaults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
wantRetain int
|
||||
wantMaxRows int
|
||||
wantAutoClean bool
|
||||
wantThreshold int
|
||||
}{
|
||||
{
|
||||
name: "全未配置时回退安全默认值",
|
||||
src: "[security]\nenable_honeypot = true\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
},
|
||||
{
|
||||
name: "显式关闭自动清理被尊重",
|
||||
src: "[security]\nhoneypot_auto_clean = false\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: false,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
},
|
||||
{
|
||||
name: "显式保留期被采用",
|
||||
src: "[security]\nhoneypot_retain_days = 7\n",
|
||||
wantRetain: 7,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
},
|
||||
{
|
||||
name: "保留期写成 0 回退默认而非永不清理",
|
||||
src: "[security]\nhoneypot_retain_days = 0\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
},
|
||||
{
|
||||
name: "行数上限显式 0 表示不限",
|
||||
src: "[security]\nhoneypot_max_rows = 0\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: 0,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(c.src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
// 重置全局,避免用例间互相污染。
|
||||
Security.HoneypotRetainDays = 0
|
||||
Security.HoneypotMaxRows = 0
|
||||
Security.HoneypotAutoClean = false
|
||||
Security.HoneypotThreshold = 0
|
||||
File = f
|
||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
sanitizeHoneypot()
|
||||
|
||||
if Security.HoneypotRetainDays != c.wantRetain {
|
||||
t.Errorf("HoneypotRetainDays = %d, want %d", Security.HoneypotRetainDays, c.wantRetain)
|
||||
}
|
||||
if Security.HoneypotMaxRows != c.wantMaxRows {
|
||||
t.Errorf("HoneypotMaxRows = %d, want %d", Security.HoneypotMaxRows, c.wantMaxRows)
|
||||
}
|
||||
if Security.HoneypotAutoClean != c.wantAutoClean {
|
||||
t.Errorf("HoneypotAutoClean = %v, want %v", Security.HoneypotAutoClean, c.wantAutoClean)
|
||||
}
|
||||
if Security.HoneypotThreshold != c.wantThreshold {
|
||||
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegalKeysOfCoversStruct 验证合法键集合确实覆盖了结构体的所有 ini tag。
|
||||
//
|
||||
// 这是「未知键告警」不误报的前提:如果 legalKeysOf 漏了某个 tag,
|
||||
// 每次启动都会误报该键。
|
||||
func TestLegalKeysOfCoversStruct(t *testing.T) {
|
||||
legal := legalKeysOf(&Security)
|
||||
for _, want := range []string{
|
||||
"enable_honeypot",
|
||||
"honeypot_paths",
|
||||
"honeypot_retain_days",
|
||||
"honeypot_auto_clean",
|
||||
"honeypot_max_rows",
|
||||
"honeypot_threshold",
|
||||
"honeypot_ban_minutes",
|
||||
} {
|
||||
if !legal[want] {
|
||||
t.Errorf("合法键集合缺少 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleIniHasNoUnknownKeys 用官方样例配置文件做回归检测。
|
||||
//
|
||||
// 意义:conf/app.sample.ini 是运维照抄的模板,它里面的每个键都必须能被代码识别。
|
||||
// 一旦结构体改了字段名而 sample.ini 没同步(或反之),这个测试就会失败 ——
|
||||
// 而不是等到某次部署时启动日志里飘出一堆「无法识别的键」让人摸不着头脑。
|
||||
//
|
||||
// 同时也保证未知键检测不会对官方模板误报。
|
||||
func TestSampleIniHasNoUnknownKeys(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)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
for _, u := range unknown {
|
||||
// [service] 是子段结构(每个后端一个子段),键在子段上,不在本段。
|
||||
if u.Section == "service" {
|
||||
continue
|
||||
}
|
||||
for _, k := range u.Keys {
|
||||
t.Errorf("样例配置中存在无法识别的键: 段 %s 键 %q", u.Section, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
||||
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
||||
//
|
||||
// 这正是现场故障的完整复现路径。
|
||||
func TestNormalizeAllSectionKeysEndToEnd(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_auto_clean = true
|
||||
honeypot_max_rows = 200000
|
||||
honeypot_retention_days <= 30
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
File = f
|
||||
Security.HoneypotRetainDays = 0
|
||||
Security.HoneypotAutoClean = false
|
||||
Security.HoneypotMaxRows = 0
|
||||
Security.HoneypotThreshold = 0
|
||||
|
||||
normalizeAllSectionKeys(f)
|
||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
sanitizeHoneypot()
|
||||
|
||||
if Security.HoneypotRetainDays != 30 {
|
||||
t.Errorf("HoneypotRetainDays = %d, want 30(错拼键应被纠正并生效)", Security.HoneypotRetainDays)
|
||||
}
|
||||
if !Security.HoneypotAutoClean {
|
||||
t.Errorf("HoneypotAutoClean = false, want true")
|
||||
}
|
||||
if Security.HoneypotMaxRows != 200000 {
|
||||
t.Errorf("HoneypotMaxRows = %d, want 200000", Security.HoneypotMaxRows)
|
||||
}
|
||||
// 归一化后不应再残留「带比较符」的怪键名。
|
||||
for _, k := range f.Section("security").KeyStrings() {
|
||||
if strings.ContainsAny(k, "<>") {
|
||||
t.Errorf("残留带比较符的键名: %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -85,20 +85,27 @@ var (
|
||||
EnableHoneypot bool `ini:"enable_honeypot"`
|
||||
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
|
||||
HoneypotPaths string `ini:"honeypot_paths"`
|
||||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
|
||||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期自动清理。
|
||||
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。写成 honeypot_retention_days
|
||||
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
||||
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
||||
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||||
// 默认 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||||
// 关闭后仍可手动执行 TamaBox honeypot prune。
|
||||
// ⚠️ 缺省值靠 HasKey 判定(见 sanitizeHoneypot):ini 的 bool 解析是
|
||||
// 「有键才赋值」,若只看零值就无法区分「未配置」与「显式关闭」。
|
||||
HoneypotAutoClean bool `ini:"honeypot_auto_clean"`
|
||||
// HoneypotMaxRows 蜜罐命中记录的行数上限。留空或 <=0 表示不设上限。
|
||||
// HoneypotMaxRows 蜜罐命中记录的行数上限。缺省 200000(约 53 MB)。
|
||||
// 显式写 0 表示不设上限(逃生口,会告警提醒表只增不减)。
|
||||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
||||
// 留空回退 1。
|
||||
// 缺省回退 1(见 sanitizeHoneypot)。
|
||||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
|
||||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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 conf
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 未知配置键检测:把「配了但不生效」的键在启动时喊出来。
|
||||
//
|
||||
// 为什么必须做:ini 库对未知键是静默忽略的。运维写错一个字母,
|
||||
// 程序照常启动、照常服务,但那个配置完全没生效 —— 现场没有任何线索。
|
||||
// 蜜罐保留期就踩过这个坑:写成 honeypot_retention_days(多一个 t),
|
||||
// 代码读 honeypot_retain_days,表现为「自动清理未启用」,
|
||||
// 而日志里没有任何「你的键名不认识」的提示。
|
||||
//
|
||||
// 检测方式:把 ini 文件里每一段的键名,与代码中该段结构体的 ini tag
|
||||
// 逐一比对(合法键名由 legalKeysOf 从反射中得出,是单一事实来源)。
|
||||
// 报不出来的风险点:
|
||||
// - 无 ini tag 的字段(ini 用字段名做键名)→ 保守起见不登记,
|
||||
// 会漏报但不会误报;
|
||||
// - 段名本身写错 → 整段被忽略,这由「未知段」检测覆盖(见 unknownSections)。
|
||||
|
||||
// unknownSection 描述一个无法识别的配置段或键。
|
||||
type unknownSection struct {
|
||||
// Section 段名。
|
||||
Section string
|
||||
// Keys 该段内无法识别的键名(已排序)。
|
||||
Keys []string
|
||||
// Suggestions 键名 → 最接近的合法键名建议。
|
||||
Suggestions map[string]string
|
||||
}
|
||||
|
||||
// detectUnknownSections 检测配置文件中无法识别的段与键。
|
||||
//
|
||||
// 合法段名与合法键名均由调用方通过反射传入(单一事实来源)。
|
||||
func detectUnknownSections(f *ini.File, knownSections map[string]map[string]bool) []unknownSection {
|
||||
var out []unknownSection
|
||||
if f == nil {
|
||||
return out
|
||||
}
|
||||
for _, sec := range f.Sections() {
|
||||
name := sec.Name()
|
||||
legal, known := knownSections[name]
|
||||
if !known {
|
||||
// 未登记的段:可能是拼错,也可能是本项目不关心的段。
|
||||
// 只在段内确实有键时才提示(空段无所谓)。
|
||||
keys := sec.KeyStrings()
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out = append(out, unknownSection{Section: name, Keys: keys})
|
||||
continue
|
||||
}
|
||||
var bad []string
|
||||
suggestions := map[string]string{}
|
||||
for _, key := range sec.KeyStrings() {
|
||||
if legal[key] {
|
||||
continue
|
||||
}
|
||||
bad = append(bad, key)
|
||||
if s := closestKey(key, legal); s != "" {
|
||||
suggestions[key] = s
|
||||
}
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(bad)
|
||||
out = append(out, unknownSection{Section: name, Keys: bad, Suggestions: suggestions})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// closestKey 在合法键名中找出与 unknown 最接近的一个。
|
||||
//
|
||||
// 用「公共前缀长度 + 包含关系」这种无需导入编辑距离算法的启发式:
|
||||
// 配置键名通常是同族词(honeypot_xxx),拼错时往往仍共享较长前缀,
|
||||
// 或者其中一个是另一个的子串(retain_days vs retention_days)。
|
||||
func closestKey(unknown string, legal map[string]bool) string {
|
||||
best := ""
|
||||
bestScore := 0
|
||||
for name := range legal {
|
||||
score := 0
|
||||
if strings.Contains(unknown, name) || strings.Contains(name, unknown) {
|
||||
// 包含关系:得分按较短者的长度算,取一半折扣。
|
||||
short := len(name)
|
||||
if len(unknown) < short {
|
||||
short = len(unknown)
|
||||
}
|
||||
score = short / 2
|
||||
} else {
|
||||
score = commonPrefixLen(unknown, name)
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = name
|
||||
}
|
||||
}
|
||||
// 至少要有 6 个字符的公共部分才给建议,否则噪音大于价值。
|
||||
if bestScore < 6 {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// commonPrefixLen 返回两个字符串的公共前缀长度。
|
||||
func commonPrefixLen(a, b string) int {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
i := 0
|
||||
for i < n && a[i] == b[i] {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
Reference in New Issue
Block a user