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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user