适用场景
日志脱敏解决的是「排障数据里混进了个人信息」这一普遍问题。Nginx 的 $request、$args、$http_referer 会把完整查询串原样写进访问日志,于是手机号、身份证号、银行卡号、邮箱、登录 Token、Authorization 头全部落盘;接口报错时若打印请求体,明文口令也会进错误日志。日志一旦被集中采集到 ELK、Loki 或云日志服务,可读范围从「少数运维」扩大到「所有有查询权限的人」,任何一次账号泄露都等于批量个人信息泄露;按《个人信息保护法》与等保 2.0 的日志审计要求,这属于典型的合规缺口。
本文给出四层脱敏方案:Nginx 生成点参数占位、日志采集端(vector / Filebeat / Logstash)全局正则替换、日志框架层(Logback / Log4j2)过滤、以及用 Python 巡检脚本做脱敏准确率验证,全部配置可直接复制。
前置条件
- Nginx 1.11+(
map正则命名捕获与escape=json需要该版本以上)。 - 日志采集端任选其一:vector 0.30+、Filebeat 8.x、Logstash 7.x+。
- Python 3.6+(用于巡检脚本,仅依赖标准库)。
- 权限:可编辑 Nginx 配置文件、
/etc/logrotate.d/与采集端配置。 - 变更建议:先在灰度节点观察 1 天,确认排障可用性再全量。
原理说明
两个脱敏位置,取舍不同
- 生成点脱敏(Nginx / 应用):原始敏感值不落盘,合规最彻底,但一旦脱敏过度就会丢排障线索。
- 收集点脱敏(vector / Filebeat):原始日志落本地盘、脱敏后进入集中存储,既能查集中日志又能保留本地原文用于取证,代价是本地盘仍需权限管控。
实践中推荐两层都做:Nginx 只做「已知敏感参数占位」,收集端做「不限位置的模式匹配替换」,这样既避免 token= 这类高价值凭据落盘,又覆盖应用自定义字段。
为什么不能只删不替换
直接删除会破坏日志结构(JSON 字段消失导致采集端解析失败),也不利于关联分析。推荐用固定占位符(***、PHONE)或加盐 HMAC 短哈希(保留「同一手机号可关联、但无法反推」的能力)。
操作步骤
步骤 1:Nginx 侧对敏感参数做占位
# /etc/nginx/conf.d/log-mask.conf
# 参数存在时统一写 ***,不存在时输出空,避免误导
map $arg_token $log_token { "" ""; default "***"; }
map $arg_password $log_password { "" ""; default "***"; }
map $arg_pwd $log_pwd { "" ""; default "***"; }
map $arg_phone $log_phone { "" ""; default "***"; }
map $arg_idcard $log_idcard { "" ""; default "***"; }
map $arg_email $log_email { "" ""; default "***"; }
# escape=json 同时解决日志注入(配合 CRLF 注入治理)
log_format masked escape=json
'{'
'"time":"$time_iso8601",'
'"ip":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"rt":$request_time,'
'"ua":"$http_user_agent",'
'"ref":"$http_referer",'
'"token":"$log_token",'
'"phone":"$log_phone",'
'"idcard":"$log_idcard"'
'}';
server {
listen 80;
server_name www.example.com;
access_log /var/log/nginx/access_masked.log masked;
# 排障用完整日志仅在回环地址或调试 IP 上开启,并用轮转短期保留
location / {
proxy_pass http://127.0.0.1:8080;
}
}
关键点:log_format 中不要出现 $args、$request、$http_cookie、$http_authorization —— 前两者是原始查询串,后两者会直接记录 Cookie 与凭证头。只记录白名单字段是最省成本的脱敏。
若必须保留查询串做接口统计,可先做「首层正则遮蔽」,再用 map 输出:
# 只遮蔽查询串中的手机号与邮箱(连续匹配,逐段处理)
map $args $args_stage1 {
"~^(?<pre>[^?]*?)(?<p>1[3-9]\d{9})(?<post>.*)$" "$pre$p_masked$post";
default $args;
}
log_format stats escape=json
'{"uri":"$uri","args":"$args_stage1","status":$status}';
注意:map 的正则替换每条只命中一次,无法全局替换多个手机号,复杂场景请交给步骤 2 的采集端处理。
步骤 2:采集端全局替换(覆盖所有位置)
方案 A:vector(推荐,性能好、支持 VRL)
# /etc/vector/vector.toml
[sources.nginx]
type = "file"
include = ["/var/log/nginx/access_masked.log"]
read_from = "beginning"
[transforms.mask]
type = "remap"
inputs = ["nginx"]
source = '''
raw = string!(.message)
# 手机号 / 身份证 / 银行卡 / 邮箱
raw = replace(raw, r'\b1[3-9]\d{9}\b', "PHONE")
raw = replace(raw, r'\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b', "IDCARD")
raw = replace(raw, r'\b(?:\d[ -]?){15,19}\b', "BANKCARD")
raw = replace(raw, r'[\w.+-]+@[\w-]+\.[\w.]+', "EMAIL")
raw = replace(raw, r'(?i)(token|apikey|api_key|access_key|secret)=[^&"\s]+', "CREDENTIAL=***")
.message = raw
'''
[sinks.out]
type = "http"
inputs = ["mask"]
uri = "http://log-collector.internal:8080/ingest"
encoding.codec = "json"
方案 B:Filebeat(已装 Beats 的场景)
# /etc/filebeat/filebeat.yml
filebeat.inputs:
- type: filestream
id: nginx-masked
paths:
- /var/log/nginx/access_masked.log
processors:
- replace:
fields:
- field: "message"
pattern: '(\?|&)(token|password|pwd|phone|idcard|email)=[^& \"]+'
replacement: '$1$2=***'
- replace:
fields:
- field: "message"
pattern: '\b1[3-9]\d{9}\b'
replacement: 'PHONE'
- drop_fields:
fields: ["host.name", "agent.ephemeral_id"]
ignore_missing: true
output.elasticsearch:
hosts: ["http://es.internal:9200"]
index: "nginx-masked-%{+yyyy.MM.dd}"
方案 C:Logstash 管道
filter {
mutate {
gsub => [
"message", "(?<=[?&])(token|password|pwd)=[^&\s]+", "\\1=***",
"message", "\b1[3-9]\d{9}\b", "PHONE",
"message", "[\w.+-]+@[\w-]+\.[\w.]+", "EMAIL"
]
}
}
步骤 3:应用框架层过滤(防止业务日志绕过)
Nginx 只能治理访问日志,业务代码里的 log.info("user=" + phone) 必须靠框架过滤器兜底。
<!-- Logback:在 pattern 上加 replace 转换器 -->
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>/var/log/app/app.log</file>
<encoder>
<pattern>%d{ISO8601} %-5level %replace(%msg){'\b1[3-9]\d{9}\b','PHONE'} %n</pattern>
</encoder>
</appender>
<!-- Log4j2:使用 RegexReplace 插件,规则按顺序应用 -->
<RegexReplace replace="PHONE" regex="\b1[3-9]\d{9}\b"/>
<RegexReplace replace="EMAIL" regex="[\w.+-]+@[\w-]+\.[\w.]+"/>
<RegexReplace replace="CRED" regex="(?i)(token|secret|password)=[^\s&]+"/>
PHP(Monolog Processor):
<?php
$logger->pushProcessor(function ($record) {
$mask = function (string $s): string {
$s = preg_replace('/\b1[3-9]\d{9}\b/', 'PHONE', $s);
$s = preg_replace('/[\w.+\-]+@[\w\-]+\.[\w.]+/', 'EMAIL', $s);
return preg_replace('/(?i)(token|secret|password)=([^\s&]+)/', '$1=***', $s);
};
$record['message'] = $mask($record['message']);
foreach ($record['context'] as $k => $v) {
if (is_string($v)) { $record['context'][$k] = $mask($v); }
}
return $record;
});
步骤 4:日志文件权限与轮转收紧
# /etc/logrotate.d/nginx-masked
/var/log/nginx/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
sharedscripts
postrotate
if [ -f /run/nginx.pid ]; then kill -USR1 $(cat /run/nginx.pid); fi
endscript
}
# 存量日志立即降权,去掉 other 读权限
chmod 640 /var/log/nginx/*.log
chgrp adm /var/log/nginx/*.log
# 禁止日志目录被普通用户遍历
chmod 750 /var/log/nginx
# 校验:不应输出任何 group/other 可读项
find /var/log -maxdepth 2 -type f -name '*.log' -perm /o+r 2>/dev/null
配置验证
用一条含敏感参数的请求做端到端验证,再用巡检脚本统计脱敏命中数:
# 1) 制造含敏感参数的请求
curl -s -o /dev/null "http://www.example.com/api/user?phone=13800138000&token=abcdef1234567890&idcard=110101199001011234"
# 2) 检查访问日志:token/phone/idcard 字段应为 *** 或 PHONE
tail -n 1 /var/log/nginx/access_masked.log
# 3) 全局搜索:以下命令应无输出(说明无明文手机号/邮箱残留)
grep -En '\b1[3-9][0-9]{9}\b|[\w.+-]+@[\w-]+\.[\w.]+' /var/log/nginx/access_masked.log | head
# 4) 确认未记录 Cookie 与凭证头
grep -ic 'authorization\|cookie' /var/log/nginx/access_masked.log
# 5) 校验 Nginx 配置语法
nginx -t && systemctl reload nginx
配套巡检脚本(标准库实现,可挂到 crontab 每日执行):
# /usr/local/bin/log_mask_audit.py
import re, sys, collections
PATTERNS = {
"手机号": re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"),
"身份证": re.compile(r"(?<!\d)[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)"),
"银行卡": re.compile(r"(?<!\d)(?:\d[ -]?){15,19}(?!\d)"),
"邮箱": re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"),
"凭据": re.compile(r"(?i)(?:token|apikey|api_key|secret)\s*[=:]\s*\S{8,}"),
}
def audit(path, limit=500000):
hits = collections.Counter()
with open(path, encoding="utf-8", errors="ignore") as fh:
for i, line in enumerate(fh):
if i >= limit:
break
for name, pat in PATTERNS.items():
if pat.search(line):
hits[name] += 1
return hits
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else "/var/log/nginx/access_masked.log"
result = audit(target)
total = sum(result.values())
print("审计文件:", target)
for k, v in result.most_common():
print(f" {k}: {v} 行命中")
if total:
print(f"[FAIL] 存在 {total} 行未脱敏记录,请检查 map 与采集端规则")
sys.exit(1)
print("[PASS] 未发现明文个人信息")
chmod +x /usr/local/bin/log_mask_audit.py
/usr/local/bin/log_mask_audit.py /var/log/nginx/access_masked.log
# 期望输出:[PASS] 未发现明文个人信息
# 加入每日巡检(命中即写日志并返回非 0)
echo '30 3 * * * root /usr/local/bin/log_mask_audit.py /var/log/nginx/access_masked.log >> /var/log/log_mask_audit.log 2>&1' \
> /etc/cron.d/log-mask-audit
常见问题
FAQ 1:脱敏后无法定位具体用户,排障怎么办?
用加盐 HMAC 短哈希替代直接替换,既不可反推又能关联同一用户:
echo -n "13800138000" | openssl dgst -sha256 -hmac "your-salt-key" | cut -c1-16
# 输出稳定值,同一手机号每次一致,可用于日志关联与投诉工单比对
盐值需与日志分离保管(放密钥管理服务或 /etc/log-mask.key,权限 600,root 持有)。
FAQ 2:已经在 ES / Loki 里的历史日志怎么处理?
集中存储侧的脱敏属于「事后治理」,两条路径:一是重新索引并叠加处理管道,例如 Elasticsearch 使用 ingest pipeline 的 gsub processor:
PUT _ingest/pipeline/mask-pii
{
"processors": [
{ "gsub": { "field": "message",
"pattern": "\\b1[3-9]\\d{9}\\b",
"replacement": "PHONE" } },
{ "gsub": { "field": "message",
"pattern": "([?&](?:token|password|pwd)=)[^&\\s]+",
"replacement": "$1***" } }
]
}
二是直接按保留策略下线超期索引(PUT /nginx-masked-*/_settings 配置 ILM,冷数据 30 天后删除)。合规上建议同时收敛索引的角色权限,避免「已脱敏字段 + 未脱敏 message 字段」双份存储。
FAQ 3:正则替换会不会明显拖慢日志吞吐?
实测影响主要取决于正则复杂度与日志量。五条简单正则在 vector 中处理 1 万行/秒的日志约增加 3% 到 8% 的 CPU 占用,可用 vector top 观察 component_cpu_usage 指标。优化手段:把手机号、邮箱这类高频模式放在前面用字面量前缀预筛(如先判断是否含 1),或仅在 status >= 400 的日志上做全量替换。避免使用嵌套量词(如 (a+)+)导致正则回溯爆炸。
FAQ 4:脱敏会不会影响安全取证?
会,因此建议保留一份受控原始日志:仅 root 可读、本地保管 7 天、自动过期,集中存储只接收脱敏副本。这样既满足合规对最小化的要求,又保留攻击溯源所需的原始证据。
总结
日志脱敏的核心是「白名单记录 + 分层替换 + 持续巡检」。落地顺序:先用自定义 log_format 剔除 $args、$http_cookie 等高危字段(收益最大、成本最低)→ 用 map 对已知敏感参数占位 → 采集端用 vector / Filebeat / Logstash 做全局正则替换 → 应用框架 Logback / Log4j2 / Monolog 兜底业务日志 → logrotate create 0640 收紧权限 → Python 巡检脚本每日自查并把 [FAIL] 接入告警。整套做完,即便日志被导出发给第三方做审计,也不会带出一行明文个人信息。