diff --git a/data/txt/common-columns.txt b/data/txt/common-columns.txt index a3d425bee12..3d35ae98a4e 100644 --- a/data/txt/common-columns.txt +++ b/data/txt/common-columns.txt @@ -2768,6 +2768,42 @@ shouji u_pass hashedPw +# API keys, tokens and secrets + +api_key +apikey +api_token +access_token +refresh_token +auth_token +session_token +remember_token +secret_key +client_secret +encryption_key +reset_token +password_reset_token +verification_token +confirmation_token +otp +otp_secret +totp_secret +mfa_secret +two_factor_secret + +# Framework and identity columns + +deleted_at +uuid +role +is_admin +is_active +is_verified +date_of_birth +dob +credit_card +postal_code + # password (international) adgangskode diff --git a/data/txt/common-files.txt b/data/txt/common-files.txt index d64015805e8..1e378d3c430 100644 --- a/data/txt/common-files.txt +++ b/data/txt/common-files.txt @@ -1807,3 +1807,48 @@ /opt/kibana/config/kibana.yml /etc/kibana/kibana.yml /etc/elasticsearch/elasticsearch.yml + +# Containers and orchestration secrets + +/.dockerenv +/proc/self/cgroup +/proc/1/cgroup +/run/secrets/kubernetes.io/serviceaccount/token +/var/run/secrets/kubernetes.io/serviceaccount/token +/run/secrets/kubernetes.io/serviceaccount/namespace +/var/run/secrets/kubernetes.io/serviceaccount/namespace +/run/secrets/kubernetes.io/serviceaccount/ca.crt + +# Cloud provider credentials + +/root/.aws/credentials +/root/.aws/config +/root/.config/gcloud/application_default_credentials.json +/root/.config/gcloud/credentials.db +/root/.azure/accessTokens.json +/root/.kube/config + +# SSH keys and DB/tool history + +/root/.ssh/authorized_keys +/root/.ssh/id_ed25519 +/root/.ssh/id_ecdsa +/root/.ssh/id_dsa +/root/.mysql_history +/root/.psql_history +/root/.rediscli_history +/root/.python_history +/root/.sqlite_history + +# dotenv, VCS and app config under common web roots + +/var/www/.env +/var/www/html/.env +/app/.env +/usr/share/nginx/html/.env +/var/www/html/.git/config +/var/www/html/.git/HEAD +/var/www/html/wp-config.php +/app/application.properties +/app/application.yml +/app/docker-compose.yml diff --git a/data/txt/common-tables.txt b/data/txt/common-tables.txt index 855593c6af3..24e1f184bdd 100644 --- a/data/txt/common-tables.txt +++ b/data/txt/common-tables.txt @@ -3372,17 +3372,23 @@ django_session django_migrations django_content_type django_admin_log +auth_user_groups +auth_user_user_permissions # laravel migrations password_resets +password_reset_tokens failed_jobs personal_access_tokens job_batches model_has_roles model_has_permissions role_has_permissions +cache +cache_locks +notifications # rails @@ -3420,3 +3426,6 @@ subscriptions users_bak users_old orders_backup +user_roles +role_permissions +tokens diff --git a/lib/core/common.py b/lib/core/common.py index ad9f5564c9f..63251cc26e8 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -74,6 +74,7 @@ from lib.core.dicts import DBWIRE_MODULES from lib.core.dicts import DEFAULT_DOC_ROOTS from lib.core.dicts import DEPRECATED_OPTIONS +from lib.core.dicts import HTML_ENTITIES from lib.core.dicts import OBSOLETE_OPTIONS from lib.core.dicts import SQL_STATEMENTS from lib.core.enums import ADJUST_TIME_DELAY @@ -189,7 +190,6 @@ from lib.core.settings import URLENCODE_CHAR_LIMIT from lib.core.settings import URLENCODE_FAILSAFE_CHARS from lib.core.settings import USER_AGENT_ALIASES -from lib.core.settings import VERSION_COMPARISON_CORRECTION from lib.core.settings import VERSION_STRING from lib.core.settings import ZIP_HEADER from lib.core.settings import WEBSCARAB_SPLITTER @@ -629,7 +629,9 @@ def paramToDict(place, parameters=None): if place in conf.parameters and not parameters: parameters = conf.parameters[place] - parameters = re.sub(r"&(\w{1,4});", r"%s\g<1>%s" % (PARAMETER_AMP_MARKER, PARAMETER_SEMICOLON_MARKER), parameters) + # Note: shield real HTML entities (e.g. & — ’) from being split on the '&'/';' delimiter; + # match a named entity of any length but only when it is a genuine one, so a plain "&word;" still splits + parameters = re.sub(r"&(\w+);", lambda match: "%s%s%s" % (PARAMETER_AMP_MARKER, match.group(1), PARAMETER_SEMICOLON_MARKER) if match.group(1) in HTML_ENTITIES else match.group(0), parameters) if place == PLACE.COOKIE: splitParams = parameters.split(conf.cookieDel or DEFAULT_COOKIE_DELIMITER) else: @@ -1807,7 +1809,17 @@ def parseTargetUrl(): errMsg += "in the hostname part" raise SqlmapGenericException(errMsg) - hostnamePort = urlSplit.netloc.split(":") if not re.search(r"\[.+\]", urlSplit.netloc) else filterNone((re.search(r"\[.+\]", urlSplit.netloc).group(0), re.search(r"\](:(?P\d+))?", urlSplit.netloc).group("port"))) + netloc = urlSplit.netloc + + # Note: strip any URL userinfo ('user:pass@') so it is not mistaken for host:port (as the proxy + # parser already does). Credentials embedded in the URL are NOT applied - '--auth-cred' is the + # supported way to pass HTTP authentication - so warn rather than silently dropping them. + if '@' in netloc: + if not any((conf.get("authType"), conf.get("authCred"), conf.get("authFile"))): + singleTimeWarnMessage("credentials in the target URL are ignored. Use '--auth-cred' for HTTP authentication") + netloc = netloc.rsplit('@', 1)[-1] + + hostnamePort = netloc.split(":") if not re.search(r"\[.+\]", netloc) else filterNone((re.search(r"\[.+\]", netloc).group(0), re.search(r"\](:(?P\d+))?", netloc).group("port"))) conf.scheme = (urlSplit.scheme.strip().lower() or "http") conf.path = urlSplit.path.strip() @@ -2943,7 +2955,12 @@ def extractErrorMessage(page): if match: candidate = htmlUnescape(match.group("result")).replace("
", "\n").strip() - if candidate and (1.0 * len(re.findall(r"[^A-Za-z,. ]", candidate)) / len(candidate) > MIN_ERROR_PARSING_NON_WRITING_RATIO): + # Note: only the generic '(fatal|error|warning|exception): ...' regexes can capture + # arbitrary prose, so guard those with the non-writing-char ratio; the specific + # DBMS-signature regexes (e.g. MSSQL 'Unclosed quotation mark ...') are definitive and + # must not be discarded just because the message happens to read like plain text + generic = "(fatal|error|warning|exception)" in regex + if candidate and (not generic or 1.0 * len(re.findall(r"[^A-Za-z,. ]", candidate)) / len(candidate) > MIN_ERROR_PARSING_NON_WRITING_RATIO): retVal = candidate break @@ -3497,42 +3514,40 @@ def isDBMSVersionAtLeast(minimum): if not any(isNoneValue(_) for _ in (Backend.getVersion(), minimum)) and Backend.getVersion() != UNKNOWN_DBMS_VERSION: version = Backend.getVersion().replace(" ", "").rstrip('.') - correction = 0.0 + # Note: a fuzzy/ranged detected version (e.g. '>2', '<2') is captured as a sign so an + # otherwise-equal comparison still resolves in the right direction + vSign = 0 if ">=" in version: pass elif '>' in version: - correction = VERSION_COMPARISON_CORRECTION + vSign = 1 elif '<' in version: - correction = -VERSION_COMPARISON_CORRECTION + vSign = -1 version = extractRegexResult(r"(?P[0-9][0-9.]*)", version) if version: - if '.' in version: - parts = version.split('.', 1) - parts[1] = filterStringValue(parts[1], '[0-9]') - version = '.'.join(parts) + minimum = minimum if isinstance(minimum, six.string_types) else getUnicode(minimum) - try: - version = float(filterStringValue(version, '[0-9.]')) + correction - except ValueError: - return None - - if isinstance(minimum, six.string_types): - if '.' in minimum: - parts = minimum.split('.', 1) - parts[1] = filterStringValue(parts[1], '[0-9]') - minimum = '.'.join(parts) - - correction = 0.0 - if minimum.startswith(">="): - pass - elif minimum.startswith(">"): - correction = VERSION_COMPARISON_CORRECTION - - minimum = float(filterStringValue(minimum, '[0-9.]')) + correction - - retVal = version >= minimum + mSign = 0 + if minimum.startswith(">="): + pass + elif minimum.startswith(">"): + mSign = 1 + + minimum = extractRegexResult(r"(?P[0-9][0-9.]*)", minimum) + + if minimum: + # Note: compare dotted versions component-wise as int tuples, not as floats; a float + # collapses e.g. 5.4.3->5.43 and 5.10.0->5.100(==5.1), silently mis-ordering multi-part + # or multi-digit-minor versions (MariaDB 10.11, PostgreSQL 9.10, Presto 0.99 vs 0.178) + vParts = tuple(int(_) for _ in re.findall(r"\d+", version)) + mParts = tuple(int(_) for _ in re.findall(r"\d+", minimum)) + length = max(len(vParts), len(mParts)) + vParts += (0,) * (length - len(vParts)) + mParts += (0,) * (length - len(mParts)) + + retVal = (vParts, vSign) >= (mParts, mSign) return retVal diff --git a/lib/core/convert.py b/lib/core/convert.py index a925bba6701..bb57e4c182e 100644 --- a/lib/core/convert.py +++ b/lib/core/convert.py @@ -114,7 +114,10 @@ def _serializeEncode(value): name = "%s.%s" % (value.__class__.__module__, value.__class__.__name__) if name in _SERIALIZE_CLASSES: return {_SERIALIZE_TAG: "o", "c": name, "d": [[_serializeEncode(k), _serializeEncode(v)] for (k, v) in value.items()], "s": _serializeEncode(dict(value.__dict__))} - elif value.__class__ is dict: + elif value.__class__ is dict or (name or "").split(".")[0] not in ("lib", "plugins", "thirdparty"): + # a plain dict, or a foreign mapping subclass (e.g. collections.OrderedDict/defaultdict): store the + # items as a plain mapping so the data round-trips, instead of silently degrading to its text repr. + # A non-allowlisted lib/plugins/thirdparty subclass still falls through to _serializeUnknown (fail loudly) return {_SERIALIZE_TAG: "m", "v": [[_serializeEncode(k), _serializeEncode(v)] for (k, v) in value.items()]} else: return _serializeUnknown(value, name) diff --git a/lib/core/decorators.py b/lib/core/decorators.py index 0490692676b..aa6c1a850b0 100644 --- a/lib/core/decorators.py +++ b/lib/core/decorators.py @@ -15,6 +15,11 @@ _cache = {} _method_locks = {} +# Private marker prefixing the slow-path (frozen) cache key so it can never collide with a raw +# fast-path key: without it e.g. args ([1,2],) freezes to ((1,2),), the exact fast key of ((1,2),), +# and the two calls would share a cache slot and return each other's result +_FROZEN_KEY_MARKER = object() + def cachedmethod(f): """ Method with a cached content @@ -62,9 +67,9 @@ def _f(*args, **kwargs): # Note: fallback (slow-path) for unhashable arguments try: if kwargs: - key = (_freeze(args), _freeze(kwargs)) + key = (_FROZEN_KEY_MARKER, _freeze(args), _freeze(kwargs)) else: - key = _freeze(args) + key = (_FROZEN_KEY_MARKER, _freeze(args)) with lock: if key in cache: diff --git a/lib/core/dicts.py b/lib/core/dicts.py index d2a28a94a9f..53fe36f4716 100644 --- a/lib/core/dicts.py +++ b/lib/core/dicts.py @@ -233,7 +233,7 @@ DBMS.ACCESS: (ACCESS_ALIASES, "python-pyodbc", "https://github.com/mkleehammer/pyodbc", "access"), DBMS.FIREBIRD: (FIREBIRD_ALIASES, "python3-firebirdsql", "https://github.com/nakagami/pyfirebirdsql/", "firebird"), DBMS.MAXDB: (MAXDB_ALIASES, None, None, None), # 'maxdb'/'sapdb' SQLAlchemy dialect was removed long ago; a dead dialect only produces a misleading warning - DBMS.SYBASE: (SYBASE_ALIASES, "python-pymssql", "https://github.com/pymssql/pymssql", "sybase"), + DBMS.SYBASE: (SYBASE_ALIASES, "python-pymssql", "https://github.com/pymssql/pymssql", None), # 'sybase' SQLAlchemy dialect was removed in 1.4 (external replacement abandoned); pymssql/dbwire cover '-d', so a dead dialect only wastes an attempt and shows a misleading warning DBMS.DB2: (DB2_ALIASES, "python ibm-db", "https://github.com/ibmdb/python-ibmdb", "ibm_db_sa"), DBMS.HSQLDB: (HSQLDB_ALIASES, "python jaydebeapi & python-jpype", "https://pypi.python.org/pypi/JayDeBeApi/ & https://github.com/jpype-project/jpype", None), DBMS.H2: (H2_ALIASES, None, None, None), diff --git a/lib/core/dump.py b/lib/core/dump.py index bbeb3d0f221..cff21165911 100644 --- a/lib/core/dump.py +++ b/lib/core/dump.py @@ -54,6 +54,8 @@ from lib.core.settings import IS_WIN from lib.core.settings import METADB_SUFFIX from lib.core.settings import MIN_BINARY_DISK_DUMP_SIZE +from lib.core.settings import SQLITE_INT_MAX +from lib.core.settings import SQLITE_INT_MIN from lib.core.settings import TRIM_STDOUT_DUMP_SIZE from lib.core.settings import UNICODE_ENCODING from lib.core.settings import UNSAFE_DUMP_FILEPATH_REPLACEMENT @@ -558,7 +560,10 @@ def dbTableValues(self, tableValues): if not value or value == " ": # NULL continue - int(value) + # Note: keep INTEGER only for values SQLite's affinity leaves untouched; leading zeros ('007'), signs ('+1') or 64-bit overflow would be silently rewritten on insert + parsed = int(value) + if str(parsed) != value or not (SQLITE_INT_MIN <= parsed <= SQLITE_INT_MAX): + raise ValueError except ValueError: colType = None break @@ -572,7 +577,9 @@ def dbTableValues(self, tableValues): if not value or value == " ": # NULL continue - float(value) + # Note: likewise REAL must round-trip textually ('2.00' or '1e5' would lose their exact form) + if repr(float(value)) != value: + raise ValueError except ValueError: colType = None break diff --git a/lib/core/settings.py b/lib/core/settings.py index 6fab8744626..d17576fa243 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.118" +VERSION = "1.10.7.142" 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) @@ -481,7 +481,7 @@ SESSION_SQLITE_FILE = "session.sqlite" # Regular expressions used for finding file paths in error messages -FILE_PATH_REGEXES = (r"(?P[^<>]+?) on line \d+", r"\bin (?P[^<>'\"]+?)['\"]? on line \d+", r"(?:[>(\[\s])(?P[A-Za-z]:[\\/][\w. \\/-]*)", r"(?:[>(\[\s])(?P/\w[/\w.~-]+)", r"\bhref=['\"]file://(?P/[^'\"]+)", r"\bin (?P[^<]+): line \d+") +FILE_PATH_REGEXES = (r"(?P[^<>]+?) on line \d+", r"\bin (?P[^<>'\"]+?)['\"]? on line \d+", r"(?:[>(\[\s'\"])(?P[A-Za-z]:[\\/][\w. \\/-]*)", r"(?:[>(\[\s'\"])(?P/\w[/\w.~-]+)", r"\bhref=['\"]file://(?P/[^'\"]+)", r"\bin (?P[^<]+): line \d+") # Regular expressions used for parsing error messages (--parse-errors) ERROR_PARSING_REGEXES = ( @@ -501,7 +501,7 @@ ) # Regular expression used for parsing charset info from meta html headers -META_CHARSET_REGEX = r'(?si).*]+charset="?(?P[^"> ]+).*' +META_CHARSET_REGEX = r"""(?si)]*>.*]+charset\s*=\s*["']?(?P[^"'> ]+).*""" # Regular expression used for parsing refresh info from meta html headers META_REFRESH_REGEX = r'(?i)]+content="?[^">]+;\s*(url=)?["\']?(?P[^\'">]+)' @@ -683,6 +683,10 @@ # Maximum integer value MAX_INT = sys.maxsize +# Signed 64-bit range of SQLite's INTEGER storage class (used for safe --dump-format=SQLITE typing) +SQLITE_INT_MIN = -0x8000000000000000 +SQLITE_INT_MAX = 0x7fffffffffffffff + # Replacement for unsafe characters in dump table filenames UNSAFE_DUMP_FILEPATH_REPLACEMENT = '_' diff --git a/lib/request/basic.py b/lib/request/basic.py index 5cddbd98338..8ea3f750a00 100644 --- a/lib/request/basic.py +++ b/lib/request/basic.py @@ -108,7 +108,10 @@ def title(self): if conf.cj: if HTTP_HEADER.COOKIE in headers: for cookie in conf.cj: - if cookie is None or cookie.domain_specified and not (conf.hostname or "").endswith(cookie.domain): + # Note: a domain-scoped cookie (Domain=example.com) is stored by the cookie jar as + # '.example.com', so a plain endswith() wrongly excludes the apex host itself + # ('example.com' does not end with '.example.com'); accept the exact domain too + if cookie is None or cookie.domain_specified and not ((conf.hostname or "").endswith(cookie.domain) or (conf.hostname or "") == cookie.domain.lstrip('.')): continue if ("%s=" % getUnicode(cookie.name)) in getUnicode(headers[HTTP_HEADER.COOKIE]): diff --git a/lib/request/connect.py b/lib/request/connect.py index b39e456ec91..fb57614fa4b 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -544,7 +544,13 @@ def getPage(**kwargs): break else: for i in xrange(max(1, kb.webSocketRecvCount)): - _page.append(ws.recv()) + # Note: a later response may carry fewer frames than the calibration one + # (e.g. a FALSE boolean-blind reply); stop on timeout instead of letting it + # bubble up as a failed request, mirroring the initial recv loop above + try: + _page.append(ws.recv()) + except websocket.WebSocketTimeoutException: + break page = "\n".join(_page) finally: @@ -1266,13 +1272,16 @@ def _adjustParameter(paramString, parameter, newValue): if urlencode(parameter) in paramString: parameter = urlencode(parameter) - match = re.search(r"%s=[^&]*" % re.escape(parameter), paramString, re.I) + # Note: anchor to a real parameter boundary (start or right after '&'/a quote) so that + # adjusting e.g. 'token' does not match inside a longer name like 'csrf_token'/'user_token' + # and rewrite the wrong parameter (which broke anti-CSRF token injection) + match = re.search(r"(?i)(?:\A|(?<=&))%s=[^&]*" % re.escape(parameter), paramString) if match: - retVal = re.sub(r"(?i)%s" % re.escape(match.group(0)), ("%s=%s" % (parameter, newValue)).replace('\\', r'\\'), paramString) + retVal = "%s%s=%s%s" % (paramString[:match.start()], parameter, newValue, paramString[match.end():]) else: - match = re.search(r"(%s[\"']\s*:\s*[\"'])([^\"']*)" % re.escape(parameter), paramString, re.I) + match = re.search(r"(?i)[\"']%s[\"']\s*:\s*[\"'](?P[^\"']*)" % re.escape(parameter), paramString) if match: - retVal = re.sub(r"(?i)%s" % re.escape(match.group(0)), "%s%s" % (match.group(1), newValue), paramString) + retVal = "%s%s%s" % (paramString[:match.start("value")], newValue, paramString[match.end("value"):]) return retVal @@ -1357,11 +1366,14 @@ def _adjustParameter(paramString, parameter, newValue): if conf.rParam: def _randomizeParameter(paramString, randomParameter): retVal = paramString - match = re.search(r"(\A|\b)%s=(?P[^&;]*)" % re.escape(randomParameter), paramString) + # Note: anchor to a real parameter boundary (start, or the '&'/';' delimiter with any + # following space), like _adjustParameter, so randomizing 'id' does not match inside a + # longer name such as 'user-id'/'session-id'; the captured prefix is kept on replace + match = re.search(r"(?:\A|[&;]\s*)%s=(?P[^&;]*)" % re.escape(randomParameter), paramString) if match: origValue = match.group("value") newValue = randomizeParameterValue(origValue) if randomParameter not in kb.randomPool else random.sample(kb.randomPool[randomParameter], 1)[0] - retVal = re.sub(r"(\A|\b)%s=[^&;]*" % re.escape(randomParameter), "%s=%s" % (randomParameter, newValue), paramString) + retVal = re.sub(r"(\A|[&;]\s*)%s=[^&;]*" % re.escape(randomParameter), lambda m: "%s%s=%s" % (m.group(1), randomParameter, newValue), paramString) else: match = re.search(r"(\A|\b)(%s\b[^\w]+)(?P\w+)" % re.escape(randomParameter), paramString) if match: diff --git a/lib/utils/crawler.py b/lib/utils/crawler.py index ff8d7bdd653..787f0a15e54 100644 --- a/lib/utils/crawler.py +++ b/lib/utils/crawler.py @@ -223,22 +223,35 @@ def crawlThread(): kb.normalizeCrawlingChoice = readInput(message, default='Y', boolean=True) if kb.normalizeCrawlingChoice: - seen = set() - results = OrderedSet() - - for target in kb.targets: - value = "%s%s%s" % (target[0], '&' if '?' in target[0] else '?', target[2] or "") - match = re.search(r"/[^/?]*\?.+\Z", value) - if match: - key = re.sub(r"=[^=&]*", "=", match.group(0)).strip("&?") - if '=' in key and key not in seen: - results.add(target) - seen.add(key) - - kb.targets = results + kb.targets = normalizeCrawlingResults(kb.targets) storeResultsToFile(kb.targets) +def normalizeCrawlingResults(targets): + """ + Collapses crawled targets that differ only in their parameter values (e.g. ?id=1 vs ?id=2), + keeping one representative per distinct endpoint+parameter-name shape + + >>> sorted(_[0] for _ in normalizeCrawlingResults([("http://h/users/edit?id=1", None, None, None, None), ("http://h/users/edit?id=2", None, None, None, None), ("http://h/products/edit?id=1", None, None, None, None)])) + ['http://h/products/edit?id=1', 'http://h/users/edit?id=1'] + """ + + seen = set() + results = OrderedSet() + + for target in targets: + value = "%s%s%s" % (target[0], '&' if '?' in target[0] else '?', target[2] or "") + # Note: key on the full path (not just the last segment) so distinct endpoints sharing an + # action name and parameters (e.g. /users/edit?id= vs /products/edit?id=) are not collapsed + match = re.search(r"\A[^?]+\?.+\Z", value) + if match: + key = re.sub(r"=[^=&]*", "=", match.group(0)).strip("&?") + if '=' in key and key not in seen: + results.add(target) + seen.add(key) + + return results + def storeResultsToFile(results): if not results: return diff --git a/lib/utils/gui.py b/lib/utils/gui.py index 97beb328b5c..452160bb468 100644 --- a/lib/utils/gui.py +++ b/lib/utils/gui.py @@ -5,11 +5,13 @@ See the file 'LICENSE' for copying permission """ +import io import os import subprocess import sys import tempfile import threading +import time import webbrowser from lib.core.common import getSafeExString @@ -28,35 +30,88 @@ from lib.core.settings import WIKI_PAGE from thirdparty.six.moves import queue as _queue -# Classic Windows (NT/9x) palette: silver 3D face, navy title/selection, white sunken fields, -# black text, and saturated VGA-style accents for the icons (presentation only) +try: + _text_type = unicode +except NameError: + _text_type = str + +_binary_type = str if sys.version_info[0] < 3 else bytes +_clock = getattr(time, "perf_counter", getattr(time, "clock", time.time)) + +def _toText(value): + """Return a Unicode text value on both Python 2.7 and Python 3.x.""" + if value is None: + return u"" + if isinstance(value, _text_type): + return value + if isinstance(value, _binary_type): + try: + return value.decode("utf-8", "replace") + except Exception: + return _text_type(value) + try: + return _text_type(value) + except Exception: + return _text_type(repr(value)) + +def _toBytes(value): + """Return UTF-8 bytes suitable for a binary subprocess pipe.""" + if isinstance(value, _binary_type): + return value + return _toText(value).encode("utf-8", "replace") + +def _waitForProcess(process, timeout): + """Python 2 compatible replacement for Popen.wait(timeout=...).""" + deadline = _clock() + max(0.0, timeout) + while process.poll() is None and _clock() < deadline: + time.sleep(0.03) + return process.poll() + +def _list2cmdline(arguments): + values = [_toText(_) for _ in arguments] + if sys.version_info[0] < 3: + return _toText(subprocess.list2cmdline([_toBytes(_) for _ in values])) + return _toText(subprocess.list2cmdline(values)) + +# A restrained security-tool palette: the layout stays familiar, while the darker +# navigation, cyan accents and terminal surfaces add a light Havij-era character. PALETTE = { - "base": "#c0c0c0", # window / control face (silver) - "mantle": "#c0c0c0", # bars (classic is uniform gray, separated by bevels) - "crust": "#ffffff", # console / edit background - "surface0": "#ffffff", # field (edit) background - "surface1": "#808080", # 3D shadow - "surface2": "#dfdfdf", # 3D light (soft) - "light": "#ffffff", # 3D highlight - "dark": "#404040", # 3D dark shadow - "text": "#000000", - "subtext": "#000000", - "overlay": "#404040", - "title2": "#1084d0", # active title-bar gradient end - "blue": "#000080", # navy: title, selection, accents - "sapphire": "#0050b0", - "sky": "#0070c0", - "green": "#008000", - "teal": "#008080", - "red": "#c00000", - "maroon": "#800000", - "mauve": "#9000a8", - "pink": "#c000b0", - "peach": "#c06000", - "yellow": "#c08000", - "lavender": "#4858c0", - "flamingo": "#c04070", - "gold": "#e0a800", + "base": "#d7dce1", + "mantle": "#243545", + "crust": "#101820", + "surface0": "#f8fafb", + "surface1": "#8d98a3", + "surface2": "#e7ebef", + "light": "#ffffff", + "dark": "#3c4650", + "text": "#17212b", + "subtext": "#33414f", + "overlay": "#657381", + "title2": "#0b79a5", + "blue": "#164d73", + "sapphire": "#087caf", + "sky": "#169ec1", + "green": "#2d9659", + "teal": "#178b86", + "red": "#bd3f45", + "maroon": "#8c3d56", + "mauve": "#86549a", + "pink": "#b34e83", + "peach": "#c56d35", + "yellow": "#b78a18", + "lavender": "#6172b8", + "flamingo": "#bf5b72", + "gold": "#d29b22", + "navText": "#eef4f8", + "navMuted": "#a9bac8", + "navHover": "#31485d", + "panel": "#eef2f5", + "border": "#9ca7b1", + "success": "#2f9b5b", + "command": "#13232e", + "commandText": "#8de19b", + "consoleText": "#d8e7de", + "consoleMuted": "#8fa69a", } # a distinct accent color per section, so the sidebar icons read as a colorful, scannable set @@ -98,6 +153,8 @@ TARGET_PLACEHOLDER = "http://www.target.com/vuln.php?id=1" HINT_DEFAULT = "Hover or focus a field to see what it does." +MAX_CONSOLE_LINES = 12000 +MAX_SEARCH_RESULTS = 12 # --- parser-backend compatibility (works for both optparse and argparse objects) --- @@ -154,34 +211,143 @@ def _optValueType(option): def _optionLabel(option): return ", ".join(_optStrings(option)) or (_optDest(option) or "") -class _Tooltip(object): - """Lightweight hover tooltip for a widget""" - - def __init__(self, widget, text, tk, palette): - self._widget = widget - self._text = text +def _preferredFlag(option): + strings = _optStrings(option) + longOptions = [_ for _ in strings if _.startswith("--")] + return (longOptions or strings or [""])[0] + +def _quoteArg(value): + value = _toText(value) + if IS_WIN: + return _list2cmdline([value]) + if not value: + return u"''" + safe = u"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" + if all(character in safe for character in value): + return value + return u"'" + value.replace(u"'", u"'\"'\"'") + u"'" + +class _TooltipManager(object): + """One shared tooltip/hint dispatcher for every option control. + + Per-widget Python/Tcl bindings are surprisingly expensive when a pane contains + dozens of options. Widgets only receive a small Python attribute; four global + bindings handle the whole application. + """ + + def __init__(self, owner, root, tk, palette, delay=500): + self._owner = owner + self._root = root self._tk = tk self._palette = palette + self._delay = delay + self._widget = None self._tip = None - widget.bind("", self._show, add="+") - widget.bind("", self._hide, add="+") - widget.bind("", self._hide, add="+") + self._job = None + root.bind_all("", self._enter, add="+") + root.bind_all("", self._leave, add="+") + root.bind_all("", self._focusIn, add="+") + root.bind_all("", self._focusOut, add="+") + root.bind_all("", self._hide, add="+") + + def attach(self, widget, text): + if text: + widget._sqlmap_help = text - def _show(self, event=None): - if self._tip or not self._text: + def _textFor(self, widget): + return getattr(widget, "_sqlmap_help", "") + + def _setHint(self, text): + try: + if hasattr(self._owner, "hint"): + self._owner.hint.set(text or HINT_DEFAULT) + except Exception: + pass + + def _enter(self, event): + text = self._textFor(event.widget) + if not text: return - x = self._widget.winfo_rootx() + 18 - y = self._widget.winfo_rooty() + self._widget.winfo_height() + 6 - self._tip = tw = self._tk.Toplevel(self._widget) - tw.wm_overrideredirect(True) - tw.wm_geometry("+%d+%d" % (x, y)) - self._tk.Label(tw, text=self._text, justify="left", background=self._palette["surface0"], - foreground=self._palette["text"], relief="flat", borderwidth=0, - wraplength=460, padx=10, pady=7).pack() + self._widget = event.widget + self._setHint(text) + self._cancel() + try: + self._job = self._root.after(self._delay, self._show) + except Exception: + self._job = None + + def _leave(self, event): + if event.widget is self._widget: + self._widget = None + self._cancel() + self._hide() + self._setHint(HINT_DEFAULT) + + def _focusIn(self, event): + text = self._textFor(event.widget) + if text: + self._setHint(text) + + def _focusOut(self, event): + if self._textFor(event.widget): + self._setHint(HINT_DEFAULT) + + def _cancel(self): + if self._job is not None: + try: + self._root.after_cancel(self._job) + except Exception: + pass + self._job = None + + def _show(self): + self._job = None + widget = self._widget + text = self._textFor(widget) if widget is not None else "" + if not text: + return + try: + if not widget.winfo_exists(): + return + x = widget.winfo_rootx() + 18 + y = widget.winfo_rooty() + widget.winfo_height() + 6 + self._tip = tw = self._tk.Toplevel(widget) + # Toplevels are initially mapped by Tk at the default 0,0 position. + # Keep the tooltip withdrawn until its children have been measured and + # its final geometry has been assigned; otherwise X11 briefly paints an + # empty box in the screen corner before the real tooltip appears. + tw.withdraw() + tw.wm_overrideredirect(True) + try: + tw.wm_transient(self._root) + except Exception: + pass + self._tk.Label(tw, text=text, justify="left", background=self._palette["surface0"], + foreground=self._palette["text"], relief="solid", borderwidth=1, + wraplength=460, padx=10, pady=7).pack() + tw.update_idletasks() + width = max(1, tw.winfo_reqwidth()) + height = max(1, tw.winfo_reqheight()) + x = min(x, max(0, tw.winfo_screenwidth() - width - 8)) + y = min(y, max(0, tw.winfo_screenheight() - height - 8)) + tw.wm_geometry("%dx%d+%d+%d" % (width, height, x, y)) + tw.deiconify() + tw.lift() + except Exception: + if self._tip is not None: + try: + self._tip.destroy() + except Exception: + pass + self._tip = None def _hide(self, event=None): - if self._tip: - self._tip.destroy() + self._cancel() + if self._tip is not None: + try: + self._tip.destroy() + except Exception: + pass self._tip = None class SqlmapGui(object): @@ -194,36 +360,128 @@ def __init__(self, parser, tk, ttk, scrolledtext, messagebox, filedialog, font): self.filedialog = filedialog self.font = font - self.widgets = {} # dest -> (type, shared Tk variable) - self.vars = {} # dest -> shared Tk variable (one per option, bound to every widget for it) + self.widgets = {} # dest -> (type, shared effective-value Tk variable) + self.vars = {} # dest -> shared Tk variable (one per option) self.optionByDest = {} + self.optionOrder = [] + self.sectionByDest = {} + self.searchIndex = [] for group in _parserGroups(parser): + title = _groupTitle(group) for option in _groupOptions(group): - if _optDest(option): - self.optionByDest[_optDest(option)] = option + dest = _optDest(option) + if dest: + if dest not in self.optionByDest: + self.optionOrder.append(dest) + self.optionByDest[dest] = option + self.sectionByDest[dest] = title + + for index, dest in enumerate(self.optionOrder): + option = self.optionByDest[dest] + section = self.sectionByDest.get(dest, "") + label = _optionLabel(option) + flag = _preferredFlag(option) + self.searchIndex.append(( + dest, + index, + label, + section, + flag, + " ".join((label, dest, section, _optHelp(option))).lower(), + )) self.panes = {} # name -> outer frame - self.navItems = {} # name -> (row frame, accent strip, icon canvas, label) + self.navItems = {} # name -> (row frame, accent strip, icon canvas, label, badge) self.canvases = {} # name -> canvas (for wheel binding) self.inners = {} # name -> scrollable inner frame (populated lazily) self.builders = {} # name -> callable that populates the inner frame self.built = set() # names whose content has been built + self.buildStates = {} # name -> generator for incremental pane construction + self._prebuildQueue = [] + self._prebuildJob = None self.badges = {} # name -> sidebar count badge label self.sectionDests = {} # name -> [option dests in that section] self.paneOrder = [] # nav order, for Up/Down navigation self.currentPane = None + self.controlsByDest = {} # dest -> [(pane name, interactive widget)] + self.searchMatches = [] + self.process = None - self.alive = False - self.queue = None + self.processQueue = None + self.processConfigFile = None + self.consoleWindow = None + self.consoleText = None + self.consoleStatus = None + self._runSerial = 0 + self._refreshJob = None + self._searchJob = None + self._headerJob = None + self._suspendRefresh = False try: self.window = tk.Tk() except Exception as ex: raise SqlmapSystemException("unable to create GUI window ('%s')" % getSafeExString(ex)) + self.tooltip = _TooltipManager(self, self.window, tk, PALETTE) + self._initializeVariables() self._initFonts() self._initStyle() self._buildLayout() + self.window.protocol("WM_DELETE_WINDOW", self._closeApplication) + + def _initializeVariables(self): + for dest in self.optionOrder: + option = self.optionByDest[dest] + isBool = not _optTakesValue(option) + otype = "bool" if isBool else _optValueType(option) + default = defaults.get(dest) + if isBool: + var = self.tk.BooleanVar(value=bool(default)) + else: + var = self.tk.StringVar(value="" if default in (None, False) else default) + self.vars[dest] = var + self.widgets[dest] = (otype, var) + try: + var.trace("w", self._onOptionChanged) + except Exception: + pass + + def _onOptionChanged(self, *unused): + if not self._suspendRefresh: + self._scheduleRefresh() + + def _scheduleRefresh(self, delay=70): + if self._refreshJob is not None: + try: + self.window.after_cancel(self._refreshJob) + except Exception: + pass + self._refreshJob = self.window.after(delay, self._refreshDerivedState) + + def _refreshDerivedState(self): + self._refreshJob = None + self._updateStats() + self.command.set(self._buildCommandString()) + self._updateStatusLight() + + def _updateStatusLight(self): + try: + canvas = self.statusLight + except AttributeError: + return + try: + canvas.delete("all") + if self._isRunning(): + color = PALETTE["success"] + elif any(self._isOptionSet(_) for _ in self.widgets): + color = PALETTE["sky"] + else: + color = PALETTE["surface1"] + canvas.create_oval(2, 2, 10, 10, fill=color, outline=PALETTE["dark"]) + except Exception: + pass + def _initFonts(self): family = self.font.nametofont("TkDefaultFont").actual("family") @@ -239,82 +497,142 @@ def _initFonts(self): def _initStyle(self): p = PALETTE - face, light, light2, shadow, dark = p["base"], p["light"], p["surface2"], p["surface1"], p["dark"] - navy, white, black, field = p["blue"], "#ffffff", p["text"], p["surface0"] + face = p["base"] + field = p["surface0"] style = self.ttk.Style() if "clam" in style.theme_names(): style.theme_use("clam") - style.configure(".", background=face, foreground=black, fieldbackground=field, - bordercolor=shadow, lightcolor=light, darkcolor=shadow, - troughcolor=face, focuscolor=face, insertcolor=black, font=self.fonts["body"]) - - for name in ("TFrame", "Bar.TFrame", "Nav.TFrame", "Card.TFrame"): - style.configure(name, background=face) - - style.configure("TLabel", background=face, foreground=black) - style.configure("Title.TLabel", background=navy, foreground=white, font=self.fonts["title"]) - style.configure("Subtitle.TLabel", background=navy, foreground=white, font=self.fonts["subtitle"]) - style.configure("Hint.TLabel", background=face, foreground=p["overlay"], font=self.fonts["small"]) - style.configure("Field.TLabel", background=face, foreground=black) - style.configure("Desc.TLabel", background=face, foreground=p["overlay"], font=self.fonts["small"]) - style.configure("Pane.TLabel", background=face, foreground=navy, font=self.fonts["title"]) - style.configure("Stat.TLabel", background=face, foreground=p["overlay"], font=self.fonts["small"]) - style.configure("Prompt.TLabel", background=field, foreground=black, font=self.fonts["mono"]) - - # classic raised 3D push button - style.configure("TButton", background=face, foreground=black, relief="raised", borderwidth=2, - lightcolor=light, darkcolor=dark, bordercolor=shadow, focuscolor=black, padding=(12, 4)) - style.map("TButton", background=[("active", face)], relief=[("pressed", "sunken")]) - - # sunken white edit fields - for name in ("TEntry", "Target.TEntry"): - style.configure(name, fieldbackground=field, foreground=black, relief="sunken", borderwidth=2, - bordercolor=shadow, lightcolor=shadow, darkcolor=light, insertcolor=black, padding=4) - - style.configure("TCheckbutton", background=face, foreground=black, focuscolor=face, padding=2, - indicatorbackground=field, indicatorforeground=black, indicatorrelief="sunken", indicatorborderwidth=2, - bordercolor=shadow, lightcolor=shadow, darkcolor=light) - style.map("TCheckbutton", background=[("active", face)], indicatorbackground=[("active", field), ("selected", field)]) - - style.configure("TCombobox", fieldbackground=field, background=face, foreground=black, arrowcolor=black, - relief="sunken", borderwidth=2, bordercolor=shadow, lightcolor=shadow, darkcolor=light, padding=3) - - # classic chunky scrollbar (raised gray thumb, light trough) - style.configure("Vertical.TScrollbar", background=face, troughcolor=light2, bordercolor=shadow, - lightcolor=light, darkcolor=dark, arrowcolor=black, relief="raised", width=17) - style.map("Vertical.TScrollbar", background=[("active", face)]) + style.configure(".", background=face, foreground=p["text"], fieldbackground=field, + bordercolor=p["border"], lightcolor=p["light"], darkcolor=p["surface1"], + troughcolor=p["surface2"], focuscolor=p["blue"], insertcolor=p["text"], + font=self.fonts["body"]) + + style.configure("TFrame", background=face) + style.configure("Bar.TFrame", background=p["panel"]) + style.configure("Nav.TFrame", background=p["mantle"]) + style.configure("Card.TFrame", background=p["panel"]) + style.configure("Panel.TFrame", background=p["surface0"]) + style.configure("PaneHeader.TFrame", background=p["surface0"]) + + style.configure("TLabel", background=face, foreground=p["text"]) + style.configure("Title.TLabel", background=p["blue"], foreground="#ffffff", font=self.fonts["title"]) + style.configure("Subtitle.TLabel", background=p["blue"], foreground="#dceaf2", font=self.fonts["subtitle"]) + style.configure("Hint.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("PanelHint.TLabel", background=p["surface0"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("PanelLabel.TLabel", background=p["surface0"], foreground=p["blue"], font=self.fonts["bodyBold"]) + style.configure("NavHint.TLabel", background=p["mantle"], foreground=p["navMuted"], font=self.fonts["small"]) + style.configure("NavTitle.TLabel", background=p["mantle"], foreground=p["navText"], font=self.fonts["bodyBold"]) + style.configure("Field.TLabel", background=p["panel"], foreground=p["text"]) + style.configure("Desc.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Pane.TLabel", background=p["surface0"], foreground=p["blue"], font=self.fonts["title"]) + style.configure("PaneCount.TLabel", background=p["surface0"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Stat.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Prompt.TLabel", background=field, foreground=p["text"], font=self.fonts["mono"]) + + style.configure("TButton", background=p["surface2"], foreground=p["text"], relief="raised", borderwidth=1, + lightcolor=p["light"], darkcolor=p["surface1"], bordercolor=p["border"], + focuscolor=p["blue"], padding=(11, 5)) + style.map("TButton", background=[("active", p["surface0"]), ("pressed", p["surface1"])], + relief=[("pressed", "sunken")]) + style.configure("Tool.TButton", padding=(9, 4), font=self.fonts["small"]) + style.configure("Primary.TButton", background=p["success"], foreground="#ffffff", bordercolor=p["green"], + lightcolor="#78c89a", darkcolor="#17643a", padding=(12, 5), font=self.fonts["bodyBold"]) + style.map("Primary.TButton", background=[("active", "#39aa68"), ("pressed", "#247c49")], + foreground=[("disabled", "#d7e4dc")]) + + style.configure("TEntry", fieldbackground=field, foreground=p["text"], relief="sunken", borderwidth=1, + bordercolor=p["border"], lightcolor=p["surface1"], darkcolor=p["light"], + insertcolor=p["text"], padding=5) + style.configure("Target.TEntry", fieldbackground="#ffffff", foreground=p["text"], relief="sunken", borderwidth=1, + bordercolor=p["sapphire"], lightcolor=p["surface1"], darkcolor=p["light"], + insertcolor=p["text"], padding=7, font=self.fonts["body"]) + style.configure("Search.TEntry", fieldbackground="#192936", foreground=p["navText"], relief="flat", borderwidth=1, + bordercolor="#4c6376", lightcolor="#4c6376", darkcolor="#17242f", + insertcolor="#ffffff", padding=6) + + style.configure("TCheckbutton", background=p["panel"], foreground=p["text"], focuscolor=p["panel"], padding=2, + indicatorbackground=field, indicatorforeground=p["blue"], indicatorrelief="sunken", + indicatorborderwidth=1, bordercolor=p["border"], lightcolor=p["surface1"], darkcolor=p["light"]) + style.map("TCheckbutton", background=[("active", p["panel"])], + indicatorbackground=[("active", field), ("selected", field)]) + + style.configure("TCombobox", fieldbackground=field, background=p["surface2"], foreground=p["text"], + arrowcolor=p["blue"], relief="sunken", borderwidth=1, bordercolor=p["border"], + lightcolor=p["surface1"], darkcolor=p["light"], padding=4) + + style.configure("Vertical.TScrollbar", background=p["surface2"], troughcolor=p["panel"], + bordercolor=p["border"], lightcolor=p["light"], darkcolor=p["surface1"], + arrowcolor=p["text"], relief="raised", width=16) + style.map("Vertical.TScrollbar", background=[("active", p["surface0"])]) self.window.configure(background=face) - # --- layout --------------------------------------------------------- - def _buildLayout(self): tk = self.tk - self.window.title("sqlmap") - self.window.minsize(960, 680) + p = PALETTE + self.window.title("sqlmap GUI") + self.window.minsize(980, 690) self._buildMenu() self._buildHeader() - target = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(20, 12, 20, 14)) - target.pack(fill=tk.X) - labelRow = self.ttk.Frame(target, style="Bar.TFrame") - labelRow.pack(fill=tk.X, pady=(0, 4)) - self.ttk.Label(labelRow, text="TARGET URL", style="Hint.TLabel").pack(side=tk.LEFT) - self.ttk.Label(labelRow, text=" e.g. %s" % TARGET_PLACEHOLDER, style="Stat.TLabel").pack(side=tk.LEFT) + targetShell = tk.Frame(self.window, background=p["border"], borderwidth=0) + targetShell.pack(fill=tk.X, padx=16, pady=(10, 8)) + target = self.ttk.Frame(targetShell, style="Panel.TFrame", padding=(14, 10, 14, 12)) + target.pack(fill=tk.X, padx=1, pady=1) + tk.Frame(target, background=p["red"], height=3).pack(fill=tk.X, pady=(0, 9)) + + labelRow = self.ttk.Frame(target, style="Panel.TFrame") + labelRow.pack(fill=tk.X, pady=(0, 5)) + self.ttk.Label(labelRow, text="TARGET URL", style="PanelLabel.TLabel").pack(side=tk.LEFT) + self.ttk.Label(labelRow, text="Ctrl+L", style="PanelHint.TLabel").pack(side=tk.RIGHT) + self.ttk.Label(labelRow, text=" e.g. %s" % TARGET_PLACEHOLDER, style="PanelHint.TLabel").pack(side=tk.LEFT) + + targetRow = self.ttk.Frame(target, style="Panel.TFrame") + targetRow.pack(fill=tk.X) urlVar = self._destVar("url", False) - self.targetEntry = self.ttk.Entry(target, style="Target.TEntry", textvariable=urlVar) - self.targetEntry.pack(fill=tk.X, ipady=2) - self.widgets["url"] = ("string", urlVar) + self.targetEntry = self.ttk.Entry(targetRow, style="Target.TEntry", textvariable=urlVar) + self.targetEntry.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=1) + self.ttk.Button(targetRow, text="Paste", style="Tool.TButton", command=self._pasteTarget, + takefocus=False).pack(side=tk.LEFT, padx=(8, 0)) + self.ttk.Button(targetRow, text="Clear", style="Tool.TButton", command=self._clearTarget, + takefocus=False).pack(side=tk.LEFT, padx=(6, 0)) + self.controlsByDest.setdefault("url", []).append((None, self.targetEntry)) body = self.ttk.Frame(self.window, style="TFrame") body.pack(expand=True, fill=tk.BOTH) - navHolder = self.ttk.Frame(body, style="Nav.TFrame", width=202) + navHolder = self.ttk.Frame(body, style="Nav.TFrame", width=224) navHolder.pack(side=tk.LEFT, fill=tk.Y) navHolder.pack_propagate(False) - self.navCanvas = tk.Canvas(navHolder, background=PALETTE["mantle"], highlightthickness=0, borderwidth=0) - navScroll = self.ttk.Scrollbar(navHolder, orient="vertical", command=self.navCanvas.yview, style="Vertical.TScrollbar") + + searchBar = self.ttk.Frame(navHolder, style="Nav.TFrame", padding=(11, 11, 11, 8)) + searchBar.pack(fill=tk.X) + searchTitle = self.ttk.Frame(searchBar, style="Nav.TFrame") + searchTitle.pack(fill=tk.X, pady=(0, 5)) + self.ttk.Label(searchTitle, text="OPTION FINDER", style="NavTitle.TLabel").pack(side=tk.LEFT) + self.ttk.Label(searchTitle, text="Ctrl+K", style="NavHint.TLabel").pack(side=tk.RIGHT) + self.searchVar = tk.StringVar(value="") + self.searchEntry = self.ttk.Entry(searchBar, style="Search.TEntry", textvariable=self.searchVar) + self.searchEntry.pack(fill=tk.X) + self.searchEntry.bind("", self._activateSearchResult) + self.searchEntry.bind("", self._searchMoveDown) + try: + self.searchVar.trace("w", self._scheduleSearch) + except Exception: + pass + + self.searchList = tk.Listbox(navHolder, height=6, activestyle="dotbox", exportselection=False, + bg="#192936", fg=p["navText"], selectbackground=p["sapphire"], + selectforeground="#ffffff", relief="flat", borderwidth=1, + highlightthickness=1, highlightbackground="#4c6376", + font=self.fonts["small"]) + self.searchList.bind("", self._clickSearchResult) + self.searchList.bind("", self._activateSearchResult) + + self.navCanvas = tk.Canvas(navHolder, background=p["mantle"], highlightthickness=0, borderwidth=0) + navScroll = self.ttk.Scrollbar(navHolder, orient="vertical", command=self.navCanvas.yview, + style="Vertical.TScrollbar") self.nav = self.ttk.Frame(self.navCanvas, style="Nav.TFrame") self.nav.bind("", lambda e: self.navCanvas.configure(scrollregion=self.navCanvas.bbox("all"))) navWin = self.navCanvas.create_window((0, 0), window=self.nav, anchor="nw") @@ -323,24 +641,31 @@ def _buildLayout(self): self.navCanvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) navScroll.pack(side=tk.RIGHT, fill=tk.Y) - tk.Frame(body, background=PALETTE["surface1"], width=1).pack(side=tk.LEFT, fill=tk.Y) + tk.Frame(body, background=p["border"], width=1).pack(side=tk.LEFT, fill=tk.Y) self.content = self.ttk.Frame(body, style="Card.TFrame") self.content.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) - cmdBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(20, 8)) + cmdBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(16, 8)) cmdBar.pack(fill=tk.X) - self.ttk.Label(cmdBar, text="Command:", style="Hint.TLabel").pack(side=tk.LEFT, padx=(0, 8)) - self.ttk.Button(cmdBar, text="Copy", command=self._copyCommand, takefocus=False).pack(side=tk.RIGHT, padx=(8, 0)) + self.ttk.Label(cmdBar, text=">_", style="PanelLabel.TLabel").pack(side=tk.LEFT, padx=(0, 8)) + self.ttk.Button(cmdBar, text="Copy", style="Tool.TButton", command=self._copyCommand, + takefocus=False).pack(side=tk.RIGHT, padx=(7, 0)) + self.ttk.Button(cmdBar, text="Reset", style="Tool.TButton", command=self.resetOptions, + takefocus=False).pack(side=tk.RIGHT, padx=(7, 0)) self.command = tk.StringVar(value="sqlmap.py") cmdEntry = tk.Entry(cmdBar, textvariable=self.command, font=self.fonts["mono"], - bg="#ffffff", fg=PALETTE["blue"], readonlybackground="#ffffff", - disabledforeground=PALETTE["blue"], relief="sunken", borderwidth=2, - highlightthickness=0, state="readonly") - cmdEntry.pack(side=tk.LEFT, fill=tk.X, expand=True) + bg=p["command"], fg=p["commandText"], readonlybackground=p["command"], + disabledforeground=p["commandText"], relief="flat", borderwidth=0, + highlightthickness=1, highlightbackground=p["border"], + highlightcolor=p["sapphire"], state="readonly") + cmdEntry.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=5) - hintBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(20, 9)) + hintBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(16, 8)) hintBar.pack(fill=tk.X) + self.statusLight = tk.Canvas(hintBar, width=12, height=12, background=p["panel"], + highlightthickness=0, borderwidth=0) + self.statusLight.pack(side=tk.LEFT, padx=(0, 8)) self.stat = tk.StringVar(value="") self.ttk.Label(hintBar, textvariable=self.stat, style="Stat.TLabel", anchor="e").pack(side=tk.RIGHT, padx=(12, 0)) self.hint = tk.StringVar(value=HINT_DEFAULT) @@ -350,6 +675,7 @@ def _buildLayout(self): for group in _parserGroups(self.parser): self._buildGroupPane(group) + self._prebuildQueue = list(self.paneOrder) self._selectPane("Quick start") self.window.bind("", lambda e: self._navKey(1)) self.window.bind("", lambda e: self._navKey(-1)) @@ -359,34 +685,14 @@ def _buildLayout(self): self.window.bind("", lambda e: self.run()) self.window.bind("", lambda e: self.run()) self.window.bind("", lambda e: self._focusTarget()) + self.window.bind("", lambda e: self._focusSearch()) + self.window.bind("", self._escapeAction) self.window.bind("", lambda e: self.saveConfigDialog()) self.window.bind("", lambda e: self.loadConfig()) self._enableSelectAll() - self._tickStats() - self._prebuildPanes() - self._center(self.window, 1000, 720) - - def _prebuildPanes(self): - # Tk isn't thread-safe, so widgets must be built on the main thread; instead of blocking, - # build the not-yet-visited panes one per idle tick so they are ready (instant) by the time - # the user navigates to them, while the UI stays responsive (on-demand build is the fallback) - pending = [_ for _ in self.paneOrder if _ not in self.built] - - def step(): - while pending and pending[0] in self.built: - pending.pop(0) - if not pending: - return - name = pending.pop(0) - try: - self.builders[name](self.inners[name]) - self.built.add(name) - except Exception: - pass - if pending: - self.window.after(30, step) - - self.window.after(250, step) + self._refreshDerivedState() + self._center(self.window, 1060, 750) + self._schedulePanePrebuild(60) def _enableSelectAll(self): # Tk binds Ctrl-A to "cursor to line start" by default; rebind it to select-all, @@ -414,21 +720,25 @@ def selectText(event): def _buildMenu(self): p = PALETTE - menubar = self.tk.Menu(self.window, bg=p["mantle"], fg=p["text"], activebackground=p["surface0"], activeforeground=p["text"], borderwidth=0) - filemenu = self.tk.Menu(menubar, tearoff=0, bg=p["mantle"], fg=p["text"], activebackground=p["surface0"], activeforeground=p["text"]) + menuKw = dict(bg=p["panel"], fg=p["text"], activebackground=p["sapphire"], + activeforeground="#ffffff") + menubar = self.tk.Menu(self.window, borderwidth=0, **menuKw) + filemenu = self.tk.Menu(menubar, tearoff=0, **menuKw) filemenu.add_command(label="Load configuration...", command=self.loadConfig) filemenu.add_command(label="Save configuration...", command=self.saveConfigDialog) + filemenu.add_command(label="Reset all options", command=self.resetOptions) filemenu.add_separator() - filemenu.add_command(label="Exit", command=self.window.quit) + filemenu.add_command(label="Exit", command=self._closeApplication) menubar.add_cascade(label="File", menu=filemenu) menubar.add_command(label="Run", command=self.run) - helpmenu = self.tk.Menu(menubar, tearoff=0, bg=p["mantle"], fg=p["text"], activebackground=p["surface0"], activeforeground=p["text"]) + helpmenu = self.tk.Menu(menubar, tearoff=0, **menuKw) helpmenu.add_command(label="Official site", command=lambda: webbrowser.open(SITE)) helpmenu.add_command(label="GitHub", command=lambda: webbrowser.open(GIT_PAGE)) helpmenu.add_command(label="Wiki", command=lambda: webbrowser.open(WIKI_PAGE)) helpmenu.add_command(label="Report issue", command=lambda: webbrowser.open(ISSUES_PAGE)) helpmenu.add_separator() - helpmenu.add_command(label="About", command=lambda: self.messagebox.showinfo("About", "%s\n\n (%s)" % (VERSION_STRING, DEV_EMAIL_ADDRESS))) + helpmenu.add_command(label="About", command=lambda: self.messagebox.showinfo( + "About", "%s\n\n (%s)" % (VERSION_STRING, DEV_EMAIL_ADDRESS))) menubar.add_cascade(label="Help", menu=helpmenu) self.window.config(menu=menubar) @@ -436,7 +746,15 @@ def _buildHeader(self): self._runHover = False self.header = self.tk.Canvas(self.window, height=76, highlightthickness=0, borderwidth=0, background=PALETTE["base"]) self.header.pack(fill=self.tk.X) - self.header.bind("", lambda e: self._drawHeader()) + self.header.bind("", self._scheduleHeaderDraw) + + def _scheduleHeaderDraw(self, event=None): + if self._headerJob is not None: + try: + self.window.after_cancel(self._headerJob) + except Exception: + pass + self._headerJob = self.window.after(35, self._drawHeader) def _interp(self, color1, color2, ratio): a = [int(color1[_:_ + 2], 16) for _ in (1, 3, 5)] @@ -444,51 +762,87 @@ def _interp(self, color1, color2, ratio): return "#%02x%02x%02x" % tuple(int(a[_] + (b[_] - a[_]) * ratio) for _ in range(3)) def _drawHeader(self): + """Draw the header only for resize or process-state changes. + + Keep this deliberately cheap. Redrawing a canvas from an / + callback can remove the item currently under the pointer, which generates a + matching leave/enter pair and can turn into an event/redraw loop on Tk/X11. + """ + self._headerJob = None p = PALETTE c = self.header c.delete("all") - width = c.winfo_width() + width = max(1, c.winfo_width()) height = 76 - steps = max(1, width // 4) - for i in range(steps): - c.create_rectangle(i * width / steps, 0, (i + 1) * width / steps + 1, height, - outline="", fill=self._interp(p["blue"], p["title2"], i / float(steps))) - c.create_text(24, 27, text="sqlmap", anchor="w", fill="#ffffff", font=self.fonts["title"]) - c.create_text(122, 31, text=VERSION_STRING.replace("sqlmap/", "v"), anchor="w", fill="#c7d8ef", font=self.fonts["subtitle"]) - c.create_text(24, 54, text="automatic SQL injection and database takeover tool", anchor="w", fill="#dfe8f6", font=self.fonts["small"]) + + # A small, fixed number of primitives paints faster and more consistently + # than a strip-per-gradient header, especially on X11 and remote displays. + c.create_rectangle(0, 0, width, height, outline="", fill="#17445f") + c.create_rectangle(0, 0, 6, height, outline="", fill=p["sky"]) + c.create_rectangle(6, height - 4, width, height, outline="", fill="#0e7697") + c.create_line(22, 64, max(22, width - 160), 64, fill="#39738a") + + c.create_text(26, 26, text="sqlmap", anchor="w", fill="#ffffff", font=self.fonts["title"]) + c.create_text(124, 30, text=VERSION_STRING.replace("sqlmap/", "v"), anchor="w", + fill="#bfe1ed", font=self.fonts["subtitle"]) + c.create_text(26, 52, text="automatic SQL injection and database takeover tool", anchor="w", + fill="#dcecf2", font=self.fonts["small"]) self._drawRunButton(width, height) + def _isRunning(self): + return self.process is not None and self.process.poll() is None + def _drawRunButton(self, width, height): p = PALETTE c = self.header + running = self._isRunning() bw, bh = 116, 34 x0 = width - bw - 22 y0 = (height - bh) // 2 x1, y1 = x0 + bw, y0 + bh - c.create_rectangle(x0, y0, x1, y1, fill=p["base"], outline="", tags=("runbtn", "runpill")) - # classic raised 3D bevel (white top/left, dark bottom/right) - c.create_line(x0, y0, x1, y0, fill="#ffffff", tags="runbtn") - c.create_line(x0, y0, x0, y1, fill="#ffffff", tags="runbtn") - c.create_line(x0, y1, x1, y1, fill=p["dark"], tags="runbtn") - c.create_line(x1, y0, x1, y1 + 1, fill=p["dark"], tags="runbtn") - c.create_line(x0 + 1, y1 - 1, x1 - 1, y1 - 1, fill=p["surface1"], tags="runbtn") - c.create_line(x1 - 1, y0 + 1, x1 - 1, y1 - 1, fill=p["surface1"], tags="runbtn") + baseFill = p["red"] if running else p["success"] + fill = ("#d15056" if running else "#3bae6b") if self._runHover else baseFill + c.create_rectangle(x0, y0, x1, y1, fill=fill, outline="#d9f1e3", width=1, + tags=("runbtn", "runpill")) + c.create_line(x0 + 1, y0 + 1, x1 - 1, y0 + 1, fill="#8fd2aa" if not running else "#ef9da1", + tags="runbtn") + c.create_line(x0 + 1, y1 - 1, x1 - 1, y1 - 1, fill="#17613a" if not running else "#75252a", + tags="runbtn") cy = (y0 + y1) // 2 - tx = x0 + 24 - c.create_polygon(tx, cy - 6, tx, cy + 6, tx + 10, cy, fill=p["blue"], outline="", tags=("runbtn", "runico")) - c.create_text((x0 + x1) // 2 + 8, cy, text="Run", fill=p["text"], font=self.fonts["bodyBold"], tags=("runbtn", "runico")) - c.tag_bind("runbtn", "", lambda e: self.run()) + tx = x0 + 23 + if running: + c.create_rectangle(tx, cy - 6, tx + 11, cy + 6, fill="#ffffff", outline="", + tags=("runbtn", "runico")) + else: + c.create_polygon(tx, cy - 6, tx, cy + 6, tx + 10, cy, fill="#ffffff", outline="", + tags=("runbtn", "runico")) + c.create_text((x0 + x1) // 2 + 8, cy, text=("Stop" if running else "Run"), fill="#ffffff", + font=self.fonts["bodyBold"], tags=("runbtn", "runico")) + c.tag_bind("runbtn", "", lambda e: self._runButtonAction()) c.tag_bind("runbtn", "", lambda e: self._hoverRun(True)) c.tag_bind("runbtn", "", lambda e: self._hoverRun(False)) + def _runButtonAction(self): + if self._isRunning(): + self.stopProcess() + else: + self.run() + def _hoverRun(self, on): + """Update only the existing button items; never rebuild the header here.""" self._runHover = on - self.header.itemconfigure("runpill", fill="#ccccc6" if on else PALETTE["base"]) try: + running = self._isRunning() + if on: + fill = "#d15056" if running else "#3bae6b" + else: + fill = PALETTE["red"] if running else PALETTE["success"] + self.header.itemconfigure("runpill", fill=fill) self.header.configure(cursor="hand2" if on else "") except Exception: pass + def _drawIcon(self, c, name, col): # minimal line-art icons, drawn as vectors so they render everywhere and need no assets c.delete("all") @@ -595,10 +949,10 @@ def _addPane(self, name, navText): icon = tk.Canvas(row, width=22, height=22, highlightthickness=0, borderwidth=0, background=p["mantle"]) icon.pack(side=tk.LEFT, padx=(13, 0), pady=8) self._drawIcon(icon, name, self._iconColor(name)) - badge = tk.Label(row, text="", background=p["mantle"], foreground=p["blue"], font=self.fonts["small"]) + badge = tk.Label(row, text="", background=p["mantle"], foreground=p["navMuted"], font=self.fonts["small"]) badge.pack(side=tk.RIGHT, padx=(0, 12)) self.badges[name] = badge - lab = tk.Label(row, text=navText, background=p["mantle"], foreground=p["subtext"], + lab = tk.Label(row, text=navText, background=p["mantle"], foreground=p["navText"], font=self.fonts["nav"], anchor="w", padx=10, pady=9) lab.pack(side=tk.LEFT, fill=tk.X, expand=True) for w in (row, lab, strip, icon, badge): @@ -609,7 +963,7 @@ def _addPane(self, name, navText): self.paneOrder.append(name) outer = self.ttk.Frame(self.content, style="Card.TFrame") - canvas = tk.Canvas(outer, background=p["base"], highlightthickness=0, borderwidth=0) + canvas = tk.Canvas(outer, background=p["panel"], highlightthickness=0, borderwidth=0) scrollbar = self.ttk.Scrollbar(outer, orient="vertical", command=canvas.yview, style="Vertical.TScrollbar") inner = self.ttk.Frame(canvas, style="Card.TFrame", padding=(24, 20)) inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) @@ -627,9 +981,11 @@ def _iconColor(self, name): return PALETTE.get(ICON_COLORS.get(name, "subtext"), PALETTE["subtext"]) def _navHover(self, name, entering): + if entering: + self._prioritizePaneBuild(name) if name == self.currentPane: return - bg = PALETTE["surface2"] if entering else PALETTE["mantle"] + bg = PALETTE["navHover"] if entering else PALETTE["mantle"] row, strip, icon, lab, badge = self.navItems[name] for w in (row, strip, icon, lab, badge): w.configure(background=bg) @@ -647,9 +1003,11 @@ def _navKey(self, delta): return "break" def _selectPane(self, name): - if name not in self.built: # lazy: populate the pane on first visit - self.builders[name](self.inners[name]) - self.built.add(name) + # Build only a tiny, time-bounded slice synchronously so a never-visited pane + # appears immediately. The remaining rows are completed by the idle prebuilder. + if name not in self.built: + self._prioritizePaneBuild(name) + self._buildPaneSlice(name, budgetMs=14, minimumSteps=5) if self.currentPane == name: return p = PALETTE @@ -658,18 +1016,22 @@ def _selectPane(self, name): row, strip, icon, lab, badge = self.navItems[self.currentPane] for w in (row, strip, icon): w.configure(background=p["mantle"]) - lab.configure(background=p["mantle"], foreground=p["text"], font=self.fonts["nav"]) - badge.configure(background=p["mantle"], foreground=p["blue"]) + lab.configure(background=p["mantle"], foreground=p["navText"], font=self.fonts["nav"]) + badge.configure(background=p["mantle"], foreground=p["navMuted"]) self._drawIcon(icon, self.currentPane, self._iconColor(self.currentPane)) self.panes[name].pack(expand=True, fill=self.tk.BOTH) row, strip, icon, lab, badge = self.navItems[name] - for w in (row, strip, icon): + for w in (row, icon): w.configure(background=p["blue"]) + strip.configure(background=p["sky"]) lab.configure(background=p["blue"], foreground="#ffffff", font=self.fonts["bodyBold"]) - badge.configure(background=p["blue"], foreground="#ffffff") + badge.configure(background=p["blue"], foreground="#d9edf5") self._drawIcon(icon, name, "#ffffff") self.currentPane = name - self._ensureNavVisible(name) + # Geometry flushing here used to make first-time pane switches feel much + # slower than the widget creation itself. Sidebar visibility can be fixed + # on the next idle turn without blocking the click handler. + self.window.after_idle(lambda n=name: self._ensureNavVisible(n) if self.currentPane == n else None) if hasattr(self, "hint"): # don't leave the previous section's option hint lingering self.hint.set(HINT_DEFAULT) @@ -678,7 +1040,6 @@ def _ensureNavVisible(self, name): # scroll the sidebar so the active item stays in view (e.g. when paging with Up/Down) try: row = self.navItems[name][0] - self.nav.update_idletasks() total = self.nav.winfo_height() viewH = self.navCanvas.winfo_height() if total <= 1 or viewH <= 1: @@ -694,8 +1055,14 @@ def _ensureNavVisible(self, name): pass def _onWheel(self, event): - # route the wheel to whichever scroll region the pointer is over (sidebar or content) - delta = 1 if getattr(event, "num", None) == 5 or getattr(event, "delta", 0) < 0 else -1 + # Route the wheel only when the pointer is actually over this window's sidebar/content. + rawDelta = getattr(event, "delta", 0) + if getattr(event, "num", None) == 5: + delta = 1 + elif getattr(event, "num", None) == 4: + delta = -1 + else: + delta = -int(rawDelta / 120) if abs(rawDelta) >= 120 else (-1 if rawDelta > 0 else 1) target = None node = self.window.winfo_containing(event.x_root, event.y_root) while node is not None: @@ -709,26 +1076,248 @@ def _onWheel(self, event): node = node.master except Exception: break - if target is None and self.currentPane: - target = self.canvases.get(self.currentPane) if target is not None: target.yview_scroll(delta, "units") + return "break" + return None + + def _schedulePanePrebuild(self, delay=1): + if self._prebuildJob is not None: + return + try: + self._prebuildJob = self.window.after(delay, self._pumpPanePrebuild) + except Exception: + self._prebuildJob = None + + def _prioritizePaneBuild(self, name): + if name in self.built: + return + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + self._prebuildQueue.insert(0, name) + self._schedulePanePrebuild() + + def _buildPaneSlice(self, name, budgetMs=7, minimumSteps=1): + if name in self.built: + return True + builder = self.builders.get(name) + if builder is None: + self.built.add(name) + return True + state = self.buildStates.get(name) + if state is None: + state = builder(self.inners[name]) + self.buildStates[name] = state + + deadline = _clock() + max(0.001, budgetMs / 1000.0) + steps = 0 + while steps < minimumSteps or _clock() < deadline: + try: + next(state) + steps += 1 + except StopIteration: + self.buildStates.pop(name, None) + self.built.add(name) + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + return True + except Exception: + # A broken optional field should not make the whole GUI unusable. + self.buildStates.pop(name, None) + self.built.add(name) + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + return True + return False + + def _pumpPanePrebuild(self): + self._prebuildJob = None + while self._prebuildQueue and self._prebuildQueue[0] in self.built: + self._prebuildQueue.pop(0) + if not self._prebuildQueue: + return + + name = self._prebuildQueue[0] + finished = self._buildPaneSlice(name, budgetMs=7, minimumSteps=1) + if finished and self._prebuildQueue and self._prebuildQueue[0] == name: + self._prebuildQueue.pop(0) + # Yield to pointer, keyboard, expose and paint events after every small slice. + self._schedulePanePrebuild(1) + + def _scheduleSearch(self, *unused): + if self._searchJob is not None: + try: + self.window.after_cancel(self._searchJob) + except Exception: + pass + self._searchJob = self.window.after(90, self._applySearch) + + def _applySearch(self): + self._searchJob = None + query = self.searchVar.get().strip().lower() + if not query: + self.searchMatches = [] + self.searchList.delete(0, self.tk.END) + self.searchList.pack_forget() + return + + tokens = query.split() + matches = [] + for dest, index, label, section, flag, haystack in self.searchIndex: + if not all(token in haystack for token in tokens): + continue + score = 0 + if dest.lower().startswith(query): + score -= 40 + if flag.lower().startswith(query) or flag.lower().startswith("--" + query): + score -= 30 + if label.lower().startswith(query): + score -= 20 + if section.lower().startswith(query): + score -= 10 + matches.append((score, index, dest, label, section)) + + matches.sort() + matches = matches[:MAX_SEARCH_RESULTS] + self.searchMatches = [item[2] for item in matches] + self.searchList.delete(0, self.tk.END) + for _, _, dest, label, section in matches: + shortSection = NAV_ALIASES.get(section, section) + self.searchList.insert(self.tk.END, "%s [%s]" % (label, shortSection)) + + if matches: + self.searchList.configure(height=min(7, len(matches))) + self.searchList.pack(fill=self.tk.X, padx=9, pady=(0, 7), before=self.navCanvas) + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(0) + self.searchList.activate(0) + else: + self.searchList.insert(self.tk.END, "No matching options") + self.searchList.configure(height=1) + self.searchList.pack(fill=self.tk.X, padx=9, pady=(0, 7), before=self.navCanvas) + + def _searchMoveDown(self, event=None): + if self.searchMatches: + self.searchList.focus_set() + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(0) + self.searchList.activate(0) return "break" + def _clickSearchResult(self, event): + if not self.searchMatches: + return "break" + try: + index = int(self.searchList.nearest(event.y)) + except Exception: + index = 0 + if index < 0 or index >= len(self.searchMatches): + return "break" + # Resolve the clicked row ourselves instead of depending on Listbox class + # bindings, whose selection update happens after this widget binding. + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(index) + self.searchList.activate(index) + return self._activateSearchIndex(index) + + def _activateSearchResult(self, event=None): + if not self.searchMatches: + return "break" + selection = self.searchList.curselection() + index = int(selection[0]) if selection else 0 + return self._activateSearchIndex(index) + + def _activateSearchIndex(self, index): + if not self.searchMatches: + return "break" + if index < 0 or index >= len(self.searchMatches): + index = 0 + dest = self.searchMatches[index] + section = self.sectionByDest.get(dest) + self.searchVar.set("") + if section in self.panes: + self._selectPane(section) + self._focusOptionWhenReady(dest, section) + elif dest == "url": + self._focusTarget() + return "break" + + def _focusOptionWhenReady(self, dest, paneName): + # A search can jump to an option that the incremental pane builder has not + # created yet. Continue that pane in tiny slices and focus as soon as the + # requested widget exists, without blocking the click handler. + for candidatePane, candidateWidget in self.controlsByDest.get(dest, ()): + if candidatePane == paneName: + self.window.after_idle(lambda d=dest, p=paneName: self._focusOption(d, p)) + return + if paneName not in self.built: + self._prioritizePaneBuild(paneName) + self._buildPaneSlice(paneName, budgetMs=6, minimumSteps=1) + self.window.after(1, lambda d=dest, p=paneName: self._focusOptionWhenReady(d, p)) + + def _focusOption(self, dest, paneName): + candidates = self.controlsByDest.get(dest, ()) + widget = None + for candidatePane, candidateWidget in candidates: + if candidatePane == paneName: + widget = candidateWidget + break + if widget is None and candidates: + widget = candidates[0][1] + if widget is None: + return + try: + widget.focus_set() + if isinstance(widget, (self.ttk.Entry, self.ttk.Combobox)): + widget.select_range(0, "end") + canvas = self.canvases.get(paneName) + inner = self.inners.get(paneName) + if canvas is not None and inner is not None: + inner.update_idletasks() + total = max(1, inner.winfo_height()) + canvas.yview_moveto(max(0.0, min(1.0, float(widget.winfo_y() - 30) / total))) + except Exception: + pass + + def _buildPaneHeading(self, parent, title, description, optionCount): + p = PALETTE + card = self.tk.Frame(parent, background=p["surface0"], highlightthickness=1, + highlightbackground=p["border"], borderwidth=0) + card.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 16)) + accent = self.tk.Frame(card, background=self._iconColor(title), width=5) + accent.pack(side=self.tk.LEFT, fill=self.tk.Y) + content = self.ttk.Frame(card, style="PaneHeader.TFrame", padding=(14, 10, 14, 10)) + content.pack(side=self.tk.LEFT, fill=self.tk.BOTH, expand=True) + titleRow = self.ttk.Frame(content, style="PaneHeader.TFrame") + titleRow.pack(fill=self.tk.X) + self.ttk.Label(titleRow, text=title, style="Pane.TLabel").pack(side=self.tk.LEFT) + self.ttk.Label(titleRow, text="%d option%s" % (optionCount, "" if optionCount == 1 else "s"), + style="PaneCount.TLabel").pack(side=self.tk.RIGHT, pady=(6, 0)) + if description: + self.ttk.Label(content, text=description, style="PanelHint.TLabel", wraplength=690, + justify="left").pack(fill=self.tk.X, pady=(3, 0)) + def _buildQuickStartPane(self): name = "Quick start" self._addPane(name, name) self.sectionDests[name] = [_ for _ in QUICK_START_DESTS if _ in self.optionByDest] def build(inner): - self.ttk.Label(inner, text="Quick start", style="Pane.TLabel").grid(row=0, column=0, columnspan=2, sticky="w") - self.ttk.Label(inner, text="The options people reach for most. Set the target above, tick what you want, then Run.", - style="Desc.TLabel", wraplength=640, justify="left").grid(row=1, column=0, columnspan=2, sticky="w", pady=(2, 14)) - row = 2 + description = "The options people reach for most. Set the target above, choose what you need, then Run." + self._buildPaneHeading(inner, name, description, len(self.sectionDests[name])) + yield + row = 1 for dest in QUICK_START_DESTS: option = self.optionByDest.get(dest) if option is not None: - row = self._buildFieldRow(inner, option, row) + row = self._buildFieldRow(inner, option, row, paneName=name) + yield inner.columnconfigure(1, weight=1) self.builders[name] = build @@ -739,61 +1328,50 @@ def _buildGroupPane(self, group): self.sectionDests[title] = [_optDest(_) for _ in _groupOptions(group) if _optDest(_)] def build(inner, group=group, title=title): - self.ttk.Label(inner, text=title, style="Pane.TLabel").grid(row=0, column=0, columnspan=2, sticky="w") + self._buildPaneHeading(inner, title, _groupDescription(group), len(self.sectionDests[title])) + yield row = 1 - description = _groupDescription(group) - if description: - self.ttk.Label(inner, text=description, style="Desc.TLabel", wraplength=640, justify="left").grid( - row=row, column=0, columnspan=2, sticky="w", pady=(2, 14)) - row += 1 for option in _groupOptions(group): - row = self._buildFieldRow(inner, option, row) + row = self._buildFieldRow(inner, option, row, paneName=title) + yield inner.columnconfigure(1, weight=1) self.builders[title] = build def _destVar(self, dest, is_bool): - # one shared variable per option, so every widget that edits it (Quick start pane, - # the proper group pane, the target bar) reflects into the same value both ways + # One shared effective-value variable per option, reflected in every duplicate control. if dest not in self.vars: - self.vars[dest] = self.tk.IntVar() if is_bool else self.tk.StringVar() + var = self.tk.BooleanVar(value=False) if is_bool else self.tk.StringVar(value="") + self.vars[dest] = var + self.widgets[dest] = ("bool" if is_bool else "string", var) + try: + var.trace("w", self._onOptionChanged) + except Exception: + pass return self.vars[dest] - def _buildFieldRow(self, parent, option, row): - p = PALETTE - tk = self.tk - label = _optionLabel(option) + def _buildFieldRow(self, parent, option, row, labelText=None, paneName=None): + label = labelText or _optionLabel(option) helptext = _optHelp(option) dest = _optDest(option) + if not dest: + return row is_bool = not _optTakesValue(option) - firstSeen = dest not in self.vars - - def bindHint(widget): - widget.bind("", lambda e: self.hint.set(helptext), add="+") - widget.bind("", lambda e: self.hint.set(helptext), add="+") - widget.bind("", lambda e: self.hint.set(HINT_DEFAULT), add="+") - widget.bind("", lambda e: self.hint.set(HINT_DEFAULT), add="+") if is_bool: var = self._destVar(dest, True) - chk = self.ttk.Checkbutton(parent, text=label, variable=var, takefocus=True) + default = bool(defaults.get(dest)) + chk = self.ttk.Checkbutton(parent, text=label, variable=var, + onvalue=(not default), offvalue=default, takefocus=True) chk.grid(row=row, column=0, columnspan=2, sticky="w", pady=5) - _Tooltip(chk, helptext, tk, p) - bindHint(chk) - if firstSeen: - self.widgets[dest] = ("bool", var) + self.tooltip.attach(chk, helptext) + self.controlsByDest.setdefault(dest, []).append((paneName, chk)) else: otype = _optValueType(option) var = self._destVar(dest, False) - if firstSeen: - default = defaults.get(dest) - if default not in (None, False): - var.set(default) - self.widgets[dest] = (otype, var) lab = self.ttk.Label(parent, text=label, style="Field.TLabel") lab.grid(row=row, column=0, sticky="w", padx=(0, 18), pady=6) - _Tooltip(lab, helptext, tk, p) - bindHint(lab) + self.tooltip.attach(lab, helptext) choices = _optChoices(option) if choices: widget = self.ttk.Combobox(parent, values=list(choices), state="readonly", textvariable=var) @@ -802,15 +1380,27 @@ def bindHint(widget): if otype in ("int", "float"): self._constrain(widget, otype) widget.grid(row=row, column=1, sticky="ew", pady=6) - _Tooltip(widget, helptext, tk, p) - bindHint(widget) + self.tooltip.attach(widget, helptext) + self.controlsByDest.setdefault(dest, []).append((paneName, widget)) return row + 1 def _constrain(self, entry, otype): - check = (lambda s: s == "" or s.replace(".", "", 1).isdigit()) if otype == "float" else (lambda s: s == "" or s.isdigit()) - vcmd = (self.window.register(lambda proposed: bool(check(proposed))), "%P") + def check(proposed): + if proposed in ("", "+", "-", ".", "+.", "-."): + return True + try: + if otype == "int": + int(proposed) + else: + float(proposed) + return True + except (TypeError, ValueError): + return False + + vcmd = (self.window.register(check), "%P") entry.configure(validate="key", validatecommand=vcmd) + # --- helpers -------------------------------------------------------- def _center(self, window, width=None, height=None): @@ -821,21 +1411,30 @@ def _center(self, window, width=None, height=None): y = window.winfo_screenheight() // 2 - height // 2 window.geometry("%dx%d+%d+%d" % (width, height, x, y)) + def _isOptionSet(self, dest): + item = self.widgets.get(dest) + if item is None: + return False + otype, var = item + try: + value = var.get() + except Exception: + return False + default = defaults.get(dest) + if otype == "bool": + return bool(value) != bool(default) + if value in (None, ""): + return False + displayDefault = "" if default in (None, False) else str(default) + return str(value) != displayDefault + def _updateStats(self): - setDests = set() - for dest, (otype, var) in self.widgets.items(): - try: - if otype == "bool": - if var.get(): - setDests.add(dest) - else: - raw = var.get() - if raw not in (None, "") and str(raw) != str(defaults.get(dest, "")): - setDests.add(dest) - except Exception: - pass + setDests = set(_ for _ in self.widgets if self._isOptionSet(_)) count = len(setDests) - self.stat.set("%d option%s set" % (count, "" if count == 1 else "s")) + status = "%d option%s set" % (count, "" if count == 1 else "s") + if self._isRunning(): + status += " | running" + self.stat.set(status) for name, dests in self.sectionDests.items(): badge = self.badges.get(name) if badge is not None: @@ -843,34 +1442,24 @@ def _updateStats(self): badge.configure(text=(str(hits) if hits else "")) def _buildCommandString(self): - parts = ["sqlmap.py"] - for dest, (otype, var) in self.widgets.items(): - option = self.optionByDest.get(dest) - if option is None: + argv = ["sqlmap.py"] + for dest in self.optionOrder: + if not self._isOptionSet(dest): continue - strings = _optStrings(option) - if not strings: + option = self.optionByDest.get(dest) + flag = _preferredFlag(option) if option is not None else "" + if not flag: continue - flag = strings[0] + otype, var = self.widgets[dest] try: - if otype == "bool": - if var.get(): - parts.append(flag) - else: - raw = var.get() - if raw not in (None, "") and str(raw) != str(defaults.get(dest, "")): - value = str(raw) - if " " in value or '"' in value: - value = '"%s"' % value.replace('"', '\\"') - parts.append("%s %s" % (flag, value)) + argv.append(flag) + if otype != "bool": + argv.append(_toText(var.get())) except Exception: pass - return " ".join(parts) - - def _tickStats(self): - self._updateStats() - self.command.set(self._buildCommandString()) - self.window.after(1200, self._tickStats) + if IS_WIN: + return _list2cmdline(argv) + return " ".join(_quoteArg(_) for _ in argv) def _copyCommand(self): try: @@ -880,6 +1469,22 @@ def _copyCommand(self): except Exception: pass + def _pasteTarget(self): + try: + value = self.window.clipboard_get() + self.vars["url"].set(_toText(value).strip()) + self.targetEntry.focus_set() + self.targetEntry.icursor("end") + except Exception: + self.hint.set("Clipboard does not contain text") + + def _clearTarget(self): + try: + self.vars["url"].set("") + self.targetEntry.focus_set() + except Exception: + pass + def _focusTarget(self): try: self.targetEntry.focus_set() @@ -888,6 +1493,24 @@ def _focusTarget(self): pass return "break" + def _focusSearch(self): + try: + self.searchEntry.focus_set() + self.searchEntry.select_range(0, "end") + except Exception: + pass + return "break" + + def _escapeAction(self, event=None): + try: + if self.searchVar.get(): + self.searchVar.set("") + self.searchEntry.focus_set() + return "break" + except Exception: + pass + return None + def _collectConfig(self): config = {} for dest, (otype, var) in self.widgets.items(): @@ -919,12 +1542,27 @@ def _setWidgetValue(self, dest, value): otype, var = self.widgets[dest] try: if otype == "bool": - var.set(1 if value else 0) + var.set(bool(value)) else: var.set("" if value in (None, False) else value) except Exception: pass + def resetOptions(self): + self._suspendRefresh = True + try: + for dest, (otype, var) in self.widgets.items(): + default = defaults.get(dest) + if otype == "bool": + var.set(bool(default)) + else: + var.set("" if default in (None, False) else default) + finally: + self._suspendRefresh = False + self._refreshDerivedState() + self.hint.set("All options reset to their defaults") + + # --- actions -------------------------------------------------------- def loadConfig(self): @@ -934,18 +1572,28 @@ def loadConfig(self): try: from thirdparty.six.moves import configparser as _configparser parser = _configparser.ConfigParser() + parser.optionxform = str parser.read(path) + byLower = dict((_.lower(), _) for _ in self.widgets) count = 0 - for section in parser.sections(): - for name, value in parser.items(section): - if name in self.widgets: - if self.widgets[name][0] == "bool": - self._setWidgetValue(name, str(value).lower() in ("1", "true", "yes", "on")) + self._suspendRefresh = True + try: + for section in parser.sections(): + for name, value in parser.items(section): + dest = name if name in self.widgets else byLower.get(name.lower()) + if dest is None: + continue + if self.widgets[dest][0] == "bool": + self._setWidgetValue(dest, str(value).lower() in ("1", "true", "yes", "on")) else: - self._setWidgetValue(name, value) + self._setWidgetValue(dest, value) count += 1 + finally: + self._suspendRefresh = False + self._refreshDerivedState() self.hint.set("Loaded %d options from %s" % (count, os.path.basename(path))) except Exception as ex: + self._suspendRefresh = False self.messagebox.showerror("Load failed", getSafeExString(ex)) def saveConfigDialog(self): @@ -959,85 +1607,294 @@ def saveConfigDialog(self): self.messagebox.showerror("Save failed", getSafeExString(ex)) def run(self): - config = self._collectConfig() - handle, configFile = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.CONFIG, text=True) - os.close(handle) - saveConfig(config, configFile) + if self._isRunning(): + self.hint.set("sqlmap is already running") + try: + self.consoleWindow.deiconify() + self.consoleWindow.lift() + except Exception: + pass + return + + configFile = None + try: + config = self._collectConfig() + handle, configFile = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.CONFIG, text=True) + os.close(handle) + saveConfig(config, configFile) + + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + proc = subprocess.Popen( + [sys.executable or "python", os.path.join(paths.SQLMAP_ROOT_PATH, "sqlmap.py"), "-c", configFile], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + bufsize=0, + close_fds=not IS_WIN, + universal_newlines=False, + env=env, + ) + except Exception as ex: + self._cleanupConfigFile(configFile) + self.messagebox.showerror("Unable to start sqlmap", getSafeExString(ex)) + return - self.alive = True - self.process = subprocess.Popen([sys.executable or "python", os.path.join(paths.SQLMAP_ROOT_PATH, "sqlmap.py"), "-c", configFile], - shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, - bufsize=1, close_fds=not IS_WIN) - self.queue = _queue.Queue() + self._runSerial += 1 + serial = self._runSerial + outputQueue = _queue.Queue() + self.process = proc + self.processQueue = outputQueue + self.processConfigFile = configFile def enqueue(stream, queue): - for line in iter(stream.readline, b''): - queue.put(line) - self.alive = False - stream.close() + try: + for line in iter(stream.readline, b""): + if not line: + break + queue.put(_toText(line)) + except Exception as ex: + queue.put("\n[console reader error: %s]\n" % getSafeExString(ex)) + finally: + try: + stream.close() + except Exception: + pass + queue.put(None) - thread = threading.Thread(target=enqueue, args=(self.process.stdout, self.queue)) + thread = threading.Thread(target=enqueue, args=(proc.stdout, outputQueue)) thread.daemon = True thread.start() - self._openConsole() - def _openConsole(self): + self.hint.set("sqlmap started") + self._scheduleHeaderDraw() + self._refreshDerivedState() + self._openConsole(proc, outputQueue, serial) + self.window.after(200, lambda: self._watchProcess(proc, serial, configFile)) + + def _watchProcess(self, proc, serial, configFile): + if proc.poll() is None: + try: + self.window.after(200, lambda: self._watchProcess(proc, serial, configFile)) + except Exception: + pass + return + + self._cleanupConfigFile(configFile) + if self.process is proc and self._runSerial == serial: + self.process = None + self.processQueue = None + self.processConfigFile = None + self._scheduleHeaderDraw() + self._refreshDerivedState() + self.hint.set("sqlmap finished with exit code %s" % proc.returncode) + + def stopProcess(self, proc=None): + proc = proc or self.process + if proc is None or proc.poll() is not None: + return + self.hint.set("Stopping sqlmap...") + try: + if self.consoleStatus is not None: + self.consoleStatus.set("Stopping...") + except Exception: + pass + try: + proc.terminate() + except Exception as ex: + self.messagebox.showerror("Unable to stop sqlmap", getSafeExString(ex)) + return + + def forceKill(): + if proc.poll() is None: + try: + proc.kill() + except Exception: + pass + + self.window.after(1800, forceKill) + + def _cleanupConfigFile(self, path): + if path: + try: + os.remove(path) + except OSError: + pass + + def _appendConsole(self, text, content, forceScroll=False): + if not content: + return + try: + atBottom = text.yview()[1] >= 0.985 + text.configure(state="normal") + text.insert(self.tk.END, content) + lineCount = int(float(text.index("end-1c").split(".")[0])) + if lineCount > MAX_CONSOLE_LINES: + text.delete("1.0", "%d.0" % (lineCount - MAX_CONSOLE_LINES)) + text.configure(state="disabled") + if forceScroll or atBottom: + text.see(self.tk.END) + except Exception: + pass + + def _openConsole(self, proc, outputQueue, serial): p = PALETTE tk = self.tk + try: + if self.consoleWindow is not None and self.consoleWindow.winfo_exists(): + self.consoleWindow.destroy() + except Exception: + pass + top = tk.Toplevel(self.window) + self.consoleWindow = top top.title("sqlmap - console") - top.configure(background=p["crust"]) - frame = self.ttk.Frame(top, style="Card.TFrame", padding=10) - frame.configure(style="Card.TFrame") + top.configure(background=p["base"]) + + toolbar = self.ttk.Frame(top, style="Bar.TFrame", padding=(10, 8)) + toolbar.pack(fill=tk.X) + status = tk.StringVar(value="Running") + self.consoleStatus = status + self.ttk.Label(toolbar, textvariable=status, style="Stat.TLabel").pack(side=tk.LEFT) + stopButton = self.ttk.Button(toolbar, text="Stop", command=lambda: self.stopProcess(proc)) + stopButton.pack(side=tk.RIGHT, padx=(8, 0)) + self.ttk.Button(toolbar, text="Save log...", command=lambda: self._saveConsoleLog(text)).pack(side=tk.RIGHT, padx=(8, 0)) + self.ttk.Button(toolbar, text="Clear", command=lambda: self._clearConsole(text)).pack(side=tk.RIGHT) + + frame = self.ttk.Frame(top, style="Card.TFrame", padding=(10, 0, 10, 8)) frame.pack(fill=tk.BOTH, expand=True) - - text = self.scrolledtext.ScrolledText(frame, wrap=tk.WORD, bg=p["crust"], fg=p["text"], - insertbackground=p["blue"], relief="flat", borderwidth=0, - font=self.fonts["mono"], padx=12, pady=10) + text = self.scrolledtext.ScrolledText(frame, wrap=tk.NONE, bg=p["crust"], fg=p["consoleText"], + insertbackground=p["commandText"], selectbackground=p["sapphire"], + selectforeground="#ffffff", relief="sunken", borderwidth=2, + font=self.fonts["mono"], padx=12, pady=10, state="disabled") text.pack(fill=tk.BOTH, expand=True) - text.focus() - lineBuffer = {"value": ""} - - def onKey(event): - if self.process: - if event.char == "\b": - lineBuffer["value"] = lineBuffer["value"][:-1] - elif event.char: - lineBuffer["value"] += event.char - - def onReturn(event): - if self.process: + self.consoleText = text + self._appendConsole(text, "$ %s\n\n" % self.command.get(), forceScroll=True) + + inputBar = self.ttk.Frame(top, style="Bar.TFrame", padding=(10, 0, 10, 10)) + inputBar.pack(fill=tk.X) + self.ttk.Label(inputBar, text="Input:", style="Hint.TLabel").pack(side=tk.LEFT, padx=(0, 8)) + inputVar = tk.StringVar(value="") + inputEntry = self.ttk.Entry(inputBar, textvariable=inputVar) + inputEntry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + def sendInput(event=None): + value = inputVar.get() + if proc.poll() is not None: + return "break" + try: + proc.stdin.write(_toBytes(_toText(value) + u"\n")) + proc.stdin.flush() + self._appendConsole(text, "> %s\n" % value, forceScroll=True) + inputVar.set("") + except Exception as ex: + self._appendConsole(text, "[input error: %s]\n" % getSafeExString(ex), forceScroll=True) + return "break" + + self.ttk.Button(inputBar, text="Send", command=sendInput).pack(side=tk.RIGHT, padx=(8, 0)) + inputEntry.bind("", sendInput) + inputEntry.focus_set() + + state = {"readerDone": False, "finishedShown": False} + + def pump(): + try: + if not top.winfo_exists(): + return + except Exception: + return + + chunks = [] + size = 0 + for _ in range(256): + try: + item = outputQueue.get_nowait() + except _queue.Empty: + break + if item is None: + state["readerDone"] = True + break + chunks.append(item) + size += len(item) + if size >= 131072: + break + if chunks: + self._appendConsole(text, "".join(chunks)) + + finished = proc.poll() is not None and state["readerDone"] and outputQueue.empty() + if finished and not state["finishedShown"]: + state["finishedShown"] = True + code = proc.returncode + self._appendConsole(text, "\n--- process finished (exit code %s) ---\n" % code, forceScroll=True) + status.set("Finished (exit code %s)" % code) try: - self.process.stdin.write(("%s\n" % lineBuffer["value"].strip()).encode()) - self.process.stdin.flush() + inputEntry.configure(state="disabled") + stopButton.configure(state="disabled") except Exception: pass - lineBuffer["value"] = "" - text.insert(tk.END, "\n") - return "break" + return + top.after(45 if chunks else 90, pump) + + def closeConsole(): + if proc.poll() is None: + if not self.messagebox.askyesno("Close console", "Stop the running sqlmap process and close the console?"): + return + self.stopProcess(proc) + try: + top.destroy() + except Exception: + pass + if self.consoleWindow is top: + self.consoleWindow = None + self.consoleText = None + self.consoleStatus = None - text.bind("", onKey) - text.bind("", onReturn) + top.protocol("WM_DELETE_WINDOW", closeConsole) + self._center(top, 920, 600) + top.after(45, pump) - def pump(): - drained = False + def _clearConsole(self, text): + try: + text.configure(state="normal") + text.delete("1.0", self.tk.END) + text.configure(state="disabled") + except Exception: + pass + + def _saveConsoleLog(self, text): + path = self.filedialog.asksaveasfilename(title="Save console log", defaultextension=".log", + filetypes=[("Log file", "*.log"), ("Text file", "*.txt"), ("All files", "*.*")]) + if not path: + return + try: + with io.open(path, "w", encoding="utf-8") as handle: + handle.write(_toText(text.get("1.0", "end-1c"))) + self.hint.set("Saved console log to %s" % os.path.basename(path)) + except Exception as ex: + self.messagebox.showerror("Save log failed", getSafeExString(ex)) + + def _closeApplication(self): + proc = self.process + if proc is not None and proc.poll() is None: + if not self.messagebox.askyesno("Exit sqlmap GUI", "Stop the running sqlmap process and exit?"): + return try: - while True: - line = self.queue.get_nowait() - text.insert(tk.END, line.decode("utf-8", errors="replace") if isinstance(line, bytes) else line) - drained = True - except _queue.Empty: + proc.terminate() + try: + _waitForProcess(proc, 1.2) + if proc.poll() is None: + proc.kill() + except Exception: + proc.kill() + except Exception: pass - if drained: - text.see(tk.END) - if self.alive or not self.queue.empty(): - top.after(80, pump) - else: - text.insert(tk.END, "\n--- process finished ---\n") - text.see(tk.END) + self._cleanupConfigFile(self.processConfigFile) + try: + self.window.destroy() + except Exception: + pass - self._center(top, 900, 580) - top.after(80, pump) def runGui(parser): try: diff --git a/lib/utils/har.py b/lib/utils/har.py index e5dde561cf3..ebdef3bfde3 100644 --- a/lib/utils/har.py +++ b/lib/utils/har.py @@ -181,16 +181,22 @@ def parse(cls, raw): def toDict(self): content = { "mimeType": self.headers.get("Content-Type"), - "text": self.content, "size": len(self.content or "") } - binary = set([b'\0', b'\1', u'\0', u'\1', 0, 1]) - if any(c in binary for c in self.content): - content["encoding"] = "base64" - content["text"] = getText(base64.b64encode(self.content)) + # HAR text must be UTF-8: a body that does not decode (binary content such as an image, or + # text in another charset) is base64-encoded losslessly rather than mangled through a lossy + # text decode. The previous check only treated NUL/0x01 as binary, so e.g. a JPEG lacking + # those bytes was corrupted into the "text" field. + raw = self.content or b"" + if not isinstance(raw, bytes): + content["text"] = getText(raw) else: - content["text"] = getText(content["text"]) + try: + content["text"] = getText(raw.decode("utf-8")) + except UnicodeDecodeError: + content["encoding"] = "base64" + content["text"] = getText(base64.b64encode(raw)) return { "httpVersion": self.httpVersion, diff --git a/lib/utils/hash.py b/lib/utils/hash.py index 9a7ef72e701..3386a322e2a 100644 --- a/lib/utils/hash.py +++ b/lib/utils/hash.py @@ -1074,9 +1074,6 @@ def hashRecognition(value): # Hashes for Oracle and old MySQL look the same hence these checks if isOracle and regex == HASH.MYSQL_OLD or isMySQL and regex == HASH.ORACLE_OLD: continue - elif regex == HASH.CRYPT_GENERIC: - if any((value.lower() == value, value.upper() == value)): - continue else: parts.append("(?P<%s>%s)" % (name, regex)) @@ -1088,6 +1085,10 @@ def hashRecognition(value): algorithm, _ = [_ for _ in match.groupdict().items() if _[1] is not None][0] retVal = getattr(HASH, algorithm) + # Note: greedy CRYPT_GENERIC requires a mixed-case value to reduce false positives + if retVal == HASH.CRYPT_GENERIC and any((value.lower() == value, value.upper() == value)): + retVal = None + return retVal def _bruteProcessVariantA(attack_info, hash_regex, suffix, retVal, proc_id, proc_count, wordlists, custom_wordlist, api): diff --git a/lib/utils/sqlalchemy.py b/lib/utils/sqlalchemy.py index d079cfb3aab..34c377c5a96 100644 --- a/lib/utils/sqlalchemy.py +++ b/lib/utils/sqlalchemy.py @@ -72,7 +72,10 @@ def connect(self): raise SqlmapFilePathException("the provided database file '%s' does not exist" % self.db) _ = self.address.split("//", 1) - self.address = "%s////%s" % (_[0], os.path.abspath(self.db)) + # Note: SQLAlchemy's absolute-SQLite form is 'sqlite:///' + abspath (the abspath's own + # leading '/' completes the triple slash on POSIX). A 4th slash yields db '//path' (only + # tolerated on Linux by accident) and, on Windows, '/C:\...' which fails to open. + self.address = "%s///%s" % (_[0], os.path.abspath(self.db)) if self.dialect == "sqlite": engine = _sqlalchemy.create_engine(self.address, connect_args={"check_same_thread": False}) diff --git a/tests/test_common.py b/tests/test_common.py index e8d217627fc..be4ad2d616a 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -86,6 +86,7 @@ getTechnique, getText, intersect, + isDBMSVersionAtLeast, isListLike, isNoneValue, isNullValue, @@ -185,6 +186,23 @@ def test_param_without_equals_ignored(self): result = paramToDict(PLACE.GET, "lonely&id=1") self.assertEqual(list(result.items()), [("id", "1")]) + def test_html_entity_in_value_not_split(self): + # a genuine HTML entity in a value must not be split on its '&'/';' (any entity length) + result = paramToDict(PLACE.GET, "q=foo&bar&id=5") + self.assertEqual(list(result.items()), [("q", "foo&bar"), ("id", "5")]) + result = paramToDict(PLACE.GET, "q=foo—bar&id=5") + self.assertEqual(list(result.items()), [("q", "foo—bar"), ("id", "5")]) + + def test_html_entity_in_cookie_value_not_corrupted(self): + # regression: the entity's own ';' must not act as the cookie delimiter + result = paramToDict(PLACE.COOKIE, "token=a—b; id=5") + self.assertEqual(list(result.items()), [("token", "a—b"), ("id", "5")]) + + def test_non_entity_ampersand_still_splits(self): + # "&nope;" is not a real entity, so '&' remains a genuine delimiter + result = paramToDict(PLACE.GET, "a=1&nope=2") + self.assertEqual(list(result.items()), [("a", "1"), ("nope", "2")]) + class TestGetCharset(unittest.TestCase): """Inference charsets are fixed integer tables.""" @@ -966,6 +984,48 @@ def test_empty_returns_none(self): self.assertIsNone(aliasToDbmsEnum("")) +class TestIsDBMSVersionAtLeast(unittest.TestCase): + """Version gating drives per-DBMS query selection; comparison must be component-wise.""" + + def setUp(self): + self._saved = kb.get("dbmsVersion") + + def tearDown(self): + kb.dbmsVersion = self._saved + + def _at_least(self, version, minimum): + kb.dbmsVersion = version + return isDBMSVersionAtLeast(minimum) + + def test_single_major_thresholds(self): + self.assertTrue(self._at_least("5.4.3", "5")) + self.assertTrue(self._at_least("8.0.32", "8")) + self.assertFalse(self._at_least("5.7.44", "8")) + + def test_multi_digit_minor_ordering(self): + # floats mis-sorted these (10.11->10.11 vs 10.5->10.5): component-wise fixes it + self.assertTrue(self._at_least("10.11", "10.5")) # MariaDB + self.assertFalse(self._at_least("10.6", "10.11")) + self.assertTrue(self._at_least("5.10.0", "5.5")) + self.assertTrue(self._at_least("9.10", "9.6")) # PostgreSQL + + def test_presto_sequential_minor(self): + # Presto 0.NNN: release 99 is OLDER than release 178 (float made 0.99 > 0.178) + self.assertFalse(self._at_least("0.99", "0.178")) + self.assertTrue(self._at_least("0.180", "0.178")) + + def test_range_and_prefix_semantics(self): + self.assertTrue(self._at_least("2", ">=2.0")) + self.assertFalse(self._at_least("2", ">2")) + self.assertFalse(self._at_least("<2", "2")) + self.assertTrue(self._at_least("<2", "1.5")) + + def test_unknown_version_is_none(self): + from lib.core.settings import UNKNOWN_DBMS_VERSION + kb.dbmsVersion = UNKNOWN_DBMS_VERSION + self.assertIsNone(isDBMSVersionAtLeast("5")) + + class TestGetPageWordSet(unittest.TestCase): def test_word_extraction(self): words = getPageWordSet(u"foobartest") @@ -1563,6 +1623,16 @@ def test_extract_error_message_oracle(self): def test_extract_error_message_none_for_plain(self): self.assertIsNone(extractErrorMessage("Warning: This is only a dummy foobar test")) + def test_extract_error_message_prose_like_dbms_signature(self): + # a specific DBMS signature must be extracted even when it reads like plain text (few + # non-writing chars) - the non-writing-char ratio guards only the generic keyword regexes + page = "Microsoft OLE DB Provider for SQL Server error '80040e14' Unclosed quotation mark after the character string ''." + self.assertEqual(extractErrorMessage(page), "Unclosed quotation mark after the character string ''.") + + def test_extract_error_message_generic_prose_still_rejected(self): + # the generic '(fatal|error|warning): ...' path must still drop natural-language prose + self.assertIsNone(extractErrorMessage("Error: everything is working fine and nothing is wrong here")) + def test_extract_error_message_non_string(self): self.assertIsNone(extractErrorMessage(None)) diff --git a/tests/test_crawler.py b/tests/test_crawler.py new file mode 100644 index 00000000000..709e25896b7 --- /dev/null +++ b/tests/test_crawler.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Crawler result normalization (lib/utils/crawler.py normalizeCrawlingResults). + +--crawl can surface thousands of near-identical URLs; normalization keeps one +representative per distinct endpoint+parameter shape so the scan is not flooded +with value-only variants. The key must span the full path: collapsing on the +last path segment alone silently drops distinct endpoints that share an action +name (e.g. /users/edit vs /products/edit), losing real attack surface. +""" + +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.crawler import normalizeCrawlingResults + + +def _t(url, data=None): + # kb.targets tuple shape: (url, method, data, ...) + return (url, None, data, None, None) + + +class TestNormalizeCrawlingResults(unittest.TestCase): + def _urls(self, targets): + return [t[0] for t in normalizeCrawlingResults(targets)] + + def test_value_only_variants_collapse(self): + kept = self._urls([_t("http://h/item?id=1"), _t("http://h/item?id=2"), _t("http://h/item?id=3")]) + self.assertEqual(kept, ["http://h/item?id=1"]) + + def test_distinct_endpoints_sharing_action_are_kept(self): + # the regression: /users/edit and /products/edit must not collapse on the shared last segment + kept = self._urls([_t("http://h/users/edit?id=1"), + _t("http://h/products/edit?id=1"), + _t("http://h/orders/edit?id=1"), + _t("http://h/users/edit?id=2")]) + self.assertEqual(set(kept), {"http://h/users/edit?id=1", + "http://h/products/edit?id=1", + "http://h/orders/edit?id=1"}) + + def test_different_parameter_names_are_kept(self): + kept = self._urls([_t("http://h/p?id=1"), _t("http://h/p?name=x")]) + self.assertEqual(set(kept), {"http://h/p?id=1", "http://h/p?name=x"}) + + def test_different_hosts_are_kept(self): + kept = self._urls([_t("http://a.tld/edit?id=1"), _t("http://b.tld/edit?id=1")]) + self.assertEqual(set(kept), {"http://a.tld/edit?id=1", "http://b.tld/edit?id=1"}) + + def test_post_data_folded_into_shape(self): + # POST body params participate in the shape, and value-only POST variants collapse + kept = self._urls([_t("http://h/login", "user=a&pass=b"), _t("http://h/login", "user=c&pass=d")]) + self.assertEqual(kept, ["http://h/login"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_decodepage.py b/tests/test_decodepage.py index 01eb899c468..69949416604 100644 --- a/tests/test_decodepage.py +++ b/tests/test_decodepage.py @@ -25,7 +25,10 @@ bootstrap() from lib.request.basic import decodePage +from lib.core.common import extractRegexResult +from lib.core.data import conf, kb from lib.core.exception import SqlmapCompressionException +from lib.core.settings import META_CHARSET_REGEX BODY = b"Hello plain body content 12345 - no markup here" @@ -68,6 +71,43 @@ def test_utf8_decoded_to_unicode(self): out = decodePage(original.encode("utf-8"), None, "text/html; charset=utf-8") self.assertEqual(out, original) + def test_meta_charset_used_when_no_http_charset(self): + # charset declared only via (no HTTP charset) with an attribute on + # must still be honored; byte 0xC0 is 'А' (U+0410) in windows-1251 + page = b'\xc0\xc1\xc2' + conf.encoding = None + kb.pageEncoding = None + out = decodePage(page, None, "text/html") + self.assertIn(u"АБВ", out) + + +class TestMetaCharsetRegex(unittest.TestCase): + """META_CHARSET_REGEX must tolerate real-world /meta forms while staying scoped + to the head so body content can't hijack the detected charset.""" + + def _charset(self, html): + return extractRegexResult(META_CHARSET_REGEX, html) + + def test_head_with_attributes(self): + self.assertEqual(self._charset(''), "iso-8859-2") + + def test_whitespace_around_equals(self): + self.assertEqual(self._charset(''), "utf-8") + + def test_single_quotes_stripped(self): + self.assertEqual(self._charset(""), "utf-8") + + def test_http_equiv_content_type(self): + self.assertEqual(self._charset(''), "windows-1251") + + def test_header_tag_not_matched(self): + #
is not + self.assertIsNone(self._charset('
')) + + def test_body_meta_not_hijacked(self): + # a meta whose content merely mentions charset= in the body must not be picked up + self.assertIsNone(self._charset('t')) + class TestMalformed(unittest.TestCase): def test_invalid_deflate_raises(self): diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 00000000000..c899c3c94c7 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Function decorators in lib/core/decorators.py: cachedmethod (memoization with a +hashable fast path and a frozen slow path for unhashable arguments), stackedmethod +(value-stack realignment) and lockedmethod (reentrant serialization). +""" + +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.decorators import cachedmethod, stackedmethod, lockedmethod +from lib.core.threads import getCurrentThreadData + + +class TestCachedMethod(unittest.TestCase): + def test_memoizes_hashable_args(self): + calls = [] + + @cachedmethod + def f(x): + calls.append(x) + return x * 2 + + self.assertEqual(f(3), 6) + self.assertEqual(f(3), 6) + self.assertEqual(len(calls), 1) # second call served from cache + + def test_memoizes_unhashable_args(self): + calls = [] + + @cachedmethod + def g(seq): + calls.append(1) + return sum(seq) + + self.assertEqual(g([1, 2, 3]), 6) + self.assertEqual(g([1, 2, 3]), 6) # same list content -> cache hit + self.assertEqual(len(calls), 1) + self.assertEqual(g([4, 5]), 9) # different content -> recomputed + self.assertEqual(len(calls), 2) + + def test_tuple_and_list_args_do_not_collide(self): + # regression: a list arg ([1,2],) freezes to ((1,2),), the raw fast key of a tuple + # arg ((1,2),); the two calls must not share a cache slot + @cachedmethod + def kind(x): + return type(x).__name__ + + self.assertEqual(kind((1, 2)), "tuple") + self.assertEqual(kind([1, 2]), "list") + + def test_kwargs_are_part_of_the_key(self): + @cachedmethod + def h(a, b=0): + return a + b + + self.assertEqual(h(1, b=2), 3) + self.assertEqual(h(1, b=5), 6) # different kwarg -> not a cache hit + + +class TestStackedMethod(unittest.TestCase): + def test_realigns_leftover_pushes(self): + td = getCurrentThreadData() + base = len(td.valueStack) + + @stackedmethod + def leaky(_): + td.valueStack.append(_) # pushes without popping + + leaky(1) + self.assertEqual(len(td.valueStack), base) # stack restored to original level + + +class TestLockedMethod(unittest.TestCase): + def test_reentrant(self): + @lockedmethod + def recursive_count(n): + return 0 if n <= 0 else n + recursive_count(n - 1) + + self.assertEqual(recursive_count(5), 15) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dump_format.py b/tests/test_dump_format.py index d3484c28fe0..2fb052f2b28 100644 --- a/tests/test_dump_format.py +++ b/tests/test_dump_format.py @@ -358,6 +358,37 @@ def test_rows_and_inferred_types(self): finally: conn.close() + def test_non_roundtrip_numbers_stay_text(self): + # Values that look numeric but would be silently rewritten by SQLite's INTEGER/REAL + # affinity (leading zeros, sign prefix, 64-bit overflow, trailing-zero/exponent floats) + # must be stored verbatim as TEXT, otherwise the export corrupts the dumped data + tv = _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "t"}), + ("zip", {"length": 5, "values": ["007"]}), + ("phone", {"length": 10, "values": ["0917123456"]}), + ("signed", {"length": 2, "values": ["+1"]}), + ("huge", {"length": 30, "values": ["123456789012345678901234567890"]}), + ("money", {"length": 4, "values": ["2.00"]}), + ("real_int", {"length": 1, "values": ["5"]}), # genuine ints still typed INTEGER + ]) + conf.dumpFormat = DUMP_FORMAT.SQLITE + self.d.dbTableValues(tv) + + import sqlite3 + conn = sqlite3.connect(os.path.join(self.tmp, "testdb.sqlite3")) + try: + cur = conn.cursor() + cur.execute("SELECT zip, phone, signed, huge, money, real_int FROM t") + self.assertEqual(cur.fetchone(), ("007", "0917123456", "+1", "123456789012345678901234567890", "2.00", 5)) + cur.execute("PRAGMA table_info(t)") + types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()} + self.assertEqual(types["zip"], "TEXT") + self.assertEqual(types["huge"], "TEXT") + self.assertEqual(types["money"], "TEXT") + self.assertEqual(types["real_int"], "INTEGER") + finally: + conn.close() + # --- replication backend tests (pure sqlite3, no network/DBMS) ----------------------------------- diff --git a/tests/test_har.py b/tests/test_har.py index 56e9b69b5b6..bf98f260833 100644 --- a/tests/test_har.py +++ b/tests/test_har.py @@ -160,6 +160,23 @@ def test_toDict_binary_content_encoded(self): d = resp.toDict() self.assertEqual(d["content"]["encoding"], "base64") + def test_toDict_binary_without_null_still_base64(self): + # regression: binary content lacking NUL/0x01 (e.g. a JPEG header) must still be + # base64-encoded, not mangled into the "text" field as a lossy decode + import base64 as _b64 + jpeg = b"\xff\xd8\xff\xe0\x10JFIF\x02\x03" + d = H.Response("HTTP/1.1", 200, "OK", {"Content-Type": "image/jpeg"}, jpeg).toDict() + self.assertEqual(d["content"]["encoding"], "base64") + self.assertEqual(_b64.b64decode(d["content"]["text"]), jpeg) # lossless + + def test_toDict_utf8_multibyte_stays_text(self): + # valid UTF-8 (incl. multibyte) is human-readable text, not base64 + original = b"caf\xc3\xa9 \xe4\xbd\xa0\xe5\xa5\xbd".decode("utf-8") # cafe+e-acute+two CJK, pure-ASCII source + d = H.Response("HTTP/1.1", 200, "OK", {"Content-Type": "text/plain; charset=utf-8"}, + original.encode("utf-8")).toDict() + self.assertNotIn("encoding", d["content"]) + self.assertEqual(d["content"]["text"], original) + def test_toDict_non_text_content(self): resp = H.Response("HTTP/1.1", 200, "OK", {"Content-Type": "text/plain"}, b"plain text") diff --git a/tests/test_hash.py b/tests/test_hash.py index 4ab5546c0e2..42db6995e4b 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -97,6 +97,15 @@ def test_sha1_generic(self): def test_mysql(self): self.assertEqual(H.hashRecognition("*00E247AC5F9AF26AE0194B41E1E769DEE1429A29"), HASH.MYSQL) + def test_crypt_generic(self): + # Traditional DES crypt(3) (hashcat -m 1500); mixed-case is required by the heuristic + self.assertEqual(H.hashRecognition("rl.3StKT.4T8M"), HASH.CRYPT_GENERIC) + + def test_crypt_generic_single_case_is_none(self): + # All-lower/all-upper 13-char values are too greedy to be trusted as crypt + self.assertIsNone(H.hashRecognition("abcdefghijklm")) + self.assertIsNone(H.hashRecognition("ABCDEFGHIJKLM")) + def test_junk_is_none(self): self.assertIsNone(H.hashRecognition("foobar")) diff --git a/tests/test_hashdb.py b/tests/test_hashdb.py index 36bbd4dc9ff..e7475474e3c 100644 --- a/tests/test_hashdb.py +++ b/tests/test_hashdb.py @@ -100,6 +100,19 @@ def test_attribdict_roundtrip(self): self.assertEqual(got.x, 1) self.assertEqual(got.y, [1, 2]) + def test_foreign_mapping_roundtrips_as_dict(self): + # a stdlib dict subclass (e.g. OrderedDict) must round-trip its data rather than + # silently degrade to its text repr (session codec: round-trip or fail loudly) + from collections import OrderedDict + from thirdparty import six + val = OrderedDict([("b", 2), ("a", [1, {"n": "v"}])]) + self.db.write("od", val, True) + self.db.flush() + got = self.db.retrieve("od", True) + self.assertEqual(got, {"b": 2, "a": [1, {"n": "v"}]}) # data preserved (was lost to text repr before) + if six.PY3: + self.assertEqual(list(got), ["b", "a"]) # order preserved where dicts are ordered + def test_bigarray_roundtrip(self): self.db.write("ba", BigArray([1, 2, 3]), True) self.db.flush() diff --git a/tests/test_request_basic.py b/tests/test_request_basic.py index 14977489b5a..29dc53c2a56 100644 --- a/tests/test_request_basic.py +++ b/tests/test_request_basic.py @@ -84,5 +84,59 @@ def test_empty_page(self): self.assertEqual(getText(decodePage(b"", None, "text/html")), "") +class TestForgeHeadersCookieMerge(unittest.TestCase): + """A domain-scoped jar cookie (Domain=example.com -> '.example.com') must merge into the + request for the apex host, not be dropped by a naive endswith() domain check.""" + + _CONF = ("cj", "hostname", "httpHeaders", "loadCookies", "cookieDel", "parameters", "csrfToken", "safeUrl") + _KB = ("mergeCookies", "testMode", "injection") + + def setUp(self): + self._c = dict((k, conf.get(k)) for k in self._CONF) + self._k = dict((k, kb.get(k)) for k in self._KB) + + def tearDown(self): + for k, v in self._c.items(): + conf[k] = v + for k, v in self._k.items(): + kb[k] = v + + def _jar_with_domain_cookie(self): + try: + from http.cookiejar import CookieJar, Cookie + except ImportError: + from cookielib import CookieJar, Cookie + # a domain-scoped cookie the jar stores as '.example.com' (domain_specified=True), + # exactly as it would after Set-Cookie: sid=NEW; Domain=example.com + cookie = Cookie(version=0, name="sid", value="NEW", port=None, port_specified=False, + domain=".example.com", domain_specified=True, domain_initial_dot=True, + path="/", path_specified=True, secure=False, expires=None, discard=True, + comment=None, comment_url=None, rest={}) + cj = CookieJar() + cj.set_cookie(cookie) + return cj + + def test_domain_cookie_merged_on_apex_host(self): + from lib.request.basic import forgeHeaders + from lib.core.enums import PLACE, HTTP_HEADER + from lib.core.datatype import AttribDict + + conf.cj = self._jar_with_domain_cookie() + conf.hostname = "example.com" # apex host == cookie domain + conf.httpHeaders = [(HTTP_HEADER.COOKIE, "sid=OLD")] + conf.loadCookies = False + conf.cookieDel = None + conf.parameters = {} + conf.csrfToken = conf.safeUrl = None + kb.mergeCookies = True + kb.testMode = False + kb.injection = AttribDict() + kb.injection.place = PLACE.GET + + headers = forgeHeaders() + # before the fix the domain cookie was skipped for the apex host, leaving 'sid=OLD' + self.assertEqual(headers.get(HTTP_HEADER.COOKIE), "sid=NEW") + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_sqlalchemy.py b/tests/test_sqlalchemy.py new file mode 100644 index 00000000000..768c1241119 --- /dev/null +++ b/tests/test_sqlalchemy.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The SQLAlchemy '-d' connector wrapper (lib/utils/sqlalchemy.py). The absolute +SQLite path must map to 'sqlite:///' + abspath: an extra slash yields the db +'//path' (tolerated only on Linux by accident) and, on Windows, a broken +'/C:\\...' that fails to open. +""" + +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() + +try: + import sqlalchemy as _sa + _HAVE_SA = hasattr(_sa, "dialects") +except ImportError: + _HAVE_SA = False + +from lib.core.data import conf +from lib.utils.sqlalchemy import SQLAlchemy + + +@unittest.skipUnless(_HAVE_SA, "SQLAlchemy not installed") +class TestSQLAlchemySqlitePath(unittest.TestCase): + _KEYS = ("direct", "dbmsUser", "dbmsPass", "hostname", "port", "dbmsDb") + + def setUp(self): + self._saved = dict((k, conf.get(k)) for k in self._KEYS) + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + + def test_absolute_sqlite_path_opens_correct_file(self): + d = tempfile.mkdtemp(prefix="sqlmap-sa-test") + dbfile = os.path.join(d, "target.db") + con = sqlite3.connect(dbfile) + con.execute("CREATE TABLE t (x TEXT)") + con.execute("INSERT INTO t VALUES ('secret')") + con.commit() + con.close() + + conf.direct = "sqlite://%s" % dbfile + conf.dbmsUser = conf.dbmsPass = None + conf.hostname = None + conf.port = None + conf.dbmsDb = dbfile + + sa = SQLAlchemy(dialect="sqlite") + sa.connect() + + # the reformatted URL must resolve to the exact absolute file (not '//...path') + self.assertEqual(_sa.engine.url.make_url(sa.address).database, os.path.abspath(dbfile)) + # and end-to-end it must read from that file + self.assertEqual(sa.select("SELECT x FROM t"), [("secret",)]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_targeturl.py b/tests/test_targeturl.py index 74c14c07163..6db349a85c4 100644 --- a/tests/test_targeturl.py +++ b/tests/test_targeturl.py @@ -11,8 +11,10 @@ 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.) +Inline URL credentials (user:pw@host) are stripped so the host/port parse +correctly - previously the userinfo was mistaken for the host (user:pass@host -> +hostname 'user'). The credentials are still NOT used for authentication (sqlmap +warns and expects --auth-cred); only the host-misparse is fixed. """ import os @@ -80,5 +82,24 @@ def test_path_extracted(self): self.assertEqual(_parse("http://host/some/path?q=1")[3], "/some/path") +class TestInlineCredentials(unittest.TestCase): + """Userinfo (user:pw@) must be stripped from the host, not mistaken for it.""" + + def test_user_pass_with_port(self): + host, port, _, _ = _parse("http://user:pass@host:8080/?id=1") + self.assertEqual((host, port), ("host", 8080)) + + def test_user_pass_default_port(self): + host, port, _, _ = _parse("http://user:pass@host/?id=1") + self.assertEqual((host, port), ("host", 80)) + + def test_user_only(self): + self.assertEqual(_parse("http://user@host/?id=1")[0], "host") + + def test_credentials_with_ipv6(self): + host, port, _, _ = _parse("http://user:pass@[::1]:8443/?id=1") + self.assertEqual((host, port), ("::1", 8443)) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_texthelpers.py b/tests/test_texthelpers.py index 0df01ee7a98..1197bc505c1 100644 --- a/tests/test_texthelpers.py +++ b/tests/test_texthelpers.py @@ -61,6 +61,20 @@ def test_windows_path(self): self.assertIn("C:\\inetpub\\wwwroot\\app\\index.asp", kb.absFilePaths, msg="windows path not harvested in full: %s" % kb.absFilePaths) + def test_quoted_paths_harvested(self): + # paths delimited by a leading quote (Python/Java/.NET stack traces) must be harvested too + parseFilePaths('File "/usr/lib/python3.11/site-packages/app.py", line 10') + parseFilePaths("Cannot read '/opt/tomcat/webapps/app/WEB-INF/web.xml' now") + parseFilePaths('Could not find file "C:\\data\\config.ini".') + self.assertIn("/usr/lib/python3.11/site-packages/app.py", kb.absFilePaths) + self.assertIn("/opt/tomcat/webapps/app/WEB-INF/web.xml", kb.absFilePaths) + self.assertIn("C:\\data\\config.ini", kb.absFilePaths) + + def test_quoted_non_path_ignored(self): + # a leading quote must not turn ordinary quoted words or URLs into "paths" + parseFilePaths("error: 'foobar' invalid; see 'https://example.com/help' for info") + self.assertEqual(kb.absFilePaths, set()) + class TestGetSafeExString(unittest.TestCase): def test_format(self):