不要把数据安全完全交给模型供应商。本文用一个可运行的 Node.js 中间件,在请求发出前完成敏感数据识别、脱敏与阻断,在响应返回后检测泄漏,并安全还原必要的个人信息。

先明确安全边界与处理策略

LLM 应用经常把用户输入、数据库查询结果和内部文档拼成一段提示词。如果缺少独立检查层,API 密钥、身份证号、客户邮箱乃至尚未公开的项目名称,都可能被发送到外部服务。

这里将敏感数据分为三类:

类别示例默认处理
访问凭据AWS Access Key、JWT、私钥、代码中的 token阻断请求
个人信息邮箱、手机号、身份证号替换为随机占位符
业务机密未公开项目名、内部客户代号阻断请求

凭据一旦进入模型上下文,就不能通过响应过滤挽回,因此应直接阻断。个人信息可以在本地保存“原文—占位符”映射,只把占位符发给模型。业务机密通常依赖企业自己的词表,适合采用默认拒绝策略。

完整链路应当是:请求扫描、策略判定、局部脱敏、模型调用、响应扫描、占位符还原。响应扫描必须发生在还原之前,否则系统会把自己还原的手机号误判为模型泄漏。

可运行的 Express 中间件

下面示例使用 Node.js 18 及以上版本和 Express。模型调用暂时采用本地模拟函数,因此无需配置任何供应商密钥,也不会真的发送数据。

mkdir llm-data-guard && cd llm-data-guard
npm init -y
npm install express

创建 server.js

const express = require("express");
const { createHmac, randomUUID } = require("node:crypto");

const app = express();
app.use(express.json({ limit: "64kb" }));

const AUDIT_KEY = process.env.AUDIT_HMAC_KEY || "development-only-key";
const ALLOWLIST = new Set(
  (process.env.DLP_ALLOWLIST || "").split(",").map(s => s.trim()).filter(Boolean)
);
const BUSINESS_TERMS = (process.env.SENSITIVE_TERMS || "").split(",")
  .map(s => s.trim()).filter(Boolean);

const RULES = [
  {
    type: "aws_access_key",
    category: "credential",
    level: "critical",
    regex: () => /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g
  },
  {
    type: "jwt",
    category: "credential",
    level: "critical",
    regex: () => /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g
  },
  {
    type: "private_key",
    category: "credential",
    level: "critical",
    regex: () => /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
  },
  {
    type: "assigned_secret",
    category: "credential",
    level: "critical",
    regex: () => /(?:api[_-]?key|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_-]{16,}/gi
  },
  {
    type: "email",
    category: "pii",
    level: "medium",
    regex: () => /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi
  },
  {
    type: "cn_mobile",
    category: "pii",
    level: "medium",
    regex: () => /(?<!\d)1[3-9]\d{9}(?!\d)/g
  },
  {
    type: "cn_id",
    category: "pii",
    level: "high",
    regex: () => /(?<!\d)\d{17}[\dXx](?!\d)/g
  }
];

function digest(value) {
  return createHmac("sha256", AUDIT_KEY).update(value).digest("hex").slice(0, 16);
}

function audit(event, findings, extra = {}) {
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    event,
    findings: findings.map(f => ({
      type: f.type,
      level: f.level,
      path: f.path,
      fingerprint: digest(f.value)
    })),
    ...extra
  }));
}

function scanText(text, path) {
  const findings = [];

  for (const rule of RULES) {
    for (const match of text.matchAll(rule.regex())) {
      const value = match[0];
      const canAllow = rule.category !== "credential" && ALLOWLIST.has(value);
      if (!canAllow) findings.push({ ...rule, value, path });
    }
  }

  for (const term of BUSINESS_TERMS) {
    if (text.includes(term) && !ALLOWLIST.has(term)) {
      findings.push({
        type: "business_term",
        category: "confidential",
        level: "high",
        value: term,
        path
      });
    }
  }
  return findings;
}

function maskPii(text, context) {
  let output = text;
  for (const rule of RULES.filter(r => r.category === "pii")) {
    output = output.replace(rule.regex(), value => {
      if (ALLOWLIST.has(value)) return value;
      if (context.reverse.has(value)) return context.reverse.get(value);

      const token = `<DLP_${context.nonce}_${context.placeholders.size + 1}>`;
      context.placeholders.set(token, value);
      context.reverse.set(value, token);
      return token;
    });
  }
  return output;
}

function sanitizeTree(value, context, path = "$") {
  if (typeof value === "string") {
    context.findings.push(...scanText(value, path));
    return maskPii(value, context);
  }
  if (Array.isArray(value)) {
    return value.map((item, index) => sanitizeTree(item, context, `${path}[${index}]`));
  }
  if (value && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([key, item]) => [
      key,
      sanitizeTree(item, context, `${path}.${key}`)
    ]));
  }
  return value;
}

function collectFindings(value, path = "$", result = []) {
  if (typeof value === "string") result.push(...scanText(value, path));
  else if (Array.isArray(value)) {
    value.forEach((item, index) => collectFindings(item, `${path}[${index}]`, result));
  } else if (value && typeof value === "object") {
    for (const [key, item] of Object.entries(value)) {
      collectFindings(item, `${path}.${key}`, result);
    }
  }
  return result;
}

function restoreTree(value, placeholders) {
  if (typeof value === "string") {
    let output = value;
    for (const [token, original] of placeholders) {
      output = output.split(token).join(original);
    }
    return output;
  }
  if (Array.isArray(value)) return value.map(v => restoreTree(v, placeholders));
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, restoreTree(item, placeholders)])
    );
  }
  return value;
}

function llmDataGuard(req, res, next) {
  const context = {
    nonce: randomUUID().replaceAll("-", "").slice(0, 12),
    placeholders: new Map(),
    reverse: new Map(),
    findings: []
  };

  const safeBody = sanitizeTree(req.body, context);
  const blockers = context.findings.filter(f => f.category !== "pii");

  if (blockers.length) {
    audit("request_blocked", blockers);
    return res.status(400).json({
      error: "sensitive_request_blocked",
      types: [...new Set(blockers.map(f => f.type))]
    });
  }

  if (context.findings.length) {
    audit("request_masked", context.findings, {
      placeholderCount: context.placeholders.size
    });
  }

  req.llm = {
    safeBody,
    finalize(rawResponse) {
      const findings = collectFindings(rawResponse);
      if (findings.length) {
        audit("response_blocked", findings);
        const error = new Error("Sensitive data detected in model response");
        error.status = 502;
        throw error;
      }
      return restoreTree(rawResponse, context.placeholders);
    }
  };
  next();
}

async function callModel(body) {
  return { text: `已处理:${body.message || ""}` };
}

app.post("/chat", llmDataGuard, async (req, res, next) => {
  try {
    const rawResponse = await callModel(req.llm.safeBody);
    res.json(req.llm.finalize(rawResponse));
  } catch (error) {
    next(error);
  }
});

app.use((error, req, res, next) => {
  console.error(error.message);
  res.status(error.status || 500).json({ error: "request_failed" });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

启动并发送测试请求:

AUDIT_HMAC_KEY='replace-with-a-long-random-value' \
SENSITIVE_TERMS='北极星项目,客户红杉' node server.js

curl -X POST http://localhost:3000/chat \
  -H 'content-type: application/json' \
  -d '{"message":"请联系 test@example.com 或 13800138000"}'

模型实际看到的是随机占位符,客户端则会收到还原后的内容。若输入包含配置的业务词,或者形如 api_key=... 的凭据,请求会在调用模型前返回 400。

占位符、响应检测与审计日志

占位符带有每次请求独立的随机 nonce,只有本次请求创建过的完整占位符才能还原。相比固定的 <EMAIL>,这能降低模型自行生成占位符并触发错误还原的可能性。映射只保存在内存中,请求结束后即可被回收,不应写入普通日志。

响应检测位于 finalize 中。模型返回的原始结果只要出现新的邮箱、手机号、凭据或业务词,就会返回 502;原始个人信息从未发送给模型,因此正常情况下,模型只能返回不匹配检测规则的占位符。通过检查后,系统才向授权调用方还原。

审计日志同样不能记录敏感原文。示例使用 HMAC 生成短指纹,既能关联重复事件,又不能直接从日志还原数据。生产环境必须使用密钥管理系统提供 AUDIT_HMAC_KEY,并限制日志平台的读取权限。

误报处理与生产接入

正则表达式适合建立第一道边界,但不是完整的数据分类系统。例如测试邮箱、演示手机号和文档中的客户代号都可能产生误报。示例支持由服务端环境变量提供精确白名单:

DLP_ALLOWLIST='test@example.com,13800138000' node server.js

白名单不能由客户端请求参数控制,也不应放行访问凭据。更稳妥的处理方式是记录规则类型和指纹,由安全人员确认后修改配置,并为白名单设置负责人、原因和过期时间。

接入真实模型时,只需替换 callModel,把 req.llm.safeBody 传给所使用供应商的官方 SDK;返回结果仍须经过 req.llm.finalize。此外还应补充请求大小限制、超时、并发控制、Unicode 规范化,以及针对企业数据格式的校验器。身份证等结构化号码可进一步验证校验位,以减少仅靠正则带来的误报。

这个中间件也不应承担全部权限控制。是否允许还原个人信息,应由业务身份和用途决定;面向公共用户的接口可以不还原,内部客服工具则可在鉴权、授权和审计均通过后还原。

总结

  • 凭据与业务机密默认阻断,个人信息使用请求级随机占位符脱敏。
  • 响应必须先检查新出现的敏感数据,再还原本次请求创建的占位符。
  • 审计日志只保存规则、路径和不可逆指纹,不记录敏感原文或完整请求。
  • 白名单应由服务端管理,并避免对 API 密钥、JWT、私钥等凭据放行。
  • 将检测层放在应用与模型供应商之间,可以形成独立、可测试且可替换的数据安全边界。