feat(harness): 输出脱敏增强 —— 跨分片 StreamRedactor + PII,杜绝密钥碎片泄漏

原逐片脱敏有两个漏:①密钥被切成两片("sk-912cf85b"|"16d0...")逐片都不命中正则而漏检;
②贪婪正则在缓冲末尾凑够最短长度就把半截密钥提前脱敏发走、剩余字符随后明文流出(碎片泄漏)。

有状态 StreamRedactor 跨分片缓冲,切点在「原文」上定且绝不切断任何完整匹配:
- opener 暂留末尾仍在增长的疑似密钥(sk/AKIA/JWT/Bearer/手机/邮箱/长数字)
- 始终留 16B 尾窗兜底 opener 未覆盖的短模式;勿切断完整匹配(循环至稳定)
- rune 边界安全:cut 退到最近 rune 起点,中文不被切成半个发出乱码
- 暂留封顶 256B,防对抗性长串无限暂留 / O(n²)
- 新增 PII:手机号 / 邮箱 / 身份证(18 位)

3 个流式点(graph/react_agent/compose_graph)统一接入,逐片 Push + 收尾 Flush。
7 单测(跨片/逐字符 JWT/碎片回归/尾窗内匹配/干净重建/PII/无误伤),-race 干净。
live 实测:26 位密钥(曾泄漏 ijkl90mnop 碎片)与邮箱整条 [已脱敏]。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 16:46:08 +08:00
parent 93e9d3b195
commit 9506a82be9
6 changed files with 278 additions and 27 deletions
@@ -37,3 +37,123 @@ func TestRedactSecrets_Clean(t *testing.T) {
t.Errorf("正常文本不应被改动: n=%d out=%q", n, out)
}
}
// PII:手机号 / 邮箱 / 身份证应被脱敏。
func TestRedactSecrets_PII(t *testing.T) {
cases := []struct {
in string
mustGone string
}{
{"联系我 13812345678 谢谢", "13812345678"},
{"邮箱 zhang.san@example.com 收", "zhang.san@example.com"},
{"身份证 11010519900307123X 备案", "11010519900307123X"},
}
for _, c := range cases {
out, n := RedactSecrets(c.in)
if n != 1 {
t.Errorf("RedactSecrets(%q) 命中=%d want 1", c.in, n)
}
if strings.Contains(out, c.mustGone) {
t.Errorf("脱敏后仍含 PII: %q", out)
}
}
}
// 不应误伤:更长数字串里的合法 11 位子串不该被吞,普通词不该当邮箱。
func TestRedactSecrets_NoFalsePositive(t *testing.T) {
for _, in := range []string{"订单号 138123456789012", "用户名 a@b(非邮箱)", "数量 12345"} {
if out, n := RedactSecrets(in); n != 0 {
t.Errorf("不应脱敏 %q,却命中 %d → %q", in, n, out)
}
}
}
// streamAll 把分片喂给 StreamRedactor,拼回消费端看到的完整文本与命中数。
func streamAll(chunks ...string) (string, int) {
r := NewStreamRedactor()
var sb strings.Builder
for _, c := range chunks {
sb.WriteString(r.Push(c))
}
sb.WriteString(r.Flush())
return sb.String(), r.Hits()
}
// 跨分片:密钥被切成两片,逐片都不单独命中正则,流式脱敏须靠缓冲拼接捕获。
func TestStreamRedactor_CrossChunkSecret(t *testing.T) {
// "sk-912cf85b" + "16d04b22bcb95f4576423bfb":第一片仅 8 位 body(<16 不命中),第二片无前缀。
out, n := streamAll("我的 key 是 sk-912cf85b", "16d04b22bcb95f4576423bfb 别外传")
if strings.Contains(out, "sk-912cf85b16d04b22bcb95f4576423bfb") {
t.Errorf("跨分片密钥漏检: %q", out)
}
if n != 1 {
t.Errorf("应脱敏 1 处,got %d (%q)", n, out)
}
if !strings.Contains(out, redactMark) {
t.Errorf("应含脱敏标记: %q", out)
}
}
// 跨分片 + 逐字符流(最严苛):JWT 一个字符一片,仍须完整捕获。
func TestStreamRedactor_CharByChar(t *testing.T) {
secret := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd1234"
full := "令牌:" + secret + ",妥善保管"
chunks := make([]string, 0, len(full))
for _, r := range full {
chunks = append(chunks, string(r))
}
out, n := streamAll(chunks...)
if strings.Contains(out, secret) {
t.Errorf("逐字符流 JWT 漏检: %q", out)
}
if n != 1 {
t.Errorf("应脱敏 1 处,got %d (%q)", n, out)
}
}
// 回归:密钥 body 跨片,且第一片恰好凑够最短长度(16)。贪婪正则会提前脱敏半截、剩余明文泄漏。
// 切点须在原文上定、勿切断完整匹配 → 整条密钥完整脱敏,无碎片。
func TestStreamRedactor_NoFragmentLeak(t *testing.T) {
out, n := streamAll("凭据 sk-ABCD1234efgh5678", "ijkl90mnop 完毕")
if strings.Contains(out, "ijkl90mnop") {
t.Errorf("密钥碎片泄漏: %q", out)
}
if strings.Contains(out, "sk-ABCD1234efgh5678") {
t.Errorf("密钥头部泄漏: %q", out)
}
if n != 1 {
t.Errorf("应脱敏 1 处,got %d (%q)", n, out)
}
}
// 完整匹配落在尾窗内(身份证后仅跟少量文字):切点须整体并入暂留,不可从中切断。
func TestStreamRedactor_MatchInTailWindow(t *testing.T) {
out, n := streamAll("身份证 11010519900307123X 完")
if strings.Contains(out, "11010519900307123X") {
t.Errorf("尾窗内身份证漏检: %q", out)
}
if n != 1 {
t.Errorf("应脱敏 1 处,got %d (%q)", n, out)
}
}
// 干净文本逐片流入,消费端拼回必须与原文逐字节一致(无乱码、无丢字、无暂留残留)。
func TestStreamRedactor_CleanReconstruct(t *testing.T) {
full := "杭州西湖十景:苏堤春晓、曲院风荷、平湖秋月、断桥残雪,四季皆宜。"
chunks := make([]string, 0)
rs := []rune(full)
for i := 0; i < len(rs); i += 2 { // 每 2 个中文字一片,制造多字节边界压力
end := i + 2
if end > len(rs) {
end = len(rs)
}
chunks = append(chunks, string(rs[i:end]))
}
out, n := streamAll(chunks...)
if out != full {
t.Errorf("干净文本未原样重建:\n got %q\nwant %q", out, full)
}
if n != 0 {
t.Errorf("干净文本不应脱敏,got %d", n)
}
}