不要把数据安全完全交给模型供应商。本文用一个可运行的 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、私钥等凭据放行。
- 将检测层放在应用与模型供应商之间,可以形成独立、可测试且可替换的数据安全边界。
评论