首页 › tutorials › 造日志脱敏Skill:PII一键打码

概述

本教程带你从零开发一个名为 pii-masker 的 Claude Code 自定义 Skill:它能扫描日志、配置文件中的手机号、邮箱、身份证、IP、银行卡、API 密钥等敏感信息,并自动打码,让你敢把生产日志贴出来排查、敢把配置文件提交进仓库。学完你将掌握 Skill 的目录结构、SKILL.md frontmatter 写法、描述触发机制、脚本挂载与渐进式披露(progressive disclosure)四大核心机制。

适用人群:已经能用 Claude Code 跑通基本任务、想把自己重复的"工作流"固化成模型可调用的能力、并对 Python/正则有基本了解的开发者与运维同学。


前置条件


一、先想清楚:Skill 到底解决什么问题

很多人把 Skill 和"提示词模板""slash 命令""MCP 工具"混为一谈。它们的边界其实很清晰:

Skill 最核心的设计哲学只有一句话:描述(description)决定何时触发,正文(body)决定怎么做。模型在每轮对话开始时只会扫描所有 Skill 的 name 和 description(成本极低),只有判断"相关"时才会把正文加载进上下文。这就是 Anthropic 反复强调的渐进式披露。

我们的 pii-masker 解决的真实痛点是:

  1. 排查线上 Bug 时想把日志贴给 Claude,但日志里全是手机号和 token,贴出去就违规。
  2. 提交配置文件进 Git 前,担心混入密钥。
  3. 手动一个个找、手动改太慢,且容易漏。

二、Skill 目录结构总览

一个标准的 Skill 长这样:

pii-masker/                       # Skill 名就是目录名
├── SKILL.md                      # 唯一必需的入口文件
├── scripts/
│   └── mask.py                   # 挂载的可执行脚本
└── references/
    └── patterns.md               # 渐进式披露:详细正则表放这里

Skill 有两个作用域:

我们的脱敏器属于"团队都要用"的工程规范,放项目级更合适。下面动手。


三、第一步:建立目录骨架

在任意项目根目录执行:

mkdir -p .claude/skills/pii-masker/scripts
mkdir -p .claude/skills/pii-masker/references
cd .claude/skills/pii-masker

预期结果:ls -R 能看到上面那棵树。Claude Code 启动时会自动扫描 .claude/skills/ 下每个含 SKILL.md 的目录。

常见问题:目录建好了但 Skill 不被识别?99% 的情况是 SKILL.md 还没创建,或 name 字段命名非法(只允许小写字母、数字、连字符,如 pii-masker,不能有空格和下划线)。


四、第二步:写 SKILL.md —— description 是灵魂

SKILL.md 由两段组成:YAML frontmatter(元数据)+ Markdown 正文(指令)。

---
name: pii-masker
description: Scans log files, config files, and pasted text for PII and secrets (Chinese phone numbers, emails, 18-digit ID cards, IPv4 addresses, bank card numbers, API keys/tokens) and masks them in place or to a new file. Use when the user wants to sanitize/脱敏 logs before sharing, remove sensitive data from configs before committing, or generate a PII report. Supports dry-run preview.
---

# PII Masker(敏感信息脱敏器)

## 何时使用
用户想"把日志发出去前先脱敏""配置文件提交前清掉密钥""给这份文件打码"时主动调用。

## 如何使用
1. 默认干跑(dry-run):只报告发现哪些敏感数据、各多少条,不改动文件。
2. 确认后,运行真正的打码,原地替换或输出到新文件。

## 调用脚本
对单个或一批文件执行脱敏:
- 干跑预览:`python3 scripts/mask.py --path <file-or-dir> --dry-run`
- 原地打码:`python3 scripts/mask.py --path <file-or-dir> --inplace`
- 输出到新文件:`python3 scripts/mask.py --path <file> --out <newfile>`

## 打码规则(概要)
- 手机号 → `138****1234`;邮箱 → `j***@example.com`;身份证 → `110101************`;
- IPv4 → `10.*.*.*`;银行卡 → `****-****-****-1234`;API 密钥 → `sk-************`。
- 完整正则与定制方法见 `references/patterns.md`。

## 行为准则
- 永远先 `--dry-run` 给用户看报告,得到确认再写盘。
- 不要去碰 `.git/` 目录和二进制文件。
- 对每条规则单独计数,输出汇总表。

这段为什么这么写——三个关键点:

  1. description 要写"做什么 + 何时用 + 带关键词"。模型匹配靠的就是这里的语义和关键词。我们把 sanitize、脱敏、PII、before sharing、before committing 这些用户真实会说的词都塞进去了,命中率会显著提高。描述最好控制在 3–5 句之内,太长反而稀释信号。

  2. 正文里给"何时使用 / 如何使用 / 行为准则"。这三段是模型最常参考的,写清楚能避免它乱跑脚本。

  3. 详细正则外移到 references/。这就是渐进式披露:模型默认只读 SKILL.md 主体,遇到需要"调正则或定制规则"的深入需求时,才去读 references/patterns.md,节省上下文。

常见问题:Skill 触发不稳定? 多半是 description 太笼统(如只写"a tool to process files")。改成"scan ... for PII/secrets and mask them",并列举用户原话关键词,触发率立刻上来。


五、第三步:写打码脚本 scripts/mask.py

这是 Skill 的"执行手"。一个完整、纯标准库、可直接运行的实现:

#!/usr/bin/env python3
"""PII Masker —— 日志/配置敏感信息脱敏,纯标准库实现。"""
import argparse
import os
import re
import sys

# 每条规则:(名字, 编译好的正则, 打码函数)
RULES = [
    ("中国手机号", re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"),
     lambda m: m.group()[:3] + "****" + m.group()[-4:]),

    ("邮箱", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
     lambda m: m.group()[0] + "***@" + m.group().split("@", 1)[1]),

    ("18位身份证号", re.compile(
        r"(?<!\d)[1-9]\d{5}(?:19|20)\d{2}"
        r"(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)"),
     lambda m: m.group()[:6] + "************"),

    ("IPv4地址", re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)"),
     lambda m: m.group().split(".", 1)[0] + ".*.*.*"),

    ("银行卡号", re.compile(r"(?<!\d)(?:\d[ -]?){15,18}(?!\d)"),
     lambda m: "****-****-****-" + re.sub(r"[^\d]", "", m.group())[-4:]),

    ("API密钥/Token", re.compile(
        r"(?i)(?:api[_-]?key|access[_-]?token|secret|password|bearer|sk-)"
        r"\s*[:=]\s*['\"]?([A-Za-z0-9_\-]{16,})['\"]?"),
     lambda m: m.group(1)[0] + "-" + "*" * (len(m.group(1)) - 4)
               + m.group(1)[-4:] if m.lastindex else "********"),
]


def mask_text(text):
    """对一段文本执行全部规则,返回 (脱敏后文本, {规则名: 命中数})。"""
    counts = {}
    out = text
    for name, pat, fn in RULES:
        def replace(m, fn=fn, name=name):
            counts[name] = counts.get(name, 0) + 1
            return fn(m)
        out = pat.sub(replace, out)
    return out, counts


def iter_files(path):
    if os.path.isfile(path):
        yield path
        return
    for root, dirs, files in os.walk(path):
        dirs[:] = [d for d in dirs if d != ".git"]
        for f in files:
            full = os.path.join(root, f)
            if os.path.getsize(full) > 5 * 1024 * 1024:  # 跳过 >5MB
                continue
            try:
                with open(full, "rb") as fh:
                    fh.read(2048).decode("utf-8")  # 二进制则跳过
                yield full
            except (UnicodeDecodeError, OSError):
                continue


def main():
    ap = argparse.ArgumentParser(description="PII 脱敏器")
    ap.add_argument("--path", required=True, help="文件或目录")
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--dry-run", action="store_true", help="只报告,不改写")
    g.add_argument("--inplace", action="store_true", help="原地替换")
    g.add_argument("--out", help="输出到新文件(仅单文件)")
    args = ap.parse_args()

    if args.out and os.path.isdir(args.path):
        sys.exit("--out 仅支持单文件")

    total = {}
    for fp in iter_files(args.path):
        with open(fp, encoding="utf-8") as fh:
            text = fh.read()
        new_text, counts = mask_text(text)
        if not counts:
            continue
        for k, v in counts.items():
            total[k] = total.get(k, 0) + v
        if args.dry_run:
            print(f"[预览] {fp}: {counts}")
        elif args.inplace:
            with open(fp, "w", encoding="utf-8") as fh:
                fh.write(new_text)
            print(f"[已脱敏] {fp}: {counts}")
        elif args.out:
            with open(args.out, "w", encoding="utf-8") as fh:
                fh.write(new_text)
            print(f"[输出] {args.out}: {counts}")

    print("\n===== 汇总 =====")
    if not total:
        print("未发现敏感信息。")
    for k, v in sorted(total.items(), key=lambda x: -x[1]):
        print(f"{k:12s} {v}")
    return 0 if total else 1


if __name__ == "__main__":
    sys.exit(main())

预期结果:先在 Skill 目录外造一份带敏感信息的样例日志测试:

cat > /tmp/app.log <<'EOF'
2026-07-04 user=张三 phone=13912345678 login from 10.20.30.40
2026-07-04 [email protected] id=110101199001011234
2026-07-04 api_key = sk-abcdef0123456789abcdef0123456789
EOF

python3 .claude/skills/pii-masker/scripts/mask.py --path /tmp/app.log --dry-run

输出形如:

[预览] /tmp/app.log: {'中国手机号': 1, 'IPv4地址': 1, '邮箱': 1, '18位身份证号': 1, 'API密钥/Token': 1}

===== 汇总 =====
API密钥/Token     1
IPv4地址          1
18位身份证号      1
中国手机号        1
邮箱              1

去掉 --dry-run 改 --out /tmp/app.masked.log,再 cat 新文件,你会看到:

2026-07-04 user=张三 phone=139****5678 login from 10.*.*.*
2026-07-04 email=z***@corp.com id=110101************
2026-07-04 api_key = sk-************6789

常见问题:


六、第四步:渐进式披露——把"细节"放进 references/

在 references/patterns.md 写详细正则与定制指南:

# 脱敏规则正则与定制指南

| 类型 | 正则(简版) | 打码样式 |
|------|-------------|---------|
| 手机号 | `1[3-9]\d{9}` | 138****1234 |
| 邮箱 | `[A-Za-z0-9._%+-]+@...` | j***@domain.com |
| 身份证 | `[1-9]\d{5}(19\|20)\d{2}...` | 110101************ |
| IPv4 | `(?:\d{1,3}\.){3}\d{1,3}` | 10.*.*.* |
| 银行卡 | `(?:\d[ -]?){15,18}` | ****-****-****-1234 |
| API密钥 | `(api_key\|token\|secret)...` | sk-************ |

## 新增规则
在 scripts/mask.py 的 RULES 列表追加一个三元组 (名字, 编译正则, 打码函数) 即可。

## 行业差异
- 金融:银行卡按 PCI-DSS 保留前6后4。
- 医疗:身份证按 HIPAA 等价规则保留前6。
- 海外:手机号改用 E.164 格式 `+\d{6,15}`。

这样做的好处:模型默认不读它,节省上下文;当用户问"我想加一条欧盟手机号的规则"时,模型才会主动打开这份参考,照着"新增规则"小节改 mask.py。这就是渐进式披露的实战价值——正文教模型"怎么用",参考文件教模型"怎么改"。


七、第五步:装载、触发与迭代

  1. 重启会话或新开一个 Claude Code 窗口,让它重新扫描 .claude/skills/。
  2. 验证被发现:在对话框输入 / 或直接问"你有哪些 skill",应能看到 pii-masker。
  3. 自然语言触发测试:把那份 /tmp/app.log 拖进来,说"帮我把这份日志里的敏感信息脱敏一下再给我"。模型若识别成功,会主动调用 mask.py --dry-run,给你报告,再问你要不要写盘。

触发不灵的诊断清单:

现象 原因 解决
完全不被发现 目录/文件名错、无 SKILL.md 确认路径与文件名
name 报错 含空格/下划线/大写 改成 kebab-case
被发现但不主动用 description 关键词与用户原话不匹配 补充用户真实说法的关键词
用了但乱跑命令 正文没写清调用方式与边界 强化"行为准则"小节

八、实战案例:把"上线前脱敏"固化成一条命令

假设团队规范要求:所有要外发的日志必须先脱敏。我们把整套流程串起来:

# 1) 拉一段线上日志
kubectl logs my-svc-pod > raw.log

# 2) 干跑看报告(让 Claude 自动调用 pii-masker)
#    在 Claude Code 里直接说:"扫描 raw.log 看有哪些敏感信息"
#    → 模型运行 mask.py --path raw.log --dry-run

# 3) 确认后输出脱敏版
python3 .claude/skills/pii-masker/scripts/mask.py \
  --path raw.log --out raw.masked.log

# 4) 安全外发
cat raw.masked.log

更进一步,你可以在项目的 CLAUDE.md 里写一行项目约定:

外发任何日志/配置前,必须先用 pii-masker 技能干跑确认无 PII。

这样模型每次帮你在会话里处理日志时,都会自觉先脱敏,形成"人 + 模型 + 工具"的闭环防线。


九、整体工作流程图

flowchart TD
    A[用户对话: 帮我脱敏这份日志] --> B{模型扫描所有 Skill 的 description}
    B -->|关键词命中 脱敏/PII| C[加载 pii-masker 的 SKILL.md 正文]
    B -->|未命中| Z[走普通对话流程]
    C --> D[默认执行 mask.py --dry-run]
    D --> E[输出命中报告: 手机号×3 邮箱×2 ...]
    E --> F{用户确认写盘?}
    F -->|是| G[执行 --inplace 或 --out]
    F -->|需要改规则| H[加载 references/patterns.md]
    H --> I[按指南修改 mask.py 的 RULES]
    I --> D
    G --> J[完成: 文件已脱敏可安全外发]

十、延伸阅读


十一、训练营适用性评估


参考来源

  1. Build a Claude Skill from Scratch: Step-by-Step Tutorial (digitalapplied.com) — 从空目录到完整 Skill,涵盖 frontmatter、references、scripts、allowed-tools 与作用域。
  2. How to Build a Claude Code Skill from Scratch (DEV Community) — 目录结构、提示词设计、slash 命令与发布流程实战。
  3. How to create Skills for Claude (claude.com 官方博客) — 创建步骤、限制与示例的官方 5 步指南。
  4. How to Create Custom Claude Skills: Complete Tutorial 2026 (skillsmd.pro) — 渐进式披露、description 触发机制与最佳实践的完整教程。
  5. Anthropic 官方 Agent Skills 文档 (docs.claude.com) — Skill 结构、frontmatter 字段、项目/用户作用域的权威定义。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入