Share

外观
风格

发布前脱敏:规则 + AI 双层防护

2026年2月8日 · 专栏

封面图

发布前脱敏:规则 + AI 双层防护

我有个习惯:写技术笔记时会把真实的命令、配置、密钥都记下来。

这样下次用的时候能直接复制,不用再去找。问题是,这些笔记我有时候想发到博客上分享。

手动脱敏太累了——要把每个 IP 改成 x.x.x.x,把每个 API Key 改成 sk-xxx,还要检查有没有漏掉的密码。漏一个就是安全事故。

后来我写了个自动脱敏模块:发布时自动扫描,规则检测常见模式,AI 兜底漏网之鱼

一句话介绍

sensitive-sanitizer 是一个发布前脱敏模块,用正则规则处理固定模式(IP、API Key、Token),用 AI 捕获规则难以覆盖的敏感片段。

效果展示

发布文章时的输出:

发布: ~/Notes/VPS配置.md
  标题: VPS 配置笔记
  Slug: vps-config
  脱敏完成: 规则 5 处, AI 3 处
  发布成功: https://s.chen.rs/vps-config

原文:

服务器 IP: <REDACTED_IP>
SSH 密码: <REDACTED_PASSWORD>
API Key: sk-proj-abc123def456...

发布后:

服务器 IP: <REDACTED_IP>
SSH 密码: <REDACTED_PASSWORD>
API Key: <REDACTED_API_KEY>

本地文件不变,只有发布内容被脱敏。

快速开始

1. 安装依赖

cd ~/mom/tools
bun install

2. 配置 AI(可选但推荐)

export AI_API_KEY="sk-..."
# 或
export OPENAI_API_KEY="sk-..."

没有 AI Key 也能用,只是少了 AI 兜底层。

3. 在代码中使用

import { sanitizeSensitiveContent } from "./lib/sensitive-sanitizer";

const result = await sanitizeSensitiveContent(markdown, {
  useAi: true,  // 是否启用 AI 脱敏
});

console.log(result.content);      // 脱敏后的内容
console.log(result.replacements); // 替换记录

4. 通过 CLI 发布(自动脱敏)

# 默认启用规则 + AI 脱敏
bun tools/share.ts article.md

# 只用规则脱敏(关闭 AI)
bun tools/share.ts article.md --no-sanitize-ai

# 完全关闭脱敏(不推荐)
bun tools/share.ts article.md --no-sanitize

核心架构

输入 Markdown
      │
      ▼
┌─────────────────┐
│  规则脱敏层     │  ← 9 条正则规则,处理固定模式
│  (确定性)       │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  AI 脱敏层      │  ← LLM 识别规则漏掉的敏感片段
│  (语义理解)     │
└────────┬────────┘
         │
         ▼
   输出脱敏内容

为什么要两层?

  • 规则层:快、准、稳。API Key 长什么样是固定的,正则能 100% 匹配。
  • AI 层:灵活。"密码是 abc123" 这种自然语言描述,规则很难覆盖,AI 能理解。

规则层详解

目前实现了 9 条规则:

规则 匹配模式 替换为
public-ipv4 <REDACTED_IP> <REDACTED_IP>
ssh-fingerprint SHA256:MkYY9q... SHA256:<REDACTED_FINGERPRINT>
authorization-bearer Authorization: Bearer xxx Authorization: Bearer <REDACTED_TOKEN>
share-api-key sk-proj-abc123... <REDACTED_API_KEY>
hysteria2-uri-auth hysteria2://<REDACTED_AUTH>@... hysteria2://<REDACTED_AUTH>@...
base64-decoded-comment base64 -d) # secret base64 -d) # <REDACTED_COMMENT_SECRET>
cloudflare-tunnel-id tunnel: 74db0e95-... tunnel: <TUNNEL_ID>
cloudflare-credentials-file-id .../74db0e95-....json .../<TUNNEL_ID>.json
url-query-token ?token=abc123... ?token=<REDACTED_TOKEN>

关键设计:保留结构,只替换敏感值

比如 hysteria2://<REDACTED_AUTH>@<REDACTED_IP>:443,替换后是 hysteria2://<REDACTED_AUTH>@<REDACTED_IP>:443,保持了 URI 格式,读者能理解这是什么。

豁免机制

有些内容看起来像敏感信息,但其实是占位符:

function shouldKeepToken(token: string): boolean {
  return (
    token.startsWith("$") ||        // $API_KEY
    token.startsWith("<") ||        // <YOUR_TOKEN>
    token.includes("xxx") ||        // sk-xxx
    token.includes("...") ||        // sk-...
    token.toLowerCase().includes("redacted")  // 已脱敏
  );
}

127.0.0.10.0.0.0 这种本地地址也会保留:

function shouldKeepIp(ip: string): boolean {
  return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "100.100.100.100";
}

AI 层详解

规则能覆盖固定模式,但自然语言描述的敏感信息很难用正则匹配:

登录密码是 <REDACTED_PASSWORD>
服务器用户名 admin,密码同上
联系我的微信:<REDACTED_WECHAT>

这时候 AI 就派上用场了。

Prompt 设计

const { data } = await chatJson<AiRedactionResponse>(
  [
    {
      role: "system",
      content: "你是安全脱敏助手。请找出 markdown 中仍可能泄露敏感信息的短片段,并给出替换。不要改写非敏感内容。",
    },
    {
      role: "user",
      content: `请只返回 JSON,格式如下:
{"replacements":[{"from":"原文片段","to":"<REDACTED_XXX>","reason":"原因"}]}

要求:
1) from 必须是原文中存在的精确片段,且尽量短
2) to 必须是 <REDACTED_XXX> 形式
3) 不要处理占位符(如 $API_KEY、<TOKEN>、xxx)
4) 最多返回 12 条

待处理文本:

${input}`,
    },
  ],
  { task: "extract", effort: "balanced", maxTokens: 1200 }
);

关键约束:

  1. 精确匹配from 必须在原文中存在,避免 AI 幻觉
  2. 格式统一to 必须是 <REDACTED_XXX> 形式
  3. 跳过占位符:避免把示例代码也脱敏了
  4. 数量限制:最多 12 条,防止过度脱敏

校验机制

AI 的输出不能直接信任,需要校验:

function validateAiReplacement(from: string, to: string): boolean {
  if (!from || !to) return false;
  if (from.length < 4 || from.length > 120) return false;  // 长度合理
  if (to.length < 10 || to.length > 80) return false;
  if (!to.startsWith("<REDACTED_")) return false;          // 格式正确
  if (!to.endsWith(">")) return false;
  if (from.includes("\n")) return false;                   // 单行
  if (shouldKeepToken(from)) return false;                 // 不是占位符
  return true;
}

然后用文本替换而非正则,确保精确匹配:

function replaceAllByText(content: string, from: string, to: string) {
  if (!content.includes(from)) {
    return { content, count: 0 };  // from 不存在就跳过
  }
  const parts = content.split(from);
  return {
    content: parts.join(to),
    count: parts.length - 1,
  };
}

安全兜底

AI 可能失败(超时、API 错误、返回格式错误),所以有多层兜底:

// 没有 API Key
if (!canUseAi()) {
  return { content: input, replacements: [], skippedReason: "未检测到 AI_API_KEY" };
}

// 内容太长
if (input.length > 30000) {
  return { content: input, replacements: [], skippedReason: "内容超过 30000 字符" };
}

// API 调用失败
try {
  // ... AI 调用
} catch (error) {
  return { content: input, replacements: [], skippedReason: `AI 脱敏失败:${error.message}` };
}

AI 失败不会阻断发布,只是跳过 AI 层,规则层的结果照常生效。

实际应用场景

场景 1:发布技术笔记

我写了一篇 VPS 配置笔记,里面有真实 IP、SSH 配置、代理密码。

发布时:

bun tools/share.ts ~/Notes/VPS配置.md

输出:

脱敏完成: 规则 8 处, AI 2 处
发布成功: https://s.chen.rs/vps-config

本地原文不变,发布内容已脱敏。

场景 2:分享排障记录

服务器被入侵的排查记录,里面有大量敏感信息(IP、指纹、时间线)。

规则层处理了 IP 和 SSH 指纹,AI 层发现了我写的 "入侵者 IP 来自 " 这种自然语言描述。

场景 3:API 使用教程

教别人用某个 API,示例代码里有真实的调用:

curl -H "Authorization: Bearer sk-proj-abc123..." https://api.example.com/v1/chat

规则自动替换 Bearer Token 和 API Key,示例代码结构保留。

我踩过的坑

1. AI 会过度脱敏

一开始 AI 会把正常的技术术语也脱敏,比如把 "使用 root 用户登录" 里的 "root" 替换成 <REDACTED_USER>

解决方案:在 prompt 里明确 "不要改写非敏感内容",并且加了长度限制(from.length < 4 太短的不处理)。

2. AI 幻觉导致替换失败

AI 有时候会"发明"一个不存在的片段。比如原文是 password: abc123,AI 返回 {"from": "password=abc123", ...}

解决方案:用 content.includes(from) 预检查,不存在就跳过。

3. 正则太贪婪

一开始 IP 正则会匹配版本号 <REDACTED_IP>(比如 sing-box 1.11.0)。

解决方案:加了 IPv4 有效性检查(每段 0-255)。

推荐资源

下一步

如果你也想在发布流程里加入脱敏:

  1. 从规则层开始,先覆盖你最常用的敏感信息格式
  2. 加入 AI 层作为兜底,但要做好校验
  3. 记录每次脱敏结果,定期 review 是否有漏网或误伤

记住:脱敏只作用于发布内容,本地原文不变。这样既安全,又不影响你自己使用。


附录:给 AI 的复现指令

帮我实现一个 Markdown 发布前脱敏模块,支持规则 + AI 双层检测。

目标:发布文章时自动检测并替换敏感信息(IP、API Key、密码等),本地原文不变。

技术栈:Bun + TypeScript

核心函数:
sanitizeSensitiveContent(input: string, options: { useAi?: boolean }): Promise<SanitizationResult>

返回结构:
{
  content: string;           // 脱敏后内容
  changed: boolean;          // 是否有变化
  replacements: Array<{      // 替换记录
    source: "rule" | "ai";
    label: string;
    count: number;
    reason?: string;
  }>;
  aiSkippedReason?: string;  // AI 跳过原因(如无 API Key)
}

规则层(确定性):
- IPv4 地址 → <REDACTED_IP>
- SSH 指纹 SHA256:xxx → SHA256:<REDACTED_FINGERPRINT>
- Bearer Token → <REDACTED_TOKEN>
- sk-xxx API Key → <REDACTED_API_KEY>
- hysteria2://<REDACTED_AUTH>@... → hysteria2://<REDACTED_AUTH>@...
- Cloudflare Tunnel ID → <TUNNEL_ID>
- URL ?token=xxx → ?token=<REDACTED_TOKEN>

豁免规则:
- 跳过占位符:$VAR、<PLACEHOLDER>、包含 xxx 或 ... 的
- 跳过本地 IP:127.0.0.1、0.0.0.0

AI 层(语义理解):
- 调用 OpenAI 兼容 API(走 AI_API_KEY 环境变量)
- Prompt 要求返回 JSON:{replacements: [{from, to, reason}]}
- 校验 AI 输出:from 必须在原文存在,to 必须是 <REDACTED_XXX> 格式
- 失败时静默跳过,不阻断发布

集成到发布 CLI:
- 默认启用规则 + AI
- --no-sanitize-ai 只用规则
- --no-sanitize 完全关闭

成功标志:
- 发布时输出 "脱敏完成: 规则 X 处, AI Y 处"
- 本地文件内容不变
- 发布内容敏感信息已替换