单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
179 lines
5.3 KiB
Go
179 lines
5.3 KiB
Go
// Package safetyscan 响应侧合规扫描:关键词分类、PII 打码、提示词注入检测、
|
||
// 注入包裹 delimiter(design-arch §5.4 响应侧行 / Contract A1-safetyscan)。
|
||
//
|
||
// 词表来源:本包 wordlist.yaml(样例,部署前由合规方扩充);NFKC 归一 +
|
||
// 前 64KB 限制;title/description/markdown 分别扫(fit 结果与元数据不混扫)。
|
||
package safetyscan
|
||
|
||
import (
|
||
_ "embed"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
//go:embed wordlist.yaml
|
||
var wordlistRaw []byte
|
||
|
||
// Wordlist 词表结构(对应 wordlist.yaml)。
|
||
type Wordlist struct {
|
||
Version string `yaml:"version"`
|
||
HighRisk []string `yaml:"high_risk"`
|
||
InjectionPatterns []string `yaml:"injection_patterns"`
|
||
PII struct {
|
||
IDCard string `yaml:"id_card"`
|
||
Mobile string `yaml:"mobile"`
|
||
BankCard string `yaml:"bank_card"`
|
||
} `yaml:"pii"`
|
||
}
|
||
|
||
// compiled 编译后的扫描器(构造后只读,Scanner 并发安全)。
|
||
type compiled struct {
|
||
wordlist *Wordlist
|
||
highRisk []term // 高危词(NFKC 归一后子串匹配)
|
||
injection []*regexWrap // 注入正则(归一文本上跑)
|
||
piiIDCard *regexWrap // 身份证(原文上替换)
|
||
piiMobile *regexWrap
|
||
piiBank *regexWrap
|
||
}
|
||
|
||
// term 高危词条目。
|
||
type term struct {
|
||
word string // 归一后的匹配词
|
||
raw string // 原词(审计展示)
|
||
}
|
||
|
||
// ScanResult 扫描结论(Warnings/Hits 恒非 nil,信封纪律 [] 非 null)。
|
||
type ScanResult struct {
|
||
Blocked bool
|
||
Warnings []string
|
||
Redacted bool
|
||
RedactCount int
|
||
Hits []string
|
||
WordlistVer string
|
||
}
|
||
|
||
// Scanner 线程安全扫描器。
|
||
type Scanner struct{ c *compiled }
|
||
|
||
// Version 返回词表版本。
|
||
func (s *Scanner) Version() string { return s.c.wordlist.Version }
|
||
|
||
// Load 用内嵌词表构造扫描器。
|
||
func Load() (*Scanner, error) { return load(wordlistRaw) }
|
||
|
||
// load 解析并编译词表。
|
||
func load(raw []byte) (*Scanner, error) {
|
||
var wl Wordlist
|
||
if err := yaml.Unmarshal(raw, &wl); err != nil {
|
||
return nil, fmt.Errorf("safetyscan: 词表解析失败: %w", err)
|
||
}
|
||
if wl.Version == "" {
|
||
return nil, fmt.Errorf("safetyscan: 词表缺少 version 字段")
|
||
}
|
||
c := &compiled{wordlist: &wl}
|
||
for _, w := range wl.HighRisk {
|
||
c.highRisk = append(c.highRisk, term{word: normalize(w), raw: w})
|
||
}
|
||
for _, p := range wl.InjectionPatterns {
|
||
re, err := compile(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("safetyscan: 注入正则 %q 编译失败: %w", p, err)
|
||
}
|
||
c.injection = append(c.injection, re)
|
||
}
|
||
piiSpecs := []struct {
|
||
raw string
|
||
dst **regexWrap
|
||
msg string
|
||
}{
|
||
{wl.PII.IDCard, &c.piiIDCard, "身份证"},
|
||
{wl.PII.Mobile, &c.piiMobile, "手机号"},
|
||
{wl.PII.BankCard, &c.piiBank, "银行卡"},
|
||
}
|
||
for _, sp := range piiSpecs {
|
||
if sp.raw == "" {
|
||
continue
|
||
}
|
||
re, err := compile(sp.raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("safetyscan: %s正则编译失败: %w", sp.msg, err)
|
||
}
|
||
*sp.dst = re
|
||
}
|
||
return &Scanner{c: c}, nil
|
||
}
|
||
|
||
// scanLimit 前 64KB 扫描(design §5.4:前 64KB 解码扫描)。
|
||
const scanLimit = 64 * 1024
|
||
|
||
// redactPlaceholder PII 打码占位(redact 不丢弃)。
|
||
var redactPlaceholder = map[string]string{
|
||
"id_card": "[身份证已脱敏]",
|
||
"mobile": "[手机号已脱敏]",
|
||
"bank_card": "[银行卡号已脱敏]",
|
||
}
|
||
|
||
// Scan 扫描 title/description/markdown 三字段(分别扫,不混扫)。
|
||
// 返回结论与打码后的字段副本;Blocked 时调用方置 error.code=blocked。
|
||
func (s *Scanner) Scan(title, description, markdown string) (res ScanResult, outTitle, outDesc, outMD string) {
|
||
res = ScanResult{WordlistVer: s.c.wordlist.Version, Warnings: []string{}, Hits: []string{}}
|
||
scanOne := func(field, text string) (string, bool) {
|
||
if text == "" {
|
||
return text, false
|
||
}
|
||
head := text
|
||
if len(head) > scanLimit {
|
||
head = head[:scanLimit]
|
||
}
|
||
norm := normalize(head)
|
||
dirty := false
|
||
redacted := text
|
||
// PII redact:正则在原文上替换(样例词表为 ASCII 数字正则,归一不影响定位)。
|
||
for _, p := range []struct {
|
||
name string
|
||
re *regexWrap
|
||
key string
|
||
}{
|
||
{"pii.id_card", s.c.piiIDCard, "id_card"},
|
||
{"pii.mobile", s.c.piiMobile, "mobile"},
|
||
{"pii.bank_card", s.c.piiBank, "bank_card"},
|
||
} {
|
||
if p.re == nil {
|
||
continue
|
||
}
|
||
if cnt := p.re.count(redacted); cnt > 0 {
|
||
if p.key == "mobile" {
|
||
// mobile 正则带边界捕获组:只替换组内号码,保留前后字符。
|
||
redacted = p.re.replaceGroup(redacted, "$1", redactPlaceholder[p.key])
|
||
} else {
|
||
redacted = p.re.replaceAll(redacted, redactPlaceholder[p.key])
|
||
}
|
||
res.RedactCount += cnt
|
||
res.Redacted = true
|
||
dirty = true
|
||
res.Hits = append(res.Hits, p.name)
|
||
}
|
||
}
|
||
// 高危词:归一后子串匹配
|
||
for _, t := range s.c.highRisk {
|
||
if strings.Contains(norm, t.word) {
|
||
res.Blocked = true
|
||
res.Hits = append(res.Hits, "high_risk:"+t.raw)
|
||
}
|
||
}
|
||
// 注入检测:命中计入 warnings
|
||
for _, r := range s.c.injection {
|
||
if r.matchString(norm) {
|
||
res.Warnings = append(res.Warnings, fmt.Sprintf("prompt_injection_detected in %s", field))
|
||
res.Hits = append(res.Hits, "injection:"+r.pattern)
|
||
}
|
||
}
|
||
return redacted, dirty
|
||
}
|
||
outTitle, _ = scanOne("title", title)
|
||
outDesc, _ = scanOne("description", description)
|
||
outMD, _ = scanOne("markdown", markdown)
|
||
return res, outTitle, outDesc, outMD
|
||
}
|