diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index 1f1e5bbd0..f6a88d4c8 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -84,7 +84,7 @@ b0f434f64105bd61ab0f6867b3f681b97fa02b4fb809ac538db382d031f0e609 data/xml/paylo 0648264166455010921df1ec431e4c973809f37ef12cbfea75f95029222eb689 data/xml/payloads/stacked_queries.xml 997556b6170964a64474a2e053abe33cf2cf029fb1acec660d4651cc67a3c7e1 data/xml/payloads/time_blind.xml 40a4878669f318568097719d07dc906a19b8520bc742be3583321fc1e8176089 data/xml/payloads/union_query.xml -9d7dcbc6c5e368c44db851865ff49c791c3dee1ee62d8c02af8f8b15f4551aed data/xml/queries.xml +38882b6ceb8bca59ce8ed927abe3b8840394c56b3881371c2103e229b8795040 data/xml/queries.xml e043101194219a2e4c8bc352f0d3a04b87e1c28b1bcd6c13f6d5d1c9e260b653 doc/ARCHITECTURE.md 0f5a9c84cb57809be8759f483c7d05f54847115e715521ac0ecf390c0aa68465 doc/AUTHORS ce20a4b452f24a97fde7ec9ed816feee12ac148e1fde5f1722772cc866b12740 doc/CHANGELOG.md @@ -162,7 +162,7 @@ df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/ 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py 63657c00a046ca0fb28fd069407ab6305bd7b95c42f26a96ed083fd05b152252 extra/vulnserver/vulnserver.py 3abecaec1a9c59645a4821463a2d761235f7a4f763a491f188a41a083bbddd98 lib/controller/action.py -6574ed70c7fe0ac305dbc85ed7102f648b6a3f42fe2fe6b89172d69717327149 lib/controller/checks.py +72707b5bdfc757c4e5271e156178919292b991a6e7337d3dcdeffea9df6db3ea lib/controller/checks.py dcd4adcd7a2447a624ca7927541941d25767a4581af2d762c3197dc93790f4df lib/controller/controller.py d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py @@ -189,7 +189,7 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -f01361d999b0cf89b8418265c4a4962924fcc03a6b87e15b39c0836788725e85 lib/core/settings.py +997888bab1d98fb9bc2550f3ab99df966d37f38719a41a8fb767e2cd79db6c4f lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py @@ -246,6 +246,7 @@ aeefb42ea0c68f72744bc1bfd7194ec1bc06480d8a7e23f4b8d3d23fbba2b014 lib/utils/api. 442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py a94958be0ec3e9d28d8171813a6a90655a9ad7e6aa33c661e8d8ebbfcf208dbb lib/utils/deps.py +0fd055877e8b21d17c11447dac7f91ef1766e0b04d470c494a6d98f5249e3186 lib/utils/dialect.py 51cfab194cd5b6b24d62706fb79db86c852b9e593f4c55c15b35f175e70c9d75 lib/utils/getch.py 853c3595e1d2efc54b8bfb6ab12c55d1efc1603be266978e3a7d96d553d91a52 lib/utils/gui.py 972c5db9c9e30ac0f91c0f8d4df4531d0304e151dac99f1399c37c952ba9f935 lib/utils/har.py @@ -394,7 +395,7 @@ ba04af3683b9a6e29e8fa6b3bf436a57e59435cebb042414f2df82018d91599e plugins/dbms/m 78f1ff4b82fd4af50e1fbdb81539862f1c31258cda212b39f4a8501960f1b95e plugins/dbms/monetdb/syntax.py 236fd244f0bbc3976b389429a8176feda6c243267564c2a0eff6fc2458c1b3f9 plugins/dbms/monetdb/takeover.py 6bdc774463ac87b1bd1b6a9d5c2346b7edbf40d9848b7870a30d1eaedde4fc51 plugins/dbms/mssqlserver/connector.py -52c19e9067f22f5c386206943d1807af4c661500bf260930a5986e9a180e96c7 plugins/dbms/mssqlserver/enumeration.py +69ba678efde8335efb8a167b63143b4fb65ea19802bc3ade30c87cb979c198e4 plugins/dbms/mssqlserver/enumeration.py 67cd70b64aed27af467682ceae8e20992b6765d2374d5762efb5a4585b8a6f79 plugins/dbms/mssqlserver/filesystem.py 38ade085f9f1b227eda8c89f78e3ce869e8f430c98bef0cc7cbd2c7dcd60c24e plugins/dbms/mssqlserver/fingerprint.py 1ecde09e80d7b709a710281f4983a6831bc02ca3458ae0b97b28446d6db241b4 plugins/dbms/mssqlserver/__init__.py @@ -479,7 +480,7 @@ e2e20e4707abe9ed8b6208837332d2daa4eaca282f847412063f2484dcca8fbd plugins/dbms/v 2b2dad6ba1d344215cad11b629546eb9f259d7c996c202edf3de5ab22418787e plugins/dbms/virtuoso/takeover.py 51c44048e4b335b306f8ed1323fd78ad6935a8c0d6e9d6efe195a9a5a24e46dc plugins/generic/connector.py a967f4ebd101c68a5dcc10ff18c882a8f44a5c3bf06613d951a739ecc3abb9b3 plugins/generic/custom.py -37351d6fb7418e3659bec5c9a6f9f181a606deae74d3bc9fb8c97f495449471f plugins/generic/databases.py +6d037861acbbabec529e10c50840820ca7b876c29c69310a571b519c3f3b72fa plugins/generic/databases.py 36b7319ac00f8fe1a33496364a76ff165ea2e66db0150f5366a45135366369ca plugins/generic/entries.py d2de7fc135cf0db3eb4ac4a509c23ebec5250a5d8043face7f8c546a09f301b5 plugins/generic/enumeration.py a02ac4ebc1cc488a2aa5ae07e6d0c3d5064e99ded7fd529dfa073735692f11df plugins/generic/filesystem.py @@ -581,6 +582,7 @@ a48c411fea864e6bcd6a1c7e1a35094b8cda8d15088fd9e7b0270542ae20daa9 tests/test_com 5016119bdb57094381afdca35ef29a4a6641e26e4b48a9119f1db633e6123d29 tests/test_datafiles.py 9c240d4f796e56376374d4ce46f358ceb7d48cc6a7427760c5bfb89ff01cb545 tests/test_datatypes.py 3804eb2d730220360f9dc07d5994eb64e9f65acf3b0d8648df8df2a2177ba8fd tests/test_decodepage.py +9c0a0cd0b2d52a53f75c98c60f87a022354b7c3dc4baaf3fe1e272a0af5b7f0a tests/test_dialectdbms.py e40a49cfa73c45b3c3c6d1d1d00738861e270cb7a07b28f5a5356f9c7c800cf2 tests/test_dialect.py 993a2d4d87c4fbaf261663b069629acc95ee4405aa0c42cf5a8f39649fdb0fff tests/test_dicts.py 9cd5841349bc4db818658d12184929a96f7f279eff1f53ad18a54dbefbd6b276 tests/test_dump_jsonl.py diff --git a/data/xml/queries.xml b/data/xml/queries.xml index 0d32e5a07..a7f0dd452 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -1321,7 +1321,7 @@ - + diff --git a/lib/controller/checks.py b/lib/controller/checks.py index f74acb796..71d86f054 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -100,6 +100,7 @@ from lib.request.connect import Connect as Request from lib.request.comparison import comparison from lib.request.inject import checkBooleanExpression from lib.request.templates import getPageTemplate +from lib.utils.dialect import dialectCheckDbms from lib.techniques.union.test import unionTest from lib.techniques.union.use import configUnion from thirdparty import six @@ -149,6 +150,13 @@ def checkSqlInjection(place, parameter, value): if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None and not kb.droppingRequests: kb.heuristicDbms = heuristicCheckDbms(injection) + # keyword-free fallback: heuristicCheckDbms() above uses SELECT/quote payloads + # and is skipped when the WAF/IPS is dropping requests; the operator-dialect + # probes carry no SELECT/quote/schema name, so they can still narrow the DBMS in + # that case (or when it was inconclusive), using the now-calibrated boolean oracle + if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None: + kb.heuristicDbms = dialectCheckDbms(injection) + # If the DBMS has already been fingerprinted (via DBMS-specific # error message, simple heuristic check or via DBMS-specific # payload), ask the user to limit the tests to the fingerprinted diff --git a/lib/core/settings.py b/lib/core/settings.py index 1d06f132f..6a295c57f 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.6.120" +VERSION = "1.10.6.121" 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) @@ -306,7 +306,7 @@ FIREBIRD_SYSTEM_DBS = ("RDB$BACKUP_HISTORY", "RDB$CHARACTER_SETS", "RDB$CHECK_CO MAXDB_SYSTEM_DBS = ("SYSINFO", "DOMAIN") SYBASE_SYSTEM_DBS = ("master", "model", "sybsystemdb", "sybsystemprocs", "tempdb") DB2_SYSTEM_DBS = ("NULLID", "SQLJ", "SYSCAT", "SYSFUN", "SYSIBM", "SYSIBMADM", "SYSIBMINTERNAL", "SYSIBMTS", "SYSPROC", "SYSPUBLIC", "SYSSTAT", "SYSTOOLS", "SYSDEBUG", "SYSINST") -HSQLDB_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SYSTEM_LOB") +HSQLDB_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SYSTEM_LOBS") H2_SYSTEM_DBS = ("INFORMATION_SCHEMA",) + ("IGNITE", "ignite-sys-cache") INFORMIX_SYSTEM_DBS = ("sysmaster", "sysutils", "sysuser", "sysadmin") MONETDB_SYSTEM_DBS = ("tmp", "json", "profiler") diff --git a/lib/utils/dialect.py b/lib/utils/dialect.py new file mode 100644 index 000000000..1d225c3d2 --- /dev/null +++ b/lib/utils/dialect.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.common import Backend +from lib.core.common import popValue +from lib.core.common import pushValue +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import DBMS +from lib.request.inject import checkBooleanExpression + +# Operator-dialect probes for a keyword-free back-end DBMS heuristic. +# +# Each probe is an arithmetic identity that holds only in the dialect(s) noted, using operator +# *semantics* alone - no SQL keywords, functions, quotes or schema names. It complements +# heuristicCheckDbms() (which uses (SELECT 'x')='x' string round-trips): the dialect probes carry +# no SELECT/quote, so they can narrow the back-end DBMS where those are dropped (e.g. a +# keyword-matching WAF/IPS, or when kb.droppingRequests has it skipped entirely). +# +# Each probe is evaluated through checkBooleanExpression(), i.e. as an appended boolean +# (... AND ()), which yields a clean true/false from the comparison oracle. (A value-position +# variant - replacing the value with id=2^0 etc. - was prototyped and rejected: those probes land on +# OTHER valid rows, which sqlmap's fuzzy page comparison conflates with the anchor row, producing +# false positives. See PROVE_DESIGN.md.) +# +# Truth table measured on a live OWASP-CRS platform across 11 engines (MySQL, MariaDB/TiDB, +# PostgreSQL, CockroachDB, Microsoft SQL Server, SQLite, Firebird, ClickHouse, H2, HSQLDB, Derby); +# only the zero-false-positive rules are kept (see _classify). With anchor value 2: +# +# * 2^0=2 -> '^' is bitwise XOR (MySQL/MSSQL: 2^0=2) vs exponentiation (PostgreSQL: 2^0=1) vs +# no such operator (SQLite/Oracle/... -> error, so false) +# * 2^3=8 -> '^' is exponentiation (PostgreSQL/CockroachDB: 2^3=8) - false for XOR dialects +# (2^3=1) and erroring dialects; a positive PostgreSQL-family marker. CAVEAT: +# '^'=exponentiation is not strictly unique to PostgreSQL - MS Access/Jet and DuckDB +# also use it (neither on the platform), so this can read as PostgreSQL there. +# * 5/2=2 -> integer division (PostgreSQL/MSSQL/SQLite) vs real division (MySQL/Oracle: 2.5) +# * 2|0=2 -> a bitwise OR operator exists (absent in Firebird/Oracle/ClickHouse/H2) +DIALECT_PROBES = ( + ("xor", "2^0=2"), + ("pgpow", "2^3=8"), + ("intdiv", "5/2=2"), + ("bitor", "2|0=2"), +) + +def _classify(signature): + """ + Maps a measured (xor, pgpow, intdiv, bitor) operator-dialect signature to a back-end + DBMS, or returns None when the signature does not *uniquely* identify a major DBMS (so + detection proceeds unchanged - the heuristic never wrong-foots the scan). + + Rules below are the subset of the measured 11-engine truth table that maps with zero + false positives. Engines whose operator profile is not distinctive enough (Oracle's + all-false signature, which a minimal engine like ClickHouse/H2/Firebird/HSQLDB/Derby or + a fully WAF-blocked channel also produces) deliberately fall through to None: + + >>> _classify((True, False, False, True)) # MySQL / MariaDB / TiDB + 'MySQL' + >>> _classify((True, False, True, True)) # Microsoft SQL Server + 'Microsoft SQL Server' + >>> _classify((False, True, True, True)) # PostgreSQL + 'PostgreSQL' + >>> _classify((False, True, False, True)) # CockroachDB (pgwire) -> PostgreSQL family + 'PostgreSQL' + >>> _classify((False, False, True, True)) # SQLite + 'SQLite' + >>> _classify((False, False, True, False)) is None # Firebird/HSQLDB/Derby/H2 -> no prior + True + >>> _classify((False, False, False, False)) is None # all-false (Oracle/ClickHouse/blocked) -> no prior + True + """ + + xor, pgpow, intdiv, bitor = signature + + if pgpow: # '^' is exponentiation -> PostgreSQL family + return DBMS.PGSQL + if xor and intdiv: # '^' is XOR AND integer division -> SQL Server + return DBMS.MSSQL + if xor and not intdiv: # '^' is XOR AND real division -> MySQL family + return DBMS.MYSQL + if not xor and intdiv and bitor: # no '^', integer division, bitwise '|' -> SQLite + return DBMS.SQLITE + + return None + +def dialectCheckDbms(injection): + """ + Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the + given (boolean-capable) injection. Complements heuristicCheckDbms() - which is skipped when the + WAF/IPS is dropping requests and otherwise relies on SELECT/quote payloads - because every probe + here is built from operator semantics alone. Returns the DBMS name or None; an ambiguous or + WAF-blocked channel yields None, leaving the scan unchanged. + """ + + retVal = None + + if conf.skipHeuristics: + return retVal + + pushValue(kb.injection) + kb.injection = injection + + try: + # channel sanity: a tautology must read TRUE and a contradiction FALSE, otherwise the + # boolean oracle is unreliable and the all-false signature (Oracle-like) would be meaningless + if checkBooleanExpression("2=2") and not checkBooleanExpression("2=3"): + signature = tuple(bool(checkBooleanExpression(expr)) for _, expr in DIALECT_PROBES) + retVal = _classify(signature) + finally: + kb.injection = popValue() + + if retVal and not Backend.getIdentifiedDbms(): + infoMsg = "heuristic (dialect) test shows that the back-end DBMS could be '%s'" % retVal + logger.info(infoMsg) + + return retVal diff --git a/plugins/dbms/mssqlserver/enumeration.py b/plugins/dbms/mssqlserver/enumeration.py index 28de4c5d6..bd27f55e2 100644 --- a/plugins/dbms/mssqlserver/enumeration.py +++ b/plugins/dbms/mssqlserver/enumeration.py @@ -93,7 +93,7 @@ class Enumeration(GenericEnumeration): if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -116,7 +116,7 @@ class Enumeration(GenericEnumeration): if not kb.data.cachedTables and isInferenceAvailable() and not conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -206,7 +206,7 @@ class Enumeration(GenericEnumeration): for db in foundTbls.keys(): db = safeSQLIdentificatorNaming(db) - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -343,7 +343,7 @@ class Enumeration(GenericEnumeration): for db in (_ for _ in dbs if _): db = safeSQLIdentificatorNaming(db) - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: continue if conf.exclude and re.search(conf.exclude, db, re.I) is not None: diff --git a/plugins/generic/databases.py b/plugins/generic/databases.py index f5d5987f6..bae73904c 100644 --- a/plugins/generic/databases.py +++ b/plugins/generic/databases.py @@ -304,7 +304,7 @@ class Databases(object): if conf.excludeSysDbs: infoMsg = "skipping system database%s '%s'" % ("s" if len(self.excludeDbsList) > 1 else "", ", ".join(unsafeSQLIdentificatorNaming(db) for db in self.excludeDbsList)) logger.info(infoMsg) - query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs) if db not in self.excludeDbsList) + query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs) if unsafeSQLIdentificatorNaming(db) not in self.excludeDbsList) else: query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs)) @@ -356,7 +356,7 @@ class Databases(object): if not kb.data.cachedTables and isInferenceAvailable() and not conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % unsafeSQLIdentificatorNaming(db) logger.info(infoMsg) continue diff --git a/tests/test_dialectdbms.py b/tests/test_dialectdbms.py new file mode 100644 index 000000000..6b464cbc5 --- /dev/null +++ b/tests/test_dialectdbms.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Operator-dialect DBMS heuristic (lib/utils/dialect.py). These lock in the empirical truth +table: the (xor, intdiv, pgcast, bitor) operator signatures measured across 11 live engines +on an OWASP-CRS test platform, asserting that _classify() maps each to the expected back-end +DBMS - and, just as importantly, that the engines whose signatures collide or are ambiguous +map to None (no prior), so the heuristic never wrong-foots detection. The end-to-end behaviour +(the probes producing these signatures through a real boolean injection) is exercised against +the live platform, not here. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.dialect as dialect +from lib.core.data import kb +from lib.core.enums import DBMS +from lib.utils.dialect import _classify +from lib.utils.dialect import dialectCheckDbms + +# measured 2026-06 across the sqli-platform (boolean form "id=2 AND ", anchor value 2); +# signature = (2^0=2, 2^3=8, 5/2=2, 2|0=2) +MEASURED = { + "mysql": ((True, False, False, True), DBMS.MYSQL), + "tidb": ((True, False, False, True), DBMS.MYSQL), # MySQL wire-compatible + "mssql": ((True, False, True, True), DBMS.MSSQL), + "postgres": ((False, True, True, True), DBMS.PGSQL), + "cockroach": ((False, True, False, True), DBMS.PGSQL), # pgwire (exponent '^', decimal division) + "sqlite": ((False, False, True, True), DBMS.SQLITE), + # not distinctive enough -> deliberately no prior (operators alone can't safely separate these) + "firebird": ((False, False, True, False), None), + "hsqldb": ((False, False, True, False), None), # collides with firebird/derby/h2 + "derby": ((False, False, True, False), None), + "h2": ((False, False, True, False), None), + "clickhouse": ((False, False, False, False), None), # all-error, like Oracle/broken channel +} + + +class TestDialectClassification(unittest.TestCase): + def test_measured_engines_map_as_expected(self): + for engine, (signature, expected) in MEASURED.items(): + self.assertEqual(_classify(signature), expected, "engine %r misclassified" % engine) + + def test_no_false_positive_across_measured_set(self): + # ambiguous engines must not borrow a major-DBMS identity; concrete ones must stay in range + for engine, (signature, expected) in MEASURED.items(): + result = _classify(signature) + if expected is None: + self.assertIsNone(result, "ambiguous engine %r leaked a DBMS prior" % engine) + else: + self.assertIn(result, (DBMS.MYSQL, DBMS.MSSQL, DBMS.PGSQL, DBMS.SQLITE, DBMS.ORACLE)) + + def test_all_error_signature_yields_no_prior(self): + # an all-error signature (Oracle, ClickHouse, or simply a WAF-blocked channel) is not + # distinctive enough - it must NOT be guessed as any DBMS + self.assertIsNone(_classify((False, False, False, False))) + + def test_pgpow_dominates_as_postgres_marker(self): + # exponentiation '^' is a positive PostgreSQL-family marker regardless of division flavour + self.assertEqual(_classify((False, True, True, True)), DBMS.PGSQL) + self.assertEqual(_classify((False, True, False, True)), DBMS.PGSQL) + + +class TestDialectCheckDbmsGuard(unittest.TestCase): + """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good + channel, and None (no prior) whenever the channel is unreliable - the safety contract.""" + + def _run(self, truth): + # truth: {expression: bool} simulating checkBooleanExpression through a confirmed injection + orig = dialect.checkBooleanExpression + dialect.checkBooleanExpression = lambda expr, **kwargs: bool(truth.get(expr, False)) + saved = kb.get("injection") + try: + return dialectCheckDbms(object()) # the injection arg is only stashed, never inspected here + finally: + dialect.checkBooleanExpression = orig + kb.injection = saved + + def test_identifies_mysql_on_good_channel(self): + truth = {"2=2": True, "2=3": False, "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True} + self.assertEqual(self._run(truth), DBMS.MYSQL) + + def test_identifies_postgres_on_good_channel(self): + truth = {"2=2": True, "2=3": False, "2^0=2": False, "2^3=8": True, "5/2=2": True, "2|0=2": True} + self.assertEqual(self._run(truth), DBMS.PGSQL) + + def test_none_on_blocked_channel(self): + # everything blocked/false -> the tautology 2=2 reads False -> sanity fails -> None + self.assertIsNone(self._run({})) + + def test_none_on_static_channel(self): + # a static page reads everything True, so the contradiction 2=3 is True -> sanity fails -> None + self.assertIsNone(self._run({"2=2": True, "2=3": True, "2^0=2": True, "2^3=8": True, "5/2=2": True, "2|0=2": True})) + + +if __name__ == "__main__": + unittest.main(verbosity=2)