9506a82be9
原逐片脱敏有两个漏:①密钥被切成两片("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>
160 lines
5.2 KiB
Go
160 lines
5.2 KiB
Go
package harness
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRedactSecrets(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantN int
|
|
mustGone string // 脱敏后不应再出现的子串
|
|
}{
|
|
{"我的 key 是 sk-912cf85b16d04b22bcb95f4576423bfb 别外传", 1, "sk-912cf85b16d04b22bcb95f4576423bfb"},
|
|
{"token: AKIAIOSFODNN7EXAMPLE", 1, "AKIAIOSFODNN7EXAMPLE"},
|
|
{"Authorization: Bearer abcdef0123456789ABCDEF", 1, "abcdef0123456789ABCDEF"},
|
|
{"jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd1234 完事", 1, "eyJhbGciOiJIUzI1NiJ9"},
|
|
}
|
|
for _, c := range cases {
|
|
out, n := RedactSecrets(c.in)
|
|
if n != c.wantN {
|
|
t.Errorf("RedactSecrets(%q) 命中数=%d want %d", c.in, n, c.wantN)
|
|
}
|
|
if strings.Contains(out, c.mustGone) {
|
|
t.Errorf("脱敏后仍含密钥: %q", out)
|
|
}
|
|
if !strings.Contains(out, redactMark) {
|
|
t.Errorf("应含脱敏标记: %q", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedactSecrets_Clean(t *testing.T) {
|
|
in := "这是一段正常的回答,介绍杭州西湖的历史与十景。"
|
|
out, n := RedactSecrets(in)
|
|
if n != 0 || out != in {
|
|
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)
|
|
}
|
|
}
|