Adding support for import sqlmap as a library (#2083)

This commit is contained in:
Miroslav Štampar 2026-07-02 09:58:48 +02:00
parent e1126a2a4e
commit d2ead9dcda
5 changed files with 312 additions and 3 deletions

View file

@ -189,7 +189,7 @@ b14628a6c9327d110afe50b01f3171f64f61823343b8de89596e854b00b74928 lib/core/dump.
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
906d17d317ef11f67d52b30cf6bbcfd67c3af35af0942f697a13c55d9aa89816 lib/core/settings.py
1d609263088c5767b4f92ead270f84cd218d9602007b75b3fd45c1169f183265 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
@ -266,6 +266,7 @@ bd9267d94390ba87d6c5a35c90f2406d6a4135a7c8ea01db76dd9e6519eee2ed lib/utils/dial
71a66ff766a2921106770b26acff380de469222dc893816a7b970b384c927666 lib/utils/hash.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/utils/__init__.py
1bbf57e43f921d4132e6e5a336ff39454a9506b36de94ebcc45879d0abcac56a lib/utils/keysetdump.py
b57aa20b7a6fd8afd07bae773fd03f8acb05655ee605362b220e65a0664dc38d lib/utils/library.py
dd30ef67da30b666c53013ee32253cd9396ed0e5d0a44d509680742e06ebcd23 lib/utils/pivotdumptable.py
c1dfc3bed0fed9b181f612d1d747955dd2b506dbe99bc9fd481495602371473a lib/utils/progress.py
c442e9ef8324fd6fdf7bc334d765f0a6ce4037397eb3d79d59b5ce3e9a043855 lib/utils/prove.py
@ -509,7 +510,7 @@ cedf45d33461bd7e5400d06611a63c8a4ffae1a4510030c5696b9d46ed6a9883 plugins/generi
46517f1444c202710e388873960130850ed092e17bd6f4dd5f2fedea3dbb8ffc sqlmapapi.py
f09d1b06901e7e02d0dbf4de607f6a4a9889acc322ae9353b98ea9101fb9548a sqlmapapi.yaml
627d90f1194335b800cbc9cc78db6697cf9e02e193a83598e0d4d0abb55b63b8 sqlmap.conf
d375c77f1f4270ec0967e67963fe410f14b5d2e51ed6483593dc1aaa4e8e106e sqlmap.py
80d66407453d34d672c389f6d9ab059d925528615429f2e6e9f286ce03d2c5d6 sqlmap.py
eb37a88357522fd7ad00d90cdc5da6b57442b4fec49366aadb2944c4fbf8b804 tamper/0eunion.py
a9785a4c111d6fee2e6d26466ba5efb3b229c00520b26e8024b041553b53efba tamper/apostrophemask.py
cf26bc8006519bd25ce06d347f72770cd75b61575cf65e5812274e8ab9392eb4 tamper/apostrophenullencode.py
@ -626,6 +627,7 @@ b23bf934dafe54c241761517a7b8c139159aa4b941db10832a626a51fea81e35 tests/test_htt
d539d0ae758b5bb91e314ab82ab4fe03d6fb2f8b377d16aefa6d7d1d77a7d5a9 tests/test_identifiers_output.py
5372270b7ed82b62f273c2e9bd1f7ecd8605371e66cd0ad70663762cb08d42f1 tests/test_inference_engine.py
0fc7bd9bae4fbd09f51027780b7a8e72eab73810dccdfdf87ed9e489e6e671c9 tests/test_ldap.py
7780bbd53f4ef48b01b689f3989c62822ee7f326dfc3b4110522c9af93a61482 tests/test_library.py
caa06fed7323b2bb6d0f2443ce343de94f75bf8ad012c055d5e07741d908ebad tests/test_misc.py
790b78c600b61eb0bdd6e07e14b1db3eb2ddd5fc5d4edb9e975f85ced38558c7 tests/test_nosql.py
88a8c7ce0ba0ca721dffbcf9351cd07f7e471ad2fe667a10608c18952b09868d tests/test_openapi_drift.py

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.7.12"
VERSION = "1.10.7.13"
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)

190
lib/utils/library.py Normal file
View file

@ -0,0 +1,190 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
# Library facade for programmatic (in-code) usage: 'import sqlmap; sqlmap.scan(...)'.
#
# This is the code-level sibling of the REST API (lib/utils/api.py): both drive the engine as an
# isolated subprocess for programmatic callers. The public names here are re-exported by sqlmap.py so
# that they are reachable as 'sqlmap.scan', 'sqlmap.scanFromRequest' and 'sqlmap.SqlmapError'.
import json
import os
import sys
import tempfile
__all__ = ["scan", "scanFromRequest", "SqlmapError"]
# Absolute path of the engine entry point (this module lives at <root>/lib/utils/library.py)
SQLMAP_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "sqlmap.py")
class SqlmapError(Exception):
"""
Raised by the library facade (scan/scanFromRequest) when a scan can not produce a result report
"""
pass
def _terminateProcess(process):
"""
Best-effort hard teardown of a scan subprocess together with its whole process group, so a
timed-out scan never leaves orphaned sqlmap workers behind (POSIX kills the group, others fall
back to killing the process itself)
"""
import signal
try:
if os.name != "nt" and hasattr(os, "killpg"):
os.killpg(os.getpgid(process.pid), getattr(signal, "SIGKILL", signal.SIGTERM))
else:
process.kill()
except (OSError, AttributeError):
try:
process.kill()
except (OSError, AttributeError):
pass
def scan(url=None, requestFile=None, timeout=None, outputDir=None, raw=None, **options):
"""
Runs a sqlmap scan in a dedicated subprocess and returns its structured result (library usage).
Keyword options are plain sqlmap option names - exactly the names used in a sqlmap configuration
file (data/sqlmap.conf) and by the REST API, i.e. the 'conf' names, NOT command line switches. So
scan(url, technique="BEU", getBanner=True, dumpTable=True, tbl="users", level=3) is equivalent to
the config file lines 'technique = BEU', 'getBanner = True', 'dumpTable = True', 'tbl = users',
'level = 3'. Unknown names are rejected. The scan is driven through a generated config file passed
with '-c' (the same mechanism the REST API uses), so there is a single option namespace and no
argument escaping. 'raw' takes a list of extra raw command line switches for the rare thing not
expressible as a config option (e.g. raw=["--fresh-queries"]).
The engine runs fully out-of-process, so a scan can never affect the calling process (no shared
global state, no HTTP-stack patching, no risk of the host being exited). The return value is the
parsed '--report-json' report - the same structure as the REST API '/scan/<id>/data' response: a
dict with keys 'success', 'data' (a list of {'type_name', 'value'} entries: TARGET, TECHNIQUES,
BANNER, DUMP_TABLE, ...), 'error' and 'meta'.
scan() is blocking and thread-safe, so it is both thread- and asyncio-ready: run several at once
in threads, or from an event loop with 'await loop.run_in_executor(None, functools.partial(scan,
url, dumpTable=True))'. For unattended/concurrent use the run is hardened like the REST API
subprocess: batch mode (never prompts) with stdin closed, isolated file descriptors, its own
output directory (so parallel scans of the same target can not collide on session/dump files and
nothing accumulates on disk), engine output streamed to a temporary file rather than buffered in
memory, and - when 'timeout' is set - the whole subprocess group is torn down on expiry. Pass
'outputDir' to keep the run's files.
Example:
import sqlmap
result = sqlmap.scan("http://target/vuln.php?id=1", dumpTable=True, tbl="users")
"""
import shutil
import subprocess
import time
from lib.core.common import saveConfig
from lib.core.optiondict import optDict
if not (url or requestFile):
raise SqlmapError("scan() requires either 'url' or 'requestFile'")
if not os.path.isfile(SQLMAP_FILE):
raise SqlmapError("could not locate the sqlmap engine ('%s')" % SQLMAP_FILE)
knownOptions = set()
for family in optDict.values():
knownOptions.update(family)
config = {}
if url:
config["url"] = url
if requestFile:
config["requestFile"] = requestFile
config.update(options)
unknown = [_ for _ in config if _ not in knownOptions]
if unknown:
raise SqlmapError("unknown option(s) %s - scan() expects sqlmap option names as used in a configuration file (e.g. getBanner, dumpTable, tbl, technique, level), not command line switches" % ", ".join(repr(_) for _ in sorted(unknown)))
handle, report = tempfile.mkstemp(prefix="sqlmap-", suffix=".json")
os.close(handle)
# Each run gets its own output directory so concurrent scans can not collide on session/dump files
# and no scan state piles up on disk. A caller-provided 'outputDir' is respected and left in place.
ownOutput = not outputDir
if ownOutput:
outputDir = tempfile.mkdtemp(prefix="sqlmap-output-")
# engine plumbing goes through the very same option namespace
config["batch"] = True
config["disableColoring"] = True
config["outputDir"] = outputDir
config["reportJson"] = report
handle, configFile = tempfile.mkstemp(prefix="sqlmap-", suffix=".conf")
os.close(handle)
saveConfig(config, configFile)
argv = [sys.executable or "python", SQLMAP_FILE, "-c", configFile, "--ignore-stdin"]
if raw:
argv += list(raw)
logHandle, logFile = tempfile.mkstemp(prefix="sqlmap-", suffix=".log")
devnull = open(os.devnull, "rb")
kwargs = {"shell": False, "close_fds": os.name != "nt", "cwd": os.path.dirname(SQLMAP_FILE) or '.', "stdin": devnull, "stdout": logHandle, "stderr": subprocess.STDOUT}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
elif sys.version_info >= (3, 2):
kwargs["start_new_session"] = True # own process group -> clean group teardown
else:
kwargs["preexec_fn"] = os.setsid
process = None
try:
process = subprocess.Popen(argv, **kwargs)
if timeout is None:
process.wait()
else:
end = time.time() + timeout
while process.poll() is None:
if time.time() > end:
_terminateProcess(process)
process.wait()
raise SqlmapError("scan timed out after %s second(s)" % timeout)
time.sleep(0.5)
try:
with open(report, "rb") as f:
return json.loads(f.read().decode("utf-8", "replace"))
except (IOError, OSError, ValueError):
try:
with open(logFile, "rb") as f:
tail = f.read().decode("utf-8", "replace").strip()
except (IOError, OSError):
tail = ""
raise SqlmapError("scan did not produce a valid report (exit code %s)\n%s" % (getattr(process, "returncode", None), tail[-1000:]))
finally:
try:
os.close(logHandle)
except OSError:
pass
devnull.close()
for path in (report, logFile, configFile):
try:
os.remove(path)
except OSError:
pass
if ownOutput:
shutil.rmtree(outputDir, ignore_errors=True)
def scanFromRequest(requestFile, **options):
"""
Convenience wrapper for scan(requestFile=...) - runs a scan from a saved HTTP request file ('-r')
"""
return scan(requestFile=requestFile, **options)

View file

@ -664,3 +664,9 @@ if __name__ == "__main__":
else:
# cancelling postponed imports (because of CI/CD checks)
__import__("lib.controller.controller")
# exposing the programmatic library facade as 'sqlmap.scan()' / 'sqlmap.scanFromRequest()'
from lib.utils.library import scan, scanFromRequest, SqlmapError
# public library API (also marks the re-exported names above as intentional for pyflakes)
__all__ = ["scan", "scanFromRequest", "SqlmapError"]

111
tests/test_library.py Normal file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit coverage for the library facade (import sqlmap; sqlmap.scan(...)).
The facade drives the engine out-of-process through a generated configuration file (the same '-c'
mechanism the REST API uses) and reads back a '--report-json' report. These tests stub
subprocess.Popen to (a) capture the argv/config sqlmap.scan() builds from its keyword options and
(b) feed back a canned report - keeping the test fast, offline and network-free (no real scan runs).
stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
"""
import json
import os
import re
import subprocess
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import sqlmap
class _FakePopen(object):
"""Stub that records argv/config and writes a canned report to the config's 'reportJson' path."""
captured = {}
returncode = 0
def __init__(self, argv, **kwargs):
_FakePopen.captured["argv"] = argv
_FakePopen.captured["kwargs"] = kwargs
with open(argv[argv.index("-c") + 1]) as f:
config = f.read()
_FakePopen.captured["config"] = config
report = re.search(r"(?im)^reportjson\s*=\s*(.+)$", config).group(1).strip()
with open(report, "w") as f:
json.dump({"success": True, "data": [{"type_name": "BANNER", "value": "3.45.1"}], "error": []}, f)
def wait(self, timeout=None):
return 0
def poll(self):
return 0
def kill(self):
pass
class TestLibraryFacade(unittest.TestCase):
def setUp(self):
self._realPopen = subprocess.Popen
subprocess.Popen = _FakePopen
_FakePopen.captured = {}
def tearDown(self):
subprocess.Popen = self._realPopen
def test_requires_a_target(self):
subprocess.Popen = self._realPopen # never reached; guard fires first
self.assertRaises(sqlmap.SqlmapError, sqlmap.scan)
def test_rejects_unknown_option(self):
# a command line switch spelling (rather than a conf option name) must be rejected loudly
self.assertRaises(sqlmap.SqlmapError, sqlmap.scan, "http://target/?id=1", current_user=True)
def test_options_go_through_config(self):
result = sqlmap.scan("http://target/vuln.php?id=1", technique="BEU", dumpTable=True,
tbl="users", level=3, getBanner=True, raw=["--fresh-queries"])
argv = _FakePopen.captured["argv"]
config = _FakePopen.captured["config"]
# driven via a generated config file, stdin ignored, engine plumbing set - no arg escaping
self.assertIn("-c", argv)
self.assertIn("--ignore-stdin", argv)
self.assertIn("--fresh-queries", argv) # raw escape hatch stays on the CLI
# options land in the config using sqlmap's own (conf) names (ConfigParser lowercases keys)
self.assertTrue(re.search(r"(?im)^url\s*=\s*http://target/vuln.php\?id=1$", config))
self.assertTrue(re.search(r"(?im)^technique\s*=\s*BEU$", config))
self.assertTrue(re.search(r"(?im)^tbl\s*=\s*users$", config))
self.assertTrue(re.search(r"(?im)^level\s*=\s*3$", config))
self.assertTrue(re.search(r"(?im)^dumptable\s*=\s*True$", config))
self.assertTrue(re.search(r"(?im)^getbanner\s*=\s*True$", config))
self.assertTrue(re.search(r"(?im)^batch\s*=\s*True$", config))
self.assertTrue(re.search(r"(?im)^outputdir\s*=", config)) # each run isolated on disk
# file descriptors are not leaked to the engine (matches the REST API subprocess)
self.assertFalse(_FakePopen.captured["kwargs"].get("close_fds") and os.name == "nt")
# canned report is returned verbatim
self.assertTrue(result["success"])
self.assertEqual(result["data"][0]["value"], "3.45.1")
def test_scan_from_request_uses_request_file(self):
sqlmap.scanFromRequest("/tmp/req.txt", technique="U")
config = _FakePopen.captured["config"]
self.assertTrue(re.search(r"(?im)^requestfile\s*=\s*/tmp/req.txt$", config))
self.assertTrue(re.search(r"(?im)^technique\s*=\s*U$", config))
def test_missing_report_raises(self):
class _NoReportPopen(_FakePopen):
def __init__(self, argv, **kwargs):
_FakePopen.captured["argv"] = argv # write nothing -> no report file
subprocess.Popen = _NoReportPopen
self.assertRaises(sqlmap.SqlmapError, sqlmap.scan, "http://target/?id=1")
if __name__ == "__main__":
unittest.main(verbosity=2)