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
+124 -10
View File
@@ -1,26 +1,140 @@
package harness
import "regexp"
import (
"regexp"
"strings"
"unicode/utf8"
)
// secretPatterns 是输出里疑似密钥/令牌的特征(命中即脱敏,防模型把密钥回吐给用户)。
var secretPatterns = []*regexp.Regexp{
regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`), // OpenAI/DeepSeek 风格 key
regexp.MustCompile(`AKIA[0-9A-Z]{16}`), // AWS Access Key ID
regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`), // OpenAI/DeepSeek 风格 key
regexp.MustCompile(`AKIA[0-9A-Z]{16}`), // AWS Access Key ID
regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), // JWT
regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._-]{16,}`), // Bearer 令牌
regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._-]{16,}`), // Bearer 令牌
}
// piiPatterns 是个人隐私信息(PII)特征:手机号 / 邮箱 / 身份证。用 \b 边界避免吞进更长数字串。
var piiPatterns = []*regexp.Regexp{
regexp.MustCompile(`\b1[3-9]\d{9}\b`), // 中国大陆手机号(11 位)
regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`), // 邮箱
regexp.MustCompile(`\b\d{17}[\dXx]\b`), // 身份证(18 位,末位可 X
}
// openerPatterns 匹配「位于字符串末尾、可能仍在增长」的敏感串前缀(锚定 $)。
// 流式逐片脱敏会漏掉被分片切断的密钥(如 "sk-912cf85b"|"16d0..."),逐片都不单独命中。
// StreamRedactor 据此把"半截疑似密钥"的起点之后整段暂留,与下一分片拼接后再判,消除跨片漏检。
var openerPatterns = []*regexp.Regexp{
regexp.MustCompile(`sk-[A-Za-z0-9_-]*$`),
regexp.MustCompile(`AKIA[0-9A-Z]*$`),
regexp.MustCompile(`eyJ[A-Za-z0-9_-]*(\.[A-Za-z0-9_-]*){0,2}$`),
regexp.MustCompile(`(?i)bearer\s*[A-Za-z0-9._-]*$`),
regexp.MustCompile(`1[3-9]\d*$`), // 手机号(增长中)
regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]*$`), // 邮箱 @ 之后增长中
regexp.MustCompile(`\d{6,}$`), // 长数字串(身份证等)增长中
}
// allPatterns 是全部完整匹配特征(密钥 + PII),供流式切点判定"勿切断完整匹配"。
var allPatterns = append(append([]*regexp.Regexp{}, secretPatterns...), piiPatterns...)
const redactMark = "[已脱敏]"
// RedactSecrets 把文本里疑似密钥/令牌替换为脱敏标记,返回脱敏后文本与命中次数。
// 输出护栏:在 token 发射前对每个分片调用(流式无法回收已发,故逐片脱敏 + 最终标记)。
// RedactSecrets 把文本里疑似密钥/令牌/PII 替换为脱敏标记,返回脱敏后文本与命中次数。
// 整段脱敏(非流式场景、StreamRedactor 内部、最终兜底都用它)。
func RedactSecrets(s string) (string, int) {
n := 0
repl := func(string) string { n++; return redactMark }
for _, re := range secretPatterns {
s = re.ReplaceAllStringFunc(s, func(string) string {
n++
return redactMark
})
s = re.ReplaceAllStringFunc(s, repl)
}
for _, re := range piiPatterns {
s = re.ReplaceAllStringFunc(s, repl)
}
return s, n
}
const (
// redactMinTail 始终暂留的尾窗:兜住 opener 未识别、却被跨片切断的短模式。
redactMinTail = 16
// redactMaxHold 暂留上限:超过即强制发射(脱敏后),防对抗性长串导致无限暂留 / O(n²)。
redactMaxHold = 256
)
// StreamRedactor 是有状态的流式脱敏器:跨分片缓冲,解决密钥被切断而漏检。
// 用法:每分片 Push() 得可安全发射的文本,流结束 Flush() 吐出暂留尾部。非并发安全(单流单实例)。
type StreamRedactor struct {
carry strings.Builder
hits int
}
// NewStreamRedactor 建一个流式脱敏器(每个 token 流一个实例)。
func NewStreamRedactor() *StreamRedactor { return &StreamRedactor{} }
// Push 吃一个分片,返回当前可安全发射的脱敏文本(可能为空——尾部疑似半截敏感串被暂留待下片)。
// 关键:切点在「原文」缓冲上定,只对已结算的头部脱敏、尾部按原文暂留 —— 否则贪婪正则会在
// 缓冲末尾凑够最短长度就把半截密钥提前脱敏发走,剩余字符随后明文泄漏。
func (r *StreamRedactor) Push(chunk string) string {
if chunk == "" {
return ""
}
r.carry.WriteString(chunk)
buf := r.carry.String()
cut := safeCut(buf)
head, n := RedactSecrets(buf[:cut])
r.hits += n
r.carry.Reset()
r.carry.WriteString(buf[cut:]) // 原文暂留(未脱敏,供下片继续判定)
return head
}
// Flush 流结束时吐出暂留尾部(做最终脱敏)。返回脱敏后的剩余文本。
func (r *StreamRedactor) Flush() string {
s, n := RedactSecrets(r.carry.String())
r.hits += n
r.carry.Reset()
return s
}
// Hits 返回累计脱敏命中次数。
func (r *StreamRedactor) Hits() int { return r.hits }
// safeCut 在「原文」缓冲上计算可安全发射的截断点:暂留 [cut:],发射并脱敏 [:cut]。
// 三层保障:① opener 暂留末尾仍在增长的疑似密钥;② 始终留一段尾窗兜底未被 opener 覆盖的短模式;
// ③ 切点绝不切断任何「完整匹配」(否则半截匹配的头部会明文流出)。最后以暂留上限封顶防 DoS。
func safeCut(s string) int {
cut := len(s)
for _, re := range openerPatterns {
if loc := re.FindStringIndex(s); loc != nil && loc[0] < cut {
cut = loc[0]
}
}
if minCut := len(s) - redactMinTail; minCut < cut {
cut = minCut // 始终暂留尾窗
}
// 勿切断完整匹配:把任何跨越 cut 的完整匹配整体并入暂留尾(循环至稳定,匹配数有限)。
for {
moved := false
for _, re := range allPatterns {
for _, loc := range re.FindAllStringIndex(s, -1) {
if loc[0] < cut && loc[1] > cut {
cut = loc[0]
moved = true
}
}
}
if !moved {
break
}
}
if maxCut := len(s) - redactMaxHold; maxCut > cut {
cut = maxCut // 暂留封顶(最终硬限,防对抗性长串无限暂留 / O(n²))
}
if cut < 0 {
cut = 0
}
// 退到最近的 rune 边界:cut 来自字节索引,直接切多字节字符(中文)会发出半个 rune 乱码。
for cut > 0 && cut < len(s) && !utf8.RuneStart(s[cut]) {
cut--
}
return cut
}