Skip to content

Commit f65dc92

Browse files
committed
Adding SQLlint for checking the sanity of queries
1 parent f46c463 commit f65dc92

2 files changed

Lines changed: 266 additions & 1 deletion

File tree

lib/core/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from thirdparty import six
2121

2222
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
23-
VERSION = "1.10.7.36"
23+
VERSION = "1.10.7.37"
2424
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
2525
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
2626
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)

lib/utils/sqllint.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env python
2+
3+
"""
4+
Copyright (c) 2006-2025 sqlmap developers (https://sqlmap.org)
5+
See the file 'LICENSE' for copying permission
6+
"""
7+
8+
import os
9+
import re
10+
11+
try:
12+
from lib.core.data import kb
13+
from lib.core.data import paths
14+
from lib.core.common import getFileItems
15+
except ImportError:
16+
kb = paths = None
17+
getFileItems = None
18+
19+
# Token type constants (kept short/local; this is a self-contained lexer)
20+
T_WS = "ws"
21+
T_LCOMMENT = "lcomment"
22+
T_BCOMMENT = "bcomment"
23+
T_STR = "str" # closed string literal ('...' or "...")
24+
T_UNTERM = "unterm" # unterminated string literal (open quote to end)
25+
T_QID = "qid" # quoted identifier (`...` or [...])
26+
T_NUM = "num"
27+
T_IDENT = "ident" # bare identifier (not a keyword)
28+
T_KEYWORD = "keyword" # identifier whose upper() is a known SQL keyword
29+
T_OP = "op"
30+
T_COMMA = "comma"
31+
T_DOT = "dot"
32+
T_SEMI = "semi"
33+
T_LPAREN = "lparen"
34+
T_RPAREN = "rparen"
35+
T_OTHER = "other" # anything the lexer could not classify
36+
37+
# Master lexer: ORDER MATTERS (longer / more specific patterns first)
38+
_LEXER = re.compile(r"""
39+
(?P<%s>\s+)
40+
| (?P<%s>(?:--|\#)[^\n]*)
41+
| (?P<%s>/\*.*?\*/)
42+
| (?P<%s>'(?:''|[^'])*'|"(?:""|[^"])*")
43+
| (?P<%s>`[^`]*`|\[[^\]]*\])
44+
| (?P<%s>0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)
45+
| (?P<%s>[A-Za-z_@$][A-Za-z0-9_@$]*)
46+
| (?P<%s><=|>=|<>|!=|==|<<|>>|\|\||&&|::|:=|[-+*/%%=<>!~&|^:])
47+
| (?P<%s>,)
48+
| (?P<%s>\.)
49+
| (?P<%s>;)
50+
| (?P<%s>\()
51+
| (?P<%s>\))
52+
""" % (T_WS, T_LCOMMENT, T_BCOMMENT, T_STR, T_QID, T_NUM, T_IDENT, T_OP,
53+
T_COMMA, T_DOT, T_SEMI, T_LPAREN, T_RPAREN), re.VERBOSE | re.DOTALL)
54+
55+
# operand-producing token types (something that evaluates to a value)
56+
_OPERANDS = frozenset((T_NUM, T_STR, T_IDENT, T_QID, T_RPAREN))
57+
58+
# operands trustworthy as the left side of a "missing separator" check.
59+
# a string is excluded because break-out payloads routinely produce a fake
60+
# merged string (e.g. "1' AND '1"->"' AND '") followed by a bare number; a
61+
# number is excluded because some dialects legitimately space-separate two
62+
# numbers (e.g. HSQLDB "LIMIT <offset> <limit>")
63+
_HARD_OPERANDS = frozenset((T_IDENT, T_RPAREN))
64+
65+
# binary keyword operators (need an operand on both sides)
66+
_BINARY_KEYWORDS = frozenset(("AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP", "DIV", "MOD"))
67+
68+
# binary symbolic operators (unary +/-/~ excluded; '*' excluded as it doubles
69+
# as the SELECT/COUNT wildcard)
70+
_BINARY_SYMBOLS = frozenset(("=", "<>", "!=", "<", ">", "<=", ">=", "/", "%", "||", "&&", "|", "&", "^"))
71+
72+
_KEYWORDS_CACHE = None
73+
74+
75+
class Token(object):
76+
__slots__ = ("type", "value", "start", "end")
77+
78+
def __init__(self, type_, value, start, end):
79+
self.type = type_
80+
self.value = value
81+
self.start = start
82+
self.end = end
83+
84+
85+
def _keywords():
86+
global _KEYWORDS_CACHE
87+
88+
if kb is not None and getattr(kb, "keywords", None):
89+
return kb.keywords
90+
91+
if _KEYWORDS_CACHE is not None:
92+
return _KEYWORDS_CACHE
93+
94+
retVal = set()
95+
96+
candidate = None
97+
if paths is not None and getattr(paths, "SQL_KEYWORDS", None):
98+
candidate = paths.SQL_KEYWORDS
99+
else:
100+
# self-sufficient fallback (e.g. bare doctest run before boot)
101+
candidate = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "txt", "keywords.txt")
102+
103+
try:
104+
if getFileItems is not None:
105+
retVal = set(getFileItems(candidate))
106+
else:
107+
with open(candidate) as f:
108+
retVal = set(_.strip().upper() for _ in f if _.strip() and not _.startswith('#'))
109+
except Exception:
110+
pass
111+
112+
_KEYWORDS_CACHE = retVal
113+
return retVal
114+
115+
116+
def tokenize(sql, keywords=None):
117+
"""
118+
Fragment-tolerant lexer. Returns a list of Token objects (whitespace kept
119+
so callers can reason about token gluing, e.g. '1UNION').
120+
121+
>>> [t.type for t in tokenize("id 1") if t.type != 'ws']
122+
['ident', 'num']
123+
>>> [t.type for t in tokenize("1foo") if t.type != 'ws']
124+
['num', 'ident']
125+
"""
126+
if keywords is None:
127+
keywords = _keywords()
128+
129+
retVal = []
130+
pos = 0
131+
length = len(sql)
132+
133+
while pos < length:
134+
match = _LEXER.match(sql, pos)
135+
if match:
136+
type_ = match.lastgroup
137+
value = match.group()
138+
if type_ == T_IDENT and value.upper() in keywords:
139+
type_ = T_KEYWORD
140+
retVal.append(Token(type_, value, pos, match.end()))
141+
pos = match.end()
142+
else:
143+
char = sql[pos]
144+
if char in "'\"`[":
145+
# an opening quote/bracket that never closes -> unterminated to end
146+
retVal.append(Token(T_UNTERM, sql[pos:], pos, length))
147+
pos = length
148+
else:
149+
retVal.append(Token(T_OTHER, char, pos, pos + 1))
150+
pos += 1
151+
152+
return retVal
153+
154+
155+
def _significant(tokens):
156+
"""Tokens that carry structure (drop whitespace and comments)."""
157+
return [_ for _ in tokens if _.type not in (T_WS, T_LCOMMENT, T_BCOMMENT)]
158+
159+
160+
def _isBinary(token):
161+
if token.type == T_KEYWORD:
162+
return token.value.upper() in _BINARY_KEYWORDS
163+
if token.type == T_OP:
164+
return token.value in _BINARY_SYMBOLS
165+
return False
166+
167+
168+
def checkSanity(sql, keywords=None):
169+
"""
170+
Fragment-tolerant SQL sanity check. Models locally-valid SQL and reports
171+
only *interior* impossibilities - constructs that no server-side prefix or
172+
suffix could ever make legal. Dangling quotes/parens at the edges are
173+
tolerated (the surrounding query supplies the other half).
174+
175+
Returns a list of human-readable issue strings (empty == looks sane).
176+
177+
Assumes SQL keyword operators (AND/OR/LIKE/...) are used as operators, not
178+
as user identifiers named after a keyword (some engines, e.g. SQLite, allow
179+
a column literally named "LIKE") - injection payloads never do the latter.
180+
181+
>>> checkSanity("1 AND 1=1")
182+
[]
183+
>>> checkSanity("1') UNION SELECT NULL-- -")
184+
[]
185+
>>> bool(checkSanity("(SELECT id 1 FROM users)"))
186+
True
187+
>>> bool(checkSanity("1UNION SELECT NULL"))
188+
True
189+
"""
190+
if not sql:
191+
return []
192+
193+
if keywords is None:
194+
keywords = _keywords()
195+
196+
issues = []
197+
tokens = tokenize(sql, keywords)
198+
199+
# -- edge tolerance for unterminated strings ---------------------------
200+
# A trailing open quote at paren-depth 0 is a legitimate break-out. One
201+
# that opens *inside* a group (depth > 0) has swallowed a needed ')', i.e.
202+
# an odd quote count within an owned scope (the classic "users'" abomination).
203+
depth = 0
204+
for token in tokens:
205+
if token.type == T_LPAREN:
206+
depth += 1
207+
elif token.type == T_RPAREN:
208+
depth -= 1
209+
elif token.type == T_UNTERM:
210+
if depth > 0:
211+
issues.append("odd quote inside a parenthesized scope at offset %d" % token.start)
212+
break
213+
214+
sig = _significant(tokens)
215+
216+
for i in range(len(sig)):
217+
cur = sig[i]
218+
prev = sig[i - 1] if i > 0 else None
219+
nxt = sig[i + 1] if i + 1 < len(sig) else None
220+
221+
# a keyword operator immediately followed by '(' is a function call
222+
# (e.g. the SQLite/MySQL LIKE(a, b) function), not a binary operator
223+
curIsFunc = cur.type == T_KEYWORD and nxt is not None and nxt.type == T_LPAREN
224+
curBinary = _isBinary(cur) and not curIsFunc
225+
226+
# -- glued number/keyword boundary: '1UNION', '1AND' ---------------
227+
if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type in (T_IDENT, T_KEYWORD):
228+
issues.append("digit glued to a word ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start))
229+
230+
# -- operand directly followed by a bare number: 'id 1' ------------
231+
# a numeric literal can never be an alias, so this is always broken
232+
if cur.type == T_NUM and prev is not None and prev.type in _HARD_OPERANDS:
233+
issues.append("missing separator before number '%s' at offset %d" % (cur.value, cur.start))
234+
235+
# -- degenerate parenthesis / punctuation adjacency ----------------
236+
if prev is not None:
237+
pair = (prev.type, cur.type)
238+
if pair == (T_COMMA, T_COMMA):
239+
issues.append("empty list item (',,') at offset %d" % cur.start)
240+
elif pair == (T_LPAREN, T_COMMA):
241+
issues.append("comma right after '(' at offset %d" % cur.start)
242+
elif pair == (T_COMMA, T_RPAREN):
243+
issues.append("comma right before ')' at offset %d" % cur.start)
244+
elif pair == (T_RPAREN, T_LPAREN):
245+
issues.append("adjacent groups ')(' at offset %d" % cur.start)
246+
elif pair == (T_LPAREN, T_RPAREN) and (i < 2 or sig[i - 2].type in (T_OP, T_COMMA, T_LPAREN)):
247+
issues.append("empty parentheses at offset %d" % prev.start)
248+
elif cur.type == T_RPAREN and _isBinary(prev):
249+
issues.append("operator right before ')' at offset %d" % cur.start)
250+
elif prev.type == T_COMMA and curBinary:
251+
issues.append("operator right after ',' at offset %d" % cur.start)
252+
elif prev.type == T_LPAREN and curBinary:
253+
issues.append("operator right after '(' at offset %d" % cur.start)
254+
255+
# -- doubled binary operators: '= =', 'AND AND' --------------------
256+
if prev is not None and _isBinary(prev) and curBinary:
257+
# allow a unary that legitimately follows (handled by NOT/~/sign)
258+
if not (cur.type == T_KEYWORD and cur.value.upper() == "NOT"):
259+
issues.append("doubled operator ('%s %s') at offset %d" % (prev.value, cur.value, prev.start))
260+
261+
# -- stray un-lexable character ------------------------------------
262+
if cur.type == T_OTHER:
263+
issues.append("stray character '%s' at offset %d" % (cur.value, cur.start))
264+
265+
return issues

0 commit comments

Comments
 (0)