几乎每个后端服务都会记录API请求日志,用于排查问题、审计分析。但请求体里往往包含用户的手机号、密码、身份证号、银行卡号等敏感信息,如果原样写进日志文件,一旦日志被导出或泄露,后果非常严重。很多团队的第一反应是在写日志的地方手动处理一遍敏感字段,比如把password字段替换成***。这种做法在小项目里勉强够用,但随着接口数量增加,硬编码的脱敏逻辑会散落在各个角落,改一条规则要翻遍代码,漏掉一处就是风险。本文介绍一种在Ruby中实现灵活脱敏规则引擎的思路,把“哪些字段要脱敏”和“怎么脱敏”两个问题拆开,用配置驱动,让规则可以集中管理、随时扩展。

为什么硬编码脱敏逻辑行不通
先看一段典型的“出事”代码。很多Rails项目的controller里会有类似的写法:
class ApiController < ApplicationController
def create
params_data = params.except(:password, :token)
Rails.logger.info("request: #{params_data.to_json}")
# 业务逻辑...
end
end这段代码的问题在于:except只能挡住你已经知道的两个字段,如果请求体里新增了一个id_card字段,它会毫无防护地进入日志。而且随着业务演进,敏感字段名可能是idCard、id_card_no、cert_number等各种变体,靠人工枚举永远追不完。
硬编码的另一个问题是策略单一。手机号通常希望保留前三位和后四位方便排查,身份证号希望全部打码,密码则希望连长度都不要暴露。把这些策略写死在各个调用点,不仅重复,而且一旦安全团队要求调整遮蔽规则,你需要全局搜索逐个修改,极易遗漏。
更隐蔽的风险来自嵌套结构。现代API请求体经常是多层JSON,敏感字段可能藏在user.profile.phone这样的深层路径里,简单的顶层字段过滤根本覆盖不到。所以我们需要一个独立的规则引擎:输入任意结构的请求参数,输出脱敏后的安全副本,业务代码完全无感知。
规则引擎的整体设计
一个实用的脱敏规则引擎可以拆成三个核心概念:匹配器(Matcher,判断某段数据是否命中规则)、策略(Strategy,决定命中后如何变换数据)、引擎(Engine,递归遍历数据结构并调度前两者)。这样拆分的好处是两者可以自由组合,比如同一个“手机号策略”既可以按字段名触发,也可以按正则触发。
匹配器建议支持三种模式:字段名精确匹配、字段名模糊匹配(如/pass|secret|token/i)、JSON路径匹配(如user.bank_accounts.*.card_no)。策略则至少内置这几种:mask部分遮蔽、redact整体替换、hash单向哈希(保留可关联性但不暴露原值)、keep白名单放行。规则配置本质上是一个“匹配器到策略”的映射表,可以放在YAML文件里集中维护。
下面是引擎的核心实现。注意递归处理Hash、Array和字符串三种类型,对字符串额外做正则兜底扫描,这样即使字段名不在规则里,内容本身像手机号也会被识别出来:
module LogSanitizer
# 策略对象
class Strategy
def self.mask(value, keep_prefix: 3, keep_suffix: 4)
s = value.to_s
return '*' * s.length if s.length <= keep_prefix + keep_suffix
s[0, keep_prefix] + '*' * (s.length - keep_prefix - keep_suffix) + s[-keep_suffix, keep_suffix]
end
def self.redact(_value)
'[REDACTED]'
end
def self.hash(value)
Digest::SHA256.hexdigest(value.to_s)[0, 12]
end
end
class Engine
SENSITIVE_PATTERNS = [
/\A1[3-9]\d{9}\z/, # 大陆手机号
/\A\d{17}[\dXx]\z/, # 身份证号
/\A\d{16,19}\z/ # 银行卡号
].freeze
def initialize(rules)
@exact = rules.fetch(:exact, {})
@fuzzy = rules.fetch(:fuzzy, [])
end
def sanitize(data)
case data
when Hash then data.each_with_object({}) { |(k, v), h| h[k] = apply_rule(k.to_s, v) }
when Array then data.map { |item| sanitize(item) }
when String then sanitize_string(data)
else data
end
end
private
def apply_rule(key, value)
if @exact.key?(key)
strategy = @exact[key]
return execute(strategy, value) unless strategy == :keep
return value
end
if @fuzzy.any? { |regex| regex.match?(key) }
return Strategy.redact(value)
end
sanitize(value)
end
def execute(strategy, value)
case strategy
when String, Symbol then Strategy.mask(value)
when Hash then Strategy.mask(value, **strategy)
when Proc then strategy.call(value)
end
end
def sanitize_string(str)
SENSITIVE_PATTERNS.reduce(str) do |result, pattern|
result.gsub(pattern) { |m| Strategy.mask(m, keep_prefix: 3, keep_suffix: 2) }
end
end
end
end这段代码的关键点有几个。apply_rule里先查精确规则表,命中且策略是:keep就直接放行,这为白名单机制留了口子;没命中精确规则再走模糊正则,最后对字符串值做内容级兜底扫描。execute支持三种策略写法:简单符号走默认遮蔽,Hash传参数定制保留位数,Proc则完全交给调用方,灵活性足够覆盖绝大多数场景。
配置管理与实际接入
规则建议放在独立的YAML文件里,由安全或架构团队统一维护,代码只负责加载。这样调整规则不需要发版改代码,配合配置中心甚至可以热更新。一个典型的配置如下:
exact:
password: redact
token: redact
phone: mask
id_card:
strategy: redact
bank_card_no:
keep_prefix: 6
keep_suffix: 4
email: hash
debug_trace: keep
fuzzy:
- !ruby/regexp '/pass|secret|token|credential/i'
- !ruby/regexp '/(phone|mobile|tel)(_no)?$/i'接入时写一个加载器把YAML转成引擎参数即可,然后在日志中间层统一调用。以Rails为例,最干净的做法是包一个SafeLogger模块,业务代码只管调SafeLogger.info(params),脱敏对使用者完全透明:
require 'yaml'
module LogSanitizer
RULES = YAML.load_file(Rails.root.join('config/sanitize_rules.yml'))
def self.engine
@engine ||= Engine.new(
exact: RULES['exact'].transform_values do |v|
v.is_a?(Hash) && v['strategy'] ? v['strategy'].to_sym : v.to_sym
end.merge(build_special_rules),
fuzzy: RULES['fuzzy'].map { |s| Regexp.new(s.source, s.options) }
)
end
def self.sanitize(data)
engine.sanitize(data)
end
end
# 使用示例
SafeLogger.info(LogSanitizer.sanitize({
user_name: 'xiaoming',
phone: '13812345678',
profile: { id_card: '110101199001011234', email: 'xm@ipipp.com' }
}))
# 输出:user_name不处理,phone变为138****5678,
# id_card变为[REDACTED],email变为哈希串有一点需要特别注意:YAML里的!ruby/regexp标签依赖Psych加载Ruby对象,如果配置来源不可信会有反序列化风险。更稳妥的做法是把正则以字符串形式存储,在加载器里用Regexp.new重建,把不可信输入和代码执行隔离开。
性能与正确性验证
脱敏发生在每次日志写入的路径上,性能不能拖后腿。上面的实现里,每个字符串最多经过三个正则的匹配,普通请求体的开销可以忽略;但如果是上传大报文或高QPS场景,可以做几层优化。首先是短路:字段值长度不在敏感数据范围内时直接跳过正则,比如手机号必然是11位,长度不匹配就不必执行正则引擎。其次是缓存:对相同结构的请求可以用Marshal深拷贝加规则版本号做结果缓存。最后是采样:调试类全量报文日志可以按比例采样,只对采中的请求做完整脱敏序列化。
正确性验证同样重要,建议给引擎写一组固定用例,覆盖长号码、短字符串、嵌套数组、nil值、非字符串类型等边界情况,并在CI里跑:
require 'minitest/autorun'
class SanitizerTest < Minitest::Test
def setup
@engine = LogSanitizer::Engine.new(
exact: { 'phone' => :mask, 'password' => :redact },
fuzzy: [/pass/i]
)
end
def test_nested_hash
input = { user: { phone: '13812345678', password: 'abc' } }
out = @engine.sanitize(input)
assert_equal '138****5678', out.dig(:user, :phone)
assert_equal '[REDACTED]', out.dig(:user, :password)
end
def test_short_string_fully_masked
assert_equal '***', @engine.sanitize('phone' => '138').values.first
end
def test_non_string_passthrough
assert_equal 42, @engine.sanitize('phone' => 42).values.first
end
end另外提醒一点:脱敏要在日志序列化之前完成,而不是在日志框架输出后再用正则清洗格式化后的文本。后者既要处理JSON转义又要处理日志前缀,规则复杂且容易漏。把脱敏做成数据层的前置变换,让日志框架拿到的永远是干净数据,这才是这条链路上最可靠的防线。