Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
237 lines
6.4 KiB
Go
237 lines
6.4 KiB
Go
// Package detect finds what a guard policy acts on in a piece of text:
|
|
// personal data and secrets, by where they are, so they can be rewritten in
|
|
// place; and signs of prompt injection, in the text as a model would read
|
|
// it once disguises are taken off.
|
|
//
|
|
// It reports kinds and positions, never values: what a caller does with a
|
|
// span is the caller's business, and nothing here keeps the text.
|
|
package detect
|
|
|
|
import (
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Kinds of finding.
|
|
const (
|
|
Email = "email"
|
|
Phone = "phone"
|
|
IBAN = "iban"
|
|
Card = "card"
|
|
TCKN = "tckn"
|
|
IP = "ip"
|
|
Secret = "secret"
|
|
|
|
// Injection kinds: a phrase telling the model to drop its
|
|
// instructions, a chat template's control tokens in user text, text a
|
|
// person cannot see, and a long encoded blob.
|
|
InjectionPhrase = "injection_phrase"
|
|
InjectionTemplate = "injection_template"
|
|
InjectionInvisible = "injection_invisible"
|
|
InjectionEncoded = "injection_encoded"
|
|
)
|
|
|
|
// PIIKinds are the kinds Spans can find, in the order a policy lists them.
|
|
var PIIKinds = []string{Email, Phone, IBAN, Card, TCKN, IP, Secret}
|
|
|
|
// Span is one finding in a text: its kind and byte range.
|
|
type Span struct {
|
|
Kind string
|
|
Start, End int
|
|
}
|
|
|
|
var (
|
|
emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,}`)
|
|
// Digits with the separators people write them with; validated after.
|
|
phoneRe = regexp.MustCompile(`(?:\+|\b00)?\(?\d[\d \-().]{8,18}\d\b`)
|
|
ibanRe = regexp.MustCompile(`\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]){11,30}\b`)
|
|
cardRe = regexp.MustCompile(`\b\d(?:[ \-]?\d){12,18}\b`)
|
|
tcknRe = regexp.MustCompile(`\b[1-9]\d{10}\b`)
|
|
ipv4Re = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
|
|
|
secretRes = []*regexp.Regexp{
|
|
regexp.MustCompile(`\bsk-(?:proj-|ant-)?[A-Za-z0-9_\-]{20,}`), // OpenAI, Anthropic
|
|
regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`), // AWS access key id
|
|
regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}\b`), // GitHub
|
|
regexp.MustCompile(`\bxox[abprs]-[A-Za-z0-9\-]{10,}`), // Slack
|
|
regexp.MustCompile(`\bAIza[0-9A-Za-z_\-]{35}\b`), // Google API key
|
|
regexp.MustCompile(`\beyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`), // JWT
|
|
regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)`),
|
|
regexp.MustCompile(`\bllmgw_[A-Za-z0-9]{20,}\b`), // this gateway's own keys
|
|
}
|
|
)
|
|
|
|
// Spans finds personal data and secrets in text, keeping only those that
|
|
// pass their validators — a checksum, a range — and the longest where they
|
|
// overlap.
|
|
func Spans(text string) []Span {
|
|
var out []Span
|
|
add := func(kind string, re *regexp.Regexp, valid func(string) bool) {
|
|
for _, m := range re.FindAllStringIndex(text, -1) {
|
|
if valid == nil || valid(text[m[0]:m[1]]) {
|
|
out = append(out, Span{Kind: kind, Start: m[0], End: m[1]})
|
|
}
|
|
}
|
|
}
|
|
for _, re := range secretRes {
|
|
add(Secret, re, nil)
|
|
}
|
|
add(Email, emailRe, nil)
|
|
add(IBAN, ibanRe, validIBAN)
|
|
add(Card, cardRe, validCard)
|
|
add(TCKN, tcknRe, validTCKN)
|
|
add(IP, ipv4Re, validIPv4)
|
|
add(Phone, phoneRe, validPhone)
|
|
return longest(out)
|
|
}
|
|
|
|
// longest keeps, of overlapping spans, the one found first in priority
|
|
// order (secrets, then the rest as added) unless a later one is longer.
|
|
func longest(in []Span) []Span {
|
|
var out []Span
|
|
for _, s := range in {
|
|
keep := true
|
|
for i := 0; i < len(out); i++ {
|
|
o := out[i]
|
|
if s.Start >= o.End || o.Start >= s.End {
|
|
continue
|
|
}
|
|
if s.End-s.Start > o.End-o.Start {
|
|
out = slices.Delete(out, i, i+1)
|
|
i--
|
|
continue
|
|
}
|
|
keep = false
|
|
break
|
|
}
|
|
if keep {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
slices.SortFunc(out, func(a, b Span) int { return a.Start - b.Start })
|
|
return out
|
|
}
|
|
|
|
func digits(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if r >= '0' && r <= '9' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func validCard(s string) bool {
|
|
d := digits(s)
|
|
if len(d) < 13 || len(d) > 19 {
|
|
return false
|
|
}
|
|
// Issuers' prefixes: Visa, Mastercard, Amex, Discover, Troy, UnionPay,
|
|
// JCB, Diners — anything else is a number, not a card.
|
|
switch {
|
|
case d[0] == '4', d[0] == '5', d[0] == '2', d[:2] == "34", d[:2] == "37", d[0] == '6',
|
|
d[:2] == "35", d[:2] == "30", d[:2] == "36", d[:2] == "38", d[:2] == "65", d[:4] == "9792":
|
|
default:
|
|
return false
|
|
}
|
|
return luhn(d)
|
|
}
|
|
|
|
func luhn(d string) bool {
|
|
sum, alt := 0, false
|
|
for i := len(d) - 1; i >= 0; i-- {
|
|
n := int(d[i] - '0')
|
|
if alt {
|
|
n *= 2
|
|
if n > 9 {
|
|
n -= 9
|
|
}
|
|
}
|
|
sum += n
|
|
alt = !alt
|
|
}
|
|
return sum%10 == 0
|
|
}
|
|
|
|
func validIBAN(s string) bool {
|
|
s = strings.ReplaceAll(s, " ", "")
|
|
if len(s) < 15 || len(s) > 34 {
|
|
return false
|
|
}
|
|
r := s[4:] + s[:4]
|
|
mod := 0
|
|
for _, c := range r {
|
|
var v int
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
v = int(c - '0')
|
|
case c >= 'A' && c <= 'Z':
|
|
v = int(c-'A') + 10
|
|
default:
|
|
return false
|
|
}
|
|
if v >= 10 {
|
|
mod = (mod*100 + v) % 97
|
|
} else {
|
|
mod = (mod*10 + v) % 97
|
|
}
|
|
}
|
|
return mod == 1
|
|
}
|
|
|
|
// validTCKN checks a Turkish identity number's two check digits.
|
|
func validTCKN(s string) bool {
|
|
if len(s) != 11 || s[0] == '0' {
|
|
return false
|
|
}
|
|
d := make([]int, 11)
|
|
for i := range s {
|
|
d[i] = int(s[i] - '0')
|
|
}
|
|
odd := d[0] + d[2] + d[4] + d[6] + d[8]
|
|
even := d[1] + d[3] + d[5] + d[7]
|
|
if ((odd*7-even)%10+10)%10 != d[9] {
|
|
return false
|
|
}
|
|
sum := 0
|
|
for _, v := range d[:10] {
|
|
sum += v
|
|
}
|
|
return sum%10 == d[10]
|
|
}
|
|
|
|
func validIPv4(s string) bool {
|
|
for _, p := range strings.Split(s, ".") {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil || n > 255 || (len(p) > 1 && p[0] == '0') {
|
|
return false
|
|
}
|
|
}
|
|
// Versions are written like addresses; 1.2.3.4-shaped strings with
|
|
// small parts are far likelier to be one.
|
|
return !strings.HasPrefix(s, "0.")
|
|
}
|
|
|
|
// validPhone accepts numbers written the way they are dialled: +country,
|
|
// 00country, or a national 0 and an area code. A bare run of digits is
|
|
// anything — an order number, part of an account number — and is not taken.
|
|
func validPhone(s string) bool {
|
|
d := digits(s)
|
|
if len(d) < 10 || len(d) > 15 || strings.Count(d, d[:1]) == len(d) {
|
|
return false
|
|
}
|
|
t := strings.TrimLeft(s, "(")
|
|
switch {
|
|
case strings.HasPrefix(t, "+"):
|
|
return d[0] != '0'
|
|
case strings.HasPrefix(d, "00"):
|
|
return len(d) >= 12 && d[2] != '0'
|
|
case d[0] == '0':
|
|
return d[1] != '0' && len(d) <= 11
|
|
}
|
|
return false
|
|
}
|