这一次要发出去的文本,每个字段能不能在分类表里找到对应的一行?找不到的那几行,凭什么判定它可以直接发?
答不出某个字段在哪一行,说明它没分类,只是凭印象判断。这时按受限处理:先把字段补进 docs/data-classification.md,再跑下面的检查命令决定能不能发。凭印象放行的一次,事后查不出自己发出去过什么。
分类的依据不是「重不重要」,而是如果它出现在别人的日志里,会怎样。三类只是粗线,要判的是字段:同一个接口的请求体里,note_id 可以外发,student_name 不可以——判的是字段,不是整个请求。表上查不到的字段,一律按受限处理。
| 类别 | 例子 | 默认怎么处理 |
|---|---|---|
| 公开 | 开源库代码、公开文档、公开数据 | 可以直接外发 |
| 内部 | 产品代码、设计文档、日志片段、错误堆栈 | 按团队约定;接入的服务需写明不留存、不用于训练 |
| 受限 | 密钥与令牌、个人信息、生产数据、未公开的业务与监管数据 | 不外发;用脱敏样本或自建样本代替 |
把这张表放进仓库,比争论一百次「注意数据安全」有用。有了表之后,争议只剩下一件事:某个字段该归到哪一类——那是一个当天能定下来的问题。
# 字段级数据分类表 判定顺序:先查这个字段在表里的哪一行;查不到就按受限处理,补进本表。 | 字段 / 内容 | 类别 | 能否外送 | 脱敏方式 | |---|---|---|---| | 开源库源码、公开文档 | 公开 | 可以 | 无需 | | 表结构 DDL | 内部 | 可以 | 去掉库名与连接串 | | 接口请求示例 | 内部 | 可以 | 用造出来的样本值 | | 日志片段 | 内部 | 可以 | 只留时间与错误类型,去掉参数值 | | 班级名称 | 内部 | 可以 | 改写为「A 班」 | | 真实姓名 | 受限 | 不可以 | 换成「张同学」这类占位 | | 学号 | 受限 | 不可以 | 保留前 2 位,其余用 * | | 手机号 | 受限 | 不可以 | 只留后 4 位 | | 邮箱 | 受限 | 不可以 | 只留域名 | | 口令与哈希 | 受限 | 不可以 | 不出现 | | 会话密钥、API Key | 受限 | 不可以 | 不出现 | | Cookie、Authorization 头 | 受限 | 不可以 | 不粘进提示词 | | 生产库连接串 | 受限 | 不可以 | 换成 localhost 占位 | | 数据库地址、内网 IP | 受限 | 不可以 | 用 127.0.0.1 或占位符 |
边界:这张表定的是「能不能进提示词与上下文」,不是「能不能存在数据库里」。同一份数据躺在数据库里完全正常,一旦进入发给外部模型的文本,就算外发。
# tests/fixtures/sample_request.py
SAMPLE = {
"student_name": "张同学",
"phone": "13800138000", ← 看着像造的样本
"api_key": "sk-live-9f2c...", ← 从 .env 里读到的真值
"scope_id": "cls-a1",
}
认它的办法:新增的每个常量值,问一遍它是不是真的。最容易放过的是「像样本的真值」——手机号格式正确、姓名换成了「张同学」,一眼看过去就是测试数据。api_key 那一行尤其典型:助手为了让示例能跑通,会去读 .env 把真值填进来。这类文件一旦提交,真值就在仓库历史里,而且它会随着每次对话被送出去。
靠人逐段核对一定会漏。用下面这个脚本:它读入待外发的文本,命中受限模式就报错退出,本地和持续集成跑的是同一条。顺序固定——先跑检查,再发出去。
它只做模式匹配,不做语义判断。它能拦住最常见的一批,不保证覆盖全部——所以按顺序第二步仍是查分类表。
#!/usr/bin/env python3
"""外送前检查:读入待外送文本,命中受限模式即报错退出。"""
import re
import sys
PATTERNS = [
("疑似密钥", r"sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}"),
("疑似口令赋值", r"(?i)(password|passwd|secret|token)\s*[:=]\s*\S+"),
("Authorization 头", r"(?i)authorization\s*:\s*\S+"),
("数据库连接串", r"(?i)(postgres|mysql|mongodb)(\+\w+)?://"),
("内网地址", r"\b(10\.\d{1,3}|192\.168\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3})\."),
("手机号", r"\b1[3-9]\d{9}\b"),
("邮箱", r"\b[\w.+-]+@[\w-]+\.[\w.]+\b"),
("学号", r"\b20\d{2}\d{6,}\b"),
]
def scan(text):
hits = []
for name, pattern in PATTERNS:
for m in re.finditer(pattern, text):
line = text.count("\n", 0, m.start()) + 1
hits.append("%s(第 %d 行):%s" % (name, line, m.group(0)[:40]))
return hits
def main():
if len(sys.argv) > 1:
text = open(sys.argv[1], encoding="utf-8").read()
else:
text = sys.stdin.read()
hits = scan(text)
if hits:
sys.stderr.write("发现受限内容,禁止外送:\n" + "\n".join(hits) + "\n")
return 1
print("未发现受限内容,可以外送。")
return 0
if __name__ == "__main__":
sys.exit(main())
照这三条跑。第三条放进工作流,之后任何要往外发的提示词都先过一遍。
# 检查一份要贴进对话的文档 python3 scripts/guard/preflight-send.py prompts/notes.md # 检查这次改动里所有会被读进上下文的文本 git diff origin/main -- '*.md' | python3 scripts/guard/preflight-send.py # 进持续集成(加到 .github/workflows/check.yml 的步骤里) cat prompts/*.md | python3 scripts/guard/preflight-send.py
边界:这个脚本会误报。邮箱和学号两条规则会命中公开文档里的联系邮箱,而公开仓库里出现邮箱是正常的。处理办法是给它加一个豁免文件(scripts/guard/allowed-patterns.txt),而不是把这两条规则从脚本里删掉——删掉规则等于把这道关也撤了。它也不能替代分类表:分类表定判据,脚本只挡明显的一批。
grep -rniE '10\.|192\.168|@|://' AGENTS.md .claude .cursor prompts/。助手可能复现训练语料里的片段。一段代码是怎么来的,和它能不能用是两件事——命名与许可不会因为「是生出来的」就免检。下面这些信号出现一个,就停下来看一眼。
grep -rn "Copyright" src/);新增文件的许可证声明要与项目一致。
可以在改动差异里认出它:整段代码带着与本组风格无关的注释或作者名;出现这个项目从没用过的第三方专有名词;新增文件里带着另一套许可证声明。处理办法很朴素:查不到出处的,当场重写,别留在仓库里等以后查。许可清单怎么写见依赖与供应链。
下面四个出口不会出现在「要不要发给模型」的念头里。它们各自有一条能跑的检查。
| 出口 | 怎么漏的 | 检查命令 |
|---|---|---|
.env 进了版本库 | 早期提交一次,之后才加了忽略规则 | gitleaks detect --redact(扫历史,不只扫当前文件) |
| 日志字段带令牌 | 调试时打了整个请求对象 | git grep -nE 'log.*(token|cookie|password)' |
| 报错回显连接串 | 异常信息里带数据库地址 | 异常统一包装后再返回;测试断言正文里不含 :// |
| 前端包里有 API Key | 构建时把密钥注入了前端变量 | grep -rE 'sk-[A-Za-z0-9]{16,}' dist/ |