Files

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
}