服务器日志脱敏实战:敏感信息防泄露配置

适用场景

日志脱敏解决的是「排障数据里混进了个人信息」这一普遍问题。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] 接入告警。整套做完,即便日志被导出发给第三方做审计,也不会带出一行明文个人信息。