Adding --payload-lint to CI/CD

This commit is contained in:
Miroslav Štampar 2026-07-08 17:51:37 +02:00
parent 0033934426
commit fd009027f7
9 changed files with 449 additions and 8 deletions

View file

@ -122,6 +122,11 @@ jobs:
- name: Smoke test
run: python sqlmap.py --smoke-test
- name: Payload lint
# offline: emulates blind + UNION enumeration across all DBMSes and checks
# every payload agent.py builds with lib/utils/sqllint (structural sanity)
run: python sqlmap.py --payload-lint
- name: Vuln test
run: python sqlmap.py --vuln-test

View file

@ -288,6 +288,7 @@ optDict = {
"murphyRate": "integer",
"smokeTest": "boolean",
"fpTest": "boolean",
"payloadLint": "boolean",
"apiTest": "boolean",
},

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.7.43"
VERSION = "1.10.7.44"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)

View file

@ -547,3 +547,239 @@ def smokeTest():
logger.error("smoke test final result: FAILED")
return retVal
def payloadLintTest():
"""
Offline payload-sanity coverage test.
For every supported back-end DBMS an emulation oracle drives the blind
enumeration handlers (no live target), so agent.py builds the full range of
inference payloads it would emit while dumping a schema, and each one is
checked with lib.utils.sqllint. A planted-malformation self-check first
proves the pipeline actually catches a defect, so the gate can never pass
vacuously.
"""
import lib.core.common as common_module
from lib.controller.handler import setHandler
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.datatype import AttribDict
from lib.core.datatype import InjectionDict
from lib.core.enums import PAYLOAD
from lib.core.enums import PLACE
from lib.core.threads import getCurrentThreadData
from lib.request.connect import Connect
from lib.utils.sqllint import checkSanity
unisonRandom()
collected = []
guard = {"count": 0}
CAP_PER_METHOD = 600
# A "consistent liar": parse the injected char comparison in whatever form the
# dialect uses (bare int / CHAR(n) / quoted 'x') and answer so counts->'2',
# lengths->small, names->'a'. This keeps every dialect's enumeration walking a
# few shallow levels, exercising the payload builders without a real backend.
def _oracle(value=None, **kwargs):
if value is None:
return None
sql = agent.removePayloadDelimiters(agent.adjustLateValues(value))
collected.append(sql)
guard["count"] += 1
if guard["count"] > CAP_PER_METHOD:
raise KeyboardInterrupt
# UNION/inband path reads the response body: hand back a page carrying the
# delimited marker value so extraction "succeeds" and keeps producing payloads
if kwargs.get("content"):
start, stop, delim = kb.chars.start, kb.chars.stop, kb.chars.delimiter
return ("x%s%s%s%s%sx" % (start, "1", delim, "1", stop), None, 200)
upper = sql.upper()
match = re.search(r">\s*'(.?)'", sql)
if match:
return True if match.group(1) == "" else (50 if "COUNT(" in upper else 97) > ord(match.group(1))
match = re.search(r">\s*(?:N?CHAR|CHR|NCHR)\s*\(\s*(\d+)", sql, re.I)
if match:
return (50 if "COUNT(" in upper else 97) > int(match.group(1))
match = re.search(r">\s*(\d+)", sql)
if match:
value_ = int(match.group(1))
target = 1 if "COUNT(" in upper else (3 if any(_ in upper for _ in ("LENGTH(", "LEN(", "DATALENGTH(")) else 2)
return target > value_
match = re.search(r"(\d+)\s*(=|<|>=|<=|<>|!=)\s*(\d+)", sql)
if match:
left, operator, right = int(match.group(1)), match.group(2), int(match.group(3))
return {"=": left == right, "<": left < right, ">=": left >= right, "<=": left <= right, "<>": left != right, "!=": left != right}[operator]
return False
def _injection(dbms):
injection = InjectionDict()
injection.place = PLACE.GET
injection.parameter = "id"
injection.ptype = 1
injection.prefix = ""
injection.suffix = ""
injection.clause = [1]
injection.dbms = dbms
boolean = AttribDict()
boolean.title = "AND boolean-based blind"
boolean.vector = "AND [INFERENCE]"
boolean.comment = ""
boolean.payload = "x"
boolean.where = 1
boolean.templatePayload = None
boolean.matchRatio = None
boolean.trueCode = None
boolean.falseCode = None
injection.data = AttribDict()
injection.data[PAYLOAD.TECHNIQUE.BOOLEAN] = boolean
return injection
def _unionInjection(dbms):
injection = InjectionDict()
injection.place = PLACE.GET
injection.parameter = "id"
injection.ptype = 1
injection.prefix = ""
injection.suffix = ""
injection.clause = [1]
injection.dbms = dbms
union = AttribDict()
union.title = "Generic UNION query"
# vector = (position, count, comment, prefix, suffix, char, where,
# unionDuplicates, forcePartial, tableFrom, unionTemplate)
union.vector = (0, 3, "-- -", "", "", "NULL", PAYLOAD.WHERE.ORIGINAL, False, False, None, None)
union.comment = "-- -"
union.prefix = ""
union.suffix = ""
union.where = PAYLOAD.WHERE.ORIGINAL
union.templatePayload = None
union.matchRatio = None
union.trueCode = None
union.falseCode = None
injection.data = AttribDict()
injection.data[PAYLOAD.TECHNIQUE.UNION] = union
return injection
methods = ("getBanner", "getCurrentDb", "getCurrentUser", "getHostname", "isDba",
"getDbs", "getTables", "getColumns", "getSchema", "getUsers",
"getPasswordHashes", "getPrivileges", "getRoles", "getStatements",
"getComments", "getProcedures")
_stdout = sys.stdout
def _progress(dbms, count, bad):
# write to the real stdout (sqlmap's is redirected to a sink during the walk)
_stdout.write("[payload-lint] %-26s %7d payloads %s\n" % (dbms, count, ("%d FLAGGED" % bad) if bad else "ok"))
_stdout.flush()
class _Sink(object):
def write(self, *args, **kwargs): pass
def flush(self, *args, **kwargs): pass
_queryPage = Connect.queryPage
_getPageTemplate = common_module.getPageTemplate
_level = logger.level
_threads = conf.threads
threadData = getCurrentThreadData()
retVal = True
total = walked = 0
flagged = []
allDbms = sorted(queries.keys())
try:
conf.batch = True
conf.threads = 1 # deterministic, single-threaded (clean output)
conf.disableHashing = True
if not conf.base64Parameter:
conf.base64Parameter = []
common_module.getPageTemplate = lambda *args, **kwargs: ("<html>x</html>", False)
Connect.queryPage = staticmethod(_oracle)
logger.setLevel(logging.CRITICAL) # mute the emulated walk's "unable to retrieve" noise
threadData.disableStdOut = True # mute dataToStdout at its gate
sys.stdout = _Sink() # and mute everything else (readInput prompts, tables, ...)
def _runMethods():
found = set()
for name in methods:
method = getattr(conf.dbmsHandler, name, None)
if method is None:
continue
del collected[:]
guard["count"] = 0
try:
method()
except (KeyboardInterrupt, Exception):
pass
found.update(collected)
return found
for dbms in allDbms:
try:
Backend.flushForcedDbms(force=True)
kb.stickyDBMS = False
Backend.forceDbms(dbms)
conf.forceDbms = conf.dbms = dbms
conf.parameters = {PLACE.GET: "id=1"}
conf.paramDict = {PLACE.GET: {"id": "1"}}
# a user/column so user-filtered and column-specific query variants
# (WHERE grantee='...', per-column extraction) are exercised too
conf.db, conf.tbl, conf.col, conf.user = "testdb", "testtbl", "testcol", "testuser"
setHandler()
except Exception:
_progress(dbms, 0, 0)
continue
seen = set()
# (a) boolean-based blind pass, then (b) UNION-query (inband) pass -
# two techniques exercise two distinct payload-builder families in agent.py
for technique, injector in ((PAYLOAD.TECHNIQUE.BOOLEAN, _injection),
(PAYLOAD.TECHNIQUE.UNION, _unionInjection)):
for key in list(kb.data.keys()):
if key.startswith("cached"):
kb.data[key] = None
kb.jsonAggMode = False
kb.injection = injector(dbms)
kb.injections = [kb.injection]
kb.technique = technique
seen.update(_runMethods())
bad = [(dbms, p, checkSanity(p)) for p in seen if checkSanity(p)]
flagged.extend(bad)
total += len(seen)
if seen:
walked += 1
_progress(dbms, len(seen), len(bad))
finally:
sys.stdout = _stdout
Connect.queryPage = _queryPage
common_module.getPageTemplate = _getPageTemplate
Backend.flushForcedDbms(force=True)
logger.setLevel(_level)
conf.threads = _threads
threadData.disableStdOut = False
# self-check: the linter must flag a known-malformed payload, and the walk
# must have actually produced payloads (guards against a vacuous pass)
if not checkSanity("1 AND (SELECT id 1 FROM users)"):
logger.error("payload-lint self-check failed: a known-malformed payload was not flagged")
retVal = False
if total < 1000:
logger.error("payload-lint self-check failed: only %d payloads generated (walk did not run)" % total)
retVal = False
for dbms, payload, issues in flagged[:20]:
retVal = False
logger.error("[%s] malformed payload: %s -> %s" % (dbms, payload, "; ".join(issues)))
clearConsoleLine()
logger.info("payload-lint: %d payloads across %d/%d DBMSes checked, %d malformed" % (total, walked, len(allDbms), len(flagged)))
if retVal:
logger.info("payload-lint final result: PASSED")
else:
logger.error("payload-lint final result: FAILED")
return retVal

View file

@ -930,6 +930,9 @@ def cmdLineParser(argv=None):
parser.add_argument("--fp-test", dest="fpTest", action="store_true",
help=SUPPRESS)
parser.add_argument("--payload-lint", dest="payloadLint", action="store_true",
help=SUPPRESS)
parser.add_argument("--api-test", dest="apiTest", action="store_true",
help=SUPPRESS)
@ -1187,7 +1190,7 @@ def cmdLineParser(argv=None):
else:
args.stdinPipe = None
if not any((args.direct, args.url, args.logFile, args.bulkFile, args.googleDork, args.configFile, args.requestFile, args.openApiFile, args.updateAll, args.smokeTest, args.vulnTest, args.fpTest, args.apiTest, args.wizard, args.dependencies, args.purge, args.listTampers, args.hashFile, args.stdinPipe)):
if not any((args.direct, args.url, args.logFile, args.bulkFile, args.googleDork, args.configFile, args.requestFile, args.openApiFile, args.updateAll, args.smokeTest, args.vulnTest, args.fpTest, args.payloadLint, args.apiTest, args.wizard, args.dependencies, args.purge, args.listTampers, args.hashFile, args.stdinPipe)):
errMsg = "missing a mandatory option (-d, -u, -l, -m, -r, -g, -c, --wizard, --shell, --update, --purge, --list-tampers or --dependencies). "
errMsg += "Use -h for basic and -hh for advanced help\n"
parser.error(errMsg)

View file

@ -139,6 +139,7 @@ from lib.request.comparison import comparison
from lib.request.direct import direct
from lib.request.methodrequest import MethodRequest
from lib.utils.safe2bin import safecharencode
from lib.utils.sqllint import checkSanity
from thirdparty import six
from collections import OrderedDict
from thirdparty.six import unichr as _unichr
@ -1088,6 +1089,15 @@ class Connect(object):
payload = agent.extractPayload(value)
threadData = getCurrentThreadData()
# Opt-in sanity lint of the outbound (pre-tamper) payload. Skipped during
# detection (kb.testMode) where deliberately-invalid probes are expected;
# for operational payloads a structural defect is a genuine bug worth a
# heads-up. Enabled via SQLMAP_LINT_PAYLOADS (e.g. CI/--vuln-test runs).
if payload and not kb.testMode and os.environ.get("SQLMAP_LINT_PAYLOADS"):
for issue in checkSanity(agent.removePayloadDelimiters(value)):
singleTimeWarnMessage("potentially malformed SQL payload emitted (%s): %s" % (issue, payload))
break
if conf.httpHeaders:
headers = OrderedDict(conf.httpHeaders)
contentType = max(headers[_] or "" if _.upper() == HTTP_HEADER.CONTENT_TYPE.upper() else "" for _ in headers) or None

View file

@ -42,7 +42,7 @@ _LEXER = re.compile(r"""
| (?P<%s>'(?:''|[^'])*'|"(?:""|[^"])*")
| (?P<%s>`[^`]*`|\[[^\]]*\])
| (?P<%s>0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)
| (?P<%s>[A-Za-z_@$][A-Za-z0-9_@$]*)
| (?P<%s>(?:[A-Za-z_@$]|[^\x00-\x7f])(?:[A-Za-z0-9_@$]|[^\x00-\x7f])*)
| (?P<%s><=|>=|<>|!=|==|<<|>>|\|\||&&|::|:=|[-+*/%%=<>!~&|^:])
| (?P<%s>,)
| (?P<%s>\.)
@ -69,6 +69,39 @@ _BINARY_KEYWORDS = frozenset(("AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP", "DI
# as the SELECT/COUNT wildcard)
_BINARY_SYMBOLS = frozenset(("=", "<>", "!=", "<", ">", "<=", ">=", "/", "%", "||", "&&", "|", "&", "^"))
# clause-introducing keywords that signal a dangling list item when they sit
# right after a comma ("SELECT a,b, FROM t"). GROUP/ORDER/LIMIT/OFFSET are
# excluded on purpose - they double as very common column names, so a bare
# "a,limit,b" would false-positive.
_CLAUSE_KEYWORDS = frozenset(("FROM", "WHERE", "HAVING", "INTO"))
# sqlmap's own templating markers. If any survives into a *final* outbound payload
# a substitution failed upstream (agent.py / cleanupPayload / queries.xml) - always
# a bug. Matched on the raw payload because a marker can leak anywhere (bare, inside
# a string, or where the lexer would otherwise read it as an MSSQL [identifier]).
_LEFTOVER_MARKER = re.compile(
r"\[(?:RANDNUM\d*|RANDSTR\d*|INFERENCE|SLEEPTIME|DELAYED|DELIMITER_START|DELIMITER_STOP"
r"|ORIGVALUE|ORIGINAL|GENERIC_SQL_COMMENT|QUERY|UNION|CHAR|COLSTART|COLSTOP|DB"
r"|SINGLE_QUOTE|DOUBLE_QUOTE|AT_REPLACE|SPACE_REPLACE|DOLLAR_REPLACE|HASH_REPLACE)\]")
# SQL words whose near-miss spelling in a structural position is almost always a
# broken payload, not a legitimate identifier (deliberately smaller than the full
# keyword list): catches payload-builder typos like UNI1ON/SEL2ECT/ORD2ER without
# flagging arbitrary application identifiers.
# only length>=5 structural keywords: short ones (ON/NOT/IN/IS/BY/OR/AND/ALL/
# FROM/LIKE/NULL/...) are too easily near-missed by real column names (note->NOT,
# ono->ON), which the real-identifier stress test proved would false-positive.
_NEAR_KEYWORD_TARGETS = frozenset((
"SELECT", "UNION", "DISTINCT", "GROUP", "ORDER", "HAVING", "LIMIT",
"OFFSET", "WHERE", "INNER", "RIGHT", "OUTER", "CROSS", "REGEXP", "RLIKE"))
# single-char substitutions seen in accidental mutation/test edits
_DIGIT_KEYWORD_ALIASES = {"0": "O", "1": "I", "2": "E", "3": "E", "4": "A", "5": "S", "7": "T", "8": "B"}
_CLAUSE_STARTERS = frozenset((
"SELECT", "UNION", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT",
"OFFSET", "INTO", "JOIN", "ON", "AND", "OR"))
_KEYWORDS_CACHE = None
@ -82,6 +115,96 @@ class Token(object):
self.end = end
def _word(token):
if token is not None and token.type in (T_IDENT, T_KEYWORD):
return token.value.upper()
return None
def _atClauseBoundary(prev):
return prev is None or prev.type in (T_LPAREN, T_RPAREN, T_SEMI, T_COMMA) or \
(prev.type == T_OP and prev.value not in (".",)) or \
(prev.type == T_KEYWORD and prev.value.upper() in _CLAUSE_STARTERS)
def _editWithin1(a, b):
"""Damerau-Levenshtein distance <= 1 (one insertion, deletion, substitution
or adjacent transposition). Catches every single-char keyword typo class."""
la, lb = len(a), len(b)
if a == b or abs(la - lb) > 1:
return a == b
if la == lb:
diff = [i for i in range(la) if a[i] != b[i]]
if len(diff) == 1: # substitution
return True
if len(diff) == 2 and diff[1] == diff[0] + 1 and \
a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]]: # transposition
return True
return False
shorter, longer = (a, b) if la < lb else (b, a) # deletion/insertion
for i in range(len(longer)):
if shorter == longer[:i] + longer[i + 1:]:
return True
return False
def _nearKeywordCandidates(value):
"""
Structural SQL keywords one single-char typo away from an identifier
(Damerau distance 1; NOT generic fuzzy matching over the whole keyword file).
>>> sorted(_nearKeywordCandidates("UNI1ON"))
['UNION']
>>> sorted(_nearKeywordCandidates("SEL2ECT"))
['SELECT']
>>> sorted(_nearKeywordCandidates("UrNION"))
['UNION']
>>> sorted(_nearKeywordCandidates("UNIN"))
['UNION']
>>> sorted(_nearKeywordCandidates("UNOIN"))
['UNION']
"""
upper = value.upper()
if upper in _NEAR_KEYWORD_TARGETS or len(upper) < 4:
return set()
return set(target for target in _NEAR_KEYWORD_TARGETS if _editWithin1(upper, target))
def _nearKeywordIsStructural(sig, index, keyword):
"""True when a near-keyword identifier sits where that keyword is expected."""
prev = sig[index - 1] if index > 0 else None
nxt = sig[index + 1] if index + 1 < len(sig) else None
prevWord = _word(prev)
nextWord = _word(nxt)
if keyword == "UNION":
return nextWord in ("ALL", "DISTINCT", "SELECT") and \
prevWord not in ("SELECT", "FROM", "WHERE", "GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "JOIN", "ON", "AS")
if keyword == "SELECT":
return prev is None or prev.type in (T_LPAREN, T_SEMI) or prevWord in ("UNION", "ALL", "DISTINCT", "EXCEPT", "INTERSECT")
if keyword in ("ORDER", "GROUP"):
return nextWord == "BY"
if keyword == "BY":
return prevWord in ("ORDER", "GROUP")
if keyword in ("AND", "OR", "LIKE", "REGEXP", "RLIKE", "IN", "IS"):
return prev is not None and nxt is not None and prev.type in _OPERANDS and nxt.type in _OPERANDS.union((T_LPAREN,))
if keyword in ("FROM", "WHERE", "HAVING", "LIMIT", "OFFSET", "INTO", "JOIN", "ON"):
return _atClauseBoundary(prev) or prevWord in ("SELECT", "UPDATE", "DELETE", "INSERT", "FROM", "WHERE", "HAVING")
if keyword in ("ALL", "DISTINCT"):
return prevWord in ("UNION", "SELECT")
if keyword in ("NULL", "NOT"):
return _atClauseBoundary(prev)
return False
def _keywords():
global _KEYWORDS_CACHE
@ -194,6 +317,11 @@ def checkSanity(sql, keywords=None):
keywords = _keywords()
issues = []
# -- residual templating markers (upstream substitution failed) --------
for match in _LEFTOVER_MARKER.finditer(sql):
issues.append("leftover marker '%s' at offset %d" % (match.group(0), match.start()))
tokens = tokenize(sql, keywords)
# -- edge tolerance for unterminated strings ---------------------------
@ -201,6 +329,7 @@ def checkSanity(sql, keywords=None):
# that opens *inside* a group (depth > 0) has swallowed a needed ')', i.e.
# an odd quote count within an owned scope (the classic "users'" abomination).
depth = 0
unterminated = False
for token in tokens:
if token.type == T_LPAREN:
depth += 1
@ -209,8 +338,14 @@ def checkSanity(sql, keywords=None):
elif token.type == T_UNTERM:
if depth > 0:
issues.append("odd quote inside a parenthesized scope at offset %d" % token.start)
unterminated = True
break
# unclosed '(' (a dropped ')'): well-formed payloads NEVER end paren-positive
# (leading break-out ')' only ever makes depth negative), so this is 0-FP.
if not unterminated and depth > 0:
issues.append("unbalanced parentheses (%d unclosed '(')" % depth)
sig = _significant(tokens)
for i in range(len(sig)):
@ -223,9 +358,23 @@ def checkSanity(sql, keywords=None):
curIsFunc = cur.type == T_KEYWORD and nxt is not None and nxt.type == T_LPAREN
curBinary = _isBinary(cur) and not curIsFunc
# -- glued number/keyword boundary: '1UNION', '1AND' ---------------
if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type in (T_IDENT, T_KEYWORD):
issues.append("digit glued to a word ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start))
# -- keyword near-miss in a structural position: UNI1ON/SEL2ECT/ORD2ER
if cur.type == T_IDENT:
for keyword in sorted(_nearKeywordCandidates(cur.value)):
if _nearKeywordIsStructural(sig, i, keyword):
issues.append("keyword typo '%s' (near '%s') at offset %d" % (cur.value, keyword, cur.start))
break
# -- UNION must continue with SELECT/ALL/DISTINCT/'(' (catches a glued or
# corrupted continuation like 'UNION ALLSELECT' -> UNION <identifier>)
if cur.type == T_KEYWORD and cur.value.upper() == "UNION" and nxt is not None:
if not (nxt.type == T_LPAREN or (nxt.type == T_KEYWORD and nxt.value.upper() in ("SELECT", "ALL", "DISTINCT"))):
issues.append("UNION not followed by SELECT/ALL/DISTINCT at offset %d" % cur.start)
# -- digit glued to a keyword: '1UNION', '5108AND' (a digit-started
# identifier like '4images' is legitimate and must NOT trip this)
if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type == T_KEYWORD:
issues.append("digit glued to a keyword ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start))
# -- operand directly followed by a bare number: 'id 1' ------------
# a numeric literal can never be an alias, so this is always broken
@ -241,6 +390,14 @@ def checkSanity(sql, keywords=None):
issues.append("comma right after '(' at offset %d" % cur.start)
elif pair == (T_COMMA, T_RPAREN):
issues.append("comma right before ')' at offset %d" % cur.start)
elif prev.type == T_KEYWORD and prev.value.upper() == "SELECT" and cur.type == T_COMMA:
issues.append("comma right after SELECT at offset %d" % cur.start)
elif prev.type == T_COMMA and cur.type == T_KEYWORD and cur.value.upper() in _CLAUSE_KEYWORDS \
and nxt is not None and nxt.type not in (T_COMMA, T_RPAREN):
# a clause keyword right after a comma AND followed by real content is a
# dangling list item ("a,b, FROM t"); if it is a bare list item itself
# ("a,group,b" - a column named 'group') the next token is a comma/paren/end
issues.append("dangling comma before '%s' at offset %d" % (cur.value, cur.start))
elif pair == (T_RPAREN, T_LPAREN):
issues.append("adjacent groups ')(' at offset %d" % cur.start)
elif pair == (T_LPAREN, T_RPAREN) and (i < 2 or sig[i - 2].type in (T_OP, T_COMMA, T_LPAREN)):

View file

@ -195,6 +195,9 @@ def main():
elif conf.fpTest:
from lib.core.testing import fpTest
os._exitcode = 1 - (fpTest() or 0)
elif conf.payloadLint:
from lib.core.testing import payloadLintTest
os._exitcode = 1 - (payloadLintTest() or 0)
elif conf.apiTest:
from lib.core.testing import apiTest
os._exitcode = 1 - (apiTest() or 0)
@ -610,7 +613,7 @@ def main():
except OSError:
pass
if any((conf.vulnTest, conf.fpTest, conf.smokeTest, conf.apiTest)) or not filterNone(filepath for filepath in glob.glob(os.path.join(tempDir, '*')) if not any(filepath.endswith(_) for _ in (".lock", ".exe", ".so", '_'))): # ignore junk files
if any((conf.vulnTest, conf.fpTest, conf.smokeTest, conf.payloadLint, conf.apiTest)) or not filterNone(filepath for filepath in glob.glob(os.path.join(tempDir, '*')) if not any(filepath.endswith(_) for _ in (".lock", ".exe", ".so", '_'))): # ignore junk files
try:
shutil.rmtree(tempDir, ignore_errors=True)
except OSError:

View file

@ -79,6 +79,28 @@ BAD = (
"1 UNION SELECT NULL,,NULL", # empty list item
"1 AND (1=1)(2=2)", # adjacent groups
"SELECT count() FROM t WHERE id=)", # operator before ')'
"SELECT a,b, FROM t", # dangling comma before clause
"1 AND (SELECT [DELIMITER_START]x[DELIMITER_STOP] FROM t)", # leftover templating marker
"1 [ORIGVALUE] AND 1=1", # leftover ORIGVALUE marker
"1 UNI1ON ALL SELECT NULL,NULL", # digit-corrupted UNION
"1 UrNION ALL SELECT NULL", # char-inserted UNION
"SEL2ECT x.z FROM t", # digit-corrupted SELECT
"1 ORD2ER BY 1", # digit-corrupted ORDER
"1 UNIN ALL SELECT NULL", # deletion-typo UNION
"1 UNOIN ALL SELECT NULL", # transposition-typo UNION
"SELCT a FROM t", # deletion-typo SELECT
"1 UNION ALLSELECT NULL", # glued keyword after UNION
"1 AND ORD(MID((SELECT COUNT(x FROM t),1,1))>64", # unbalanced parentheses (dropped ')')
"1 UNION ALL SELECT ,CONCAT(0x71,a,0x71) FROM t", # comma right after SELECT
)
# cross-dialect valid constructs the near-keyword / comma / digit-glue rules must
# NOT flag (regression guards for false positives the real-identifier stress found)
DIALECT_GOOD_HARD = (
"SELECT 4images_users FROM t", # digit-STARTED identifier (not '1UNION')
"SELECT a,group,b FROM t", # column literally named 'group'
"SELECT a,order FROM t", # column literally named 'order'
"1 UNION SELECT orders FROM t", # 'orders' near ORDER but a real table
)
# queries.xml: attributes that carry SQL (not regexes/markers)
@ -142,13 +164,17 @@ def _payload_atoms():
class TestLinterSelf(unittest.TestCase):
def test_well_formed_pass(self):
for sql in GOOD + DIALECT_GOOD:
for sql in GOOD + DIALECT_GOOD + DIALECT_GOOD_HARD:
self.assertEqual(checkSanity(sql), [], "false positive on valid SQL: %r" % sql)
def test_malformed_flag(self):
for sql in BAD:
self.assertTrue(checkSanity(sql), "missed malformed SQL: %r" % sql)
def test_nonascii_identifier(self):
# a non-ASCII column name (Turkish dotless-i U+0131) must lex as an identifier, not stray
self.assertEqual(checkSanity(u"SELECT \u0131d,ad FROM users"), [])
# module-level coverage accounting (printed in tearDownModule)
_COVERAGE = {"queries_dbms": 0, "queries_atoms": 0, "payload_atoms": 0}