Files
Blizzard 9506a82be9 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>
2026-06-25 16:46:08 +08:00

141 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package harness
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(`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 令牌
}
// 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 把文本里疑似密钥/令牌/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, 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
}