📋 目录
背景介绍
安全扫描器的配置复杂度通常比普通 CLI 工具高很多。
一个完整扫描任务可能涉及:
目标范围
├── URL
├── 域名白名单
├── 路径黑名单
└── 爬虫深度
HTTP行为
├── Header
├── Cookie
├── 代理
├── 超时
└── 重试
扫描策略
├── 插件启用/禁用
├── 并发数
├── QPS
├── 被动代理监听地址
└── 反连平台地址
输出配置
├── HTML报告
├── JSON结果
├── TXT日志
└── Webhook回调
xray README 中可以看到多种命令行参数:
xray webscan --basic-crawler http://example.com --html-output vuln.html
xray webscan --listen 127.0.0.1:7777 --html-output proxy.html
xray webscan --plugins cmd-injection,sqldet --url http://example.com
同时,仓库中的 webhook 示例配置也体现了典型 YAML 配置方式:
version: 1
server:
host: 127.0.0.1
port: 5000
debug: false
token: ""
plugins:
demo:
enabled: true
args:
arg1: foo
arg2: bar
配置管理的目标是:让默认行为开箱即用,让高级配置可控可解释,让错误配置能尽早失败。
核心挑战
挑战1:配置来源很多
一个参数可能来自多个地方:
内置默认值
↓
配置文件
↓
环境变量
↓
命令行参数
比如超时时间:
- 默认值:10s
- 配置文件:15s
- 环境变量:20s
- 命令行:5s
最终应该用哪个?必须有清晰的优先级。
挑战2:零值和未配置要区分
Go 中很多字段都有零值:
Timeout: 0
Debug: false
Port: 0
但 0 和 false 可能有两种含义:
- 用户没有配置,应该使用默认值
- 用户明确配置为 0 或 false
如果不区分,会导致配置合并出现隐蔽问题。
挑战3:配置错误要可读
对 CLI 工具来说,糟糕的配置错误提示会极大降低可用性。
不好的错误
invalid config
好的错误
config.webscan.max_concurrent must be between 1 and 200, got 0
错误提示要告诉用户:
- 哪个字段错了
- 当前值是什么
- 合法范围是什么
- 如何修复
挑战4:配置会随版本演进
随着功能增加,配置文件会不断变化:
- 新增字段
- 字段改名
- 默认值调整
- 插件配置结构变化
如果没有版本字段和迁移策略,旧配置可能在新版本中产生不可预期行为。
解决方案
配置加载流程
┌─────────────────────────────┐
│ Default Config │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ YAML Config File │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ Environment Vars │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ CLI Flags │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ Validate + Normalize │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ Runtime Config │
└─────────────────────────────┘
优先级规则
推荐规则:
命令行参数 > 环境变量 > 配置文件 > 默认值
原因:
- 默认值保证开箱即用
- 配置文件适合长期保存
- 环境变量适合容器和 CI
- 命令行参数适合一次性覆盖
完整实现
步骤1:配置结构定义
pkg/config/config.go
package config
import "time"
type Config struct {
Version int `yaml:"version" json:"version"`
HTTP HTTPConfig `yaml:"http" json:"http"`
WebScan WebScanConfig `yaml:"webscan" json:"webscan"`
Reverse ReverseConfig `yaml:"reverse" json:"reverse"`
Report ReportConfig `yaml:"report" json:"report"`
Plugins PluginConfig `yaml:"plugins" json:"plugins"`
}
type HTTPConfig struct {
Proxy string `yaml:"proxy" json:"proxy"`
Timeout time.Duration `yaml:"timeout" json:"timeout"`
MaxRetries int `yaml:"max_retries" json:"max_retries"`
Headers map[string]string `yaml:"headers" json:"headers"`
FollowRedirect bool `yaml:"follow_redirect" json:"follow_redirect"`
}
type WebScanConfig struct {
URL string `yaml:"url" json:"url"`
Listen string `yaml:"listen" json:"listen"`
BasicCrawler string `yaml:"basic_crawler" json:"basic_crawler"`
MaxDepth int `yaml:"max_depth" json:"max_depth"`
MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"`
QPS int `yaml:"qps" json:"qps"`
Plugins []string `yaml:"plugins" json:"plugins"`
}
type ReverseConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
HTTPBase string `yaml:"http_base" json:"http_base"`
BaseDomain string `yaml:"base_domain" json:"base_domain"`
TokenTTL time.Duration `yaml:"token_ttl" json:"token_ttl"`
}
type ReportConfig struct {
HTMLPath string `yaml:"html_output" json:"html_output"`
JSONPath string `yaml:"json_output" json:"json_output"`
TextPath string `yaml:"text_output" json:"text_output"`
}
type PluginConfig map[string]PluginItem
type PluginItem struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Args map[string]string `yaml:"args" json:"args"`
}
步骤2:默认配置
pkg/config/default.go
package config
import "time"
func Default() Config {
return Config{
Version: 1,
HTTP: HTTPConfig{
Timeout: 10 * time.Second,
MaxRetries: 1,
Headers: map[string]string{"User-Agent": "xray-compatible-scanner"},
FollowRedirect: true,
},
WebScan: WebScanConfig{
MaxDepth: 3,
MaxConcurrent: 20,
QPS: 10,
},
Reverse: ReverseConfig{
Enabled: false,
TokenTTL: 5 * time.Minute,
},
Report: ReportConfig{},
Plugins: PluginConfig{},
}
}
默认值要保守:
- 并发不能太高
- 超时不能太长
- 反连平台默认不强制开启
- 输出路径默认由命令行决定
步骤3:读取YAML配置
pkg/config/loader.go
package config
import (
"os"
"gopkg.in/yaml.v3"
)
func LoadFile(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
这种方式的特点是:先填默认值,再让 YAML 覆盖默认值。
但要注意:如果需要区分“未配置”和“配置为零值”,应使用指针字段或独立的 patch 结构。
步骤4:Patch结构解决零值问题
pkg/config/patch.go
package config
import "time"
type Patch struct {
HTTP HTTPPatch
WebScan WebScanPatch
Reverse ReversePatch
Report ReportPatch
}
type HTTPPatch struct {
Proxy *string
Timeout *time.Duration
MaxRetries *int
FollowRedirect *bool
}
type WebScanPatch struct {
URL *string
Listen *string
BasicCrawler *string
MaxDepth *int
MaxConcurrent *int
QPS *int
Plugins *[]string
}
type ReversePatch struct {
Enabled *bool
HTTPBase *string
BaseDomain *string
TokenTTL *time.Duration
}
type ReportPatch struct {
HTMLPath *string
JSONPath *string
TextPath *string
}
Patch 中的 nil 表示“未设置”,非 nil 表示“用户明确覆盖”。
步骤5:配置合并
pkg/config/merge.go
package config
func ApplyPatch(cfg Config, patch Patch) Config {
if patch.HTTP.Proxy != nil {
cfg.HTTP.Proxy = *patch.HTTP.Proxy
}
if patch.HTTP.Timeout != nil {
cfg.HTTP.Timeout = *patch.HTTP.Timeout
}
if patch.HTTP.MaxRetries != nil {
cfg.HTTP.MaxRetries = *patch.HTTP.MaxRetries
}
if patch.HTTP.FollowRedirect != nil {
cfg.HTTP.FollowRedirect = *patch.HTTP.FollowRedirect
}
if patch.WebScan.URL != nil {
cfg.WebScan.URL = *patch.WebScan.URL
}
if patch.WebScan.Listen != nil {
cfg.WebScan.Listen = *patch.WebScan.Listen
}
if patch.WebScan.BasicCrawler != nil {
cfg.WebScan.BasicCrawler = *patch.WebScan.BasicCrawler
}
if patch.WebScan.MaxDepth != nil {
cfg.WebScan.MaxDepth = *patch.WebScan.MaxDepth
}
if patch.WebScan.MaxConcurrent != nil {
cfg.WebScan.MaxConcurrent = *patch.WebScan.MaxConcurrent
}
if patch.WebScan.QPS != nil {
cfg.WebScan.QPS = *patch.WebScan.QPS
}
if patch.WebScan.Plugins != nil {
cfg.WebScan.Plugins = *patch.WebScan.Plugins
}
if patch.Report.HTMLPath != nil {
cfg.Report.HTMLPath = *patch.Report.HTMLPath
}
if patch.Report.JSONPath != nil {
cfg.Report.JSONPath = *patch.Report.JSONPath
}
if patch.Report.TextPath != nil {
cfg.Report.TextPath = *patch.Report.TextPath
}
return cfg
}
步骤6:命令行参数覆盖
pkg/config/flags.go
package config
import "time"
type FlagValues struct {
URL string
Listen string
BasicCrawler string
Plugins []string
HTMLOutput string
JSONOutput string
TextOutput string
MaxConcurrent int
Timeout time.Duration
}
func PatchFromFlags(flags FlagValues) Patch {
var patch Patch
if flags.URL != "" {
patch.WebScan.URL = &flags.URL
}
if flags.Listen != "" {
patch.WebScan.Listen = &flags.Listen
}
if flags.BasicCrawler != "" {
patch.WebScan.BasicCrawler = &flags.BasicCrawler
}
if len(flags.Plugins) > 0 {
patch.WebScan.Plugins = &flags.Plugins
}
if flags.HTMLOutput != "" {
patch.Report.HTMLPath = &flags.HTMLOutput
}
if flags.JSONOutput != "" {
patch.Report.JSONPath = &flags.JSONOutput
}
if flags.TextOutput != "" {
patch.Report.TextPath = &flags.TextOutput
}
if flags.MaxConcurrent > 0 {
patch.WebScan.MaxConcurrent = &flags.MaxConcurrent
}
if flags.Timeout > 0 {
patch.HTTP.Timeout = &flags.Timeout
}
return patch
}
命令行覆盖只处理用户显式传入的参数。实际工程中可通过 CLI 框架判断 flag 是否被设置,而不只依赖零值。
步骤7:配置校验
pkg/config/validate.go
package config
import (
"errors"
"fmt"
"net"
"net/url"
"time"
)
func Validate(cfg Config) error {
var list []error
if cfg.Version <= 0 {
list = append(list, fmt.Errorf("config.version must be positive, got %d", cfg.Version))
}
if cfg.HTTP.Timeout <= 0 || cfg.HTTP.Timeout > 120*time.Second {
list = append(list, fmt.Errorf("config.http.timeout must be between 1s and 120s, got %s", cfg.HTTP.Timeout))
}
if cfg.WebScan.MaxConcurrent < 1 || cfg.WebScan.MaxConcurrent > 200 {
list = append(list, fmt.Errorf("config.webscan.max_concurrent must be between 1 and 200, got %d", cfg.WebScan.MaxConcurrent))
}
if cfg.WebScan.QPS < 0 || cfg.WebScan.QPS > 1000 {
list = append(list, fmt.Errorf("config.webscan.qps must be between 0 and 1000, got %d", cfg.WebScan.QPS))
}
if cfg.WebScan.URL != "" {
if _, err := url.ParseRequestURI(cfg.WebScan.URL); err != nil {
list = append(list, fmt.Errorf("config.webscan.url is invalid: %w", err))
}
}
if cfg.WebScan.Listen != "" {
if _, _, err := net.SplitHostPort(cfg.WebScan.Listen); err != nil {
list = append(list, fmt.Errorf("config.webscan.listen must be host:port, got %q", cfg.WebScan.Listen))
}
}
if cfg.Reverse.Enabled && cfg.Reverse.HTTPBase == "" && cfg.Reverse.BaseDomain == "" {
list = append(list, errors.New("config.reverse requires http_base or base_domain when enabled"))
}
return errors.Join(list...)
}
校验阶段要尽量一次性返回多个错误,减少用户反复修改配置的成本。
步骤8:统一加载入口
pkg/config/runtime.go
package config
func LoadRuntime(configPath string, envPatch Patch, flags FlagValues) (Config, error) {
cfg, err := LoadFile(configPath)
if err != nil {
return Config{}, err
}
cfg = ApplyPatch(cfg, envPatch)
cfg = ApplyPatch(cfg, PatchFromFlags(flags))
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
入口函数应该清楚表达优先级:
Default + File + Env + Flags + Validate
测试策略
默认值测试
func TestDefaultConfig(t *testing.T) {
cfg := Default()
assert.Equal(t, 1, cfg.Version)
assert.Equal(t, 20, cfg.WebScan.MaxConcurrent)
assert.True(t, cfg.HTTP.FollowRedirect)
assert.NoError(t, Validate(cfg))
}
命令行覆盖测试
func TestFlagOverrideFile(t *testing.T) {
cfg := Default()
cfg.WebScan.MaxConcurrent = 10
flags := FlagValues{MaxConcurrent: 30}
cfg = ApplyPatch(cfg, PatchFromFlags(flags))
assert.Equal(t, 30, cfg.WebScan.MaxConcurrent)
}
配置校验测试
func TestValidateInvalidConcurrent(t *testing.T) {
cfg := Default()
cfg.WebScan.MaxConcurrent = 0
err := Validate(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "max_concurrent")
}
零值覆盖测试
func TestPatchCanSetBoolFalse(t *testing.T) {
cfg := Default()
value := false
cfg = ApplyPatch(cfg, Patch{
HTTP: HTTPPatch{FollowRedirect: &value},
})
assert.False(t, cfg.HTTP.FollowRedirect)
}
最容易踩的5个坑
坑1:命令行参数和配置文件优先级不清晰
错误示例
cfg = loadFlags()
cfg = loadFile()
正确做法
cfg := Default()
cfg = mergeFile(cfg)
cfg = mergeEnv(cfg)
cfg = mergeFlags(cfg)
优先级必须固定,并写进文档。
坑2:用零值判断是否配置
错误示例
if flags.MaxConcurrent != 0 {
cfg.MaxConcurrent = flags.MaxConcurrent
}
这个写法无法表达“用户明确设置为 0”。虽然并发数不应为 0,但布尔值、限速值等字段会受影响。
正确做法
if flagChanged("max-concurrent") {
cfg.MaxConcurrent = flags.MaxConcurrent
}
或使用指针 Patch。
坑3:配置错误启动后才暴露
错误示例
startScanner(cfg)
正确做法
if err := Validate(cfg); err != nil {
return err
}
startScanner(cfg)
配置错误越早失败越好。
坑4:默认并发过高
错误示例
MaxConcurrent: 1000
正确做法
MaxConcurrent: 20
扫描器默认行为应该保守,用户需要高性能时再显式调大。
坑5:插件配置没有命名空间
错误示例
enabled: true
timeout: 10s
正确做法
plugins:
sqldet:
enabled: true
args:
timeout: 10s
插件配置必须隔离,否则不同插件之间会产生字段冲突。
面试高频考点
考点1:复杂CLI工具如何设计配置优先级?
回答要点:
- 默认值保证开箱即用
- 配置文件适合长期配置
- 环境变量适合容器和CI
- 命令行参数用于临时覆盖
- 推荐优先级:命令行 > 环境变量 > 配置文件 > 默认值
考点2:如何区分未配置和配置为零值?
回答要点:
- 使用指针字段表示可选值
- 使用 Patch 结构合并配置
- CLI 框架通常能判断 flag 是否被显式设置
- 不要只靠
0、false、空字符串判断
考点3:配置校验应该放在哪里?
回答要点:
- 配置加载和合并之后
- 业务启动之前
- 一次性返回尽可能多的错误
- 错误信息包含字段路径、当前值和合法范围
- 校验后再生成运行时配置
总结与扩展
核心经验总结
-
配置管理是用户体验的一部分
- 默认值要保守
- 错误提示要清楚
- 文档示例要可直接复制
-
优先级必须明确
- 命令行覆盖环境变量
- 环境变量覆盖配置文件
- 配置文件覆盖默认值
-
零值问题要认真处理
- 布尔值尤其容易出错
- Patch 或 flag changed 是常见方案
- 合并逻辑要可测试
-
启动前完成校验
- 范围校验
- URL校验
- host:port校验
- 插件配置校验
-
配置要支持演进
- 保留 version 字段
- 新字段提供默认值
- 改名字段提供迁移提示
xray系列收官:从 POC 插件、漏洞检测引擎、爬虫与被动扫描,到指纹识别、反连平台、报告输出和配置管理,完整覆盖了一个安全扫描器从检测能力到工程交付的关键模块。

387

被折叠的 条评论
为什么被折叠?



