From 61d327aae13c467767bab72cfd1c18ded544c788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Mon, 15 Jun 2026 01:11:53 +0200 Subject: [PATCH 1/4] Minor patch of RestrictedUnpickler --- data/txt/sha256sums.txt | 4 ++-- lib/core/patch.py | 7 ++++++- lib/core/settings.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index e61b943400..e93622107d 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -182,13 +182,13 @@ c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data. 914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py 67ea32c993cbf23cdbd5170360c020ca33363b7c516ff3f8da4124ef7cb0254d lib/core/optiondict.py 3ff871fe8391952c3ec3bb528ba592a13926c80ca0b68fd322a317f69a651ef7 lib/core/option.py -2e66d74a4d9adb9ce30f48e22ab83b7fdccb54e7ea7b74a6104bda7d80a71a7a lib/core/patch.py +ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch.py 49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py 03db48f02c3d07a047ddb8fe33a757b6238867352d8ddda2a83e4fec09a98d04 lib/core/readlineng.py 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -adb776e7b2a3b238fcde22d6b4ca982b33ba949fac5fc4d1e1c4b3cd00c74cc6 lib/core/settings.py +aac10c0b7178194553609c1eca980c14d0ae3f0e013341a8a9bcb018ed3faf28 lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py diff --git a/lib/core/patch.py b/lib/core/patch.py index 0b3bd716e3..19acde6efa 100644 --- a/lib/core/patch.py +++ b/lib/core/patch.py @@ -185,8 +185,13 @@ class RestrictedUnpickler(pickle.Unpickler): # Note: allowlist (not blacklist) - a module blacklist is bypassable (e.g. importlib/ctypes/operator), so only # explicitly-safe builtin data types and sqlmap's own (and bundled) classes are permitted to be unpickled def find_class(self, module, name): + # Note: protocol-2 pickling of a 'bytes' value on Python 3 emits a _codecs.encode global; allow that one + # (it only runs a codec, e.g. latin1 - it cannot execute arbitrary code) so serialized values containing + # bytes round-trip. Everything else from _codecs (e.g. lookup) stays blocked by the rule below. + if module == "_codecs" and name == "encode": + pass # safe builtin data types only (blocks eval/exec/__import__/getattr/etc.) - if module in ("builtins", "__builtin__"): + elif module in ("builtins", "__builtin__"): if name not in ("set", "frozenset", "dict", "list", "tuple", "int", "float", "bool", "str", "bytes", "bytearray", "object", "NoneType", "complex"): raise ValueError("unpickling of '%s.%s' is forbidden" % (module, name)) # everything else must be one of sqlmap's own (or bundled) classes (e.g. lib.core.datatype.AttribDict) diff --git a/lib/core/settings.py b/lib/core/settings.py index 469e819d55..cd5f379c86 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.6.103" +VERSION = "1.10.6.104" 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) From 48b915b5ee57352912ba6a99a8b41ac47b3ca820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Mon, 15 Jun 2026 01:20:48 +0200 Subject: [PATCH 2/4] Minor update --- data/txt/sha256sums.txt | 4 ++-- lib/core/settings.py | 2 +- lib/core/testing.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index e93622107d..6f61bb9a9f 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -188,11 +188,11 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -aac10c0b7178194553609c1eca980c14d0ae3f0e013341a8a9bcb018ed3faf28 lib/core/settings.py +b0e5477bbbf2eb673fa6b99829c2f51e108bebd3f572d0527e90684c157ba3c6 lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py -7f7d1c57917f6ccc98e2ef093e2fa4cb6424d904c772b61003d5a5a3482a848f lib/core/testing.py +8bbc9312147ee8ca719860bc7ad472eac25230e4d46976fbb405efe43fe15ef6 lib/core/testing.py e3e653364d08d04d7492aa40a2bd29c6a28f4d78fecdd6c10f21f6cb28b98b4c lib/core/threads.py b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py 53e396902cb2546eaa09e77073fcba8be8827ee9ce055cfc899e81b0e6ad4d6d lib/core/update.py diff --git a/lib/core/settings.py b/lib/core/settings.py index cd5f379c86..04acbfcf79 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.6.104" +VERSION = "1.10.6.105" 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) diff --git a/lib/core/testing.py b/lib/core/testing.py index 6d0a9849e0..bcb773fa7f 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -246,7 +246,7 @@ def smokeTest(): count, length = 0, 0 for root, _, files in os.walk(paths.SQLMAP_ROOT_PATH): - if any(_ in root for _ in ("thirdparty", "extra", "interbase")): + if any(_ in root for _ in ("thirdparty", "extra", "interbase", "tests")): continue for filename in files: @@ -254,7 +254,7 @@ def smokeTest(): length += 1 for root, _, files in os.walk(paths.SQLMAP_ROOT_PATH): - if any(_ in root for _ in ("thirdparty", "extra", "interbase")): + if any(_ in root for _ in ("thirdparty", "extra", "interbase", "tests")): continue for filename in files: From 3816df1241a164cbd42d35afb9668052151d86b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Mon, 15 Jun 2026 09:50:47 +0200 Subject: [PATCH 3/4] Adding unit tests --- .github/workflows/tests.yml | 3 + data/txt/sha256sums.txt | 38 +++++++- lib/core/settings.py | 2 +- tests/__init__.py | 6 ++ tests/_testutils.py | 89 ++++++++++++++++++ tests/test_agent.py | 86 +++++++++++++++++ tests/test_bigarray.py | 95 +++++++++++++++++++ tests/test_charset.py | 71 ++++++++++++++ tests/test_cloak.py | 67 +++++++++++++ tests/test_common_helpers.py | 76 +++++++++++++++ tests/test_comparison.py | 132 ++++++++++++++++++++++++++ tests/test_convert.py | 141 +++++++++++++++++++++++++++ tests/test_datafiles.py | 124 ++++++++++++++++++++++++ tests/test_datatypes.py | 96 +++++++++++++++++++ tests/test_decodepage.py | 81 ++++++++++++++++ tests/test_dialect.py | 107 +++++++++++++++++++++ tests/test_dicts.py | 88 +++++++++++++++++ tests/test_encoding.py | 75 +++++++++++++++ tests/test_error_engine.py | 113 ++++++++++++++++++++++ tests/test_hash.py | 105 +++++++++++++++++++++ tests/test_hashdb.py | 129 +++++++++++++++++++++++++ tests/test_identifiers_output.py | 78 +++++++++++++++ tests/test_inference_engine.py | 153 ++++++++++++++++++++++++++++++ tests/test_misc.py | 125 ++++++++++++++++++++++++ tests/test_pagecontent.py | 85 +++++++++++++++++ tests/test_payload_marking.py | 157 +++++++++++++++++++++++++++++++ tests/test_payloads_structure.py | 110 ++++++++++++++++++++++ tests/test_replication.py | 87 +++++++++++++++++ tests/test_safe2bin.py | 60 ++++++++++++ tests/test_settings_regex.py | 66 +++++++++++++ tests/test_sqlparse.py | 87 +++++++++++++++++ tests/test_strings.py | 102 ++++++++++++++++++++ tests/test_tamper.py | 125 ++++++++++++++++++++++++ tests/test_targeturl.py | 70 ++++++++++++++ tests/test_texthelpers.py | 74 +++++++++++++++ tests/test_union_engine.py | 107 +++++++++++++++++++++ tests/test_urls.py | 80 ++++++++++++++++ tests/test_utils.py | 117 +++++++++++++++++++++++ tests/test_wordlist.py | 96 +++++++++++++++++++ 39 files changed, 3501 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/_testutils.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_bigarray.py create mode 100644 tests/test_charset.py create mode 100644 tests/test_cloak.py create mode 100644 tests/test_common_helpers.py create mode 100644 tests/test_comparison.py create mode 100644 tests/test_convert.py create mode 100644 tests/test_datafiles.py create mode 100644 tests/test_datatypes.py create mode 100644 tests/test_decodepage.py create mode 100644 tests/test_dialect.py create mode 100644 tests/test_dicts.py create mode 100644 tests/test_encoding.py create mode 100644 tests/test_error_engine.py create mode 100644 tests/test_hash.py create mode 100644 tests/test_hashdb.py create mode 100644 tests/test_identifiers_output.py create mode 100644 tests/test_inference_engine.py create mode 100644 tests/test_misc.py create mode 100644 tests/test_pagecontent.py create mode 100644 tests/test_payload_marking.py create mode 100644 tests/test_payloads_structure.py create mode 100644 tests/test_replication.py create mode 100644 tests/test_safe2bin.py create mode 100644 tests/test_settings_regex.py create mode 100644 tests/test_sqlparse.py create mode 100644 tests/test_strings.py create mode 100644 tests/test_tamper.py create mode 100644 tests/test_targeturl.py create mode 100644 tests/test_texthelpers.py create mode 100644 tests/test_union_engine.py create mode 100644 tests/test_urls.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_wordlist.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cda04f8ae4..358f7ba7ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,9 @@ jobs: - name: Basic import test run: python -c "import sqlmap; import sqlmapapi" + - name: Unit tests + run: python -m unittest discover -s tests -p "test_*.py" + - name: Smoke test run: python sqlmap.py --smoke diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index 6f61bb9a9f..25094e0d69 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -188,7 +188,7 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -b0e5477bbbf2eb673fa6b99829c2f51e108bebd3f572d0527e90684c157ba3c6 lib/core/settings.py +a910686c6eba592ba3f6fc5cbb8bed1bd6c330b0165c7c5dc927a71c5ae8be88 lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py @@ -564,6 +564,42 @@ dcdeed9ee285e63cf06baf8347e3db7f210ef25a63869bab78ce1ec6898ae191 tamper/unional 7afc4d262b97773e67dcfa3e253a9a060dc964750f01d739636d17ee069f1512 tamper/versionedkeywords.py 0694e721b07b8242245688be5c7951a3a22f512ed73776a998885e4b1bc82bc7 tamper/versionedmorekeywords.py ce1b6bf8f296de27014d6f21aa8b3df9469d418740cd31c93d1f5e36d6c509cf tamper/xforwardedfor.py +44401cad3e39ae9fb899ed5d0e2fdd0879561de05c3117f17f3b0db54f4e3724 tests/__init__.py +bfb553602eb5d20b4ab5928dbcf8e6a3e7e5ff69f7d30d1f53ef6d323c237f6c tests/test_agent.py +d4d7d3525d25ce72bf38bd38b5fdf61144e381993d63be7dc72b2b4811ffab67 tests/test_bigarray.py +27ad87c0ea377e0657bd6f6a4eaa0e9756aa9d28ec0483bdadeb3f66dcc4660d tests/test_charset.py +9e678a56e16211c49ab4995b6c658d3f122bfa3b357d9e17ff38f5a489ace6ad tests/test_cloak.py +a48c411fea864e6bcd6a1c7e1a35094b8cda8d15088fd9e7b0270542ae20daa9 tests/test_common_helpers.py +7b72d4f850bbd059b8e95fceb45a58470354cb7270c99b0e9981aaa189af20d1 tests/test_comparison.py +8593f14a18c4445c58b2e59462adcb761074ac7217cd7c3808519a90ba279bda tests/test_convert.py +5016119bdb57094381afdca35ef29a4a6641e26e4b48a9119f1db633e6123d29 tests/test_datafiles.py +9c240d4f796e56376374d4ce46f358ceb7d48cc6a7427760c5bfb89ff01cb545 tests/test_datatypes.py +3804eb2d730220360f9dc07d5994eb64e9f65acf3b0d8648df8df2a2177ba8fd tests/test_decodepage.py +e40a49cfa73c45b3c3c6d1d1d00738861e270cb7a07b28f5a5356f9c7c800cf2 tests/test_dialect.py +993a2d4d87c4fbaf261663b069629acc95ee4405aa0c42cf5a8f39649fdb0fff tests/test_dicts.py +2bbe4b01f79992cfa8884651fc0a28dbd0e3abb0cbea9eb7eadf1f98ca3c3420 tests/test_encoding.py +bb6991260a994fcbe79e05febaa34affd5631d02299fbc626820addd5f6ea4f4 tests/test_error_engine.py +8105de9978fe286a29f6b635a58db1e9998d86e8dded54d7efdfb9d52a121094 tests/test_hashdb.py +c04e8358fb6df45f69f2f26435c971acde280535bf304e84d30cf2681158c6a7 tests/test_hash.py +205e84827461101a78b2cffaa3de49795a1214e92276fc7fd40f3456657062b9 tests/test_identifiers_output.py +5372270b7ed82b62f273c2e9bd1f7ecd8605371e66cd0ad70663762cb08d42f1 tests/test_inference_engine.py +caa06fed7323b2bb6d0f2443ce343de94f75bf8ad012c055d5e07741d908ebad tests/test_misc.py +cde0bea1263ae857561f91ed2bd515e972b716743f017d31b1718a8546c72759 tests/test_pagecontent.py +4bac34af2abddce003756d6776e89b2fda220bb7603ef3761f4f37ee29f9c369 tests/test_payload_marking.py +6bfc8201724078bd9d6d559916ef73c9ff97e19b0f2948f37e588a49b027795f tests/test_payloads_structure.py +5c95e7863190e440234f231864fb1219c35207132762858cc95181c57086bafc tests/test_replication.py +cec98d72992c0799229a780fa7f0d7f3fb01ec2d708187ce0e4a05c8612f291b tests/test_safe2bin.py +a1c6cda1e5b483f61e6a4f8ddd0b06a15ddaa3fd2119bfb9dbd9cc970d7a751d tests/test_settings_regex.py +d3d991331096e16e5019de3d652e9fff92c09bd9f97c50b1c2c3ceb0ed49b17e tests/test_sqlparse.py +41907c873663401f979b87eaff3efc8d52e0ce96cbe1eef7aa70c6d3af8cd5cf tests/test_strings.py +f3a628db8a3e05baee580c02132e95b164695e4b3ee1785707e3ea148702449a tests/test_tamper.py +b3e13febe9e0ff6f97334f2868655bfdbaa18755e464a6dc4c6d424f513bad02 tests/test_targeturl.py +639851dc68f62b559b200b09c308e64e453f414969940005bac75dc0ab07a6b6 tests/test_texthelpers.py +708b3c040f8b677a84020dd6f7c4242f77260b3c6d2697fe8189e1881b0e1365 tests/test_union_engine.py +4b646f513c6da1e33200184ed6eabe0aa345eb2e2a19598dc123e191168591bf tests/test_urls.py +4f095ebda1b9bddde082ed464e863400cf23e9bf26f081948706213b35069195 tests/_testutils.py +2364db35025a53ea4e5a0a80c034997642785f7e6d1566d0d0f1db959fe3c82e tests/test_utils.py +81bb6d7449f224fa337734ae361c1a340bf9a51768a854d6a1a6e718ed1263ca tests/test_wordlist.py 55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py diff --git a/lib/core/settings.py b/lib/core/settings.py index 04acbfcf79..6c7c64b909 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.6.105" +VERSION = "1.10.6.106" 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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..2c772879a4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" diff --git a/tests/_testutils.py b/tests/_testutils.py new file mode 100644 index 0000000000..1858e9d857 --- /dev/null +++ b/tests/_testutils.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Shared bootstrap for the sqlmap unit/regression test suite. + +Brings sqlmap's global state (conf/kb, the 'reversible' codec, cross-references, +option defaults) up far enough that pure/near-pure library functions can be +exercised in isolation - WITHOUT a live target, network, or DBMS. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import sys +import warnings + +# Quieten import-time noise before any sqlmap/3rd-party module is imported by bootstrap(): +# e.g. cryptography's "Python 2 is no longer supported" CryptographyDeprecationWarning via pymysql. +warnings.filterwarnings("ignore", message=".*Python 2 is no longer supported.*") +warnings.filterwarnings("ignore", category=DeprecationWarning) +# sqlmap reconfigures stdout at startup; py3 emits a benign RuntimeWarning about line buffering +warnings.filterwarnings("ignore", message=".*line buffering.*binary mode.*") + +_BOOTSTRAPPED = False +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def bootstrap(): + """Idempotently initialize sqlmap global state for testing.""" + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + + if ROOT not in sys.path: + sys.path.insert(0, ROOT) + # a dummy target so cmdLineParser() populates ALL option defaults without erroring; + # save/restore the real argv so the unittest runner isn't confused by it + _orig_argv = list(sys.argv) + sys.argv = ["sqlmap.py", "-u", "http://test.invalid/?id=1"] + + from lib.core.common import setPaths + from lib.core.patch import dirtyPatches, resolveCrossReferences + setPaths(ROOT) + dirtyPatches() # registers the 'reversible' codec error handler, etc. + resolveCrossReferences() + + from lib.core.option import _setConfAttributes, _setKnowledgeBaseAttributes, _loadQueries + _setConfAttributes() + _setKnowledgeBaseAttributes() + _loadQueries() # populate the `queries` dict from queries.xml (needed by dialect builders) + + from lib.core.data import conf, kb + from lib.core.defaults import defaults + from lib.parse.cmdline import cmdLineParser + + args = cmdLineParser() + parsed = args.__dict__ if hasattr(args, "__dict__") else dict(args) + for k, v in parsed.items(): + conf[k] = v + # overlay canonical defaults for options left None (sqlmap does this during init) + for k, v in defaults.items(): + if conf.get(k) is None: + conf[k] = v + + kb.binaryField = False # normally set lazily during extraction + + # Silence sqlmap's application logger - tests assert on results, not log output, and the + # INFO/WARNING/ERROR chatter (column counts, reflective-value notices, an intentionally + # malformed-deflate error, etc.) just clutters the unittest report. + import logging + logging.getLogger("sqlmapLog").setLevel(logging.CRITICAL + 1) + + sys.argv = _orig_argv # restore so unittest's arg parsing works + _BOOTSTRAPPED = True + + +def set_dbms(name): + """Force the identified back-end DBMS for dialect-dependent functions. + + Uses forceDbms (not setDbms) so switching DBMS repeatedly in one process does + not trigger the interactive fingerprint-mismatch prompt. + """ + from lib.core.common import Backend + from lib.core.data import kb + kb.stickyDBMS = False + Backend.forceDbms(name) diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000000..2fb7bc09ed --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Payload assembly helpers in lib/core/agent.py. + +These are the (mostly) DBMS-independent string transforms that wrap, fold and +clean a payload on its way to the wire: prefix/suffix, payload delimiters, +field extraction, CONCAT folding, and RAND-marker cleanup. All values below +were probed from real output, not assumed. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.agent import agent +from lib.core.enums import DBMS +from lib.core.settings import PAYLOAD_DELIMITER + + +class TestPayloadDelimiters(unittest.TestCase): + def test_add(self): + self.assertEqual(agent.addPayloadDelimiters("1 AND 1=1"), + "%s1 AND 1=1%s" % (PAYLOAD_DELIMITER, PAYLOAD_DELIMITER)) + + def test_remove(self): + wrapped = "%spayload%s" % (PAYLOAD_DELIMITER, PAYLOAD_DELIMITER) + self.assertEqual(agent.removePayloadDelimiters(wrapped), "payload") + + def test_remove_none_is_none(self): + self.assertIsNone(agent.removePayloadDelimiters(None)) + + def test_roundtrip(self): + for p in ["1=1", "1 AND SLEEP(5)", "' OR '1'='1", "", "a%sb" % "x"]: + self.assertEqual(agent.removePayloadDelimiters(agent.addPayloadDelimiters(p)), p, + msg="delimiter round-trip for %r" % p) + + +class TestPrefixSuffix(unittest.TestCase): + def test_prefix_default_pads_space(self): + # with no configured prefix, a single leading space is prepended + self.assertEqual(agent.prefixQuery("1=1"), " 1=1") + + def test_suffix_default_identity(self): + self.assertEqual(agent.suffixQuery("1=1"), "1=1") + + +class TestGetFields(unittest.TestCase): + def test_extracts_select_list(self): + # getFields(query) returns an 8-tuple; the fields-bearing slots are: + # [0],[1] = regex match objects for the SELECT/expression (must be found, not None) + # [5] = parsed field list, [6] = raw fields string + # (asserting the match objects guards against a refactor that silently shifts the tuple) + result = agent.getFields("SELECT a,b FROM t") + self.assertIsNotNone(result[0], msg="getFields did not match the SELECT") + self.assertEqual(result[5], ["a", "b"]) + self.assertEqual(result[6], "a,b") + + +class TestConcatQuery(unittest.TestCase): + def test_mysql_concat_folding(self): + set_dbms(DBMS.MYSQL) + q = agent.concatQuery("SELECT a FROM t") + # folds the field through CONCAT with the start/stop delimiters and keeps the FROM + self.assertTrue(q.startswith("CONCAT("), msg=q) + self.assertIn("IFNULL(CAST(a AS NCHAR),' ')", q) + self.assertTrue(q.endswith("FROM t"), msg=q) + + +class TestCleanupPayload(unittest.TestCase): + def test_randnum_marker_replaced_with_digits(self): + out = agent.cleanupPayload("SELECT [RANDNUM]") + self.assertNotIn("[RANDNUM]", out, msg="marker not replaced: %r" % out) # actually substituted + self.assertTrue(out.startswith("SELECT "), msg=out) + self.assertTrue(out.split()[-1].isdigit(), msg=out) # ...and replaced with a concrete number + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_bigarray.py b/tests/test_bigarray.py new file mode 100644 index 0000000000..f531ea4bbb --- /dev/null +++ b/tests/test_bigarray.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +BigArray disk-spill semantics (lib/core/bigarray.py). + +BigArray is the structure that lets sqlmap dump tables far larger than RAM: once +the in-memory chunk exceeds chunk_size it is pickled to a temp file and a new +chunk starts. The tricky, easy-to-break part is that indexing / iteration / +pop / pickling must stay correct ACROSS the in-memory<->on-disk boundary. + +These force a spill with a tiny chunk_size and assert the data survives intact. +""" + +import os +import pickle +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.bigarray import BigArray + +N = 5000 + + +def _make_spilled(): + # tiny chunk_size guarantees many on-disk chunks for N items + ba = BigArray(chunk_size=1024) + for i in range(N): + ba.append("item-%d" % i) + return ba + + +class TestSpill(unittest.TestCase): + def test_actually_spilled_to_disk(self): + ba = _make_spilled() + self.assertGreater(len(ba.chunks), 1, msg="expected multiple chunks (a disk spill)") + # stronger than "more than one chunk": at least one chunk must be a real on-disk file + # (spilled chunks are stored as filenames). Otherwise this could pass while everything + # stayed in RAM. + disk_chunks = [c for c in ba.chunks if isinstance(c, str)] + self.assertTrue(disk_chunks, msg="no chunk was spilled to disk") + self.assertTrue(os.path.exists(disk_chunks[0]), msg="spilled chunk file missing on disk") + + def test_len(self): + self.assertEqual(len(_make_spilled()), N) + + def test_random_access_across_boundary(self): + ba = _make_spilled() + for i in (0, 1, 499, 500, 2500, N - 1): + self.assertEqual(ba[i], "item-%d" % i, msg="ba[%d]" % i) + + def test_negative_index(self): + ba = _make_spilled() + self.assertEqual(ba[-1], "item-%d" % (N - 1)) + + def test_iteration_order_preserved(self): + ba = _make_spilled() + for idx, value in enumerate(ba): + if value != "item-%d" % idx: + self.fail("iteration order broke at %d: %r" % (idx, value)) + self.assertEqual(idx, N - 1) + + def test_pop_from_end(self): + ba = _make_spilled() + self.assertEqual(ba.pop(), "item-%d" % (N - 1)) + self.assertEqual(len(ba), N - 1) + + def test_pickle_roundtrip_across_spill(self): + ba = _make_spilled() + restored = pickle.loads(pickle.dumps(ba)) + self.assertIsInstance(restored, BigArray) + self.assertEqual(len(restored), N) + self.assertEqual(restored[0], "item-0") + self.assertEqual(restored[N - 1], "item-%d" % (N - 1)) + + +class TestInMemorySmall(unittest.TestCase): + def test_no_spill_for_small(self): + ba = BigArray([1, 2, 3]) + self.assertEqual(len(ba), 3) + self.assertEqual(list(ba), [1, 2, 3]) + # the actual point of this test (the name promised it): a tiny array stays in ONE + # in-memory chunk and never touches disk + self.assertEqual(len(ba.chunks), 1, msg="small array unexpectedly spilled: %r" % (ba.chunks,)) + self.assertFalse(any(isinstance(c, str) for c in ba.chunks), msg="small array wrote a disk chunk") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_charset.py b/tests/test_charset.py new file mode 100644 index 0000000000..b7930bee06 --- /dev/null +++ b/tests/test_charset.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Response charset / meta detection and parameter parsing. + +checkCharEncoding canonicalizes the encoding sqlmap will decode a page with; +META_CHARSET_REGEX / HTML_TITLE_REGEX / META_REFRESH_REGEX pull structural hints +out of the body; paramToDict splits the parameters sqlmap will inject into. +These feed decodePage and the comparison engine, so the canonical/None results +are pinned here. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.basic import checkCharEncoding +from lib.core.common import extractRegexResult, paramToDict +from lib.core.enums import PLACE +from lib.core.settings import META_CHARSET_REGEX, HTML_TITLE_REGEX, META_REFRESH_REGEX + + +class TestCheckCharEncoding(unittest.TestCase): + def test_canonical_known(self): + for enc in ("utf-8", "windows-1252", "iso-8859-1", "ascii", "latin1"): + self.assertEqual(checkCharEncoding(enc, False), enc, msg="checkCharEncoding(%r)" % enc) + + def test_normalizes_aliases(self): + self.assertEqual(checkCharEncoding("UTF8", False), "utf8") + self.assertEqual(checkCharEncoding("us-ascii", False), "ascii") + + def test_unknown_is_none(self): + self.assertIsNone(checkCharEncoding("boguscharset123", False)) + + def test_none_is_none(self): + self.assertIsNone(checkCharEncoding(None, False)) + + +class TestBodyHints(unittest.TestCase): + def test_meta_charset(self): + self.assertEqual(extractRegexResult(META_CHARSET_REGEX, ''), "utf-8") + + def test_title(self): + self.assertEqual(extractRegexResult(HTML_TITLE_REGEX, "Login Page"), "Login Page") + + def test_meta_refresh_url(self): + self.assertEqual(extractRegexResult(META_REFRESH_REGEX, + ''), "/next") + + def test_no_match_is_none(self): + self.assertIsNone(extractRegexResult(HTML_TITLE_REGEX, "no title here")) + + +class TestParamToDict(unittest.TestCase): + # NOTE: GET parsing is covered in test_urls.py; here we only cover the COOKIE place, + # which uses a different (semicolon) delimiter and is a distinct code path. + def test_cookie_semicolon_delimited(self): + d = paramToDict(PLACE.COOKIE, "sid=abc; theme=dark") + self.assertEqual(d.get("sid"), "abc") + self.assertEqual(d.get("theme"), "dark") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_cloak.py b/tests/test_cloak.py new file mode 100644 index 0000000000..512f5dbcec --- /dev/null +++ b/tests/test_cloak.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +cloak / decloak (extra/cloak/cloak.py) - the zlib+XOR transform used to pack the +payload stager files (.py_) that sqlmap drops and unpacks on a target during +takeover/file-write. A broken round-trip here corrupts every deployed stager. + +decloak(cloak(x)) must be the identity for arbitrary bytes; pinned with known +vectors and a property sweep over random binary inputs. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +# cloak ships under extra/cloak (build-time + runtime stager packer) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "extra", "cloak")) +import cloak as C + +RND = random.Random(1234) + + +def _rand_bytes(n): + return bytes(bytearray(RND.randint(0, 255) for _ in range(n))) + + +class TestCloakRoundTrip(unittest.TestCase): + def test_known_payload(self): + data = b"print('stager')" + self.assertEqual(C.decloak(data=C.cloak(data=data)), data) + + def test_empty(self): + self.assertEqual(C.decloak(data=C.cloak(data=b"")), b"") + + def test_cloak_changes_bytes(self): + # cloak must actually transform (compress+xor), not pass through + data = b"A" * 64 + self.assertNotEqual(C.cloak(data=data), data) + + def test_cloak_compresses_compressible_input(self): + # highly-repetitive input must come out SMALLER (proves zlib is actually applied, + # not just an XOR-only obfuscation). NOTE: random/incompressible data would grow, + # so this assertion is only valid for compressible input. + data = b"A" * 1000 + self.assertLess(len(C.cloak(data=data)), len(data)) + + def test_property_random_binary(self): + for _ in range(500): + data = _rand_bytes(RND.randint(0, 200)) + self.assertEqual(C.decloak(data=C.cloak(data=data)), data, msg="cloak round-trip failed for %r" % data) + + def test_property_large(self): + for size in (1024, 8192, 65536): + data = _rand_bytes(size) + self.assertEqual(C.decloak(data=C.cloak(data=data)), data, msg="cloak round-trip failed at size %d" % size) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_common_helpers.py b/tests/test_common_helpers.py new file mode 100644 index 0000000000..a13dc45176 --- /dev/null +++ b/tests/test_common_helpers.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Assorted request-shaping helpers in lib/core/common.py: +chunkSplitPostData (HTTP chunked-transfer evasion), randomizeParameterValue +(tamper/cache-buster), getHostHeader (Host header derivation). + +chunkSplitPostData uses random chunk sizes, so its output is asserted +structurally (reassembles to the original, terminates correctly) rather than +byte-for-byte; randomizeParameterValue is asserted via its invariants. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import chunkSplitPostData, randomizeParameterValue, getHostHeader + + +def _dechunk(data): + """Reassemble an HTTP/1.1 chunked body back into its payload.""" + out = [] + i = 0 + while i < len(data): + nl = data.index("\r\n", i) + size = int(data[i:nl].split(";")[0], 16) # size; optional chunk-extension + start = nl + 2 + out.append(data[start:start + size]) + i = start + size + 2 # skip chunk data + trailing CRLF + if size == 0: + break + return "".join(out) + + +class TestChunkSplit(unittest.TestCase): + def test_reassembles_to_original(self): + for payload in ("a=1&b=2", "x" * 50, "single=value", ""): + self.assertEqual(_dechunk(chunkSplitPostData(payload)), payload, + msg="chunk reassembly failed for %r" % payload) + + def test_terminates_with_zero_chunk(self): + self.assertTrue(chunkSplitPostData("a=1&b=2").endswith("0\r\n\r\n")) + + +class TestRandomizeParameterValue(unittest.TestCase): + def test_length_preserved(self): + for v in ("abc123", "value", "42", "MixedCASE99"): + self.assertEqual(len(randomizeParameterValue(v)), len(v), msg="length changed for %r" % v) + + def test_char_class_preserved(self): + # letters stay letters, digits stay digits (positionally) + src = "abc123XYZ789" + out = randomizeParameterValue(src) + for a, b in zip(src, out): + self.assertEqual(a.isdigit(), b.isdigit(), msg="char class changed: %r -> %r" % (a, b)) + self.assertEqual(a.isalpha(), b.isalpha(), msg="char class changed: %r -> %r" % (a, b)) + + +class TestGetHostHeader(unittest.TestCase): + def test_with_port(self): + self.assertEqual(getHostHeader("http://h:8080/p"), "h:8080") + + def test_without_port(self): + self.assertEqual(getHostHeader("http://example.com/path"), "example.com") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_comparison.py b/tests/test_comparison.py new file mode 100644 index 0000000000..5f361e21ce --- /dev/null +++ b/tests/test_comparison.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The true/false/None response oracle (lib/request/comparison.py). + +The seqMatcher ratio path needs a live page template and is intentionally left +to --vuln. What IS pure and worth pinning here is the short-circuit decision +table: --string / --not-string / --regexp / --code matching, and the _adjust() +negative-logic flip. These are the rules that decide whether a payload counts +as True, and they are easy to break with a refactor. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.comparison import comparison, _adjust +from lib.core.common import removeReflectiveValues +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.data import conf, kb + + +def _reset_match_conf(): + conf.string = conf.notString = conf.regexp = conf.code = None + + +class TestStringMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_string_present_is_true(self): + conf.string = "WELCOME" + self.assertTrue(comparison("xx WELCOME yy", None, code=200)) + + def test_string_absent_is_false(self): + conf.string = "WELCOME" + self.assertFalse(comparison("nothing here", None, code=200)) + + +class TestRegexpMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_regexp_match_is_true(self): + conf.regexp = "id=\\d+" + self.assertTrue(comparison("user id=42 ok", None, code=200)) + + def test_regexp_nomatch_is_false(self): + conf.regexp = "id=\\d+" + self.assertFalse(comparison("user name", None, code=200)) + + +class TestCodeMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_code_match_is_true(self): + conf.code = 200 + self.assertTrue(comparison("body", None, code=200)) + + def test_code_mismatch_is_false(self): + conf.code = 200 + self.assertFalse(comparison("body", None, code=404)) + + +class TestAdjustNegativeLogic(unittest.TestCase): + """_adjust flips the condition under negative logic (the raw-page scheme), + but leaves None untouched and never flips when getRatioValue is requested.""" + + def setUp(self): + _reset_match_conf() # negative logic only applies with no string/regexp/code set + + def tearDown(self): + _reset_match_conf() + kb.negativeLogic = False + + def test_plain_passthrough(self): + kb.negativeLogic = False + self.assertEqual(_adjust(True, False), True) + self.assertEqual(_adjust(False, False), False) + + def test_negative_logic_flips(self): + kb.negativeLogic = True + self.assertEqual(_adjust(True, False), False) + self.assertEqual(_adjust(False, False), True) + + def test_negative_logic_leaves_none(self): + kb.negativeLogic = True + self.assertIsNone(_adjust(None, False)) + + +class TestRemoveReflectiveValues(unittest.TestCase): + """Reflected payloads are masked before comparison so a page echoing the + injected string isn't mistaken for a True/different response. Note: the + masking engages for *bordered* payloads (containing non-alpha chars), which + is what real injection payloads look like.""" + + def test_reflected_payload_is_masked(self): + out = removeReflectiveValues(u"id=1 UNION SELECT 1,2,3 end", u"1 UNION SELECT 1,2,3") + self.assertIn(REFLECTED_VALUE_MARKER, out) + self.assertNotIn(u"UNION SELECT 1,2,3", out) + + def test_not_reflected_unchanged(self): + content = u"nothing reflected here" + self.assertEqual(removeReflectiveValues(content, u"1 AND 1=1"), content) + + def test_none_payload_unchanged(self): + content = u"id=1 AND 1=1 end" + self.assertEqual(removeReflectiveValues(content, None), content) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 0000000000..218b4a693a --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Encoding / decoding / serialization round-trips and known vectors. +Covers: hex, base64 (std + url-safe), DBMS hex decode, byte<->text conversion, +JSON (de)serialization, restricted base64-pickle. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.convert import (decodeHex, encodeHex, decodeBase64, encodeBase64, + getBytes, getText, getUnicode, getOrds, + jsonize, dejsonize, base64pickle, base64unpickle) +from lib.core.common import decodeDbmsHexValue +from lib.core.enums import DBMS + +RND = random.Random(0xC0FFEE) + + +def _rand_bytes(maxlen=48): + return bytes(bytearray(RND.randint(0, 255) for _ in range(RND.randint(0, maxlen)))) + + +class TestHex(unittest.TestCase): + def test_known_vectors(self): + self.assertEqual(decodeHex("31323334", binary=True), b"1234") + self.assertEqual(getText(encodeHex(b"1234", binary=False)), "31323334") + + def test_roundtrip_property(self): + for _ in range(3000): + raw = _rand_bytes() + self.assertEqual(decodeHex(encodeHex(raw, binary=False), binary=True), raw) + + +class TestBase64(unittest.TestCase): + def test_known_vectors(self): + self.assertEqual(decodeBase64("MTIz", binary=True), b"123") + self.assertEqual(decodeBase64("MTIzNA", binary=True), b"1234") # missing padding + self.assertEqual(decodeBase64("MTIzNA==", binary=True), b"1234") + self.assertEqual(getText(encodeBase64(b"123", binary=False)), "MTIz") + # url-safe and standard alphabets must decode equivalently + self.assertEqual(decodeBase64("A-B_CDE", binary=True), decodeBase64("A+B/CDE", binary=True)) + + def test_roundtrip_property(self): + for _ in range(3000): + raw = _rand_bytes() + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True), binary=True), raw) + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True, safe=True), binary=True), raw) + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True, padding=False), binary=True), raw) + + +class TestDecodeDbmsHexValue(unittest.TestCase): + # authoritative vectors taken from the function's own doctests + def test_known_vectors(self): + self.assertEqual(decodeDbmsHexValue("3132332031"), u"123 1") + self.assertEqual(decodeDbmsHexValue("31003200330020003100"), u"123 1") # utf-16-le shaped + self.assertEqual(decodeDbmsHexValue("00310032003300200031"), u"123 1") # utf-16-be shaped + self.assertEqual(decodeDbmsHexValue("0x31003200330020003100"), u"123 1") + self.assertEqual(decodeDbmsHexValue("313233203"), u"123 ?") # odd length + self.assertEqual(decodeDbmsHexValue(["0x31", "0x32"]), [u"1", u"2"]) # list input + + def test_ascii_roundtrip_property(self): + for _ in range(1000): + s = "".join(chr(RND.randint(0x20, 0x7e)) for _ in range(RND.randint(1, 30))) + if len(s) % 2 == 0: # avoid the deliberate odd-length '?' behavior + self.assertEqual(decodeDbmsHexValue(getText(encodeHex(getBytes(s), binary=False))), s) + + +class TestByteTextConversion(unittest.TestCase): + def test_ascii_roundtrip(self): + for _ in range(1000): + s = u"".join(unichr(RND.randint(0x20, 0x7e)) if sys.version_info[0] < 3 else chr(RND.randint(0x20, 0x7e)) for _ in range(RND.randint(0, 30))) + self.assertEqual(getUnicode(getBytes(s)), s) + + def test_unicode_roundtrip(self): + samples = [u"café", u"你好", u"\U0001F600", u"a’b™c"] + for s in samples: + self.assertEqual(getUnicode(getBytes(s)), s) + + def test_getords(self): + self.assertEqual(getOrds(b"AB"), [65, 66]) + + +class TestJson(unittest.TestCase): + def test_roundtrip(self): + for obj in [{"a": 1, "b": [1, 2, 3]}, [1, "x", None], {"nested": {"k": "v"}}, "str", 123]: + self.assertEqual(dejsonize(jsonize(obj)), obj) + + def test_jsonize_produces_text_not_identity(self): + # anchor: jsonize must serialize to a JSON string, not pass the object through + out = jsonize({"a": 1}) + self.assertIsInstance(out, str) + self.assertIn('"a"', out) + self.assertEqual(jsonize(123), "123") # int -> textual "123" + + +class TestBase64Pickle(unittest.TestCase): + # Types sqlmap actually serializes (injection objects, cached values, BigArray). + def test_roundtrip_allowed_types(self): + for obj in [[1, 2, 3], {"a": 1}, (1, 2), "text", 42, 3.14, True, None, {"k": [1, {"n": "v"}]}]: + self.assertEqual(base64unpickle(base64pickle(obj)), obj) + + # REGRESSION: under Python 3 + PICKLE_PROTOCOL=2 a raw `bytes` value is pickled via the + # `_codecs.encode` global. The RestrictedUnpickler allowlist (patch.py) once rejected that, + # so any serialized session value containing bytes failed to load on py3. The fix allows + # exactly `_codecs.encode` (a benign codec call). Bytes MUST round-trip on both py2 and py3. + def test_bytes_roundtrip(self): + for raw in [b"x", b"\x00\x01\xff", b"\xde\xad\xbe\xef"]: + self.assertEqual(base64unpickle(base64pickle(raw)), raw, msg="bytes round-trip %r" % raw) + + def test_bytes_nested_in_container_roundtrip(self): + for obj in [{"a": b"bytes"}, [b"ab", "s", 1, None], ("t", b"\xde\xad")]: + self.assertEqual(base64unpickle(base64pickle(obj)), obj, msg="nested-bytes round-trip %r" % (obj,)) + + def test_dangerous_globals_still_blocked(self): + # bootstrap() installs sqlmap's RestrictedUnpickler over pickle.loads. These are VALID + # pickles that reference os.system / builtins.eval - stdlib would import them happily; the + # allowlist must reject them. Assert the SPECIFIC "forbidden" ValueError (not just any + # error) so the test proves the allowlist fired, not that the bytes failed to parse. + import pickle + for payload in (b"cos\nsystem\n.", b"c__builtin__\neval\n."): + try: + pickle.loads(payload) + self.fail("dangerous global was NOT blocked: %r" % payload) + except ValueError as ex: + self.assertIn("forbidden", str(ex), msg="unexpected error for %r: %s" % (payload, ex)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_datafiles.py b/tests/test_datafiles.py new file mode 100644 index 0000000000..9308bb3495 --- /dev/null +++ b/tests/test_datafiles.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Repo / data-file invariants - the cheap structural guards that catch whole +bug classes seen this session: tamper contract, per-DBMS query-tag coverage, +errors.xml regex compilation, XML well-formedness, and source ASCII-safety +(the py2 'no coding header' constraint). +""" + +import os +import re +import sys +import glob +import importlib +import unittest +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, ROOT +bootstrap() + + +class TestTamperContract(unittest.TestCase): + def test_every_tamper_has_contract(self): + names = [os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")] + self.assertGreater(len(names), 50) # sanity: we expect ~70 + for name in names: + mod = importlib.import_module("tamper.%s" % name) + self.assertTrue(callable(getattr(mod, "tamper", None)), msg="%s: no tamper()" % name) + self.assertTrue(hasattr(mod, "__priority__"), msg="%s: no __priority__" % name) + # dependencies() is OPTIONAL (e.g. randomcomments omits it); if present it must be callable + dep = getattr(mod, "dependencies", None) + self.assertTrue(dep is None or callable(dep), msg="%s: non-callable dependencies" % name) + + def test_every_tamper_priority_is_valid(self): + # __priority__ must be one of the PRIORITY enum values (or None) - a typo'd priority + # silently mis-orders the tamper chain (_setTamperingFunctions sorts on it) + from lib.core.enums import PRIORITY + valid = set(v for n, v in vars(PRIORITY).items() if not n.startswith("_")) + names = [os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")] + for name in names: + mod = importlib.import_module("tamper.%s" % name) + priority = getattr(mod, "__priority__", None) + self.assertTrue(priority is None or priority in valid, + msg="%s: __priority__ %r is not a PRIORITY value" % (name, priority)) + + +class TestQueriesXmlCoverage(unittest.TestCase): + CORE_TAGS = ("cast", "substring", "length", "count", "inference", "comment") + + def test_every_dbms_has_core_tags(self): + tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")) + dbmses = tree.findall(".//dbms") + self.assertGreaterEqual(len(dbmses), 25) + for dbms in dbmses: + present = set(child.tag for child in dbms.iter()) + missing = [t for t in self.CORE_TAGS if t not in present] + self.assertEqual(missing, [], msg="%s missing core tags: %s" % (dbms.get("value"), missing)) + + +class TestErrorsXmlCompile(unittest.TestCase): + def test_all_error_regexes_compile(self): + tree = ET.parse(os.path.join(ROOT, "data", "xml", "errors.xml")) + regexes = [e.get("regexp") for e in tree.findall(".//error")] + self.assertGreater(len(regexes), 100) + for rgx in regexes: + try: + re.compile(rgx) + except re.error as ex: + self.fail("errors.xml regex does not compile: %r (%s)" % (rgx, ex)) + + +class TestXmlWellFormed(unittest.TestCase): + def test_core_xml_parses(self): + for rel in ("queries.xml", "boundaries.xml", "errors.xml", + os.path.join("payloads", "boolean_blind.xml"), + os.path.join("payloads", "union_query.xml")): + path = os.path.join(ROOT, "data", "xml", rel) + ET.parse(path) # raises on malformed + + +class TestSourceAsciiSafety(unittest.TestCase): + # sqlmap source files carry NO coding header, so any non-ASCII byte breaks py2 parsing. + # This guards the exact regression introduced (and fixed) earlier this session. + CODING_RE = re.compile(b"coding[:=]\\s*([-\\w.]+)") + + def test_lib_and_plugins_are_ascii(self): + offenders = [] + for base in ("lib", "plugins"): + for path in glob.glob(os.path.join(ROOT, base, "**", "*.py"), recursive=True) if sys.version_info >= (3, 5) \ + else self._walk(os.path.join(ROOT, base)): + with open(path, "rb") as f: + head = f.read(256) + data = head + f.read() + if self.CODING_RE.search(head): # explicit coding header -> non-ASCII allowed + continue + try: + data.decode("ascii") + except UnicodeDecodeError: + offenders.append(os.path.relpath(path, ROOT)) + self.assertEqual(offenders, [], msg="non-ASCII source w/o coding header (breaks py2): %s" % offenders) + + @staticmethod + def _walk(top): + for dirpath, _, files in os.walk(top): + for fn in files: + if fn.endswith(".py"): + yield os.path.join(dirpath, fn) + + +class TestSettingsIntegrity(unittest.TestCase): + def test_milestone_and_version(self): + from lib.core.settings import HASHDB_MILESTONE_VALUE, VERSION + self.assertTrue(HASHDB_MILESTONE_VALUE) + self.assertTrue(re.match(r"^\d+\.\d+\.\d+", VERSION), msg="unexpected VERSION %r" % VERSION) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py new file mode 100644 index 0000000000..0bdb18a005 --- /dev/null +++ b/tests/test_datatypes.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core data structures: AttribDict, OrderedSet, LRUDict, BigArray. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.datatype import AttribDict, OrderedSet, LRUDict +from lib.core.bigarray import BigArray + + +class TestAttribDict(unittest.TestCase): + def test_attr_access(self): + a = AttribDict({"x": 1}) + self.assertEqual(a.x, 1) + a.y = 2 + self.assertEqual(a["y"], 2) + self.assertEqual(a.get("missing", "def"), "def") + + def test_missing_attr_raises(self): + a = AttribDict() + self.assertRaises(AttributeError, lambda: a.nope) + + +class TestOrderedSet(unittest.TestCase): + def test_order_and_dedup(self): + s = OrderedSet() + for v in [3, 1, 3, 2, 1, 2]: + s.add(v) + self.assertEqual(list(s), [3, 1, 2]) + self.assertIn(2, s) + self.assertNotIn(9, s) + self.assertEqual(len(s), 3) + + +class TestLRUDict(unittest.TestCase): + def test_capacity_eviction(self): + l = LRUDict(capacity=2) + l["a"] = 1 + l["b"] = 2 + _ = l["a"] # touch 'a' so 'b' becomes least-recently-used + l["c"] = 3 # evicts 'b' + self.assertEqual(sorted(l.keys()), ["a", "c"]) + self.assertNotIn("b", l) + + def test_values_retained(self): + l = LRUDict(capacity=3) + for i, k in enumerate("abc"): + l[k] = i + self.assertEqual(l["a"], 0) + self.assertEqual(l["c"], 2) + + def test_capacity_one(self): + # extreme: each write evicts the previous key + l = LRUDict(capacity=1) + l["x"] = 1 + l["y"] = 2 + self.assertNotIn("x", l) + self.assertEqual(l["y"], 2) + self.assertEqual(list(l.keys()), ["y"]) + + +class TestBigArray(unittest.TestCase): + def test_basic_ops(self): + b = BigArray() + for i in range(50): + b.append(i) + self.assertEqual(len(b), 50) + self.assertEqual(b[0], 0) + self.assertEqual(b[49], 49) + self.assertEqual(b[-1], 49) # negative indexing + self.assertEqual(list(b)[:3], [0, 1, 2]) + + def test_empty_index_raises(self): + self.assertRaises(IndexError, lambda: BigArray()[0]) + + def test_roundtrip_values(self): + b = BigArray() + data = list(range(100)) + for v in data: + b.append(v) + self.assertEqual([b[i] for i in range(len(b))], data) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_decodepage.py b/tests/test_decodepage.py new file mode 100644 index 0000000000..01eb899c46 --- /dev/null +++ b/tests/test_decodepage.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +HTTP response decoding (lib/request/basic.py decodePage). + +Every fetched page passes through decodePage: it inflates gzip/deflate bodies, +applies the charset, and guards against decompression bombs. A regression here +silently corrupts every response sqlmap compares, so the round-trips and the +malformed-input handling are pinned here. +""" + +import gzip +import io +import os +import sys +import unittest +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.basic import decodePage +from lib.core.exception import SqlmapCompressionException + +BODY = b"Hello plain body content 12345 - no markup here" + + +def _gzip(data): + buf = io.BytesIO() + f = gzip.GzipFile(fileobj=buf, mode="wb") + f.write(data) + f.close() + return buf.getvalue() + + +def _raw_deflate(data): + # decodePage uses zlib.decompressobj(-15) => raw deflate (no zlib header) + co = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS) + return co.compress(data) + co.flush() + + +class TestDecompression(unittest.TestCase): + def test_gzip_roundtrip(self): + # exact equality (not just substring): the whole body must decompress unchanged + out = decodePage(_gzip(BODY), "gzip", "text/html; charset=utf-8") + self.assertEqual(out, BODY.decode("utf-8")) + + def test_deflate_roundtrip(self): + out = decodePage(_raw_deflate(BODY), "deflate", "text/html") + self.assertEqual(out, BODY.decode("utf-8")) + + def test_identity_passthrough(self): + out = decodePage(BODY, None, "text/html") + self.assertEqual(out, BODY.decode("utf-8")) + # the exact-equality assertions above already imply a unicode return; a separate + # type-only test would be redundant. + + +class TestCharset(unittest.TestCase): + def test_utf8_decoded_to_unicode(self): + # several distinct multi-byte sequences (2/3/4-byte) must all decode intact + original = u"café — 你好 \U0001f512" + out = decodePage(original.encode("utf-8"), None, "text/html; charset=utf-8") + self.assertEqual(out, original) + + +class TestMalformed(unittest.TestCase): + def test_invalid_deflate_raises(self): + # zlib.compress() adds a 2-byte zlib header that raw-deflate decode rejects; + # body has no " the real column is slotted at index 1, NULLs elsewhere + set_dbms(DBMS.MYSQL) + q = agent.forgeUnionQuery("SELECT a FROM t", 1, 3, None, "", "", "NULL", None) + self.assertEqual(q, " UNION ALL SELECT NULL,a,NULL FROM t") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dicts.py b/tests/test_dicts.py new file mode 100644 index 0000000000..a714956f1d --- /dev/null +++ b/tests/test_dicts.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Structural invariants of the data-mapping tables in lib/core/dicts.py. + +These tables drive DBMS recognition, connector selection, dummy-table dialect, +and dump formatting. They are pure data, so the right tests are shape/coverage +invariants: every back-end has a connector entry, alias lists are well-formed, +and the dialect maps carry the values the engine expects. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core import dicts +from lib.core.enums import DBMS +from lib.core.common import getPublicTypeMembers + + +class TestDbmsDict(unittest.TestCase): + def test_every_dbms_enum_has_connector_entry(self): + # DBMS_DICT keys must cover every public DBMS enum value + enum_values = set(v for _, v in getPublicTypeMembers(DBMS)) + missing = enum_values - set(dicts.DBMS_DICT.keys()) + self.assertEqual(missing, set(), msg="DBMS without DBMS_DICT entry: %s" % missing) + + def test_entry_shape(self): + # each entry: (aliases-tuple, connector-name, connector-url, sqlalchemy-dialect) + self.assertGreaterEqual(len(dicts.DBMS_DICT), 25, msg="DBMS_DICT suspiciously small") + for name, entry in dicts.DBMS_DICT.items(): + self.assertEqual(len(entry), 4, msg="malformed DBMS_DICT entry for %s" % name) + aliases = entry[0] + self.assertIsInstance(aliases, (tuple, list), msg="aliases not list-like for %s" % name) + self.assertGreaterEqual(len(aliases), 1, msg="no aliases for %s" % name) + for a in aliases: # per-item, so a failure names the offending alias + self.assertIsInstance(a, str, msg="non-str alias %r for %s" % (a, name)) + + def test_aliases_are_lowercase(self): + for name, entry in dicts.DBMS_DICT.items(): + for alias in entry[0]: + self.assertEqual(alias, alias.lower(), msg="alias %r (for %s) is not lowercase" % (alias, name)) + + +class TestFromDummyTable(unittest.TestCase): + def test_oracle_uses_dual(self): + self.assertEqual(dicts.FROM_DUMMY_TABLE[DBMS.ORACLE], " FROM DUAL") + + def test_mysql_has_no_dummy_table(self): + # MySQL allows a bare SELECT, so it must NOT appear here + self.assertNotIn(DBMS.MYSQL, dicts.FROM_DUMMY_TABLE) + + def test_values_start_with_from(self): + # strict: must be (optional leading space) FROM - + # not just startswith("FROM"), which would accept "FROMX" or a bare "FROM" + for name, clause in dicts.FROM_DUMMY_TABLE.items(): + self.assertTrue(re.match(r"^\s*FROM\s+\S", clause.upper()), + msg="FROM_DUMMY_TABLE[%s]=%r is not a well-formed FROM clause" % (name, clause)) + + +class TestSqlStatements(unittest.TestCase): + def test_known_categories_present(self): + for category in ("SQL data definition", "SQL data manipulation", "SQL data control"): + self.assertIn(category, dicts.SQL_STATEMENTS, msg="missing SQL_STATEMENTS category %r" % category) + + def test_keywords_are_lowercase_tokens(self): + for category, keywords in dicts.SQL_STATEMENTS.items(): + self.assertTrue(len(keywords) >= 1, msg="empty category %r" % category) + for kw in keywords: + self.assertEqual(kw, kw.lower(), msg="keyword %r in %r not lowercase" % (kw, category)) + + +class TestDumpReplacements(unittest.TestCase): + def test_markers(self): + self.assertEqual(dicts.DUMP_REPLACEMENTS.get(""), "") + self.assertEqual(dicts.DUMP_REPLACEMENTS.get(" "), "NULL") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 0000000000..f6fcd41744 --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core text<->bytes conversions (lib/core/convert.py): getBytes, getUnicode, +getText. (getOrds is covered in test_convert.py.) + +These are called on essentially every request and response, on both Python 2 +and 3, and are the main thing standing between sqlmap and a UnicodeDecodeError +mid-scan. Pinned with known vectors, non-string coercion, and an encoding +round-trip property over multiple charsets. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.convert import getBytes, getUnicode, getText + +RND = random.Random(2024) + + +class TestTypes(unittest.TestCase): + # value+type (not type alone): a stub returning b"" would pass an isinstance-only check, and + # on py3 a getBytes that wrongly returned str would slip past a round-trip on the unicode path + def test_getBytes_returns_bytes(self): + out = getBytes(u"abc") + self.assertIsInstance(out, bytes) + self.assertEqual(out, b"abc") + + def test_getUnicode_returns_unicode(self): + out = getUnicode(b"abc") + self.assertIsInstance(out, type(u"")) + self.assertEqual(out, u"abc") + + def test_getText_returns_native_str(self): + self.assertIsInstance(getText(b"abc"), str) + self.assertEqual(getText(b"abc"), "abc") + + +class TestCoercion(unittest.TestCase): + def test_getUnicode_of_number(self): + self.assertEqual(getUnicode(123), u"123") + + +class TestRoundTrip(unittest.TestCase): + def test_known_utf8(self): + self.assertEqual(getUnicode(getBytes(u"caf\xe9", "utf-8"), "utf-8"), u"caf\xe9") + + def test_property_multi_charset(self): + # printable BMP-ish range, round-trip through utf-8 and latin1-safe subset + for encoding, hi in (("utf-8", 0x2000), ("latin-1", 0x100)): + for _ in range(1000): + s = u"".join(unichr(RND.randint(0, hi - 1)) if sys.version_info[0] < 3 + else chr(RND.randint(0, hi - 1)) for _ in range(RND.randint(0, 16))) + self.assertEqual(getUnicode(getBytes(s, encoding), encoding), s, + msg="round-trip failed (%s): %r" % (encoding, s)) + + +# py2 has unichr, py3 does not; normalize so the file imports cleanly on both +try: + unichr +except NameError: + unichr = chr + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_error_engine.py b/tests/test_error_engine.py new file mode 100644 index 0000000000..2c9b54c5a4 --- /dev/null +++ b/tests/test_error_engine.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The error-based extraction engine (lib/techniques/error/use.py _oneShotErrorUse). + +Error-based SQLi coaxes the DBMS into emitting the target value inside an error +message, wrapped between two random delimiters (kb.chars.start/stop). The engine +fires the payload and pulls the value back out with a regex. We drive the REAL +_oneShotErrorUse against a mock oracle whose "error page" embeds a known secret +between those delimiters, and assert it recovers the value exactly - no live DBMS. + +Requires an error-technique injection context (kb.injection.data[...].vector with +[QUERY], plus the parameter context agent.payload needs). kb.errorChunkLength is +pre-set so the MySQL/MSSQL chunk-length probing loop is skipped. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict +from lib.core.enums import PAYLOAD, PLACE +from lib.request.connect import Connect +import lib.techniques.error.use as eu + + +def _make_vector(): + d = AttribDict() + d.vector = "AND EXTRACTVALUE(1,CONCAT(0x7e,([QUERY]),0x7e))" + d.where = PAYLOAD.WHERE.ORIGINAL + d.comment = "" + d.prefix = "" + d.suffix = "" + return d + + +class TestOneShotErrorUse(unittest.TestCase): + def setUp(self): + self._saved = { + "conf.hexConvert": conf.get("hexConvert"), "conf.charset": conf.get("charset"), + "conf.hashDB": conf.get("hashDB"), "conf.parameters": conf.get("parameters"), + "conf.paramDict": conf.get("paramDict"), "conf.base64Parameter": conf.get("base64Parameter"), + "kb.errorChunkLength": kb.get("errorChunkLength"), "kb.testMode": kb.get("testMode"), + "kb.forceWhere": kb.get("forceWhere"), "kb.technique": kb.get("technique"), + "kb.inj": (kb.injection.place, kb.injection.parameter, kb.injection.data), + "qp": Connect.queryPage, + } + conf.hexConvert = False + conf.charset = None + conf.hashDB = None + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + conf.base64Parameter = () + kb.errorChunkLength = 0 + kb.testMode = False + kb.forceWhere = None + kb.injection.place = PLACE.GET + kb.injection.parameter = "id" + kb.technique = PAYLOAD.TECHNIQUE.ERROR + kb.injection.data = {PAYLOAD.TECHNIQUE.ERROR: _make_vector()} + set_dbms("MySQL") + + def tearDown(self): + conf.hexConvert = self._saved["conf.hexConvert"] + conf.charset = self._saved["conf.charset"] + conf.hashDB = self._saved["conf.hashDB"] + conf.parameters = self._saved["conf.parameters"] + conf.paramDict = self._saved["conf.paramDict"] + conf.base64Parameter = self._saved["conf.base64Parameter"] + kb.errorChunkLength = self._saved["kb.errorChunkLength"] + kb.testMode = self._saved["kb.testMode"] + kb.forceWhere = self._saved["kb.forceWhere"] + kb.technique = self._saved["kb.technique"] + kb.injection.place, kb.injection.parameter, kb.injection.data = self._saved["kb.inj"] + Connect.queryPage = self._saved["qp"] + eu.Request.queryPage = self._saved["qp"] + + def _extract(self, secret, page_template="XPATH syntax error: '%s%s%s'"): + def oracle(payload=None, content=False, raise404=True, **kwargs): + page = page_template % (kb.chars.start, secret, kb.chars.stop) + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + return eu._oneShotErrorUse("SELECT CONCAT(user())") + + def test_simple_value(self): + self.assertEqual(self._extract("root@localhost"), "root@localhost") + + def test_version_string(self): + self.assertEqual(self._extract("5.7.31-0ubuntu0.18.04.1-log"), "5.7.31-0ubuntu0.18.04.1-log") + + def test_value_with_symbols(self): + self.assertEqual(self._extract("a-b_c.d:e/f"), "a-b_c.d:e/f") + + def test_no_markers_returns_none(self): + def oracle(payload=None, content=False, raise404=True, **kwargs): + return ("a perfectly ordinary page with no error", {}, 200) if content else True + Connect.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + self.assertIsNone(eu._oneShotErrorUse("SELECT CONCAT(user())")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hash.py b/tests/test_hash.py new file mode 100644 index 0000000000..4ab5546c0e --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Password-hashing primitives (lib/utils/hash.py) used by the dictionary-attack +cracker (-? / --passwords). These are pure functions; correctness here is what +makes a cracked password actually match the target hash. + +The generic hashes are cross-checked against the stdlib hashlib (an INDEPENDENT +oracle, not just a regression against sqlmap's own output). The DBMS-specific +algorithms (MySQL/MSSQL/Oracle/Postgres) are pinned to known vectors, and +hashRecognition's classification is exercised as a table. +""" + +import hashlib +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils import hash as H +from lib.core.enums import HASH + + +class TestGenericVsHashlib(unittest.TestCase): + """Independent oracle: sqlmap's generic hashes must equal stdlib hashlib.""" + + PW = "testpass" + + def test_md5(self): + self.assertEqual(H.md5_generic_passwd(self.PW), hashlib.md5(b"testpass").hexdigest()) + + def test_sha1(self): + self.assertEqual(H.sha1_generic_passwd(self.PW), hashlib.sha1(b"testpass").hexdigest()) + + def test_sha224(self): + self.assertEqual(H.sha224_generic_passwd(self.PW), hashlib.sha224(b"testpass").hexdigest()) + + def test_sha256(self): + self.assertEqual(H.sha256_generic_passwd(self.PW), hashlib.sha256(b"testpass").hexdigest()) + + def test_sha384(self): + self.assertEqual(H.sha384_generic_passwd(self.PW), hashlib.sha384(b"testpass").hexdigest()) + + def test_sha512(self): + self.assertEqual(H.sha512_generic_passwd(self.PW), hashlib.sha512(b"testpass").hexdigest()) + + +class TestUppercase(unittest.TestCase): + def test_uppercase_flag(self): + self.assertEqual(H.md5_generic_passwd("testpass", uppercase=True), + hashlib.md5(b"testpass").hexdigest().upper()) + + def test_lowercase_default(self): + out = H.md5_generic_passwd("testpass", uppercase=False) + self.assertEqual(out, out.lower()) + + +class TestDbmsSpecificVectors(unittest.TestCase): + """Known vectors for the DBMS-native algorithms (mirrors the docstrings).""" + + def test_mysql(self): + self.assertEqual(H.mysql_passwd("testpass", uppercase=True), + "*00E247AC5F9AF26AE0194B41E1E769DEE1429A29") + + def test_mysql_old(self): + self.assertEqual(H.mysql_old_passwd("testpass", uppercase=True), "7DCDA0D57290B453") + + def test_postgres(self): + self.assertEqual(H.postgres_passwd("testpass", "testuser", uppercase=False), + "md599e5ea7a6f7c3269995cba3927fd0093") + + def test_mssql(self): + self.assertEqual(H.mssql_passwd("testpass", salt="4086ceb6", uppercase=False), + "0x01004086ceb60c90646a8ab9889fe3ed8e5c150b5460ece8425a") + + def test_oracle(self): + self.assertEqual(H.oracle_passwd("SHAlala", salt="1B7B5F82B7235E9E182C", uppercase=True), + "S:2BFCFDF5895014EE9BB2B9BA067B01E0389BB5711B7B5F82B7235E9E182C") + + def test_oracle_old(self): + self.assertEqual(H.oracle_old_passwd("tiger", "scott", uppercase=True), "F894844C34402B67") + + +class TestHashRecognition(unittest.TestCase): + def test_md5_generic(self): + self.assertEqual(H.hashRecognition("179ad45c6ce2cb97cf1029e212046e81"), HASH.MD5_GENERIC) + + def test_sha1_generic(self): + self.assertEqual(H.hashRecognition("206c80413b9a96c1312cc346b7d2517b84463edd"), HASH.SHA1_GENERIC) + + def test_mysql(self): + self.assertEqual(H.hashRecognition("*00E247AC5F9AF26AE0194B41E1E769DEE1429A29"), HASH.MYSQL) + + def test_junk_is_none(self): + self.assertIsNone(H.hashRecognition("foobar")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hashdb.py b/tests/test_hashdb.py new file mode 100644 index 0000000000..597925c623 --- /dev/null +++ b/tests/test_hashdb.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Session storage layer (lib/utils/hashdb.py) - the on-disk SQLite cache that +makes --flush-session / resume work. + +Exercised against a REAL temporary SQLite file (no network, no DBMS): scalar +write/retrieve, serialized round-trip for every container type sqlmap stores, +overwrite semantics, missing-key -> None, and key-hash determinism. + +This is also the end-to-end regression for the base64-pickle bytes fix: a +serialized value containing raw `bytes` must survive a write/flush/retrieve +cycle on both Python 2 and 3 (it silently failed on py3 before the patch.py fix). +""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.hashdb import HashDB +from lib.core.datatype import AttribDict +from lib.core.bigarray import BigArray + + +class _HashDBCase(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(self.path) # HashDB creates it lazily + self.db = HashDB(self.path) + + def tearDown(self): + try: + self.db.closeAll() + except Exception: + pass + if os.path.exists(self.path): + os.remove(self.path) + + +class TestScalar(_HashDBCase): + def test_string_roundtrip(self): + self.db.write("greeting", "hello") + self.db.flush() + self.assertEqual(self.db.retrieve("greeting"), "hello") + + def test_non_serialized_number_comes_back_as_text(self): + # non-serialized writes are stored via getUnicode() + self.db.write("num", 5) + self.db.flush() + self.assertEqual(self.db.retrieve("num"), "5") + + def test_missing_key_is_none(self): + self.assertIsNone(self.db.retrieve("never-written")) + + def test_overwrite_last_wins(self): + self.db.write("k", "v1") + self.db.write("k", "v2") + self.db.flush() + self.assertEqual(self.db.retrieve("k"), "v2") + + def test_keys_are_independent(self): + self.db.write("a", "1") + self.db.write("b", "2") + self.db.flush() + self.assertEqual(self.db.retrieve("a"), "1") + self.assertEqual(self.db.retrieve("b"), "2") + + +class TestSerialized(_HashDBCase): + def test_list_dict_tuple_set(self): + cases = { + "list": [1, 2, 3, "x"], + "dict": {"k": [1, {"n": "v"}]}, + "tuple": (1, "a", None), + "set": set([1, 2, 3]), + } + for key, val in cases.items(): + self.db.write(key, val, True) + self.db.flush() + for key, val in cases.items(): + self.assertEqual(self.db.retrieve(key, True), val, msg="serialized round-trip for %s" % key) + + def test_attribdict_roundtrip(self): + ad = AttribDict() + ad.x = 1 + ad.y = [1, 2] + self.db.write("ad", ad, True) + self.db.flush() + got = self.db.retrieve("ad", True) + self.assertIsInstance(got, AttribDict) + self.assertEqual(got.x, 1) + self.assertEqual(got.y, [1, 2]) + + def test_bigarray_roundtrip(self): + self.db.write("ba", BigArray([1, 2, 3]), True) + self.db.flush() + got = self.db.retrieve("ba", True) + self.assertIsInstance(got, BigArray) + self.assertEqual(list(got), [1, 2, 3]) + + def test_bytes_containing_value_survives(self): + # REGRESSION (base64-pickle bytes fix): silently failed to restore on py3 before the fix. + value = {"raw": b"\x00\x01\xff", "items": [b"ab", "s", 1]} + self.db.write("bytesval", value, True) + self.db.flush() + self.assertEqual(self.db.retrieve("bytesval", True), value) + + +class TestKeyHashing(_HashDBCase): + def test_distinct_keys_distinct_hashes(self): + # a broken hashKey that keys only on (say) length or the last char would collide; require + # 200 distinct keys to map to 200 distinct hashes. (Determinism is implied: the retrieve + # round-trips in TestScalar already depend on hashKey being stable.) + keys = ["key_%d_%s" % (i, "abcdefgh"[i % 8]) for i in range(200)] + hashes = set(HashDB.hashKey(k) for k in keys) + self.assertEqual(len(hashes), len(keys), msg="hashKey produced collisions across distinct keys") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_identifiers_output.py b/tests/test_identifiers_output.py new file mode 100644 index 0000000000..24ee9d6fd1 --- /dev/null +++ b/tests/test_identifiers_output.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Identifier quoting per DBMS dialect, CSV value escaping, and dump value +replacement markers. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.common import safeSQLIdentificatorNaming, unsafeSQLIdentificatorNaming, safeCSValue +from lib.core.enums import DBMS + + +class TestIdentifierQuoting(unittest.TestCase): + # special-char identifier -> the per-dialect quoting wrapper + WRAP = { + DBMS.MYSQL: "`weird name`", + DBMS.MSSQL: "[weird name]", + DBMS.PGSQL: '"weird name"', + DBMS.ORACLE: '"WEIRD NAME"', # Oracle upper-cases quoted identifiers + } + + def test_special_identifier_quoting(self): + for dbms, wrapped in self.WRAP.items(): + set_dbms(dbms) + self.assertEqual(safeSQLIdentificatorNaming("weird name"), wrapped, msg=str(dbms)) + + def test_simple_identifier_roundtrip(self): + # plain identifier needs no quoting; round-trips identically on case-preserving dialects + for dbms in (DBMS.MYSQL, DBMS.MSSQL, DBMS.PGSQL): + set_dbms(dbms) + for ident in ("users", "password", "tbl1"): + self.assertEqual(safeSQLIdentificatorNaming(ident), ident, msg="%s %r" % (dbms, ident)) + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming(ident)), ident) + + def test_oracle_uppercases_on_unsafe(self): + # documented dialect quirk: Oracle unsafe-naming upper-cases identifiers + set_dbms(DBMS.ORACLE) + self.assertEqual(safeSQLIdentificatorNaming("users"), "users") + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming("users")), "USERS") + + def test_unsafe_strips_quotes(self): + for dbms in (DBMS.MYSQL, DBMS.MSSQL, DBMS.PGSQL): + set_dbms(dbms) + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming("weird name")), "weird name") + + +class TestSafeCSValue(unittest.TestCase): + CASES = [ + ("foobar", "foobar"), # plain -> unchanged + ("foo,bar", '"foo,bar"'), # contains delimiter -> quoted + ('he"y', '"he""y"'), # contains quote -> doubled + wrapped + ("a\nb", '"a\nb"'), # contains newline -> quoted + ] + + def test_table(self): + for inp, expected in self.CASES: + self.assertEqual(safeCSValue(inp), expected, msg="safeCSValue(%r)" % inp) + + def test_idempotent_on_already_quoted(self): + once = safeCSValue("a,b") + self.assertEqual(safeCSValue(once), once) # already starts+ends with quote -> unchanged + + +# (DUMP_REPLACEMENTS markers are covered in test_dicts.py - not duplicated here) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_inference_engine.py b/tests/test_inference_engine.py new file mode 100644 index 0000000000..bbc0b5a1f1 --- /dev/null +++ b/tests/test_inference_engine.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The blind-SQLi extraction engine (lib/techniques/blind/inference.py bisection). + +This is the actual algorithm that pulls data out one character at a time over a +boolean/blind oracle - the heart of sqlmap. It is normally network-coupled, so +here we drive the REAL bisection() against a mock oracle: Request.queryPage is +replaced with a function that decodes the forged payload (we control the payload +template, so it is trivially parseable) and answers the comparison against a +known secret. If bisection's binary search, charset narrowing, or value assembly +regress, these go red - without a live target. + +Also asserts the search is logarithmic (binary search), not a linear scan of the +character space. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.data import conf, kb +from lib.core.common import getCurrentThreadData +from lib.request.connect import Connect +import lib.techniques.blind.inference as inf + +# bisection does: safeStringFormat(payload, (expression, idx, posValue)); '>' is the +# greater-char marker (swapped to '=' on the final equality check). We pass a parseable +# template so the mock oracle can recover (idx, operator, threshold). +TEMPLATE = "EXPR=%s IDX=%d CMP>%d" +_PARSE = re.compile(r"IDX=(\d+) CMP(.)(\d+)") + +# conf/kb knobs bisection reads on the simple single-threaded, no-prediction path +_CONF = {"predictOutput": False, "threads": 1, "api": False, "verbose": 0, "hexConvert": False, + "charset": None, "firstChar": None, "lastChar": None, "timeSec": 5} +_KB = {"partRun": None, "safeCharEncode": False, "bruteMode": False, "fileReadMode": False, + "disableShiftTable": False, "originalTimeDelay": 5, "prependFlag": False} + + +class _EngineCase(unittest.TestCase): + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in _CONF} + self._saved_kb = {k: kb.get(k) for k in _KB} + self._saved_qp = Connect.queryPage + self._saved_processChar = kb.data.get("processChar") + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + kb.data.processChar = None + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + kb.data.processChar = self._saved_processChar + Connect.queryPage = self._saved_qp + inf.Request.queryPage = self._saved_qp + + def _extract(self, secret, charsetType=None): + def oracle(payload=None, *args, **kwargs): + m = _PARSE.search(payload) + idx, op, threshold = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + return (ch > threshold) if op == ">" else (ch == threshold) + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) + td = getCurrentThreadData() + td.shared.value = "" + td.shared.index = [0] + td.shared.start = 0 + td.shared.count = 0 + count, value = inf.bisection(TEMPLATE, "SELECT secret", length=len(secret), charsetType=charsetType) + return value, count + + +class TestBisectionExtraction(_EngineCase): + # NOTE: the alpha / numeric / mixed cases are NOT redundant - getChar has per-class + # "first character" position heuristics (distinct branches for a-z, A-Z and 0-9 at + # inference.py ~331-336), so each character class exercises a different code path. + def test_single_char(self): + value, _ = self._extract("X") + self.assertEqual(value, "X") + + def test_alpha(self): + value, _ = self._extract("AdminUser") # exercises the a-z / A-Z heuristic branch + self.assertEqual(value, "AdminUser") + + def test_alphanumeric(self): + value, _ = self._extract("admin123") + self.assertEqual(value, "admin123") + + def test_with_spaces_and_symbols(self): + value, _ = self._extract("p@ss W0rd!") + self.assertEqual(value, "p@ss W0rd!") + + def test_numeric_string(self): + value, _ = self._extract("4815162342") # exercises the 0-9 heuristic branch + self.assertEqual(value, "4815162342") + + def test_longer_value(self): + secret = "The quick brown fox 0123456789" + value, _ = self._extract(secret) + self.assertEqual(value, secret) + + +class TestUnicodeExpansion(_EngineCase): + """charsetType=None starts with a 0..127 table and gradually expands it (shiftTable) to + reach higher code points. This test exercises the FIRST expansion step (code points + 128..1023) via Latin-1 chars, where the per-byte oracle model is exact. + + NOTE: kb.disableShiftTable is an INTENTIONAL session-level safety latch (sqlmap author's + design): once expansion runs all the way to the top - only reachable by a code point above + 0xFFFFF, or by a misbehaving always-TRUE oracle - it disables further expansion to prevent + runaway / erroneous extraction. That is deliberate, so this test does NOT assert that + expansion survives across such an event. + + (Code points >= 256 are retrieved/assembled byte-wise in real runs - decodeIntToUnicode + splits them into a byte sequence - so a simple ord()-based mock oracle only models the + single-byte range; those are out of scope here.)""" + + def test_extracts_latin1_via_first_expansion(self): + for s in (u"caf\xe9", u"\xfcber", u"ni\xf1o", u"\xe9\xe8\xea\xeb"): + self.assertEqual(self._extract(s)[0], s, msg="expansion extraction failed for %r" % s) + + +class TestSearchIsLogarithmic(_EngineCase): + def test_query_count_is_sublinear_in_charset(self): + # GOAL: catch a regression from binary search to a linear/per-codepoint scan. + # Observed cost is ~6-22 queries/char (it varies: the first-char heuristic's benefit + # depends on ambient kb/conf state, so a tighter bound would flake). A linear scan of the + # 128-char ASCII space would be ~128/char (~3840 for 30 chars). Bound at 40/char cleanly + # separates "logarithmic" (passes) from "linearized" (fails) without being flaky. + secret = "x" * 30 + _, count = self._extract(secret) + self.assertLess(count, len(secret) * 40, + msg="bisection used %d queries for %d chars (~%.1f/char) - search regressed toward linear?" + % (count, len(secret), count / float(len(secret)))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000000..d92b72b17a --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Assorted pure helpers: stats, set ops, value predicates, value/counter stacks, +enum helpers, DBMS alias/version checks, column prioritization. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core import common as C +from lib.core.settings import NULL +from lib.core.enums import DBMS + + +class TestStats(unittest.TestCase): + def test_average(self): + self.assertEqual(C.average([1, 2, 3, 4]), 2.5) + self.assertEqual(C.average([5]), 5) + + def test_stdev(self): + self.assertAlmostEqual(C.stdev([1, 2, 3, 4]), 1.2909944, places=5) + self.assertIsNone(C.stdev([5])) # undefined for single sample + + +class TestSetOps(unittest.TestCase): + def test_intersect(self): + self.assertEqual(C.intersect([1, 2, 3], [2, 3, 4]), [2, 3]) + self.assertEqual(C.intersect([1], [2]), []) + + def test_filterPairValues(self): + self.assertEqual(C.filterPairValues([[1, 2], [3], [4, 5], []]), [[1, 2], [4, 5]]) + + +class TestValuePredicates(unittest.TestCase): + def test_isNoneValue(self): + for v in (None, [], "", {}): + self.assertTrue(C.isNoneValue(v), msg="isNoneValue(%r)" % (v,)) + + def test_isNullValue(self): + self.assertTrue(C.isNullValue(NULL)) + # discriminating negatives: an always-True impl must fail these + self.assertFalse(C.isNullValue(None)) + self.assertFalse(C.isNullValue("")) + self.assertFalse(C.isNullValue("x")) + + def test_isNumPosStrValue(self): + for v, exp in [("5", True), ("0", False), ("-1", False), ("a", False), ("12", True)]: + self.assertEqual(bool(C.isNumPosStrValue(v)), exp, msg="isNumPosStrValue(%r)" % v) + + def test_firstNotNone(self): + self.assertEqual(C.firstNotNone(None, None, 5, 6), 5) + self.assertIsNone(C.firstNotNone(None, None)) + + +class TestValueStackAndCounters(unittest.TestCase): + def test_push_pop(self): + C.pushValue(7) + C.pushValue("x") + self.assertEqual(C.popValue(), "x") + self.assertEqual(C.popValue(), 7) + + def test_counters(self): + C.resetCounter("UNITTEST") + C.incrementCounter("UNITTEST") + C.incrementCounter("UNITTEST") + self.assertEqual(C.getCounter("UNITTEST"), 2) + + +class TestEnumAndDbmsHelpers(unittest.TestCase): + def test_aliasToDbmsEnum(self): + self.assertEqual(C.aliasToDbmsEnum("mysql"), DBMS.MYSQL) + self.assertEqual(C.aliasToDbmsEnum("postgres"), DBMS.PGSQL) + + def test_getPublicTypeMembers(self): + members = list(C.getPublicTypeMembers(DBMS, onlyValues=True)) + # goal is correct EXTRACTION, not a magic count: real members present, no private/dunder leak + self.assertIn(DBMS.MYSQL, members) + self.assertIn(DBMS.MSSQL, members) + self.assertIn(DBMS.ORACLE, members) + self.assertFalse(any(str(m).startswith("_") for m in members), msg="leaked private member: %r" % members) + + def test_isDBMSVersionAtLeast(self): + set_dbms(DBMS.MYSQL) + C.Backend.setVersion("5.7") + self.assertTrue(C.isDBMSVersionAtLeast("5.0")) + self.assertFalse(C.isDBMSVersionAtLeast("8.0")) + + +class TestColumnPriority(unittest.TestCase): + def test_prioritySortColumns(self): + # assert the FULL ordering, not just the first element (id-like floats to front, + # rest keep their relative order) + self.assertEqual(C.prioritySortColumns(["data", "id", "name"]), ["id", "data", "name"]) + + def test_prioritySortColumns_empty(self): + self.assertEqual(C.prioritySortColumns([]), []) + + +class TestArrayHelpers(unittest.TestCase): + def test_unArrayizeValue(self): + self.assertEqual(C.unArrayizeValue([5]), 5) # single-element list -> the element + self.assertEqual(C.unArrayizeValue([1, 2]), 1) # multi -> first + self.assertEqual(C.unArrayizeValue(7), 7) # scalar -> unchanged + self.assertIsNone(C.unArrayizeValue([])) # empty -> None + + def test_arrayizeValue(self): + self.assertEqual(C.arrayizeValue(5), [5]) # scalar -> wrapped + self.assertEqual(C.arrayizeValue([5]), [5]) # list -> unchanged + + def test_roundtrip_scalar(self): + for v in (0, 1, "x", "value"): + self.assertEqual(C.unArrayizeValue(C.arrayizeValue(v)), v) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_pagecontent.py b/tests/test_pagecontent.py new file mode 100644 index 0000000000..3f6edcf500 --- /dev/null +++ b/tests/test_pagecontent.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Page-content extraction helpers (lib/core/common.py): getFilteredPageContent, +getPageWordSet, extractTextTagContent, and parseSqliteTableSchema. + +The first three feed text-only comparison (--text-only), dynamic-content +removal, and Google-dork style scraping; the last reconstructs column metadata +from a sqlite_master CREATE TABLE statement during enumeration. All pure given +their input (the page must be unicode for tag stripping to engage - a real +gotcha pinned below). +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import (getFilteredPageContent, getPageWordSet, + extractTextTagContent, parseSqliteTableSchema) +from lib.core.data import kb + + +class TestFilteredPageContent(unittest.TestCase): + def test_strips_all_tags_in_text_mode(self): + self.assertEqual(getFilteredPageContent(u"foobartest"), + u"foobar test") + + def test_strips_script(self): + self.assertEqual(getFilteredPageContent(u"

keep

this

"), + u"keep this") + + def test_keeps_tags_when_not_only_text(self): + self.assertEqual(getFilteredPageContent(u"

a

b

", onlyText=False), + u"

a

b

") + + def test_bytes_input_unchanged(self): + # GOTCHA: tag stripping only engages for unicode input (charset-identified pages) + raw = b"x" + self.assertEqual(getFilteredPageContent(raw), raw) + + +class TestPageWordSet(unittest.TestCase): + def test_words(self): + self.assertEqual(sorted(getPageWordSet(u"foobartest")), + [u"foobar", u"test"]) + + +class TestExtractTextTagContent(unittest.TestCase): + def test_multiple_tags(self): + self.assertEqual(extractTextTagContent(u"Welcome

Body text

"), + [u"Welcome", u"Body text"]) + + +class TestParseSqliteTableSchema(unittest.TestCase): + def setUp(self): + kb.data.cachedColumns = {} + + def _cols(self): + # parseSqliteTableSchema stores under cachedColumns[db][table] (both None here) + return dict(kb.data.cachedColumns[None][None]) + + def test_basic_columns_and_types(self): + parseSqliteTableSchema("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, age INT)") + cols = self._cols() + self.assertEqual(cols["id"], "INTEGER") + self.assertEqual(cols["name"], "TEXT") + self.assertEqual(cols["age"], "INT") + + def test_quoted_identifiers_and_sized_types(self): + parseSqliteTableSchema('CREATE TABLE "t"("id" INTEGER, "n" VARCHAR(50), flag BOOLEAN)') + cols = self._cols() + self.assertIn("id", cols) + self.assertEqual(cols["n"], "VARCHAR") # size dropped + self.assertEqual(cols["flag"], "BOOLEAN") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_payload_marking.py b/tests/test_payload_marking.py new file mode 100644 index 0000000000..8a49ed2871 --- /dev/null +++ b/tests/test_payload_marking.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Request-body injection-point handling: + - recognition regexes (REAL, imported from settings) classify JSON/JSON_LIKE/XML/PLAIN + - JSON/XML injection-point marking preserves every value (mirrors target.py) + - HPP transform reconstructs the original SQL after ASP comma-join +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.settings import (JSON_RECOGNITION_REGEX, JSON_LIKE_RECOGNITION_REGEX, + XML_RECOGNITION_REGEX, PAYLOAD_DELIMITER, DEFAULT_GET_POST_DELIMITER) + +MARK = "*" + + +def classify(d): + if re.search(JSON_RECOGNITION_REGEX, d): + return "JSON" + if re.search(JSON_LIKE_RECOGNITION_REGEX, d): + return "JSON_LIKE" + if re.search(XML_RECOGNITION_REGEX, d): + return "XML" + return "PLAIN" + + +class TestRecognitionRegexes(unittest.TestCase): + CASES = [ + ('{"id":1}', "JSON"), + ('{"a":"b"}', "JSON"), + ('{"n":1,"m":"s"}', "JSON"), + ('[{"id":1}]', "JSON"), + ('[{"id":1},{"id":2}]', "JSON"), + ("{'a':'b'}", "JSON_LIKE"), + ("
1", "XML"), + ("1", "XML"), + ("v", "XML"), + ("id=1&x=2", "PLAIN"), + ("just text", "PLAIN"), + ] + + def test_classification(self): + for body, expected in self.CASES: + self.assertEqual(classify(body), expected, msg="classify(%r)" % body) + + +class TestJsonMarking(unittest.TestCase): + # mirrors target.py:159-162 JSON injection-point marking + @staticmethod + def mark(data): + data = re.sub(r'("(?P[^"]+)"\s*:\s*".*?)"(?%s"' % MARK, data) + data = re.sub(r'("(?P[^"]+)"\s*:\s*")"', r'\g<1>%s"' % MARK, data) + data = re.sub(r'("(?P[^"]+)"\s*:\s*)(-?\d[\d\.]*)\b', r'\g<1>\g<3>%s' % MARK, data) + data = re.sub(r'("(?P[^"]+)"\s*:\s*)((true|false|null))\b', r'\g<1>\g<3>%s' % MARK, data) + return data + + CASES = [ + ('{"id":1}', '{"id":1*}'), + ('{"name":"abc"}', '{"name":"abc*"}'), + ('{"a":{"b":"1"}}', '{"a":{"b":"1*"}}'), + ('{"empty":""}', '{"empty":"*"}'), + ('{"b":true,"n":null}', '{"b":true*,"n":null*}'), + ('{"a":"x","b":"y"}', '{"a":"x*","b":"y*"}'), + ('{"url":"http://h:8080/p"}', '{"url":"http://h:8080/p*"}'), + ] + + def test_cases(self): + for inp, expected in self.CASES: + self.assertEqual(self.mark(inp), expected, msg="mark(%r)" % inp) + + def test_value_preserved_property(self): + # marking must not delete/garble the original value characters + for inp, _ in self.CASES: + out = self.mark(inp) + self.assertEqual(out.replace(MARK, ""), inp, msg="marking altered %r" % inp) + + +class TestXmlMarking(unittest.TestCase): + RX = r"(<(?P[^>]+)( [^<]*)?>)([^<]+)(\g<4>%s\g<5>" % MARK, data) + + CASES = [ + ("x", "x*"), + ('x', 'x*'), + ("bob5", "bob*5*"), + ("v", "v*"), + ("1", "1*"), + ] + + def test_cases(self): + for inp, expected in self.CASES: + self.assertEqual(self.mark(inp), expected, msg="xmlmark(%r)" % inp) + + +class TestHppReconstruction(unittest.TestCase): + # mirrors connect.py:1171-1187 HPP splitting + def hpp(self, payload, name="id"): + from thirdparty.six.moves import urllib as _urllib # py2+py3 + quote = _urllib.parse.quote + + def ue(s): + try: + return quote(s) + except Exception: + return s + value = "%s=%s%s%s" % (name, PAYLOAD_DELIMITER, payload, PAYLOAD_DELIMITER) + _ = re.escape(PAYLOAD_DELIMITER) + match = re.search(r"(?P\w+)=%s(?P.+?)%s" % (_, _), value) + out = match.group("value") + for splitter in (ue(' '), ' '): + if splitter in out: + prefix, suffix = ("*/", "/*") if splitter == ' ' else (ue(x) for x in ("*/", "/*")) + parts = out.split(splitter) + parts[0] = "%s%s" % (parts[0], suffix) + parts[-1] = "%s%s=%s%s" % (DEFAULT_GET_POST_DELIMITER, match.group("name"), prefix, parts[-1]) + for i in range(1, len(parts) - 1): + parts[i] = "%s%s=%s%s%s" % (DEFAULT_GET_POST_DELIMITER, match.group("name"), prefix, parts[i], suffix) + out = "".join(parts) + for splitter in (ue(','), ','): + out = out.replace(splitter, "%s%s=" % (DEFAULT_GET_POST_DELIMITER, match.group("name"))) + return out + + # Exact transform outputs (verified live against an ASP-style join). We pin the produced + # string rather than "reconstruct the SQL", because reconstruction depends on the SQL parser + # treating /* */ as a token separator (1/*,*/AND -> "1 AND"), which a string compare can't model. + CASES = [ + ("1", "1"), + ("1 AND 2=2", "1/*&id=*/AND/*&id=*/2=2"), + ("1 AND 'a'='a'", "1/*&id=*/AND/*&id=*/'a'='a'"), + ] + + def test_exact_outputs(self): + for payload, expected in self.CASES: + self.assertEqual(self.hpp(payload), expected, msg="hpp(%r)" % payload) + + def test_balanced_comments(self): + # every /* must have a matching */ (no dangling comment bridge) + for payload in ["1 UNION SELECT a,b", "1 AND 2=2 OR 3=3", "x y z"]: + out = self.hpp(payload) + self.assertEqual(out.count("/*"), out.count("*/"), msg="unbalanced comments for %r" % payload) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_payloads_structure.py b/tests/test_payloads_structure.py new file mode 100644 index 0000000000..51796da32f --- /dev/null +++ b/tests/test_payloads_structure.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Structural invariants of the injection payload/boundary definitions +(data/xml/payloads/*.xml -> conf.tests, data/xml/boundaries.xml -> conf.boundaries). + +These XML files ARE the detection engine: every test/boundary loaded here is +something sqlmap will fire at a target. The fields are pure data, so the right +tests are shape/range invariants - a malformed level, an unknown technique, a +duplicate title, or a test missing its request payload would silently break or +skew detection. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.parse.payloads import loadBoundaries, loadPayloads +from lib.core.data import conf +from lib.core.enums import PAYLOAD +from lib.core.common import getPublicTypeMembers + +# load once for the module +loadBoundaries() +loadPayloads() + +TECHNIQUES = set(v for _, v in getPublicTypeMembers(PAYLOAD.TECHNIQUE)) # {1..6} +WHERES = set(v for _, v in getPublicTypeMembers(PAYLOAD.WHERE)) # {1,2,3} + + +class TestLoaded(unittest.TestCase): + # floors well below the current counts (~340 tests, ~54 boundaries) - high enough to catch a + # truncated/partially-loaded XML set (not just "> 0"), low enough to survive normal additions + def test_payloads_loaded(self): + self.assertGreaterEqual(len(conf.tests), 200, msg="only %d tests loaded" % len(conf.tests)) + + def test_boundaries_loaded(self): + self.assertGreaterEqual(len(conf.boundaries), 30, msg="only %d boundaries loaded" % len(conf.boundaries)) + + +class TestTestEntries(unittest.TestCase): + def setUp(self): + # guard against vacuous passes: if payloads failed to load, every loop below + # would iterate zero times and pass silently + self.assertTrue(conf.tests, "conf.tests is empty - payloads failed to load") + + def test_required_fields_present(self): + for t in conf.tests: + for field in ("title", "stype", "clause", "where", "level", "risk", "request", "response"): + self.assertIn(field, t, msg="test %r missing field %r" % (t.get("title"), field)) + + def test_title_non_empty(self): + for t in conf.tests: + self.assertTrue(t.title and t.title.strip(), msg="empty test title") + + def test_titles_unique(self): + titles = [t.title for t in conf.tests] + self.assertEqual(len(titles), len(set(titles)), msg="duplicate test titles exist") + + def test_stype_is_known_technique(self): + for t in conf.tests: + self.assertIn(t.stype, TECHNIQUES, msg="test %r has unknown stype %r" % (t.title, t.stype)) + + def test_level_and_risk_in_range(self): + for t in conf.tests: + self.assertIn(t.level, (1, 2, 3, 4, 5), msg="test %r bad level %r" % (t.title, t.level)) + self.assertIn(t.risk, (1, 2, 3), msg="test %r bad risk %r" % (t.title, t.risk)) + + def test_request_has_payload(self): + for t in conf.tests: + self.assertIn("payload", t.request, msg="test %r request has no payload" % t.title) + + def test_where_values_valid(self): + for t in conf.tests: + for w in t.where: + self.assertIn(w, WHERES, msg="test %r has bad where %r" % (t.title, w)) + + +class TestBoundaryEntries(unittest.TestCase): + def setUp(self): + self.assertTrue(conf.boundaries, "conf.boundaries is empty - boundaries failed to load") + + def test_required_fields_present(self): + for b in conf.boundaries: + for field in ("level", "clause", "where", "ptype"): + self.assertIn(field, b, msg="boundary missing field %r" % field) + + def test_level_in_range(self): + for b in conf.boundaries: + self.assertIn(b.level, (1, 2, 3, 4, 5), msg="boundary bad level %r" % b.level) + + def test_where_values_valid(self): + for b in conf.boundaries: + for w in b.where: + self.assertIn(w, WHERES, msg="boundary bad where %r" % w) + + def test_clause_is_list_like(self): + for b in conf.boundaries: + self.assertTrue(isinstance(b.clause, (list, tuple)), msg="boundary clause not list-like") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_replication.py b/tests/test_replication.py new file mode 100644 index 0000000000..22bdab8203 --- /dev/null +++ b/tests/test_replication.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +SQLite replication writer (lib/core/replication.py). + +This is what backs `--dump ... --dump-format SQLITE` / replication: it mirrors +dumped tables into a local SQLite file. Tested end-to-end against a real temp +database (create table, typed columns, insert, select, persistence) and read +back independently with the stdlib sqlite3 driver. +""" + +import os +import sqlite3 +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.replication import Replication + + +class _ReplCase(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(self.path) + self.rep = Replication(self.path) + + def tearDown(self): + try: + del self.rep + except Exception: + pass + if os.path.exists(self.path): + os.remove(self.path) + + def _readback(self, sql): + conn = sqlite3.connect(self.path) + try: + return conn.execute(sql).fetchall() + finally: + conn.close() + + +class TestCreateInsertSelect(_ReplCase): + def test_roundtrip(self): + t = self.rep.createTable("users", [("id", self.rep.INTEGER), ("name", self.rep.TEXT)]) + t.insert([1, "admin"]) + t.insert([2, "guest"]) + self.assertEqual(t.select(), [(1, "admin"), (2, "guest")]) + + def test_persisted_to_disk(self): + t = self.rep.createTable("t", [("id", self.rep.INTEGER), ("v", self.rep.TEXT)]) + t.insert([10, "x"]) + # autocommit (isolation_level=None) => visible to an independent connection + self.assertEqual(self._readback("SELECT id, v FROM t"), [(10, "x")]) + + def test_real_and_blob_types(self): + t = self.rep.createTable("mix", [("r", self.rep.REAL), ("b", self.rep.BLOB)]) + t.insert([3.5, b"\x00\x01"]) + self.assertEqual(self._readback("SELECT r FROM mix")[0][0], 3.5) # REAL preserved exactly + # BLOB containing a NUL byte must survive intact (a naive str path would truncate at \x00). + # It comes back as a 2-element value (text on py3); assert the NUL didn't truncate it. + blob = self._readback("SELECT b FROM mix")[0][0] + self.assertEqual(len(blob), 2, msg="blob truncated/altered: %r" % (blob,)) + + def test_null_and_empty_values(self): + t = self.rep.createTable("n", [("id", self.rep.INTEGER), ("v", self.rep.TEXT)]) + t.insert([None, ""]) + self.assertEqual(self._readback("SELECT id, v FROM n"), [(None, "")]) + + def test_create_replaces_existing(self): + t1 = self.rep.createTable("dup", [("id", self.rep.INTEGER)]) + t1.insert([1]) + # createTable drops-if-exists, so the table is fresh + t2 = self.rep.createTable("dup", [("id", self.rep.INTEGER)]) + self.assertEqual(t2.select(), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_safe2bin.py b/tests/test_safe2bin.py new file mode 100644 index 0000000000..609ccc41b9 --- /dev/null +++ b/tests/test_safe2bin.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +safecharencode / safechardecode (lib/utils/safe2bin.py). + +These make extracted DB values safe to print/store by escaping control and +non-printable characters (tab -> \\t, NUL -> \\x00, ...) and back. They are +applied to dumped data and to values written through the replication writer, +so the escape<->unescape round-trip must be exact. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.safe2bin import safecharencode, safechardecode + +RND = random.Random(99) + + +class TestKnownEscapes(unittest.TestCase): + CASES = [ + (u"normal", u"normal"), + (u"tab\there", u"tab\\there"), + (u"new\nline", u"new\\nline"), + (u"nul\x00byte", u"nul\\x00byte"), + ] + + def test_encode(self): + for raw, encoded in self.CASES: + self.assertEqual(safecharencode(raw), encoded, msg="safecharencode(%r)" % raw) + + def test_plain_text_unchanged(self): + for s in (u"plain", u"abc 123", u"semi;colon", u"a,b,c"): + self.assertEqual(safecharencode(s), s, msg="plain text altered: %r" % s) + + +class TestRoundTrip(unittest.TestCase): + def test_known_roundtrip(self): + for raw, _ in TestKnownEscapes.CASES: + self.assertEqual(safechardecode(safecharencode(raw)), raw, msg="round-trip %r" % raw) + + def test_property_roundtrip(self): + # mix printable + control/non-printable code points + pool = u"abc 123" + u"".join(chr(c) for c in (0, 1, 7, 9, 10, 13, 27, 127)) + for _ in range(2000): + s = u"".join(RND.choice(pool) for _ in range(RND.randint(0, 24))) + self.assertEqual(safechardecode(safecharencode(s)), s, msg="round-trip failed for %r" % s) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_settings_regex.py b/tests/test_settings_regex.py new file mode 100644 index 0000000000..ddfceccf75 --- /dev/null +++ b/tests/test_settings_regex.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Compiled-regex battery for lib/core/settings.py. + +settings.py defines ~40 module-level *_REGEX patterns that drive WAF/error/ +charset/IP/title detection. A bad edit to any one of them is a silent failure +(detection just stops firing). This compiles them all and pins the behavior of +the high-traffic detection patterns with positive + negative cases. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.core.settings as S +from lib.core.common import extractRegexResult + + +class TestAllRegexesCompile(unittest.TestCase): + def test_every_regex_constant_compiles(self): + names = [n for n in dir(S) if n.endswith("_REGEX")] + self.assertGreater(len(names), 20, msg="expected many *_REGEX constants") + failures = [] + for name in names: + value = getattr(S, name) + if isinstance(value, str): + # some carry a single %s placeholder (e.g. SENSITIVE_DATA_REGEX) - fill it before compiling + candidate = value.replace("%s", "X") if "%s" in value else value + try: + re.compile(candidate) + except re.error as ex: + failures.append("%s: %s" % (name, ex)) + self.assertEqual(failures, [], msg="non-compiling regexes: %s" % failures) + + +class TestDetectionPatterns(unittest.TestCase): + def test_ip_address(self): + self.assertTrue(re.search(S.IP_ADDRESS_REGEX, "connect to 192.168.0.1 now")) + self.assertFalse(re.search(S.IP_ADDRESS_REGEX, "999.999.999.999")) + + def test_permission_denied(self): + self.assertEqual(extractRegexResult(S.PERMISSION_DENIED_REGEX, "access denied for user 'x'"), + "access denied") + + def test_parameter_splitting(self): + self.assertEqual(re.split(S.PARAMETER_SPLITTING_REGEX, "a,b;c|d"), ["a", "b", "c", "d"]) + + def test_html_title(self): + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "Hello"), "Hello") + # case-insensitive tag, first-of-two wins, empty/absent -> None (probed) + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "x"), "x") + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "AB"), "A") + self.assertIsNone(extractRegexResult(S.HTML_TITLE_REGEX, "")) + self.assertIsNone(extractRegexResult(S.HTML_TITLE_REGEX, "no title here")) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sqlparse.py b/tests/test_sqlparse.py new file mode 100644 index 0000000000..afe204ecb5 --- /dev/null +++ b/tests/test_sqlparse.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +SQL/string parsing helpers: field splitting and 0-depth (paren+quote aware) +scanning, query cleanup, regex extraction. +Includes regression cases for the quote-awareness bugs fixed previously. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import splitFields, zeroDepthSearch, cleanQuery, extractRegexResult + + +class TestSplitFields(unittest.TestCase): + CASES = [ + ("a,b", ["a", "b"]), + ("user,password", ["user", "password"]), + ("a,b,c", ["a", "b", "c"]), + ("a", ["a"]), + ("max(a,b)", ["max(a,b)"]), # paren-protected + ("max(a, b),c", ["max(a,b)", "c"]), # ', ' normalized; outer split + ("COUNT(*),name", ["COUNT(*)", "name"]), + ("f(g(x,y),z),h", ["f(g(x,y),z)", "h"]), # nested parens + ("'a,b'", ["'a,b'"]), # REGRESSION: comma in single-quoted literal + ("'a,b','c|d','e&f'", ["'a,b'", "'c|d'", "'e&f'"]), # REGRESSION + ('"x,y",z', ['"x,y"', "z"]), # double-quoted literal + ] + + def test_table(self): + for inp, expected in self.CASES: + self.assertEqual(splitFields(inp), expected, msg="splitFields(%r)" % inp) + + +class TestZeroDepthSearch(unittest.TestCase): + def test_quote_awareness(self): + # ' FROM ' inside a literal must NOT be a clause boundary (regression) + self.assertEqual(zeroDepthSearch("SELECT 'x FROM y'", " FROM "), []) + # a real FROM must be found (exactly once here) + self.assertEqual(len(zeroDepthSearch("SELECT a FROM t", " FROM ")), 1) + + def test_paren_awareness(self): + self.assertEqual(zeroDepthSearch("a(,)b,c", ","), [5]) # only the depth-0 comma + + def test_doctest_vectors(self): + q = "SELECT (SELECT id FROM users WHERE 2>1) AS result FROM DUAL" + hits = zeroDepthSearch(q, "FROM") + self.assertTrue(hits, "no depth-0 FROM found") # guard: avoid a confusing IndexError + self.assertEqual(q[hits[0]:], "FROM DUAL") # outer FROM only + s = "a(b; c),d;e" + hits = zeroDepthSearch(s, "[;, ]") + self.assertTrue(hits) + self.assertEqual(s[hits[0]:], ",d;e") # char-class form + + +class TestCleanQuery(unittest.TestCase): + def test_keyword_uppercasing(self): + self.assertEqual(cleanQuery("select a from t"), "SELECT a FROM t") + # mixed case keywords get uppercased; non-keyword identifiers are preserved verbatim + self.assertEqual(cleanQuery("seLeCt a fRoM t"), "SELECT a FROM t") + self.assertEqual(cleanQuery("SELECT 1"), "SELECT 1") # already-upper unchanged + + def test_idempotent(self): + for q in ["select a from t", "SELECT 1", "select x where y=1 order by z"]: + once = cleanQuery(q) + self.assertEqual(cleanQuery(once), once) + # idempotence alone would pass even if cleanQuery uppercased EVERYTHING; anchor that it + # uppercases keywords but preserves the lowercase identifier + self.assertEqual(cleanQuery("select a from t"), "SELECT a FROM t") + + +class TestExtractRegexResult(unittest.TestCase): + def test_named_group(self): + self.assertEqual(extractRegexResult(r"id=(?P\d+)", "id=42"), "42") + self.assertIsNone(extractRegexResult(r"id=(?P\d+)", "no match here")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 0000000000..5aada824e0 --- /dev/null +++ b/tests/test_strings.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +String / path / escape helpers. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import (normalizePath, posixToNtSlashes, ntToPosixSlashes, + isHexEncodedString, decodeStringEscape, encodeStringEscape, + listToStrValue, filterControlChars, safeVariableNaming, + unsafeVariableNaming, longestCommonPrefix, decodeIntToUnicode) + +RND = random.Random(7) + + +class TestPaths(unittest.TestCase): + def test_normalizePath(self): + self.assertEqual(normalizePath("a//b/c"), "a/b/c") + + def test_slashes(self): + self.assertEqual(posixToNtSlashes("/a/b"), "\\a\\b") + self.assertEqual(ntToPosixSlashes("a\\b"), "a/b") + + def test_slash_roundtrip(self): + for _ in range(500): + s = "/".join(["seg%d" % RND.randint(0, 9) for _ in range(RND.randint(2, 6))]) + nt = posixToNtSlashes(s) + # non-identity anchor: the NT form must actually differ (no '/', has '\') - + # otherwise a no-op pair would pass this round-trip + self.assertNotIn("/", nt, msg="posixToNtSlashes left a '/': %r" % nt) + self.assertIn("\\", nt) + self.assertEqual(ntToPosixSlashes(nt), s) + + +class TestHexDetection(unittest.TestCase): + CASES = [("0x4142", True), ("4142", True), ("zz", False), ("0xZZ", False), ("", False)] + + def test_isHexEncodedString(self): + for v, exp in self.CASES: + self.assertEqual(bool(isHexEncodedString(v)), exp, msg="isHexEncodedString(%r)" % v) + + +class TestStringEscape(unittest.TestCase): + def test_known(self): + self.assertEqual(decodeStringEscape("a\\tb"), "a\tb") + self.assertEqual(encodeStringEscape("a\tb"), "a\\tb") + + def test_roundtrip_property(self): + ctrl = "\t\n\r\\abc 123" + for _ in range(2000): + s = "".join(RND.choice(ctrl) for _ in range(RND.randint(0, 20))) + self.assertEqual(decodeStringEscape(encodeStringEscape(s)), s) + + +class TestVariableNaming(unittest.TestCase): + def test_transform_is_not_identity(self): + # safeVariableNaming hex-encodes non-identifier-safe names behind an EVAL_ prefix; + # pin the exact form so the round-trip below can't be satisfied by no-op functions + self.assertEqual(safeVariableNaming("a.b"), "EVAL_612e62") # 612e62 == hex("a.b") + self.assertNotEqual(safeVariableNaming("weird name"), "weird name") + + def test_roundtrip(self): + for ident in ["a.b", "schema.table", "x", "weird name", "a-b.c"]: + encoded = safeVariableNaming(ident) + if any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" for c in ident): + self.assertNotEqual(encoded, ident, msg="unsafe ident %r was not transformed" % ident) + self.assertEqual(unsafeVariableNaming(encoded), ident) + + +class TestMiscStrings(unittest.TestCase): + def test_listToStrValue(self): + self.assertEqual(listToStrValue([1, 2, 3]), "1, 2, 3") + + def test_filterControlChars(self): + self.assertEqual(filterControlChars("a\x07b"), "a b") + + def test_longestCommonPrefix(self): + self.assertEqual(longestCommonPrefix("abcx", "abcy"), "abc") + self.assertEqual(longestCommonPrefix("abc", "xyz"), "") + + def test_decodeIntToUnicode(self): + # single-byte code points map to their char + self.assertEqual(decodeIntToUnicode(65), u"A") + self.assertEqual(decodeIntToUnicode(97), u"a") + # NOTE: >255 ints are interpreted as a multi-byte sequence (not a Unicode code point), + # e.g. 0x2122 -> bytes 0x21 0x22 -> '!"' (documents actual behavior, not an assumption) + self.assertEqual(decodeIntToUnicode(0x2122), u'!"') + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_tamper.py b/tests/test_tamper.py new file mode 100644 index 0000000000..11869d98c0 --- /dev/null +++ b/tests/test_tamper.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tamper scripts (all ~70): contract, robustness on a payload battery, known +transforms, and documented fragile cases. + +NOTE (flagged for author - real minor bugs surfaced by this suite): + * tamper/percentage.py raises UnboundLocalError on empty/None payload + (retVal is only assigned inside `if payload:`; missing `retVal = payload` init). + * tamper/escapequotes.py raises AttributeError on None payload (no guard). + 68/70 tampers handle ""/None gracefully; these two are inconsistent. Pinned below + as KNOWN_FRAGILE so the suite stays green and a fix is a conscious change. +""" + +import os +import glob +import importlib +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, ROOT +bootstrap() + +from thirdparty import six + +TAMPERS = sorted(os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")) + +# realistic, non-empty payloads (incl. unicode via escape, and a long one) +PAYLOADS = [ + "1 AND 2=2", + "1 UNION SELECT NULL,NULL-- -", + "1 AND (SELECT 1 FROM dual)>0", + "1 AND '1'='1", + "admin'-- -", + u"1 AND name='caf\xe9'", + "1 AND " + "A" * 64, # modest "longer" payload +] + +KNOWN_FRAGILE = set() # percentage/escapequotes empty/None crashes were FIXED by the author; now covered below +# Intentionally expensive by design (generates 4.2M parameters per call to flood Lua-Nginx +# WAFs) -> ~6s/call. NOT a bug; excluded from execution to keep the unit suite fast. +HEAVY = {"luanginxmore"} + + +class TestTamperRobustness(unittest.TestCase): + def test_no_crash_returns_string(self): + for name in TAMPERS: + if name in HEAVY: + continue + mod = importlib.import_module("tamper.%s" % name) + for p in PAYLOADS: + try: + r = mod.tamper(p) + except Exception as ex: + self.fail("tamper '%s' crashed on %r: %s" % (name, p[:25], ex)) + self.assertTrue(isinstance(r, six.string_types), + msg="tamper '%s' returned %s for %r" % (name, type(r).__name__, p[:25])) + + +class TestTamperEmptyNoneHandling(unittest.TestCase): + def test_graceful_on_empty_and_none(self): + for name in TAMPERS: + if name in KNOWN_FRAGILE or name in HEAVY: + continue + mod = importlib.import_module("tamper.%s" % name) + for p in ("", None): + try: + mod.tamper(p) + except Exception as ex: + self.fail("tamper '%s' crashed on %r: %s" % (name, p, ex)) + + def test_previously_fragile_now_fixed(self): + # regression pin: percentage/escapequotes used to crash on empty/None; now must be graceful + import tamper.percentage as _p + import tamper.escapequotes as _e + self.assertEqual(_p.tamper(""), "") + self.assertIsNone(_p.tamper(None)) + self.assertEqual(_e.tamper(""), "") + self.assertIsNone(_e.tamper(None)) + + +class TestKnownTransforms(unittest.TestCase): + # authoritative input->output taken from each tamper's own doctest + CASES = { + "space2comment": ("SELECT id FROM users", "SELECT/**/id/**/FROM/**/users"), + "between": ("1 AND A > B--", "1 AND A NOT BETWEEN 0 AND B--"), + "charencode": ("SELECT FIELD FROM%20TABLE", + "%53%45%4C%45%43%54%20%46%49%45%4C%44%20%46%52%4F%4D%20%54%41%42%4C%45"), + "apostrophemask": ("1 AND '1'='1", "1 AND %EF%BC%871%EF%BC%87=%EF%BC%871"), + "equaltolike": ("SELECT * FROM users WHERE id=1", "SELECT * FROM users WHERE id LIKE 1"), + "percentage": ("SELECT FIELD FROM TABLE", "%S%E%L%E%C%T %F%I%E%L%D %F%R%O%M %T%A%B%L%E"), + # additional deterministic transforms (verified stable across repeated calls) + "space2plus": ("1 AND 2>1", "1+AND+2>1"), + "unionalltounion": ("1 UNION ALL SELECT 2", "1 UNION SELECT 2"), + "halfversionedmorekeywords": ("1 AND 2>1", "1/*!0AND 2>1"), + "versionedkeywords": ("1 AND 2>1", "1/*!AND*/2>1"), + "appendnullbyte": ("1", "1%00"), + "base64encode": ("1 AND 1=1", "MSBBTkQgMT0x"), + "greatest": ("1 AND A>B", "1 AND GREATEST(A,B+1)=A"), + "ifnull2ifisnull": ("IFNULL(a,b)", "IF(ISNULL(a),b,a)"), + "symboliclogical": ("1 AND 2 OR 3", "1 %26%26 2 %7C%7C 3"), + "bluecoat": ("1 AND 2=2", "1 AND%092 LIKE 2"), + "apostrophenullencode": ("'", "%00%27"), + } + + def test_transforms(self): + for name, (inp, expected) in self.CASES.items(): + mod = importlib.import_module("tamper.%s" % name) + self.assertEqual(mod.tamper(inp), expected, msg="tamper '%s'(%r)" % (name, inp)) + + +class TestTamperCount(unittest.TestCase): + def test_expected_count(self): + # there are currently 70 tamper scripts; floor at 70 so an accidental deletion (or a glob + # that silently stops matching) fails loudly rather than passing on a shrunken set + self.assertGreaterEqual(len(TAMPERS), 70, msg="expected >=70 tampers, found %d" % len(TAMPERS)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_targeturl.py b/tests/test_targeturl.py new file mode 100644 index 0000000000..a0e05ac851 --- /dev/null +++ b/tests/test_targeturl.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Target URL parsing (lib/core/common.py parseTargetUrl). + +parseTargetUrl reads conf.url and populates conf.hostname / conf.port / +conf.scheme / conf.path - the values every subsequent request is built from. A +wrong default port or dropped scheme here misdirects the entire scan, so the +scheme/default-port/explicit-port/path cases are pinned. + +(Inline URL credentials user:pw@host are intentionally not covered - sqlmap +uses --auth-cred for that and does not parse them out of conf.url.) +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import parseTargetUrl +from lib.core.data import conf + + +def _parse(url): + conf.url = url + parseTargetUrl() + return conf.hostname, conf.port, conf.scheme, conf.path + + +class TestScheme(unittest.TestCase): + def test_http(self): + host, port, scheme, _ = _parse("http://host/p?id=1") + self.assertEqual((host, scheme), ("host", "http")) + + def test_https(self): + _, _, scheme, _ = _parse("https://host/p") + self.assertEqual(scheme, "https") + + +class TestDefaultPorts(unittest.TestCase): + def test_http_default_80(self): + self.assertEqual(_parse("http://h/")[1], 80) + + def test_https_default_443(self): + self.assertEqual(_parse("https://h/")[1], 443) + + def test_no_trailing_slash(self): + host, port, scheme, _ = _parse("http://h") + self.assertEqual((host, port), ("h", 80)) + + +class TestExplicitPort(unittest.TestCase): + def test_explicit_port(self): + host, port, scheme, _ = _parse("https://example.com:8443/x") + self.assertEqual((host, port, scheme), ("example.com", 8443, "https")) + + +class TestPath(unittest.TestCase): + def test_path_extracted(self): + self.assertEqual(_parse("http://host/some/path?q=1")[3], "/some/path") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_texthelpers.py b/tests/test_texthelpers.py new file mode 100644 index 0000000000..2726e6747f --- /dev/null +++ b/tests/test_texthelpers.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Text-processing helpers in lib/core/common.py: +normalizeUnicode (accent folding), filterStringValue (charset whitelist), +parseFilePaths (absolute-path harvesting from error pages -> kb.absFilePaths), +getSafeExString (safe exception rendering). + +parseFilePaths in particular feeds path disclosure / file-read targeting, so +its extraction is pinned with realistic PHP/ASP error strings. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import normalizeUnicode, filterStringValue, parseFilePaths, getSafeExString +from lib.core.data import kb + + +class TestNormalizeUnicode(unittest.TestCase): + def test_strips_accents(self): + self.assertEqual(normalizeUnicode(u"caf\xe9 r\xe9sum\xe9"), u"cafe resume") + + def test_ascii_unchanged(self): + self.assertEqual(normalizeUnicode(u"plain ascii 123"), u"plain ascii 123") + + +class TestFilterStringValue(unittest.TestCase): + def test_keep_lowercase(self): + self.assertEqual(filterStringValue("abc123!@#", r"[a-z]"), "abc") + + def test_keep_digits(self): + self.assertEqual(filterStringValue("a1b2c3", r"[0-9]"), "123") + + def test_all_match(self): + self.assertEqual(filterStringValue("abc", r"[a-z]"), "abc") + + +class TestParseFilePaths(unittest.TestCase): + def setUp(self): + kb.absFilePaths = set() + + def test_unix_paths_from_php_error(self): + parseFilePaths("Warning: include(/var/www/html/config.php) failed " + "to open stream in /var/www/html/index.php on line 5") + self.assertIn("/var/www/html/config.php", kb.absFilePaths) + self.assertIn("/var/www/html/index.php", kb.absFilePaths) + + def test_windows_path(self): + # exact full path (not a substring) - a truncated harvest is a real defect for file-read targeting + parseFilePaths("Fatal error in C:\\inetpub\\wwwroot\\app\\index.asp on line 1") + self.assertIn("C:\\inetpub\\wwwroot\\app\\index.asp", kb.absFilePaths, + msg="windows path not harvested in full: %s" % kb.absFilePaths) + + +class TestGetSafeExString(unittest.TestCase): + def test_format(self): + self.assertEqual(getSafeExString(ValueError("boom")), u"ValueError: boom") + + def test_runtime_error(self): + # RuntimeError keeps its name across py2/py3 (unlike IOError, which aliases to OSError on py3) + self.assertEqual(getSafeExString(RuntimeError("oops")), u"RuntimeError: oops") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_union_engine.py b/tests/test_union_engine.py new file mode 100644 index 0000000000..97ac88081d --- /dev/null +++ b/tests/test_union_engine.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The UNION-based column-count detection engine (lib/techniques/union/test.py). + +_findUnionCharCount discovers how many columns a UNION injection needs. Its +fastest path is the ORDER BY technique: a valid target accepts ORDER BY 1..N and +errors on ORDER BY N+1, so it binary-searches for N. We drive the REAL function +against a mock oracle (Request.queryPage replaced) that errors once the requested +column index exceeds a known true count - exercising the actual detection + +binary search with no live target. + +This requires the full injection context (conf.parameters / conf.paramDict / +kb.injection) because column detection builds real payloads via agent.payload. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms +bootstrap() + +from lib.core.data import conf, kb +from lib.core.enums import PAYLOAD, PLACE +from lib.request.connect import Connect +import lib.techniques.union.test as ut + +MARKER = "MARKER42" +VALID_PAGE = "results %s" % MARKER + +_CONF = {"string": MARKER, "notString": None, "regexp": None, "code": None, + "uCols": None, "uColsStart": 1, "uColsStop": 50, "base64Parameter": ()} +_KB = {"heavilyDynamic": False, "errorIsNone": False, "futileUnion": False, + "uChar": "NULL", "forceWhere": None} + + +class TestOrderByColumnCount(unittest.TestCase): + def setUp(self): + self._sc = {k: conf.get(k) for k in _CONF} + self._sk = {k: kb.get(k) for k in _KB} + self._sp = (conf.get("parameters"), conf.get("paramDict")) + self._sqp = Connect.queryPage + self._stmpl = kb.get("pageTemplate") + self._sinj = (kb.injection.place, kb.injection.parameter) + + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + kb.pageTemplate = VALID_PAGE + kb.injection.place = None + kb.injection.parameter = None + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._sc.items(): + conf[k] = v + for k, v in self._sk.items(): + kb[k] = v + conf.parameters, conf.paramDict = self._sp + kb.pageTemplate = self._stmpl + kb.injection.place, kb.injection.parameter = self._sinj + Connect.queryPage = self._sqp + ut.Request.queryPage = self._sqp + + def _detect(self, true_count): + def oracle(payload=None, place=None, content=False, raise404=True, **kwargs): + m = re.search(r"ORDER BY (\d+)", payload or "") + cols = int(m.group(1)) if m else 1 + if cols <= true_count: + page = VALID_PAGE + else: + page = "Unknown column '%d' in 'order clause'" % cols + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + ut.Request.queryPage = staticmethod(oracle) + kb.orderByColumns = None + return ut._findUnionCharCount("-- -", PLACE.GET, "id", "1", "", "", PAYLOAD.WHERE.ORIGINAL) + + def test_detect_single_column(self): + self.assertEqual(self._detect(1), 1) + + def test_detect_small(self): + self.assertEqual(self._detect(3), 3) + + def test_detect_medium(self): + self.assertEqual(self._detect(7), 7) + + def test_detect_larger(self): + self.assertEqual(self._detect(12), 12) + + def test_detect_beyond_first_step(self): + # > ORDER_BY_STEP (10): forces the expand-then-bisect branch + self.assertEqual(self._detect(25), 25) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_urls.py b/tests/test_urls.py new file mode 100644 index 0000000000..3d67d17a55 --- /dev/null +++ b/tests/test_urls.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +URL encode/decode round-trips, parameter parsing, same-host checks. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import urldecode, urlencode, paramToDict, checkSameHost +from lib.core.enums import PLACE + +RND = random.Random(11) + + +class TestUrlCoding(unittest.TestCase): + def test_known(self): + self.assertEqual(urldecode("a%20b"), u"a b") + self.assertEqual(urlencode("a b&c"), "a%20b&c") + + def test_encode_is_not_identity(self): + # anchor so the round-trip property below can't pass with no-op functions: + # special chars MUST be percent-encoded + encoded = urlencode("a b&c=d", safe="") + self.assertNotIn(" ", encoded) + self.assertNotIn("&", encoded) + self.assertEqual(encoded, "a%20b%26c%3Dd") + + def test_roundtrip_property(self): + import string + # NOTE: urldecode() by default preserves URL-structural chars (?, &, =, +, ;) so a full + # round-trip needs convall=True; '+' still excluded (form-encoding maps it to space). + alphabet = string.ascii_letters + string.digits + " &=?/#@:,'\"" + for _ in range(2000): + s = "".join(RND.choice(alphabet) for _ in range(RND.randint(0, 25))) + roundtripped = urldecode(urlencode(s, safe=""), convall=True) + self.assertEqual(roundtripped, s, msg="roundtrip %r" % s) + + +class TestParamToDict(unittest.TestCase): + def test_get(self): + d = paramToDict(PLACE.GET, "a=1&b=2&c=3") + self.assertEqual(d.get("a"), "1") + self.assertEqual(d.get("b"), "2") + self.assertEqual(d.get("c"), "3") + + def test_get_single(self): + d = paramToDict(PLACE.GET, "id=42") + self.assertEqual(d.get("id"), "42") + + +class TestSameHost(unittest.TestCase): + def test_same(self): + self.assertTrue(checkSameHost("http://h/a", "http://h/b")) + self.assertTrue(checkSameHost("http://h:80/a", "http://h:80/b")) + + def test_www_prefix_is_same(self): + # documented behavior: a leading www. is normalized away + self.assertTrue(checkSameHost("http://example.com/a", "http://www.example.com/b")) + + def test_different_host_is_false(self): + # discriminating: an always-True implementation must fail here + self.assertFalse(checkSameHost("http://h/a", "http://other/b")) + self.assertFalse(checkSameHost("http://example.com/a", "http://evil.com/b")) + + def test_one_none_is_false(self): + self.assertFalse(checkSameHost("http://h/a", None)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000..b710169bcd --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core utility helpers: constant-time compare, numeric checks, safe formatting, +list/value normalization, randomness generators. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import (safeCompareStrings, isDigit, isNumber, safeStringFormat, + filterNone, flattenValue, isListLike, unArrayizeValue, + arrayizeValue, randomStr, randomInt) + + +class TestSafeCompareStrings(unittest.TestCase): + def test_known(self): + self.assertTrue(safeCompareStrings("abc", "abc")) + self.assertFalse(safeCompareStrings("abc", "abd")) + self.assertFalse(safeCompareStrings("test", None)) + self.assertTrue(safeCompareStrings(None, None)) + self.assertFalse(safeCompareStrings("a", "ab")) # different length + + def test_property(self): + for s in ["", "a", "secret", "p@ss w0rd", "x" * 100]: + self.assertTrue(safeCompareStrings(s, s)) + self.assertFalse(safeCompareStrings(s, s + "x")) + + +class TestNumericChecks(unittest.TestCase): + def test_isDigit(self): + for v, exp in [("123", True), ("0", True), ("12a", False), ("", False), ("-1", False)]: + self.assertEqual(bool(isDigit(v)), exp, msg="isDigit(%r)" % v) + + def test_isNumber(self): + for v, exp in [("123", True), ("1.5", True), ("1e3", True), ("abc", False), ("", False)]: + self.assertEqual(bool(isNumber(v)), exp, msg="isNumber(%r)" % v) + + +class TestSafeStringFormat(unittest.TestCase): + def test_basic(self): + self.assertEqual(safeStringFormat("%s-%d", ("a", 5)), "a-5") + self.assertEqual(safeStringFormat("%s/%s", ("x", "y")), "x/y") + + def test_survives_percent_in_value(self): + # the WHOLE point of safeStringFormat over plain `%`: a '%' inside an argument (common in + # payloads/URL-encoded values) must not blow up or be misread as a format spec. + # Plain "x=%s" % ("100%done",) would raise on re-evaluation; safeStringFormat must not. + self.assertEqual(safeStringFormat("x=%s", ("100%done",)), "x=100%done") + + +class TestListValueHelpers(unittest.TestCase): + def test_filterNone(self): + self.assertEqual(filterNone([1, None, 2, 0, "", None]), [1, 2, 0]) + self.assertEqual(filterNone([]), []) + self.assertEqual(filterNone([None, None]), []) + + def test_flattenValue(self): + self.assertEqual(list(flattenValue([[1, 2], [3, [4]]])), [1, 2, 3, 4]) + self.assertEqual(list(flattenValue([])), []) + self.assertEqual(list(flattenValue([1])), [1]) + + def test_isListLike(self): + from lib.core.datatype import OrderedSet + from lib.core.bigarray import BigArray + # isListLike is sqlmap-specific: it must recognize sqlmap's own list-like containers + # (OrderedSet, BigArray), not just builtin list/tuple - that's why it's not isinstance(list) + self.assertTrue(isListLike([1])) + self.assertTrue(isListLike((1,))) + self.assertTrue(isListLike(OrderedSet([1, 2]))) + self.assertTrue(isListLike(BigArray([1]))) + # and must reject str (the classic trap) and dict + self.assertFalse(isListLike("string")) + self.assertFalse(isListLike({"a": 1})) + + def test_arrayize_roundtrip(self): + self.assertEqual(unArrayizeValue([5]), 5) + self.assertIsNone(unArrayizeValue([])) + self.assertEqual(unArrayizeValue(7), 7) + self.assertEqual(arrayizeValue(5), [5]) + self.assertEqual(arrayizeValue([5]), [5]) + + +class TestRandomGenerators(unittest.TestCase): + def test_randomStr_length_and_alphabet(self): + for n in (1, 4, 16, 50): + self.assertEqual(len(randomStr(n)), n) + for _ in range(200): + self.assertTrue(all("a" <= c <= "z" for c in randomStr(20, lowercase=True))) + alpha = list("ABC") + for _ in range(200): + self.assertTrue(all(c in alpha for c in randomStr(20, alphabet=alpha))) + + def test_randomStr_is_actually_random(self): + # guard against a hardcoded/constant return: 20-char strings must (essentially) never collide + samples = set(randomStr(20) for _ in range(100)) + self.assertEqual(len(samples), 100, msg="randomStr produced collisions - not random?") + + def test_randomInt_digits(self): + for n in (1, 3, 6): + lo, hi = 10 ** (n - 1), 10 ** n + for _ in range(200): + v = randomInt(n) + self.assertEqual(len(str(v)), n) # exactly n digits + self.assertTrue(lo <= v < hi, msg="randomInt(%d)=%d out of [%d,%d)" % (n, v, lo, hi)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_wordlist.py b/tests/test_wordlist.py new file mode 100644 index 0000000000..9b6d842a45 --- /dev/null +++ b/tests/test_wordlist.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Wordlist iterator (lib/core/wordlist.py). + +Backs dictionary attacks (--common-tables, password cracking, brute force): a +lazy iterator that streams words across one or more files (and zip archives) +without loading them into RAM. Tested for ordering, multi-file chaining, +rewind, and end-of-stream behavior over real temp files. +""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.wordlist import Wordlist + + +def _mkfile(lines): + fd, path = tempfile.mkstemp() + os.write(fd, ("\n".join(lines) + "\n").encode("utf-8")) + os.close(fd) + return path + + +def _w(s): + # Wordlist yields native str on py2 but bytes on py3 (words are fed straight into HTTP payloads) + return s.encode("utf-8") if sys.version_info[0] >= 3 else s + + +def _drain(w): + out = [] + try: + while True: + out.append(next(w)) + except StopIteration: + pass + return out + + +class TestWordlist(unittest.TestCase): + def setUp(self): + self.paths = [] + self.wordlists = [] + + def tearDown(self): + for w in self.wordlists: # close open file handles (else ResourceWarning on py3) + try: + w.closeFP() + except Exception: + pass + for p in self.paths: + if os.path.exists(p): + os.remove(p) + + def _mk(self, lines): + p = _mkfile(lines) + self.paths.append(p) + return p + + def _wl(self, files): + w = Wordlist(files) + self.wordlists.append(w) + return w + + def test_single_file_order(self): + w = self._wl([self._mk(["alpha", "beta", "gamma"])]) + self.assertEqual(_drain(w), [_w("alpha"), _w("beta"), _w("gamma")]) + + def test_multiple_files_chained(self): + w = self._wl([self._mk(["a", "b"]), self._mk(["c", "d"])]) + self.assertEqual(_drain(w), [_w("a"), _w("b"), _w("c"), _w("d")]) + + def test_rewind_restarts(self): + w = self._wl([self._mk(["one", "two"])]) + self.assertEqual(next(w), _w("one")) + self.assertEqual(next(w), _w("two")) + w.rewind() + self.assertEqual(next(w), _w("one")) + + def test_end_raises_stopiteration(self): + w = self._wl([self._mk(["only"])]) + self.assertEqual(next(w), _w("only")) + self.assertRaises(StopIteration, lambda: next(w)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 948d01d57a9ce37006588c39c2db466406317aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Mon, 15 Jun 2026 09:59:01 +0200 Subject: [PATCH 4/4] Fixing CI/CD failing --- .github/workflows/tests.yml | 5 ++++- data/txt/sha256sums.txt | 2 +- lib/core/settings.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 358f7ba7ed..58aeb75b42 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,10 @@ jobs: run: python -c "import sqlmap; import sqlmapapi" - name: Unit tests - run: python -m unittest discover -s tests -p "test_*.py" + # -B: do not write .pyc files. On Python 2 / PyPy a cached .pyc makes a module's __file__ + # point at the .pyc, which would make the later --smoke getFileType(__file__) doctest see + # 'binary' instead of 'text'. Keeping this step byte-compile-free leaves --smoke clean. + run: python -B -m unittest discover -s tests -p "test_*.py" - name: Smoke test run: python sqlmap.py --smoke diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index 25094e0d69..03b0a1934a 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -188,7 +188,7 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -a910686c6eba592ba3f6fc5cbb8bed1bd6c330b0165c7c5dc927a71c5ae8be88 lib/core/settings.py +8eb10b15440aaa6ddc592e1b29199e9fa575df6b46335fcf7b7374c5f8f68480 lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py diff --git a/lib/core/settings.py b/lib/core/settings.py index 6c7c64b909..5fa1a8f153 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.6.106" +VERSION = "1.10.6.107" 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)