mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-10-12 02:22:55 +00:00
Adding support for Sybase into dbwire
This commit is contained in:
@@ -598,11 +598,11 @@
|
||||
<blind/>
|
||||
</dbs>
|
||||
<tables>
|
||||
<inband query="SELECT name FROM %s..sysobjects WHERE type IN ('U','V')"/>
|
||||
<inband query="SELECT %s..sysusers.name+'.'+%s..sysobjects.name AS name FROM %s..sysobjects,%s..sysusers WHERE %s..sysobjects.uid=%s..sysusers.uid AND %s..sysobjects.type IN ('U','V')"/>
|
||||
<blind/>
|
||||
</tables>
|
||||
<columns>
|
||||
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns,%s..sysobjects WHERE %s..syscolumns.id=%s..sysobjects.id AND %s..sysobjects.name='%s'" condition="[DB]..syscolumns.name"/>
|
||||
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns WHERE %s..syscolumns.id=object_id('%s.%s')" condition="[DB]..syscolumns.name"/>
|
||||
<blind/>
|
||||
</columns>
|
||||
<dump_table>
|
||||
|
||||
@@ -23,6 +23,7 @@ A wire protocol is shared across a whole family of products, so one client serve
|
||||
| `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica |
|
||||
| `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona |
|
||||
| `tds.py` | TDS 7.x | Microsoft SQL Server |
|
||||
| `sybase.py` | TDS 5.0 | Sybase / SAP ASE |
|
||||
| `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 |
|
||||
| `cubrid.py` | CUBRID CAS | CUBRID |
|
||||
| `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks |
|
||||
@@ -58,8 +59,11 @@ parameter binding, prepared statements, bulk load/`COPY`, or TLS. Notable per-pr
|
||||
supported; use a `mysql_native_password` account for the dependency-free path.
|
||||
- **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database)
|
||||
require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. LOGIN7 is the
|
||||
Microsoft dialect (TDS 7.x); Sybase ASE speaks TDS 5.0 (LOGINREC + its own token dialect), which is not
|
||||
implemented yet - ASE `-d` goes through the pymssql/FreeTDS tier for now.
|
||||
Microsoft dialect (TDS 7.x) and does not reach Sybase - see the next bullet.
|
||||
- **TDS 5.0 (Sybase)** - cleartext LOGINREC login. The client declares no optional capabilities, so the
|
||||
server converts anything exotic (`date`/`time`/`bigdatetime`, wide formats) down to the baseline types
|
||||
before sending it. Packet size is whatever the login negotiates. Sends and expects UTF-8, so a server
|
||||
running any character set is decoded correctly.
|
||||
- **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by
|
||||
default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented.
|
||||
- **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as
|
||||
|
||||
@@ -11,8 +11,9 @@ sqlmap's direct ('-d') connection when no native driver (and no SQLAlchemy) is i
|
||||
|
||||
Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole
|
||||
compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum;
|
||||
a MySQL client serves MariaDB/TiDB/Aurora. Each module exposes a small PEP 249 (DB-API 2.0) subset
|
||||
(connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
|
||||
a MySQL client serves MariaDB/TiDB/Aurora. Where a family split the protocol, so does the client: tds.py
|
||||
speaks Microsoft's TDS 7.x and sybase.py the TDS 5.0 that ASE kept. Each module exposes a small PEP 249
|
||||
(DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
"""
|
||||
Minimal pure-python Sybase ASE client speaking TDS 5.0 (stdlib only, no pymssql/FreeTDS).
|
||||
|
||||
Sybase and Microsoft share the 8-byte TDS packet framing (reused from tds.py) and part company at the
|
||||
login: ASE speaks TDS 5.0, which means a fixed-layout LOGINREC instead of LOGIN7, a capability
|
||||
negotiation, SQL sent as a LANGUAGE token in a normal packet, and results described by ROWFMT rather than
|
||||
COLMETADATA. Cleartext login only. Read-oriented for sqlmap: execute() takes a fully-formed query string,
|
||||
binary values come back as bytes (sqlmap hex-encodes them).
|
||||
|
||||
Two encodings look like their Microsoft namesakes and are not: DECIMAL/NUMERIC carries a big-endian
|
||||
magnitude with 1 meaning *negative* (Microsoft is little-endian with 1 meaning positive), and DONE reports
|
||||
a 4-byte row count (Microsoft uses 8).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from extra.dbwire import DatabaseError
|
||||
from extra.dbwire import DataError
|
||||
from extra.dbwire import IntegrityError
|
||||
from extra.dbwire import InterfaceError
|
||||
from extra.dbwire import NotSupportedError
|
||||
from extra.dbwire import OperationalError
|
||||
from extra.dbwire import handshake_done
|
||||
from extra.dbwire import keepalive
|
||||
from extra.dbwire import ProgrammingError
|
||||
|
||||
# the packet framing is byte-identical to the Microsoft dialect - only what travels inside it differs
|
||||
from extra.dbwire.tds import _decode_datetime
|
||||
from extra.dbwire.tds import _decode_money
|
||||
from extra.dbwire.tds import _decode_smalldatetime
|
||||
from extra.dbwire.tds import _read_message
|
||||
from extra.dbwire.tds import _send_message
|
||||
|
||||
_PKT_LOGIN = 0x02
|
||||
_PKT_NORMAL = 0x0f
|
||||
_LOGIN_CHUNK = 504 # the login travels in 512-byte packets: the server's buffer is not negotiated yet
|
||||
_PACKET_SIZE = 2048 # ASE's default 'max network packet size'; a bigger request is negotiated down
|
||||
_HEADER_SIZE = 8
|
||||
|
||||
# tokens
|
||||
_TOKEN_LANGUAGE = 0x21
|
||||
_TOKEN_ROWFMT = 0xee
|
||||
_TOKEN_ROWFMT2 = 0x61
|
||||
_TOKEN_ROW = 0xd1
|
||||
_TOKEN_CAPABILITY = 0xe2
|
||||
_TOKEN_ENVCHANGE = 0xe3
|
||||
_TOKEN_EED = 0xe5
|
||||
_TOKEN_RETURNSTATUS = 0x79
|
||||
_TOKEN_LOGOUT = 0x71
|
||||
_TOKEN_DONE = frozenset((0xfd, 0xfe, 0xff)) # DONE / DONEPROC / DONEINPROC
|
||||
_DONE_COUNT = 0x0010 # DONE status bit: the row count is meaningful
|
||||
_ENV_PACKET_SIZE = 4 # ENVCHANGE type: the server's answer to the requested size
|
||||
|
||||
# SYB* datatype codes. Fixed-length types carry no per-value length; everything else is length-prefixed,
|
||||
# and a zero length means NULL (TDS 5 cannot tell an empty string from NULL - neither can ASE itself).
|
||||
_T_INT1, _T_BIT, _T_INT2, _T_INT4, _T_INT8 = 0x30, 0x32, 0x34, 0x38, 0xbf
|
||||
_T_REAL, _T_FLT8, _T_MONEY, _T_MONEY4 = 0x3b, 0x3e, 0x3c, 0x7a
|
||||
_T_DATETIME, _T_DATETIME4, _T_DATE, _T_TIME = 0x3d, 0x3a, 0x31, 0x33
|
||||
_T_INTN, _T_FLTN, _T_MONEYN, _T_DATETIMN, _T_DATEN, _T_TIMEN = 0x26, 0x6d, 0x6e, 0x6f, 0x7b, 0x93
|
||||
_T_DECIMAL, _T_NUMERIC = 0x6a, 0x6c
|
||||
_T_CHAR, _T_VARCHAR, _T_BINARY, _T_VARBINARY, _T_NVARCHAR = 0x2f, 0x27, 0x2d, 0x25, 0x67
|
||||
_T_TEXT, _T_IMAGE, _T_UNITEXT = 0x23, 0x22, 0xae
|
||||
_T_LONGBINARY, _T_LONGCHAR = 0xe1, 0xaf
|
||||
_T_BIGDATETIME, _T_BIGTIME = 0xbb, 0xbc
|
||||
_T_BIGDATETIMEN, _T_BIGTIMEN = 0xbd, 0xbe
|
||||
_T_VOID = 0x1f
|
||||
|
||||
_FIXED_LENGTH = {
|
||||
_T_INT1: 1, _T_BIT: 1, _T_INT2: 2, _T_INT4: 4, _T_INT8: 8, _T_REAL: 4, _T_FLT8: 8,
|
||||
_T_MONEY: 8, _T_MONEY4: 4, _T_DATETIME: 8, _T_DATETIME4: 4, _T_DATE: 4, _T_TIME: 4,
|
||||
_T_BIGDATETIME: 8, _T_BIGTIME: 8, _T_VOID: 0,
|
||||
}
|
||||
_BLOB_TYPES = frozenset((_T_TEXT, _T_IMAGE, _T_UNITEXT)) # textptr + timestamp + 4-byte length
|
||||
_LONG_TYPES = frozenset((_T_LONGBINARY, _T_LONGCHAR)) # 4-byte length
|
||||
_BINARY_TYPES = frozenset((_T_BINARY, _T_VARBINARY, _T_IMAGE))
|
||||
_DECIMAL_TYPES = frozenset((_T_DECIMAL, _T_NUMERIC))
|
||||
# univarchar/unichar/unitext are carried as LONGBINARY/UNITEXT holding UTF-16 - only the user type says so
|
||||
_UNICODE_USERTYPES = frozenset((34, 35, 36))
|
||||
# a client that does not claim the date/time/bigdatetime capabilities gets those columns converted to a
|
||||
# plain datetime, which is lossless but would render a date as '... 00:00:00'. The user type survives the
|
||||
# conversion, so it is what says how much of the value the column actually holds.
|
||||
_USERTYPE_DATE = 37
|
||||
_USERTYPE_TIME = frozenset((38, 49))
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z_#][A-Za-z0-9_#$]*$")
|
||||
|
||||
def _u8(data, off):
|
||||
return struct.unpack("<B", data[off:off + 1])[0]
|
||||
|
||||
def _u16(data, off):
|
||||
return struct.unpack("<H", data[off:off + 2])[0]
|
||||
|
||||
def _i32(data, off):
|
||||
return struct.unpack("<i", data[off:off + 4])[0]
|
||||
|
||||
def _login_string(value, size):
|
||||
# fixed-width field: `size` bytes of (truncated, NUL-padded) text followed by the actual length
|
||||
data = (value or "").encode("utf-8")[:size]
|
||||
return data + b"\x00" * (size - len(data)) + struct.pack("<B", len(data))
|
||||
|
||||
def _loginrec(host, user, password, appname, servername, charset, packetsize):
|
||||
"""
|
||||
The TDS 5.0 LOGINREC: one fixed 568-byte record, then a 35-byte CAPABILITY token.
|
||||
|
||||
Field order and widths mirror what ASE's own Open Client puts on the wire. The two reserved runs are
|
||||
spare in every implementation that documents them (Open Client sets one byte inside the second run,
|
||||
ASE accepts the record without it), so they go out as zeros rather than as an unexplained constant.
|
||||
"""
|
||||
|
||||
out = _login_string(host, 30) # client host name
|
||||
out += _login_string(user, 30) # user name
|
||||
out += _login_string(password, 30) # password (cleartext)
|
||||
out += _login_string("%d" % os.getpid(), 30) # client process id, as text
|
||||
# number/char/date byte orders, then: notify on USE, refuse dump-load & bulk-copy, interface, network
|
||||
out += struct.pack("<9B", 3, 1, 6, 10, 9, 1, 1, 0, 0)
|
||||
out += b"\x00" * 7 # spare
|
||||
out += _login_string(appname, 30) # application name
|
||||
out += _login_string(servername, 30) # server name
|
||||
remote = b"\x00" + struct.pack("<B", len(password or "")) + (password or "").encode("utf-8")
|
||||
out += remote + b"\x00" * (255 - len(remote)) + struct.pack("<B", len(remote)) # remote passwords
|
||||
out += struct.pack("<4B", 5, 0, 0, 0) # TDS version 5.0.0.0
|
||||
out += _login_string("dbwire", 10) # library name
|
||||
out += struct.pack("<4B", 1, 0, 0, 0) # library version
|
||||
out += struct.pack("<3B", 0, 0x0d, 0x11) # auto-convert short, float4 & date4 formats
|
||||
out += b"\x00" # reserved
|
||||
out += _login_string("", 30) # language (empty -> the server default)
|
||||
out += b"\x00" * 13 # notify-on-language-change, security & HA fields
|
||||
out += _login_string(charset, 30) # client character set
|
||||
out += b"\x00" # notify on character-set change
|
||||
out += _login_string("%d" % packetsize, 6) # network packet size, as text
|
||||
out += b"\x00" * 4 # spare
|
||||
|
||||
# capability negotiation: request bits say what the client can do, response bits what it wants back.
|
||||
# All-zero is the baseline dialect - the wide/streaming formats a fallback client has no use for are
|
||||
# exactly the ones it must not claim, or the server starts answering in them.
|
||||
capability = struct.pack("<3B", _TOKEN_CAPABILITY, 32, 0)
|
||||
capability += struct.pack("<2B", 1, 14) + b"\x00" * 14
|
||||
capability += struct.pack("<2B", 2, 14) + b"\x00" * 14
|
||||
return out + capability
|
||||
|
||||
class _Column(object):
|
||||
__slots__ = ("name", "type", "size", "scale", "usertype")
|
||||
|
||||
def _parse_rowfmt(data, off, length, codec):
|
||||
end = off + length
|
||||
count = _u16(data, off); off += 2
|
||||
columns = []
|
||||
for _ in range(count):
|
||||
col = _Column()
|
||||
namelen = _u8(data, off); off += 1
|
||||
col.name = data[off:off + namelen].decode(codec, "replace"); off += namelen
|
||||
off += 1 # status (nullability - the value length says it)
|
||||
col.usertype = _i32(data, off); off += 4
|
||||
col.type = _u8(data, off); off += 1
|
||||
col.size, col.scale = _FIXED_LENGTH.get(col.type, 0), 0
|
||||
if col.type in _DECIMAL_TYPES:
|
||||
col.size = _u8(data, off)
|
||||
col.scale = _u8(data, off + 2) # precision (off + 1) is not needed to decode
|
||||
off += 3
|
||||
elif col.type in _BLOB_TYPES:
|
||||
col.size = _i32(data, off); off += 4
|
||||
off += 2 + _u16(data, off) # the blob's table name
|
||||
elif col.type in _LONG_TYPES:
|
||||
col.size = _i32(data, off); off += 4
|
||||
elif col.type not in _FIXED_LENGTH:
|
||||
col.size = _u8(data, off); off += 1
|
||||
off += 1 + _u8(data, off) # locale
|
||||
columns.append(col)
|
||||
if off != end:
|
||||
raise InterfaceError("malformed ROWFMT token (%d columns did not fill %d bytes)" % (count, length))
|
||||
return columns, off
|
||||
|
||||
def _decode_numeric(raw, scale):
|
||||
# sign byte then a BIG-endian magnitude, and 1 means NEGATIVE (the Microsoft encoding is the mirror
|
||||
# image of this: little-endian, 1 == positive)
|
||||
magnitude = 0
|
||||
for b in bytearray(raw[1:]):
|
||||
magnitude = (magnitude << 8) | b
|
||||
value = -magnitude if bytearray(raw)[0] else magnitude
|
||||
if scale:
|
||||
text = "%0*d" % (scale + 1, abs(value))
|
||||
return ("-" if value < 0 else "") + text[:-scale] + "." + text[-scale:]
|
||||
return "%d" % value
|
||||
|
||||
def _decode_date(raw):
|
||||
import datetime
|
||||
return "%s" % (datetime.date(1900, 1, 1) + datetime.timedelta(days=_i32(raw, 0)))
|
||||
|
||||
def _decode_time(raw):
|
||||
import datetime
|
||||
ticks = struct.unpack("<I", raw)[0] # 300ths of a second since midnight
|
||||
return "%s" % (datetime.datetime(1900, 1, 1) + datetime.timedelta(milliseconds=ticks * 10.0 / 3.0)).time()
|
||||
|
||||
def _decode_bigdatetime(raw, time_only=False):
|
||||
import datetime
|
||||
micros = struct.unpack("<Q", raw)[0] # microseconds since 0001-01-01 00:00:00
|
||||
value = datetime.datetime(1, 1, 1) + datetime.timedelta(microseconds=micros)
|
||||
return "%s" % (value.time() if time_only else value)
|
||||
|
||||
def _decode_value(col, raw, codec):
|
||||
t = col.type
|
||||
if t in (_T_INT1, _T_BIT) or (t == _T_INTN and len(raw) == 1):
|
||||
value = bytearray(raw)[0] # tinyint is unsigned; bit is 0/1
|
||||
return "%d" % value
|
||||
if t == _T_INT2 or (t == _T_INTN and len(raw) == 2):
|
||||
return "%d" % struct.unpack("<h", raw)[0]
|
||||
if t == _T_INT4 or (t == _T_INTN and len(raw) == 4):
|
||||
return "%d" % struct.unpack("<i", raw)[0]
|
||||
if t == _T_INT8 or (t == _T_INTN and len(raw) == 8):
|
||||
return "%d" % struct.unpack("<q", raw)[0]
|
||||
if t in (_T_REAL, _T_FLT8, _T_FLTN):
|
||||
return repr(struct.unpack("<f" if len(raw) == 4 else "<d", raw)[0])
|
||||
if t in (_T_MONEY, _T_MONEY4, _T_MONEYN):
|
||||
return _decode_money(raw)
|
||||
if t in _DECIMAL_TYPES:
|
||||
return _decode_numeric(raw, col.scale)
|
||||
if t in (_T_DATETIME, _T_DATETIME4, _T_DATETIMN):
|
||||
text = _decode_datetime(raw) if len(raw) == 8 else _decode_smalldatetime(raw)
|
||||
if col.usertype == _USERTYPE_DATE:
|
||||
return text.split(" ")[0]
|
||||
if col.usertype in _USERTYPE_TIME:
|
||||
return text.split(" ")[1]
|
||||
return text
|
||||
if t in (_T_DATE, _T_DATEN):
|
||||
return _decode_date(raw)
|
||||
if t in (_T_TIME, _T_TIMEN):
|
||||
return _decode_time(raw)
|
||||
if t in (_T_BIGDATETIME, _T_BIGDATETIMEN):
|
||||
return _decode_bigdatetime(raw)
|
||||
if t in (_T_BIGTIME, _T_BIGTIMEN):
|
||||
return _decode_bigdatetime(raw, time_only=True)
|
||||
if t in _BINARY_TYPES:
|
||||
return raw # sqlmap hex-encodes binary columns
|
||||
if t == _T_UNITEXT or col.usertype in _UNICODE_USERTYPES:
|
||||
return raw.decode("utf-16-le", "replace")
|
||||
if t in (_T_CHAR, _T_VARCHAR, _T_NVARCHAR, _T_LONGCHAR, _T_TEXT):
|
||||
return raw.decode(codec, "replace")
|
||||
return raw # LONGBINARY that is not unichar, a locator handle, an unmapped type: bytes, not a guess
|
||||
|
||||
def _read_value(col, data, off, codec):
|
||||
if col.type in _FIXED_LENGTH:
|
||||
size = _FIXED_LENGTH[col.type]
|
||||
raw, off = data[off:off + size], off + size
|
||||
if not size:
|
||||
return None, off
|
||||
elif col.type in _BLOB_TYPES:
|
||||
ptrlen = _u8(data, off); off += 1
|
||||
if not ptrlen:
|
||||
return None, off # no text pointer -> NULL
|
||||
off += ptrlen + 8 # text pointer + timestamp
|
||||
length = _i32(data, off); off += 4
|
||||
raw, off = data[off:off + length], off + length
|
||||
elif col.type in _LONG_TYPES:
|
||||
length = _i32(data, off); off += 4
|
||||
if not length:
|
||||
return None, off
|
||||
raw, off = data[off:off + length], off + length
|
||||
else:
|
||||
length = _u8(data, off); off += 1
|
||||
if not length:
|
||||
return None, off
|
||||
raw, off = data[off:off + length], off + length
|
||||
return _decode_value(col, raw, codec), off
|
||||
|
||||
def _eed(data, off, codec):
|
||||
# EED: number(4) state(1) class(1) sqlstate, status(1) transtate(2) message, server, procedure, line
|
||||
number = _i32(data, off)
|
||||
severity = _u8(data, off + 5)
|
||||
pos = off + 6
|
||||
pos += 1 + _u8(data, pos) # SQLSTATE
|
||||
pos += 1 + 2 # status + transaction state
|
||||
length = _u16(data, pos); pos += 2
|
||||
return number, severity, data[pos:pos + length].decode(codec, "replace").strip()
|
||||
|
||||
def _raise_server_error(number, message):
|
||||
if number in (2601, 2615, 2627, 1105, 546, 547): # duplicate key/row, constraint, foreign key
|
||||
raise IntegrityError(message)
|
||||
if number in (247, 249, 257, 260, 264, 512, 8115): # conversion, overflow, arithmetic
|
||||
raise DataError(message)
|
||||
raise ProgrammingError(message)
|
||||
|
||||
class Cursor(object):
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
self.description = None
|
||||
self.rowcount = -1
|
||||
self._rows = []
|
||||
self._pos = 0
|
||||
|
||||
def execute(self, query, params=None):
|
||||
if params is not None:
|
||||
raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string")
|
||||
self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0
|
||||
self.description, self._rows, affected = self.connection._query(query)
|
||||
self.rowcount = len(self._rows) if self.description is not None else (affected if affected is not None else -1)
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
retVal = self._rows[self._pos:]
|
||||
self._pos = len(self._rows)
|
||||
return retVal
|
||||
|
||||
def fetchone(self):
|
||||
if self._pos >= len(self._rows):
|
||||
return None
|
||||
retVal = self._rows[self._pos]
|
||||
self._pos += 1
|
||||
return retVal
|
||||
|
||||
def close(self):
|
||||
self._rows = []
|
||||
|
||||
class Connection(object):
|
||||
def __init__(self, sock, codec):
|
||||
self._sock = sock
|
||||
self._codec = codec
|
||||
# a packet larger than the size agreed at login is not an error the server reports - it drops the
|
||||
# connection, so every send has to be chopped to what the login negotiated (ENVCHANGE 4 below)
|
||||
self._chunk = _PACKET_SIZE - _HEADER_SIZE
|
||||
|
||||
def cursor(self):
|
||||
return Cursor(self)
|
||||
|
||||
def commit(self):
|
||||
pass # sqlmap issues autonomous statements; ASE is in unchained (auto-commit) mode by default
|
||||
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
_send_message(self._sock, _PKT_NORMAL, struct.pack("<2B", _TOKEN_LOGOUT, 0))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _query(self, query):
|
||||
# LANGUAGE token: length covers the status byte and the (single-byte encoded) statement text
|
||||
text = query.encode(self._codec, "replace")
|
||||
token = struct.pack("<BiB", _TOKEN_LANGUAGE, len(text) + 1, 0) + text
|
||||
_send_message(self._sock, _PKT_NORMAL, token, self._chunk)
|
||||
try:
|
||||
return self._read_response()
|
||||
except (struct.error, IndexError, ValueError, KeyError) as ex:
|
||||
raise InterfaceError("malformed server response: %s" % ex)
|
||||
|
||||
def _read_response(self, login=False):
|
||||
data = _read_message(self._sock)
|
||||
off, columns, rows, description, affected = 0, [], [], None, None
|
||||
error = None
|
||||
while off < len(data):
|
||||
token = _u8(data, off); off += 1
|
||||
if token == _TOKEN_ROWFMT:
|
||||
length = _u16(data, off); off += 2
|
||||
columns, off = _parse_rowfmt(data, off, length, self._codec)
|
||||
description = [(c.name, c.type, None, None, None, c.scale, None) for c in columns]
|
||||
rows, affected = [], None
|
||||
elif token == _TOKEN_ROW:
|
||||
row = []
|
||||
for col in columns:
|
||||
value, off = _read_value(col, data, off, self._codec)
|
||||
row.append(value)
|
||||
rows.append(tuple(row))
|
||||
elif token in _TOKEN_DONE:
|
||||
status, _transtate, count = struct.unpack("<HHi", data[off:off + 8]); off += 8
|
||||
if status & _DONE_COUNT:
|
||||
affected = count if affected is None else affected + count
|
||||
elif token == _TOKEN_EED:
|
||||
length = _u16(data, off)
|
||||
number, severity, message = _eed(data, off + 2, self._codec)
|
||||
if severity > 10: # 10 and below are informational (e.g. 'Changed database')
|
||||
error = (number, "(remote) %s" % message)
|
||||
off += 2 + length
|
||||
elif token == _TOKEN_ENVCHANGE:
|
||||
length = _u16(data, off)
|
||||
if _u8(data, off + 2) == _ENV_PACKET_SIZE:
|
||||
size = data[off + 4:off + 4 + _u8(data, off + 3)]
|
||||
self._chunk = max(_LOGIN_CHUNK, int(size) - _HEADER_SIZE)
|
||||
off += 2 + length
|
||||
elif token == _TOKEN_RETURNSTATUS:
|
||||
off += 4
|
||||
elif token == _TOKEN_ROWFMT2:
|
||||
raise NotSupportedError("the server answered with the wide ROWFMT2 format")
|
||||
else:
|
||||
off += 2 + _u16(data, off) # every other TDS 5 token is 2-byte length prefixed
|
||||
if error is not None:
|
||||
if login:
|
||||
raise OperationalError(error[1])
|
||||
_raise_server_error(*error)
|
||||
return description, rows, affected
|
||||
|
||||
def connect(host=None, port=5000, user=None, password=None, database=None, connect_timeout=None, **kwargs):
|
||||
charset, codec = kwargs.get("charset", "utf8"), "utf-8"
|
||||
try:
|
||||
sock = socket.create_connection((host or "localhost", int(port or 5000)), timeout=connect_timeout)
|
||||
keepalive(sock)
|
||||
except (socket.error, socket.timeout) as ex:
|
||||
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
|
||||
|
||||
connection = Connection(sock, codec)
|
||||
try:
|
||||
login = _loginrec("dbwire", user or "", password or "", "dbwire", host or "localhost", charset, _PACKET_SIZE)
|
||||
_send_message(sock, _PKT_LOGIN, login, _LOGIN_CHUNK)
|
||||
connection._read_response(login=True)
|
||||
handshake_done(sock)
|
||||
if database:
|
||||
if not _IDENTIFIER.match(database):
|
||||
raise ProgrammingError("unsupported database name %r" % database)
|
||||
connection._query("USE %s" % database)
|
||||
except (DatabaseError, InterfaceError):
|
||||
connection.close()
|
||||
raise
|
||||
except Exception as ex:
|
||||
connection.close()
|
||||
raise OperationalError("Sybase login failed (%s)" % ex)
|
||||
return connection
|
||||
+4
-4
@@ -9,7 +9,7 @@ See the file 'LICENSE' for copying permission
|
||||
Minimal pure-python TDS (Tabular Data Stream) client for Microsoft SQL Server (stdlib only).
|
||||
|
||||
LOGIN7 / TDS 7.4 is the Microsoft dialect. Sybase ASE speaks TDS 5.0 - same 8-byte packet framing, but a
|
||||
LOGINREC login and its own token/type dialect - which is not implemented yet, so ASE still needs pymssql.
|
||||
LOGINREC login and its own token/type dialect - and lives in sybase.py, which reuses the framing below.
|
||||
|
||||
Cleartext login only (TDS pre-login encryption negotiated to NOT_SUP); a server that forces encryption
|
||||
would need TLS-in-TDS which is out of scope for the dependency-free client. Implements PRELOGIN, LOGIN7,
|
||||
@@ -43,9 +43,9 @@ _STATUS_EOM = 0x01
|
||||
def _u8(data, off):
|
||||
return struct.unpack("<B", data[off:off + 1])[0]
|
||||
|
||||
def _send_message(sock, mtype, data):
|
||||
# split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status bit
|
||||
chunk_size = 4088
|
||||
def _send_message(sock, mtype, data, chunk_size=4088):
|
||||
# split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status
|
||||
# bit. chunk_size is smaller for a TDS 5 login, whose packet size is not negotiated yet (see sybase.py)
|
||||
packet_id = 0
|
||||
off = 0
|
||||
while True:
|
||||
|
||||
+23
-1
@@ -124,6 +124,28 @@ SYBASE_TYPES = {
|
||||
3: "binary",
|
||||
19: "text",
|
||||
20: "image",
|
||||
# the rest of master..systypes on ASE 15/16 - without these, --schema prints the raw user type number
|
||||
# for columns as ordinary as bigint, date and univarchar
|
||||
34: "unichar",
|
||||
35: "univarchar",
|
||||
36: "unitext",
|
||||
37: "date",
|
||||
38: "time",
|
||||
39: "daten",
|
||||
40: "timen",
|
||||
42: "longsysname",
|
||||
43: "bigint",
|
||||
44: "usmallint",
|
||||
45: "uint",
|
||||
46: "ubigint",
|
||||
47: "uintn",
|
||||
48: "bigdatetime",
|
||||
49: "bigtime",
|
||||
50: "bigdatetimen",
|
||||
51: "bigtimen",
|
||||
53: "text_locator",
|
||||
54: "image_locator",
|
||||
55: "unitext_locator",
|
||||
}
|
||||
|
||||
ALTIBASE_TYPES = {
|
||||
@@ -266,7 +288,7 @@ DBWIRE_MODULES = {
|
||||
DBMS.CRATEDB: "postgres", # CrateDB speaks the PostgreSQL wire protocol
|
||||
DBMS.MYSQL: "mysql",
|
||||
DBMS.MSSQL: "tds",
|
||||
DBMS.SYBASE: "tds",
|
||||
DBMS.SYBASE: "sybase", # ASE speaks TDS 5.0, a different login and token dialect than Microsoft's 7.x
|
||||
DBMS.CLICKHOUSE: "clickhouse",
|
||||
DBMS.MONETDB: "monetdb",
|
||||
DBMS.PRESTO: "presto",
|
||||
|
||||
@@ -20,7 +20,7 @@ from lib.core.enums import OS
|
||||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.8.31"
|
||||
VERSION = "1.10.8.32"
|
||||
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)
|
||||
|
||||
@@ -145,7 +145,7 @@ class Enumeration(GenericEnumeration):
|
||||
|
||||
for db in dbs:
|
||||
for blind in blinds:
|
||||
query = rootQuery.inband.query % db
|
||||
query = rootQuery.inband.query % ((db,) * 7)
|
||||
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName], blind=blind, alias=kb.aliasName)
|
||||
|
||||
if retVal:
|
||||
@@ -276,7 +276,7 @@ class Enumeration(GenericEnumeration):
|
||||
logger.info(infoMsg)
|
||||
|
||||
for blind in blinds:
|
||||
query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl))
|
||||
query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True))
|
||||
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName, '%s.usertype' % kb.aliasName], blind=blind, alias=kb.aliasName)
|
||||
|
||||
if retVal:
|
||||
|
||||
@@ -79,6 +79,43 @@ class TestQueriesXmlCoverage(unittest.TestCase):
|
||||
% (dbms.get("value"), query, ex))
|
||||
|
||||
|
||||
def test_tds_table_queries_carry_the_owner(self):
|
||||
# Regression: safeSQLIdentificatorNaming() prepends DEFAULT_MSSQL_SCHEMA ('dbo.') to any MSSQL or
|
||||
# Sybase table name that has no dot in it. A <tables> query that projects the bare name therefore
|
||||
# makes every table owned by anyone but dbo unreachable: --columns/--count/--dump all end up asking
|
||||
# for 'db.dbo.<table>'. Both dialects must join sysusers and project '<owner>.<table>'.
|
||||
tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml"))
|
||||
seen = 0
|
||||
for dbms in tree.findall(".//dbms"):
|
||||
if dbms.get("value") not in ("Microsoft SQL Server", "Sybase"):
|
||||
continue
|
||||
for node in dbms.iter("tables"):
|
||||
query = (node.find("inband") if node.find("inband") is not None else node).get("query") or ""
|
||||
if not query:
|
||||
continue
|
||||
seen += 1
|
||||
self.assertIn("sysusers", query, msg="%s <tables> does not resolve the table owner" % dbms.get("value"))
|
||||
self.assertIn("+'.'+", query, msg="%s <tables> does not qualify the table with its owner" % dbms.get("value"))
|
||||
self.assertEqual(seen, 2)
|
||||
|
||||
def test_sybase_catalog_queries_take_the_argument_count_the_plugin_passes(self):
|
||||
# plugins/dbms/sybase/enumeration.py formats these with ((db,) * 7) and (db, db, db, db, db, table).
|
||||
# pivotDumpTable() then selects '<alias>.name' (and '<alias>.usertype'), so those output column
|
||||
# names have to survive the projection.
|
||||
tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml"))
|
||||
sybase = [_ for _ in tree.findall(".//dbms") if _.get("value") == "Sybase"][0]
|
||||
tables = sybase.find("tables").find("inband").get("query")
|
||||
columns = sybase.find("columns").find("inband").get("query")
|
||||
self.assertEqual(tables.count("%s"), 7)
|
||||
self.assertEqual(columns.count("%s"), 6)
|
||||
self.assertIn("AS name", tables)
|
||||
self.assertIn("usertype", columns)
|
||||
# the table has to be resolved by owner, not by a bare sysobjects.name match: two owners can hold
|
||||
# a same-named table, and matching on the name alone returned both tables' columns merged
|
||||
self.assertIn("object_id(", columns)
|
||||
self.assertNotIn("sysobjects.name='%s'", columns)
|
||||
|
||||
|
||||
class TestErrorsXmlCompile(unittest.TestCase):
|
||||
def test_all_error_regexes_compile(self):
|
||||
tree = ET.parse(os.path.join(ROOT, "data", "xml", "errors.xml"))
|
||||
|
||||
+154
-1
@@ -40,6 +40,7 @@ from extra.dbwire import mysql as _mysql
|
||||
from extra.dbwire import postgres as _postgres
|
||||
from extra.dbwire import presto as _presto
|
||||
from extra.dbwire import recvn
|
||||
from extra.dbwire import sybase as _sybase
|
||||
from extra.dbwire import tds as _tds
|
||||
|
||||
|
||||
@@ -348,6 +349,158 @@ class TdsFramingTest(unittest.TestCase):
|
||||
self.assertIsNone(_tds._parse_tokens(sock)[2])
|
||||
|
||||
|
||||
class SybaseLoginTest(unittest.TestCase):
|
||||
"""The TDS 5.0 LOGINREC is one fixed-layout record: every field sits at a byte offset the server counts
|
||||
on, so a field that changes width silently shifts the credentials into the reserved area."""
|
||||
|
||||
def _loginrec(self):
|
||||
return _sybase._loginrec("dbwire", "tester", "guest1234", "dbwire", "srv", "utf8", 2048)
|
||||
|
||||
def test_field_offsets_match_the_fixed_layout(self):
|
||||
login = self._loginrec()
|
||||
self.assertEqual(len(login), 568 + 35, "LOGINREC is not the fixed 568 bytes plus a CAPABILITY token")
|
||||
for offset, size, expected in ((0, 30, b"dbwire"), (31, 30, b"tester"), (62, 30, b"guest1234"),
|
||||
(140, 30, b"dbwire"), (171, 30, b"srv"), (462, 10, b"dbwire"),
|
||||
(525, 30, b"utf8"), (557, 6, b"2048")):
|
||||
self.assertEqual(login[offset:offset + size].rstrip(b"\x00"), expected, offset)
|
||||
self.assertEqual(login[offset + size:offset + size + 1], struct.pack("<B", len(expected)), offset)
|
||||
self.assertEqual(login[458:462], b"\x05\x00\x00\x00", "TDS version is not 5.0.0.0")
|
||||
self.assertEqual(login[202:204], b"\x00\x09", "remote password is not prefixed with its length")
|
||||
self.assertEqual(login[457:458], struct.pack("<B", 11), "remote password length excludes the prefix")
|
||||
|
||||
def test_no_optional_capability_is_claimed(self):
|
||||
"""A claimed capability makes the server answer in that format. Everything this client cannot parse
|
||||
- wide ROWFMT2 rows above all - has to stay unclaimed, so the server converts down to the baseline."""
|
||||
|
||||
capability = self._loginrec()[568:]
|
||||
self.assertEqual(bytearray(capability)[0], 0xe2)
|
||||
self.assertEqual(capability[3:5], b"\x01\x0e") # request capabilities, 14 bytes
|
||||
self.assertEqual(capability[5:19], b"\x00" * 14)
|
||||
self.assertEqual(capability[19:21], b"\x02\x0e") # response capabilities, 14 bytes
|
||||
self.assertEqual(capability[21:35], b"\x00" * 14)
|
||||
|
||||
|
||||
class SybaseTokenTest(unittest.TestCase):
|
||||
"""Fixtures are bytes recorded off ASE 16's wire."""
|
||||
|
||||
def _response(self, blob):
|
||||
class Replay(object):
|
||||
def __init__(self, data):
|
||||
self.data = bytearray(data)
|
||||
|
||||
def recv(self, count):
|
||||
chunk = bytes(self.data[:count]); del self.data[:count]; return chunk
|
||||
|
||||
packet = struct.pack(">BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob
|
||||
return _sybase.Connection(Replay(packet), "utf-8")._read_response()
|
||||
|
||||
def test_rowfmt_and_row(self):
|
||||
blob = (b"\xee\x19\x00\x02\x00\x03one\x10\x07\x00\x00\x00\x38\x00"
|
||||
b"\x03txt\x10\x02\x00\x00\x00\x27\x03\x00"
|
||||
b"\xd1\x01\x00\x00\x00\x03abc"
|
||||
b"\xfd\x10\x00\x02\x00\x01\x00\x00\x00")
|
||||
description, rows, affected = self._response(blob)
|
||||
self.assertEqual([_[0] for _ in description], ["one", "txt"])
|
||||
self.assertEqual(rows, [("1", "abc")])
|
||||
self.assertEqual(affected, 1)
|
||||
|
||||
def test_done_row_count_is_four_bytes(self):
|
||||
"""Microsoft widened DoneRowCount to 8 bytes; ASE did not, so reading 8 here eats the next token."""
|
||||
|
||||
description, rows, affected = self._response(b"\xfd\x10\x00\x02\x00\x2a\x00\x00\x00")
|
||||
self.assertIsNone(description)
|
||||
self.assertEqual(affected, 42)
|
||||
self.assertIsNone(self._response(b"\xfd\x00\x00\x02\x00\x2a\x00\x00\x00")[2],
|
||||
"a row count without the DONE_COUNT status bit is meaningless")
|
||||
|
||||
def test_server_error_is_raised_and_informational_message_is_not(self):
|
||||
def eed(number, severity, message):
|
||||
body = (struct.pack("<i", number) + struct.pack("<3B", 1, severity, 5) + b"ZZZZZ" +
|
||||
b"\x00" + struct.pack("<H", 1) + struct.pack("<H", len(message)) + message +
|
||||
struct.pack("<B", 8) + b"MYSYBASE" + b"\x00" + struct.pack("<H", 1))
|
||||
return b"\xe5" + struct.pack("<H", len(body)) + body
|
||||
|
||||
# severity 10 and below is informational - 'Changed database context' arrives on every USE
|
||||
self._response(eed(5701, 10, b"Changed database context to 'master'.") +
|
||||
b"\xfd\x00\x00\x02\x00\x00\x00\x00\x00")
|
||||
fatal = eed(208, 16, b"nope not found. Specify owner.objectname")
|
||||
try:
|
||||
self._response(fatal + b"\xfd\x02\x00\x02\x00\x00\x00\x00\x00")
|
||||
self.fail("a severity 16 server error was swallowed")
|
||||
except dbwire.ProgrammingError as ex:
|
||||
self.assertIn("not found", str(ex))
|
||||
|
||||
def test_packet_size_negotiation_is_honoured(self):
|
||||
"""A packet bigger than the login negotiated is not refused, the connection is dropped - so the
|
||||
server's answer to the requested size has to become the send chunk size."""
|
||||
|
||||
envchange = b"\xe3\x07\x00\x04\x04" + b"8192" + b"\x00"
|
||||
class Replay(object):
|
||||
def __init__(self, data):
|
||||
self.data = bytearray(data)
|
||||
|
||||
def recv(self, count):
|
||||
chunk = bytes(self.data[:count]); del self.data[:count]; return chunk
|
||||
|
||||
blob = envchange + b"\xfd\x00\x00\x02\x00\x00\x00\x00\x00"
|
||||
connection = _sybase.Connection(Replay(struct.pack(">BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob), "utf-8")
|
||||
self.assertEqual(connection._chunk, _sybase._PACKET_SIZE - 8)
|
||||
connection._read_response()
|
||||
self.assertEqual(connection._chunk, 8192 - 8)
|
||||
|
||||
|
||||
class SybaseDecoderTest(unittest.TestCase):
|
||||
def _column(self, dtype, scale=0, usertype=0):
|
||||
col = _sybase._Column()
|
||||
col.name, col.type, col.size, col.scale, col.usertype = "c", dtype, 0, scale, usertype
|
||||
return col
|
||||
|
||||
def test_numeric_is_the_mirror_image_of_the_microsoft_encoding(self):
|
||||
"""ASE sends a big-endian magnitude with 1 meaning negative; Microsoft sends little-endian with 1
|
||||
meaning positive. Sharing one decoder between the two silently returns wrong numbers."""
|
||||
|
||||
for raw, scale, expected in ((b"\x01\x00\x00\xbcaN", 3, "-12345.678"),
|
||||
(b"\x00\x00\x00'\x0f", 2, "99.99"),
|
||||
(b"\x00\x00\x00\x00\x01", 0, "1"),
|
||||
(b"\x01\x00\x00\x00\x01", 0, "-1")):
|
||||
self.assertEqual(_sybase._decode_numeric(raw, scale), expected, raw)
|
||||
self.assertNotEqual(_sybase._decode_numeric(b"\x01\x00\x00\xbcaN", 3),
|
||||
_tds._decode_numeric(b"\x01\x00\x00\xbcaN", 3))
|
||||
|
||||
def test_date_and_time_columns_survive_the_servers_conversion(self):
|
||||
"""Unclaimed date/time capabilities make ASE send both as a plain datetime; only the user type still
|
||||
says how much of it the column holds."""
|
||||
|
||||
raw = struct.pack("<iI", 46245, 0)
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x6f, usertype=37), raw, "utf-8"), "2026-08-13")
|
||||
raw = struct.pack("<iI", 0, 11001600)
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x6f, usertype=38), raw, "utf-8"), "10:11:12")
|
||||
raw = struct.pack("<iI", 46245, 11001704)
|
||||
self.assertTrue(_sybase._decode_value(self._column(0x6f), raw, "utf-8").startswith("2026-08-13 10:11:12"))
|
||||
|
||||
def test_unicode_columns_are_told_apart_by_user_type(self):
|
||||
"""univarchar travels as LONGBINARY: without the user type it would be hex-dumped as binary."""
|
||||
|
||||
raw = u"\u017eabac".encode("utf-16-le")
|
||||
self.assertEqual(_sybase._decode_value(self._column(0xe1, usertype=35), raw, "utf-8"), u"\u017eabac")
|
||||
self.assertEqual(_sybase._decode_value(self._column(0xe1), raw, "utf-8"), raw)
|
||||
|
||||
def test_integers_and_money(self):
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xc8", "utf-8"), "200") # tinyint
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xf9\xff", "utf-8"), "-7")
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xff" * 7 + b"\x7f", "utf-8"),
|
||||
"9223372036854775807")
|
||||
self.assertEqual(_sybase._decode_value(self._column(0x6e), b"\x00\x00\x00\x00\x40\xe2\x01\x00", "utf-8"),
|
||||
"12.3456")
|
||||
|
||||
def test_a_zero_length_value_is_null(self):
|
||||
"""TDS 5 has no separate NULL marker for a length-prefixed value - and neither has ASE."""
|
||||
|
||||
col = self._column(0x27)
|
||||
self.assertIsNone(_sybase._read_value(col, b"\x00", 0, "utf-8")[0])
|
||||
self.assertEqual(_sybase._read_value(col, b"\x03abc", 0, "utf-8")[0], "abc")
|
||||
|
||||
|
||||
class TrinoSessionStateTest(unittest.TestCase):
|
||||
"""Trino is stateless on the wire: the server reports each session change as a response header and the
|
||||
client must echo it back, or USE / SET SESSION silently do nothing on the next statement."""
|
||||
@@ -534,7 +687,7 @@ class HelperTest(unittest.TestCase):
|
||||
self.assertRaises(dbwire.InterfaceError, recvn, FakeSocket(b"AB"), 4)
|
||||
|
||||
def test_every_module_exposes_the_dbapi_surface(self):
|
||||
for name in ("postgres", "mysql", "tds", "firebird", "cubrid", "monetdb", "clickhouse", "presto"):
|
||||
for name in ("postgres", "mysql", "tds", "sybase", "firebird", "cubrid", "monetdb", "clickhouse", "presto"):
|
||||
module = __import__("extra.dbwire.%s" % name, fromlist=["connect"])
|
||||
self.assertTrue(callable(getattr(module, "connect", None)), name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user