DibaGuard-Open / runtime /go /sqlctx.go
DibaAi's picture
DibaGuard open release: licence-clean network models, runtime, docs (no HTTP model)
e02228b verified
Raw History Blame Contribute Delete
24.6 kB
package dibaguard
import (
"regexp"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// The hard part of A22 - the SQL-context tier (dibaguard/http/sqlctx.py, judged by dibaguard/shield/database.py
// classify) - inline in the fast path (V-09): every parameter of the canonical text is placed in the query contexts
// applications build (numeric, string, double-quoted, parenthesised, LIKE) and a value that breaks out of one into an
// attack's shape is a hard hit, refused inline like a payload signature. The learned second opinion (DbNet) and the
// soft hits (a request too large to judge) stay with the sidecar, which is sent the same text.
//
// Only the families A22 acts on (injection, rce, privilege, destructive) are ported. Branches anchored at the start of
// the query (^\s*CONFIG SET ..., ^\s*DELETE FROM ...) can never match a query that starts with the context's SELECT and
// are left out; the two back-references (or x = x; "$eq": [n, n]) and the look-aheads are code. Python's \d and \w are
// Unicode-aware and so are the Go classes (\p{Nd}, \p{L}\p{N}_); RE2's \b is ASCII-only, a divergence only for a SQL
// word glued to a non-ASCII letter. Parity: sqlctx_parity in canon_parity.json (TestSQLContextParity).
const (
sqlWindow = 960 // sqlctx.WINDOW
sqlWindowOverlap = 256 // sqlctx.WINDOW_OVERLAP
sqlBudget = 6144 // sqlctx.BUDGET
)
// pyRx compiles a Python pattern for RE2: \d and \w become their Unicode classes (Python's str patterns are Unicode).
func pyRx(flags, p string) *regexp.Regexp {
var b strings.Builder
inClass := false
for i := 0; i < len(p); i++ {
c := p[i]
switch {
case c == '\\' && i+1 < len(p):
n := p[i+1]
i++
switch {
case n == 'd':
b.WriteString(`\p{Nd}`)
case n == 'w' && inClass:
b.WriteString(`\p{L}\p{N}_`)
case n == 'w':
b.WriteString(`[\p{L}\p{N}_]`)
default:
b.WriteByte('\\')
b.WriteByte(n)
}
continue
case c == '[' && !inClass:
inClass = true
b.WriteByte(c)
if i+1 < len(p) && p[i+1] == '^' {
b.WriteByte('^')
i++
}
if i+1 < len(p) && p[i+1] == ']' { // a ']' first in a class is a literal
b.WriteByte(']')
i++
}
continue
case c == ']' && inClass:
inClass = false
}
b.WriteByte(c)
}
return regexp.MustCompile(flags + b.String())
}
var (
sqlContexts = []struct{ name, tpl string }{
{"numeric", "SELECT a FROM t WHERE id = {v}"},
{"string", "SELECT a FROM t WHERE s = '{v}'"},
{"dquote", "SELECT a FROM t WHERE s = \"{v}\""},
{"paren_num", "SELECT a FROM t WHERE (id = {v})"},
{"paren_str", "SELECT a FROM t WHERE (s = '{v}')"},
{"like", "SELECT a FROM t WHERE s LIKE '%{v}%'"},
}
sqlSkipHeaders = map[string]bool{"h:accept": true, "h:accept-encoding": true, "h:content-type": true, "h:cache-control": true,
"h:content-length": true, "h:connection": true}
// sqlctx._PRE / _GATE with Python's Unicode word boundaries: a word must not touch a letter, digit or '_'
sqlPre = regexp.MustCompile("(?i)['\"`;()#=<>|&*\\\\]|--|/\\*|(?:^|[^\\p{L}\\p{N}_])(?:select|union|sleep|benchmark|waitfor|or|and|xor|having|order|group|exec|declare|" +
"extractvalue|updatexml|pg_sleep|information_schema|concat|char|chr|cast|convert|drop|insert|delete|update|like|rlike|regexp)(?:$|[^\\p{L}\\p{N}_])")
sqlGate = regexp.MustCompile("(?i)['\"`#]|--|/\\*|(?:^|[^\\p{L}\\p{N}_])(?:(?:select|union|sleep|benchmark|waitfor|pg_sleep|having|exec|execute|declare|information_schema|" +
"extractvalue|updatexml|dual|concat|char|chr|rlike|regexp)(?:$|[^\\p{L}\\p{N}_])|xp_[\\p{L}\\p{N}_]+|(?:or|and|xor)(?:\\s+[\\p{Nd}'\"(@]|['\"(@]))|&&|\\|\\|")
sqlStrongWords = []string{"union", "select", "sleep", "benchmark", "or", "and", "xor", "from", "where", "exec", "waitfor", "extractvalue", "updatexml"}
// database.SIGS / RAW for the families A22 acts on (see the file comment for what is left out and why)
dbInjection = pyRx("(?is)", `(?:\bor|\|\|)\s*\(?\s*(\d+|'s'|0x[0-9a-f]+)\s*(=|like|>|<)\s*(\d+|'s'|0x[0-9a-f]+)`+
`|\bor\s+(true|not\s+0)\b|\(\s*select\s+(cast|convert)\s*\(`+
`|(?:\bor|\band|\bxor|\|\||&&)\s*\(?\s*\d+\s*[-*+/]\s*\d+\s*=\s*\d+`+
`|=\s*\(\s*select\s*[\d(]`+
`|\b(and|or|xor|&&)\s+\d+\s*[=<>]+\s*\d+\s*(--|#|;|$)`+
`|\band\s+'s'\s*=\s*'s'\s*;?\s*$`+
`|(?:\bor|\bxor|\|\|)\s*\(?\s*(-?\d+|'s'|true|false|null)\s+(not\s+)?(between\s+\S+\s+and\s+\S+|in\s*\(|is\s+(not\s+)?(true|false|null|unknown)\b`+
`|r?like\s|regexp\s|sounds\s+like\s|div\s|mod\s|<=>)`+
`|(?:\bor|\bxor|\|\|)\s+(-?[1-9]\d*|true|'s')\s*(;|--|#|\)|$)|\bxor\s+(-?\d+|'s'|true)\b`+
`|(?:\bor|\bxor)\s+(greatest|least|if|ifnull|coalesce|abs|sign|length|ascii|char|concat)\s*\(\s*(-?\d+|'s')\s*(,\s*(-?\d+|'s')\s*)*\)`+
`|;\s*(and|or|xor)\s`+
`|\bunion(\s+all|\s+distinct)?\s+select\s+(null\b|\d|0x|concat|char\s*\(|@@|version\s*\(|user\s*\(|database\s*\(|group_concat|'s')`+
`|;\s*(drop|truncate|delete|insert|update|create|alter|exec|execute|shutdown|grant|copy|declare|waitfor)\b|\b(pg_sleep|sleep|benchmark|randomblob)\s*\(`+
`|\bwaitfor\s+delay\b|\bdbms_(pipe\.receive_message|lock\.sleep)\b|\b(extractvalue|updatexml)\s*\(|\bcast\s*\(\s*\(\s*select\b`+
`|\bconvert\s*\(\s*int\s*,\s*\(\s*select\b|\bexp\s*\(\s*~|\bfloor\s*\(\s*rand\s*\(|\b(order|group)\s+by\s+(\d+\s*,\s*){0,9}\d+\s*(--|#)`+
`|\b(ascii|substring|substr|mid|ord)\s*\(\s*\(\s*select\b|\bif\s*\(\s*\d+\s*=\s*\d+\s*,\s*sleep`+
`|\b(ascii|substring|substr|mid|ord|length)\s*\(\s*(@@\w+|(database|user|current_user|version|schema)\s*\(\s*\))|\border\s+by\s+\d+\s+order\s+by\b`+
`|\bunion(\s+all|\s+distinct)?\s+select\b[^;]*\b(from\s+(information_schema|mysql\.|pg_|sys\.|master\.|users|members|accounts|auth_user)`+
`|password|passwd|authentication_string|pass\b)|\bunion(\s+all|\s+distinct)?\s+select\b[^;]*(--|#)\s*-?\s*$`)
dbRCE = pyRx("(?is)", `\blo_(import|export)\s*\(|\bpg_(read_file|read_binary_file|ls_dir|write_file|stat_file)\s*\(`+
`|\bxp_(cmdshell|dirtree|fileexist)\b`+
`|\bsp_oa(create|method)\b|\bopen(rowset|datasource)\s*\(|\bsys_(exec|eval)\s*\(|\bload\s+data\s+(local\s+)?infile\b|\bsoname\b`+
`|\binto\s+(out|dump)file\b|\bload_file\s*\(|\bdbms_(java|scheduler)\b|\butl_(http|file|tcp|inaddr)\b|\bhttpuritype\b|\$where\b|\$function\b`+
`|\$accumulator\b|\bmapreduce\b|\brunprogram\s*\(`+
`|redis\.p?call\s*\(\s*['"](config|slaveof|replicaof|module|debug)['"]`+
`|\bpackage\.loadlib\b|\bdblink(_exec|_connect)?\s*\(`+
`|\bjava\.lang\.|\bgetruntime\s*\(|\bapoc\.(load|cypher\.run\w*|import)\w*|\binsert\s+into\s+function\b|\bfrom\s+(url|file|s3|hdfs)\s*\(`+
`|\bcopy\b.*\b(to|from)\s+program\b|\bcreate\s+(or\s+replace\s+)?function\b.*\blanguage\s+(c|plpythonu|plpython3u|plperlu|plsh|java|javascript)\b`+
`|\bcreate\s+extension\b.*\b(dblink|plpython\w*|adminpack)\b`)
dbPrivilege = pyRx("(?is)", `\bgrant\s+all\b|\bset\s+global\b|\bsp_addsrvrolemember\b`+
`|\balter\s+server\s+role\s+sysadmin\b|\bupdate\s+(pg_authid|mysql\.user)\b|\bset\s+role\s+postgres\b|\balter\s+system\s+set\b`+
`|\bgrant\s+all\s+permissions\b|redis\.p?call\s*\(\s*['"]acl['"]\s*,\s*['"]setuser`+
`|/_security/(user|role)/\S+\s*\{|\bdbms\.security\.createuser\b|\bgrant\s+role\s+admin\b|\bcreate\s+login\b`+
`|\balter\s+(user|role)\b.*\b(superuser|createrole|bypassrls|replication)\b|\bgrant\b.*\bto\s+public\b|\bcreate\s+(user|role)\b.*\bsuperuser\b`+
`|\bgrant\b.*\bwith\s+grant\s+option\b|\b(createuser|grantrolestouser|updateuser)\b.*\b(root|useradminanydatabase|readwriteanydatabase|dbowner|clusteradmin|__system)\b`)
dbDestructive = pyRx("(?is)", `\bdrop\s+(database|schema|table|keyspace|collection)\b|\btruncate\b`+
`|\bdropdatabase\b|"drop"\s*:\s*"|\.drop\s*\(\s*\)|\b(deletemany|remove)\s*\(\s*\{\s*\}\s*\)|"deletes"\s*:\s*\[\s*\{\s*"q"\s*:\s*\{\s*\}`+
`|redis\.p?call\s*\(\s*['"](flushall|flushdb|shutdown)['"]`+
`|\bmatch\s*\(\s*\w+\s*\)\s*(detach\s+)?delete\b`+
`|"q"\s*:\s*\{\s*\}\s*,\s*"u"\s*:.*"multi"\s*:\s*true|_delete_by_query.*match_all`)
dbRawRCE = pyRx("(?is)", `\bsp_configure\s+'(xp_cmdshell|ole automation procedures)'|\bload\s+csv\s+from\s+'file:|\b(url|file|s3)\s*\(\s*'(https?://(169\.254|metadata)|/)`+
`|"(script|source|inline|body|map|reduce|init|accumulate)"\s*:\s*"[^"]*(java\.lang|getruntime|runtime\.exec|runprogram|processbuilder)`)
dbRawDestructive = pyRx("(?is)", `\b(btc|bitcoin|monero|xmr)\b.*\b(recover|backed\s+up|pay|send)\b|\b(recover|backed\s+up|pay|send)\b.*\b(btc|bitcoin)\b`+
`|\bcreate\s+(table|database)\s+\W?\w*(readme|read_me|recover|warning|please_read)`)
dbRawInjection = pyRx("(?is)", `"(pass\w*|pwd|token|secret|api_?key)"\s*:\s*\{\s*"\$(ne|gt|gte|lt|regex|exists|nin|in|not)"|\b(pass\w*|pwd|token)\s*:\s*\{\s*\$(ne|gt|regex|exists)\b`+
`|"\$or"\s*:\s*\[\s*\{\s*\}`+
`|"(user(name)?|login|e-?mail)"\s*:\s*\{\s*"\$(ne"\s*:\s*(null|"")\s*\}`+
`|gte?"\s*:\s*""\s*\}|regex"\s*:\s*"(\^|\.\*))`)
dbExprEq = pyRx("(?is)", `"\$expr"\s*:\s*\{\s*"\$eq"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]`) // "$eq": [n, n] (a back-reference in Python)
dbOrSelf = regexp.MustCompile(`(?i)(?:^|[^\p{L}\p{N}_])or|\|\|`) // the start of "or x = x" (a back-reference in Python)
dbSQ = regexp.MustCompile(`'(?:[^'\\]|\\.|'')*'`)
dbSQStd = regexp.MustCompile(`'(?:[^']|'')*'`) // database._SQ_STD: no backslash escapes (standard SQL)
dbVC = pyRx("(?s)", `/\*!\d*\s*(.*?)\*/`)
dbCM = regexp.MustCompile(`(?s)/\*.*?\*/`)
dbLCHash = regexp.MustCompile(`(?:#|--)[^\r\n]*[\r\n]`)
dbTermLit = regexp.MustCompile(`--|#|/\*|;`)
dbNeedRCE = strings.Fields("program lo_ pg_ language xp_ sp_ openrowset opendatasource sys_ infile soname outfile dumpfile load_file dbms_ utl_ httpuritype $where $function $accumulator mapreduce runprogram config slaveof replicaof module system.exec loadlib dblink extension java.lang getruntime apoc function url file s3 hdfs redis. script source inline body map reduce init accumulate processbuilder")
dbNeedPriv = []string{"alter", "grant", "create", "set global", "sp_addsrvrolemember", "sysadmin", "pg_authid", "mysql.user", "set role",
"createuser", "grantrolestouser", "updateuser", "acl", "requirepass", "protected-mode", "_security", "dbms.security"}
dbNeedDestr = []string{"drop", "truncate", "delete", "update", "remove", "deletes", "\"q\"", "flushall", "flushdb", "shutdown", "debug",
"_delete_by_query", "match", "btc", "bitcoin", "monero", "xmr", "recover", "backed", "readme", "read_me", "warning", "please_read", "redis."}
dbNeedInj = []string{" or", "or ", "||", "xor", "and", "&&", "union", ";", "sleep", "benchmark", "randomblob", "waitfor", "dbms_", "extractvalue",
"updatexml", "cast", "convert", "exp", "rand", "order", "group", "ascii", "substr", "mid", "ord", "length", "if", "select",
"$", "pass", "pwd", "token", "secret"}
sqlLeadWords = strings.Fields("or and xor not union select having group order limit procedure into from where like rlike regexp in is between div mod sounds collate case exists glob notnull isnull")
sqlStmtWords = strings.Fields("select insert update delete drop create alter exec execute declare waitfor shutdown grant truncate set show call load with begin handler do")
)
func isPyWord(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' }
func isASCIIWord(c byte) bool {
return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '_'
}
// hasAny is `any(k in a or k in b for k in need)`.
func hasAny(a, b string, need []string) bool {
for _, k := range need {
if strings.Contains(a, k) || strings.Contains(b, k) {
return true
}
}
return false
}
// pyFoldWS is database._WS.sub(" ", s).strip().
func pyFoldWS(s string) string {
var b strings.Builder
b.Grow(len(s))
space := false
for _, r := range s {
if pyIsSpace(r) {
space = true
continue
}
if space && b.Len() > 0 {
b.WriteByte(' ')
}
space = false
b.WriteRune(r)
}
return b.String()
}
// dbViews is database.views(query, "mysql"): (raw, literals stripped, structural).
func dbViews(q string) (string, string, string) {
raw := q
if runeLen(raw) > 1024 {
rs := []rune(raw)
raw = string(rs[:768]) + " " + string(rs[len(rs)-256:])
}
s1 := dbSQ.ReplaceAllString(raw, "'s'")
s2 := dbCM.ReplaceAllString(dbVC.ReplaceAllString(s1, " ${1} "), " ")
if strings.ContainsAny(s2, "\n\r") {
s2 = dbLCHash.ReplaceAllString(s2, " ")
}
return raw, s1, pyFoldWS(s2)
}
// orSelf is the back-reference branch `(?:\bor|\|\|)\s+\(?\s*(?P<t1>[\w'()]+)\s*=\s*(?P=t1)(?![\w(])`.
func orSelf(s string) bool {
inT := func(r rune) bool { return isPyWord(r) || r == '\'' || r == '(' || r == ')' }
for _, m := range dbOrSelf.FindAllStringIndex(s, -1) {
p := m[1] // after "or" / "||" (the boundary character before "or" is part of the match)
// \s+
q := p
for q < len(s) {
r, n := utf8.DecodeRuneInString(s[q:])
if !pyIsSpace(r) {
break
}
q += n
}
if q == p {
continue
}
starts := []int{q}
if q < len(s) && s[q] == '(' { // \(? consumed, then \s*
k := q + 1
for k < len(s) {
r, n := utf8.DecodeRuneInString(s[k:])
if !pyIsSpace(r) {
break
}
k += n
}
starts = append(starts, k)
}
for _, st := range starts {
e := st
for e < len(s) {
r, n := utf8.DecodeRuneInString(s[e:])
if !inT(r) {
break
}
e += n
}
if e == st {
continue
}
t1 := s[st:e]
k := e
for k < len(s) {
r, n := utf8.DecodeRuneInString(s[k:])
if !pyIsSpace(r) {
break
}
k += n
}
if k >= len(s) || s[k] != '=' {
continue
}
k++
for k < len(s) {
r, n := utf8.DecodeRuneInString(s[k:])
if !pyIsSpace(r) {
break
}
k += n
}
if len(s)-k < len(t1) || !strings.EqualFold(s[k:k+len(t1)], t1) {
continue
}
k += len(t1)
if k < len(s) {
if r, _ := utf8.DecodeRuneInString(s[k:]); isPyWord(r) || r == '(' {
continue
}
}
return true
}
}
return false
}
// literalCut is database._literal_cut, the classify fallback for a value placed inside a string literal (V-18): the
// query's first literal is the one the payload closed; up to the first comment or ';' after it there must be nothing
// (admin'--, x';) or SQL (x' or name is not null--). Prose breaks out with letters ("It's great; ..." -> "s great").
func literalCut(s1 string) bool {
i := strings.IndexByte(s1, '\'')
if i < 0 || !strings.HasPrefix(s1[i:], "'s'") { // the application's literal never closed: no break-out
return false
}
b := i + 3
lastClose := strings.LastIndex(s1, "*/")
for _, m := range dbTermLit.FindAllStringIndex(s1[b:], -1) {
ts, te := b+m[0], b+m[1]
if s1[ts:te] == "/*" && lastClose >= te { // a closed /* */ cuts nothing: the next one decides
continue
}
return sqlLead(s1[b:ts])
}
return false
}
// sqlLead is `_NOTHING.fullmatch(lead) or _SQL_LEAD.match(lead)`.
func sqlLead(lead string) bool {
i := 0
for i < len(lead) {
r, n := utf8.DecodeRuneInString(lead[i:])
if !pyIsSpace(r) && r != ')' {
break
}
i += n
}
rest := lead[i:]
if rest == "" {
return true
}
word := func(s string, ws []string) bool {
low := strings.ToLower(s)
for _, w := range ws {
if strings.HasPrefix(low, w) && (len(s) == len(w) || !isASCIIWord(s[len(w)])) {
return true
}
}
return false
}
switch {
case word(rest, sqlLeadWords), strings.HasPrefix(rest, "||"), strings.HasPrefix(rest, "&&"), strings.IndexByte("=<>!+*/%^~|&,?:@", rest[0]) >= 0:
return true
case rest[0] == '-':
return len(rest) == 1 || rest[1] != '-'
case rest[0] == ';':
k := 1
for k < len(rest) {
r, n := utf8.DecodeRuneInString(rest[k:])
if !pyIsSpace(r) {
break
}
k += n
}
return word(rest[k:], sqlStmtWords)
}
return false
}
// dbHard is classify(q, "mysql", literal) restricted to the families A22 acts on: true when one of them is found.
func dbHard(q string, literal bool) bool {
raw, s1, s2 := dbViews(q)
ls2, lraw := strings.ToLower(s2), strings.ToLower(raw)
if hasAny(ls2, lraw, dbNeedRCE) && (dbRCE.MatchString(s2) || dbRawRCE.MatchString(raw)) {
return true
}
if hasAny(ls2, lraw, dbNeedPriv) && dbPrivilege.MatchString(s2) {
return true
}
if hasAny(ls2, lraw, dbNeedDestr) && (dbDestructive.MatchString(s2) || dbRawDestructive.MatchString(raw)) {
return true
}
if hasAny(ls2, lraw, dbNeedInj) {
if dbInjection.MatchString(s2) || orSelf(s2) || dbRawInjection.MatchString(raw) {
return true
}
for _, m := range dbExprEq.FindAllStringSubmatch(raw, -1) {
if m[1] == m[2] {
return true
}
}
}
// the break-out as MySQL reads backslashes, and as standard SQL does (x\' closes a PostgreSQL / SQLite literal)
return literal && (literalCut(s1) || strings.Contains(raw, "\\'") && literalCut(dbSQStd.ReplaceAllString(raw, "'s'")))
}
var swapQuotes = strings.NewReplacer("'", "\"", "\"", "'")
// sqlJudge is sqlctx.judge_value without the learned tier: the first context the value breaks out of, or "".
func sqlJudge(v string) string {
sq := strings.ContainsAny(v, "'\\")
dq := strings.ContainsAny(v, "\"\\")
for _, c := range sqlContexts {
if strings.HasPrefix(c.name, "paren") && !strings.Contains(v, ")") || c.name == "like" && !strings.Contains(v, "%") {
continue
}
if (c.name == "string" || c.name == "paren_str" || c.name == "like") && !sq || c.name == "dquote" && !dq {
continue
}
var q string
if c.name == "dquote" {
q = strings.Replace(sqlContexts[1].tpl, "{v}", swapQuotes.Replace(v), 1)
} else {
q = strings.Replace(c.tpl, "{v}", v, 1)
}
if dbHard(q, c.name == "string" || c.name == "dquote" || c.name == "paren_str" || c.name == "like") {
return c.name
}
}
return ""
}
// unquotePlus is urllib.parse.unquote_plus.
func unquotePlus(s string) string {
s = strings.ReplaceAll(s, "+", " ")
if strings.IndexByte(s, '%') < 0 {
return s
}
b := make([]byte, 0, len(s))
for i := 0; i < len(s); {
if s[i] == '%' && i+2 < len(s) && ishex(s[i+1]) && ishex(s[i+2]) {
run := make([]byte, 0, 8)
for i+2 < len(s) && s[i] == '%' && ishex(s[i+1]) && ishex(s[i+2]) {
run = append(run, unhex(s[i+1])<<4|unhex(s[i+2]))
i += 3
}
b = append(b, validUTF8(run)...)
continue
}
b = append(b, s[i])
i++
}
return string(b)
}
// pctU is sqlctx._PCTU.sub.
func pctU(s string) string {
if !strings.Contains(s, "%u") {
return s
}
b := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+5 < len(s) && s[i+1] == 'u' && ishex(s[i+2]) && ishex(s[i+3]) && ishex(s[i+4]) && ishex(s[i+5]) {
b = appendRune(b, rune(unhex(s[i+2]))<<12|rune(unhex(s[i+3]))<<8|rune(unhex(s[i+4]))<<4|rune(unhex(s[i+5])))
i += 5
continue
}
b = append(b, s[i])
}
return string(b)
}
// sqlDecode is sqlctx.decode: the value as the application finally sees it.
func sqlDecode(v string) string {
for i := 0; i < 3; i++ {
d := v
if strings.ContainsAny(v, "%+") {
d = unquotePlus(v)
}
d = pctU(d)
if d == v {
break
}
v = d
}
if strings.IndexByte(v, '&') >= 0 {
v = pyUnescapeHTML(v)
}
if strings.IndexByte(v, '\\') >= 0 { // \xHH first, then \uXXXX (the reverse of canon's order)
v = escDecodeOrder(v, []byte{'x', 'u'})
}
return strings.ReplaceAll(v, "\x00", "")
}
// escDecodeOrder runs one left-to-right pass per escape kind, in the given order.
func escDecodeOrder(s string, order []byte) string {
for _, c := range order {
n := 2
if c == 'u' {
n = 4
}
var b []byte
last := 0
for i := 0; i+1+n < len(s); i++ {
if s[i] != '\\' || s[i+1] != c {
continue
}
ok := true
for k := 0; k < n; k++ {
if !ishex(s[i+2+k]) {
ok = false
break
}
}
if !ok {
continue
}
v := rune(0)
for k := 0; k < n; k++ {
v = v<<4 | rune(unhex(s[i+2+k]))
}
b = append(b, s[last:i]...)
b = appendRune(b, v)
i += 1 + n
last = i + 1
}
if b != nil {
s = string(append(b, s[last:]...))
}
}
return s
}
type sqlField struct{ name, v string }
// sqlFields is sqlctx.fields.
func sqlFields(text string) []sqlField {
parts := strings.Split(text, " | ")
var out []sqlField
_, rest, two := strings.Cut(parts[0], " ")
if two && strings.Contains(rest, "?") {
_, q, _ := strings.Cut(rest, "?")
for _, kv := range strings.Split(q, "&") {
k, v, _ := strings.Cut(kv, "=")
out = append(out, sqlField{"q:" + k, sqlDecode(v)})
}
}
if two {
path, _, _ := strings.Cut(rest, "?")
for _, seg := range strings.Split(path, "/") {
if seg != "" && sqlPre.MatchString(seg) {
out = append(out, sqlField{"path", sqlDecode(seg)})
}
}
}
for _, p := range parts[1:] {
if runeLen(p) < 3 {
continue
}
_, n0 := utf8.DecodeRuneInString(p)
if r1, _ := utf8.DecodeRuneInString(p[n0:]); r1 != ':' {
continue
}
k, v, _ := strings.Cut(p, "=")
if strings.HasPrefix(p, "h:sec-") || sqlSkipHeaders[k] {
continue
}
if v != "" {
out = append(out, sqlField{k, sqlDecode(v)})
}
name := sqlDecode(k[n0+1:])
if sqlPre.MatchString(name) && strings.ContainsAny(name, " '\"(#") {
out = append(out, sqlField{k + "#key", name}, sqlField{k + "#part", sqlDecode(p[n0+1:])})
}
}
return out
}
// sqlGated is sqlctx.gated: contraction apostrophes (it's, don't, I'm, we'll, they're, I've, I'd) do not count.
func sqlGated(v string) bool {
if strings.IndexByte(v, '\'') >= 0 {
var b strings.Builder
for i := 0; i < len(v); i++ {
if v[i] == '\'' && i > 0 && isLetterASCII(v[i-1]) && contractionAt(v[i+1:]) {
continue
}
b.WriteByte(v[i])
}
v = b.String()
}
return sqlGate.MatchString(v)
}
func isLetterASCII(c byte) bool { return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' }
func contractionAt(s string) bool {
for _, suf := range []string{"s", "t", "m", "d", "ll", "re", "ve"} {
if strings.HasPrefix(s, suf) && (len(s) == len(suf) || !isASCIIWord(s[len(suf)])) {
return true
}
}
return false
}
// strongCount is len(sqlctx._STRONG.findall(s)) (case-insensitive, Unicode word boundaries).
func strongCount(s string) int {
n := 0
prevWord := false
for i := 0; i < len(s); {
c := s[i]
switch {
case c == '\'' || c == '"' || c == '`' || c == '#' || c == ';':
n++
i++
prevWord = false
continue
case strings.HasPrefix(s[i:], "--") || strings.HasPrefix(s[i:], "/*"):
n++
i += 2
prevWord = false
continue
}
if !prevWord {
low := strings.ToLower(s[i:minInt(len(s), i+16)])
matched := 0
for _, w := range sqlStrongWords {
if strings.HasPrefix(low, w) {
if j := i + len(w); j >= len(s) || func() bool { r, _ := utf8.DecodeRuneInString(s[j:]); return !isPyWord(r) }() {
matched = len(w)
break
}
}
}
if matched > 0 {
n++
i += matched
prevWord = true
continue
}
}
r, w := utf8.DecodeRuneInString(s[i:])
prevWord = isPyWord(r)
i += w
}
return n
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// sqlWindows is sqlctx.windows (code points).
func sqlWindows(v string) []string {
n := runeLen(v)
if n <= sqlWindow {
return []string{v}
}
rs := []rune(v)
step := sqlWindow - sqlWindowOverlap
var out []string
for i := 0; i < n-sqlWindowOverlap; i += step {
e := i + sqlWindow
if e > n {
e = n
}
out = append(out, string(rs[i:e]))
}
return out
}
// SQLHit is a hard A22 hit: the field, the context it breaks out of, the window judged.
type SQLHit struct {
Field, Context, Value string
}
// SQLContext is the hard part of sqlctx.analyse on a canonical request text: the first field whose value breaks out
// of a query context into an attack's shape, within the same budget and in the same order as the Python tier.
func SQLContext(text string) *SQLHit {
var cand []sqlField
for _, f := range sqlFields(text) {
if runeLen(f.v) >= 2 && sqlGated(f.v) {
cand = append(cand, f)
}
}
if len(cand) > 1 {
key := make([]int, len(cand))
for i, f := range cand {
key[i] = strongCount(runeCut(f.v, sqlWindow))
}
idx := make([]int, len(cand))
for i := range idx {
idx[i] = i
}
sort.SliceStable(idx, func(a, b int) bool { return key[idx[a]] > key[idx[b]] })
c2 := make([]sqlField, len(cand))
for i, j := range idx {
c2[i] = cand[j]
}
cand = c2
}
budget := sqlBudget
for _, f := range cand {
for _, w := range sqlWindows(f.v) {
wl := runeLen(w)
if wl < 2 || !sqlGated(w) {
continue
}
if wl > budget {
return nil // the budget ran out: a soft hit, the sidecar's to answer
}
budget -= wl
if ctx := sqlJudge(w); ctx != "" {
return &SQLHit{Field: f.name, Context: ctx, Value: runeCut(w, 200)}
}
}
}
return nil
}