Adding --report-json option

This commit is contained in:
Miroslav Štampar 2026-06-15 15:35:33 +02:00
parent 948d01d57a
commit 17e94c3409
13 changed files with 581 additions and 69 deletions

View file

@ -181,6 +181,12 @@ def _showInjections():
conf.dumper.string("", {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, content_type=CONTENT_TYPE.TARGET)
conf.dumper.string("", kb.injections, content_type=CONTENT_TYPE.TECHNIQUES)
else:
# --report-json: capture the same TARGET/TECHNIQUES structures the API emits, without
# printing them (the human-readable injection points are rendered just below)
if conf.reportJson:
conf.dumper._reportData({"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, CONTENT_TYPE.TARGET)
conf.dumper._reportData(kb.injections, CONTENT_TYPE.TECHNIQUES)
data = "".join(set(_formatInjection(_) for _ in kb.injections)).rstrip("\n")
conf.dumper.string(header, data)

View file

@ -14,6 +14,7 @@ import threading
from lib.core.common import Backend
from lib.core.common import checkFile
from lib.core.common import clearColors
from lib.core.common import dataToDumpFile
from lib.core.common import dataToStdout
from lib.core.common import filterNone
@ -30,6 +31,7 @@ from lib.core.common import unsafeSQLIdentificatorNaming
from lib.core.compat import xrange
from lib.core.convert import getBytes
from lib.core.convert import getConsoleLength
from lib.core.convert import stdoutEncode
from lib.core.convert import getText
from lib.core.convert import getUnicode
from lib.core.convert import htmlEscape
@ -96,6 +98,19 @@ class Dump(object):
kb.dataOutputFlag = True
def _reportData(self, data, content_type):
"""
--report-json: capture a structured result exactly as the REST API would store it (the raw
value + COMPLETE status), independent of console/file rendering. No-op unless a report
collector is active - which is only ever the case for a CLI --report-json run, never under
--api - so this never double-captures alongside StdDbOut. A None content_type is resolved
via the kb.partRun fallback (e.g. the fingerprint line), mirroring the API exactly.
"""
if conf.get("reportCollector") is not None:
from lib.utils.api import _storeData, REPORT_TASKID
_storeData(conf.reportCollector, REPORT_TASKID, stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type)
def flush(self):
if self._outputFP:
try:
@ -116,9 +131,12 @@ class Dump(object):
raise SqlmapGenericException(errMsg)
def singleString(self, data, content_type=None):
self._reportData(data, content_type)
self._write(data, content_type=content_type)
def string(self, header, data, content_type=None, sort=True):
self._reportData(data, content_type)
if conf.api:
self._write(data, content_type=content_type)
@ -153,6 +171,8 @@ class Dump(object):
except:
pass
self._reportData(elements, content_type)
if conf.api:
self._write(elements, content_type=content_type)
@ -204,6 +224,8 @@ class Dump(object):
users = [_ for _ in userSettings.keys() if _ is not None]
users.sort(key=lambda _: _.lower() if hasattr(_, "lower") else _)
self._reportData(userSettings, content_type)
if conf.api:
self._write(userSettings, content_type=content_type)
@ -237,6 +259,8 @@ class Dump(object):
def dbTables(self, dbTables):
if isinstance(dbTables, dict) and len(dbTables) > 0:
self._reportData(dbTables, CONTENT_TYPE.TABLES)
if conf.api:
self._write(dbTables, content_type=CONTENT_TYPE.TABLES)
@ -279,6 +303,8 @@ class Dump(object):
def dbTableColumns(self, tableColumns, content_type=None):
if isinstance(tableColumns, dict) and len(tableColumns) > 0:
self._reportData(tableColumns, content_type)
if conf.api:
self._write(tableColumns, content_type=content_type)
@ -352,6 +378,8 @@ class Dump(object):
def dbTablesCount(self, dbTables):
if isinstance(dbTables, dict) and len(dbTables) > 0:
self._reportData(dbTables, CONTENT_TYPE.COUNT)
if conf.api:
self._write(dbTables, content_type=CONTENT_TYPE.COUNT)
@ -413,6 +441,8 @@ class Dump(object):
safeDb = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(db))
safeTable = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(table))
self._reportData(tableValues, CONTENT_TYPE.DUMP_TABLE)
if conf.api:
self._write(tableValues, content_type=CONTENT_TYPE.DUMP_TABLE)
@ -679,6 +709,8 @@ class Dump(object):
logger.warning(msg)
def dbColumns(self, dbColumnsDict, colConsider, dbs):
self._reportData(dbColumnsDict, CONTENT_TYPE.COLUMNS)
if conf.api:
self._write(dbColumnsDict, content_type=CONTENT_TYPE.COLUMNS)

View file

@ -235,6 +235,7 @@ optDict = {
"postprocess": "string",
"preprocess": "string",
"repair": "boolean",
"reportJson": "string",
"saveConfig": "string",
"scope": "string",
"skipHeuristics": "boolean",

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.6.107"
VERSION = "1.10.6.108"
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)
@ -843,6 +843,15 @@ LIMITED_ROWS_TEST_NUMBER = 15
# Default adapter to use for bottle server
RESTAPI_DEFAULT_ADAPTER = "wsgiref"
# REST API / scan-data contract version (semantic versioning), INDEPENDENT of the sqlmap version.
# Bump MAJOR for breaking changes (removed/renamed field, changed type, restructured response),
# MINOR for additive backward-compatible changes (new field/endpoint), PATCH for non-contract fixes.
# Exposed at GET /version (as "api_version"), in the --report-json "meta", and as the OpenAPI
# info.version (keep sqlmapapi.yaml in sync). Maintained by hand when the contract changes.
# 2.0.0: first explicitly-versioned contract; a MAJOR break from the old implicit shape
# (TECHNIQUES is now a named list, DUMP_TABLE restructured, internal fields dropped, type_name added).
RESTAPI_VERSION = "2.0.0"
# Default REST API server listen address
RESTAPI_DEFAULT_ADDRESS = "127.0.0.1"
@ -850,7 +859,7 @@ RESTAPI_DEFAULT_ADDRESS = "127.0.0.1"
RESTAPI_DEFAULT_PORT = 8775
# Unsupported options by REST API server
RESTAPI_UNSUPPORTED_OPTIONS = ("sqlShell", "wizard", "evalCode", "alert")
RESTAPI_UNSUPPORTED_OPTIONS = ("sqlShell", "wizard", "evalCode", "alert", "reportJson")
# Use "Supplementary Private Use Area-A"
INVALID_UNICODE_PRIVATE_AREA = False

View file

@ -727,6 +727,9 @@ def cmdLineParser(argv=None):
general.add_argument("--repair", dest="repair", action="store_true",
help="Redump entries having unknown character marker (%s)" % INFERENCE_UNKNOWN_CHAR)
general.add_argument("--report-json", dest="reportJson",
help="Store run results to a JSON file")
general.add_argument("--save", dest="saveConfig",
help="Save options to a configuration INI file")

View file

@ -127,10 +127,11 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
expression = match.group(2).strip()
try:
# Set kb.partRun in case "common prediction" feature (a.k.a. "good samaritan") is used or the engine is called from the API
# Set kb.partRun in case "common prediction" feature (a.k.a. "good samaritan") is used, or the
# engine is called from the API, or a JSON report is being collected (so enumeration output is tagged)
if conf.predictOutput:
kb.partRun = getPartRun()
elif conf.api:
elif conf.api or conf.reportJson:
kb.partRun = getPartRun(alias=False)
else:
kb.partRun = None

View file

@ -314,8 +314,8 @@ def errorUse(expression, dump=False):
_, _, _, _, _, expressionFieldsList, expressionFields, _ = agent.getFields(expression)
# Set kb.partRun in case the engine is called from the API
kb.partRun = getPartRun(alias=False) if conf.api else None
# Set kb.partRun in case the engine is called from the API or a JSON report is being collected
kb.partRun = getPartRun(alias=False) if (conf.api or conf.reportJson) else None
# We have to check if the SQL query might return multiple entries
# and in such case forge the SQL limiting the query output one

View file

@ -258,8 +258,8 @@ def unionUse(expression, unpack=True, dump=False):
_, _, _, _, _, expressionFieldsList, expressionFields, _ = agent.getFields(origExpr)
# Set kb.partRun in case the engine is called from the API
kb.partRun = getPartRun(alias=False) if conf.api else None
# Set kb.partRun in case the engine is called from the API or a JSON report is being collected
kb.partRun = getPartRun(alias=False) if (conf.api or conf.reportJson) else None
if expressionFieldsList and len(expressionFieldsList) > 1 and "ORDER BY" in expression.upper():
# Removed ORDER BY clause because UNION does not play well with it

View file

@ -44,7 +44,9 @@ from lib.core.defaults import _defaults
from lib.core.dicts import PART_RUN_CONTENT_TYPES
from lib.core.enums import AUTOCOMPLETE_TYPE
from lib.core.enums import CONTENT_STATUS
from lib.core.enums import CONTENT_TYPE
from lib.core.enums import MKSTEMP_PREFIX
from lib.core.enums import PAYLOAD
from lib.core.exception import SqlmapConnectionException
from lib.core.log import LOGGER_HANDLER
from lib.core.optiondict import optDict
@ -53,6 +55,7 @@ from lib.core.settings import RESTAPI_DEFAULT_ADAPTER
from lib.core.settings import RESTAPI_DEFAULT_ADDRESS
from lib.core.settings import RESTAPI_DEFAULT_PORT
from lib.core.settings import RESTAPI_UNSUPPORTED_OPTIONS
from lib.core.settings import RESTAPI_VERSION
from lib.core.settings import VERSION_STRING
from lib.core.shell import autoCompletion
from lib.core.subprocessng import Popen
@ -80,6 +83,195 @@ class DataStore(object):
RESTAPI_READONLY_OPTIONS = ("api", "taskid", "database")
# Reverse map CONTENT_TYPE int -> name (e.g. 2 -> "DBMS_FINGERPRINT"), for machine-readable reports
CONTENT_TYPE_NAMES = dict((v, k) for k, v in vars(CONTENT_TYPE).items() if not k.startswith("_") and isinstance(v, int))
# Task id used for the single-target CLI collector backing --report-json
REPORT_TASKID = 0
def _storeData(cursor, taskid, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None):
"""
Records a single (status, content_type, value) result row into an IPC-style 'data' table.
Shared by the REST API (via StdDbOut) and the CLI --report-json collector so both capture
results through identical logic (partial outputs are appended; a COMPLETE output replaces
its partials). Mirrors the API's per-content_type merge semantics.
"""
if content_type is None:
if kb.partRun is not None:
content_type = PART_RUN_CONTENT_TYPES.get(kb.partRun)
else:
# Ignore all non-relevant (untyped) messages
return
output = cursor.execute("SELECT id, status, value FROM data WHERE taskid = ? AND content_type = ?", (taskid, content_type))
# Delete partial output from the database if we have got a complete output
if status == CONTENT_STATUS.COMPLETE:
if len(output) > 0:
for index in xrange(len(output)):
cursor.execute("DELETE FROM data WHERE id = ?", (output[index][0],))
cursor.execute("INSERT INTO data VALUES(NULL, ?, ?, ?, ?)", (taskid, status, content_type, jsonize(value)))
if kb.partRun:
kb.partRun = None
elif status == CONTENT_STATUS.IN_PROGRESS:
if len(output) == 0:
cursor.execute("INSERT INTO data VALUES(NULL, ?, ?, ?, ?)", (taskid, status, content_type, jsonize(value)))
else:
new_value = "%s%s" % (dejsonize(output[0][2]), value)
cursor.execute("UPDATE data SET value = ? WHERE id = ?", (jsonize(new_value), output[0][0]))
# Internal detection/plumbing fields that are meaningless to API/report consumers and are stripped
# from the assembled output (the underlying kb/session structures keep them; only the output is cleaned)
INJECTION_INTERNAL_FIELDS = ("conf", "prefix", "suffix", "ptype", "clause") # detection/construction internals, irrelevant to a result consumer
TECHNIQUE_INTERNAL_FIELDS = ("matchRatio", "trueCode", "falseCode", "templatePayload", "where") # per-technique internals
def _cleanIdentifier(name):
"""
Strips SQL identifier quoting (`backticks`, "double quotes", [brackets]) in a DBMS-INDEPENDENT
way. Used instead of unsafeSQLIdentificatorNaming (which needs Backend.getIdentifiedDbms) so the
result is identical in the CLI and in the API server process - which has no Backend context
because the scan ran in a subprocess. Context-free => API and report stay in parity.
"""
if isinstance(name, six.string_types):
for ch in ("`", "\"", "[", "]"):
name = name.replace(ch, "")
return name
def _cleanIdentifiersDeep(value):
"""
Recursively unquotes every identifier in a metadata structure (dict keys and string leaves -
db/table/column names). Used for the schema-listing content types (TABLES/COLUMNS/SCHEMA/COUNT)
whose payload is entirely identifiers + types/counts (never user row data), so cleaning every
string is safe. NOT used for DUMP_TABLE, whose leaf values are real row data.
"""
if isinstance(value, dict):
return dict((_cleanIdentifier(k), _cleanIdentifiersDeep(v)) for k, v in value.items())
elif isinstance(value, (list, tuple)):
return [_cleanIdentifiersDeep(_) for _ in value]
elif isinstance(value, six.string_types):
return _cleanIdentifier(value)
return value
# Schema-listing content types: pure identifiers + types/counts, so identifier quoting is cleaned
# recursively for consistency with DUMP_TABLE (which is handled separately because it carries row data)
IDENTIFIER_KEYED_TYPES = (CONTENT_TYPE.TABLES, CONTENT_TYPE.COLUMNS, CONTENT_TYPE.SCHEMA, CONTENT_TYPE.COUNT)
def _sanitizeScanData(content_type, value):
"""
Reshapes an assembled result value into the clean, consumer-facing form used by BOTH the API
response and the --report-json file: internal detection/plumbing fields are dropped, the
per-technique map becomes a named list, and dumped-table identifiers are unquoted. Operates on
the dejsonized copy, so the live kb/session structures are never modified. Falls back to the raw
value on any surprise.
"""
try:
if content_type == CONTENT_TYPE.TECHNIQUES and isinstance(value, (list, tuple)):
cleaned = []
for injection in value:
if not isinstance(injection, dict):
cleaned.append(injection)
continue
injection = dict(injection)
for field in INJECTION_INTERNAL_FIELDS:
injection.pop(field, None)
techniques = injection.get("data")
if isinstance(techniques, dict):
# turn the {"1": {...}, "2": {...}} map (keyed by opaque technique ids) into an
# ordered list, each entry naming its technique (e.g. "boolean-based blind")
reduced = []
for stype in sorted(techniques, key=lambda _: int(_) if str(_).isdigit() else _):
details = techniques[stype]
if isinstance(details, dict):
details = dict(details)
for field in TECHNIQUE_INTERNAL_FIELDS:
details.pop(field, None)
key = int(stype) if str(stype).isdigit() else stype
entry = {"technique": PAYLOAD.SQLINJECTION.get(key, key)}
entry.update(details)
details = entry
reduced.append(details)
injection["data"] = reduced
cleaned.append(injection)
return cleaned
elif content_type == CONTENT_TYPE.DUMP_TABLE and isinstance(value, dict):
infos = value.get("__infos__") or {}
result = {"db": _cleanIdentifier(infos.get("db")), "table": _cleanIdentifier(infos.get("table")), "count": infos.get("count"), "columns": {}}
for column, cell in value.items():
if column == "__infos__":
continue
# clean the identifier, drop the per-column display 'length', keep just the values list
values = cell.get("values") if isinstance(cell, dict) else cell
if isinstance(values, (list, tuple)):
# sqlmap represents a DB NULL as a single space (DUMP_REPLACEMENTS); surface it as
# JSON null. An empty string "" is a genuine empty value and is left as-is.
values = [None if _ == " " else _ for _ in values]
result["columns"][_cleanIdentifier(column)] = values
return result
elif content_type in IDENTIFIER_KEYED_TYPES and isinstance(value, (dict, list, tuple)):
return _cleanIdentifiersDeep(value)
except Exception as ex:
logger.debug("failed to sanitize scan data (content type %s): %s" % (content_type, getSafeExString(ex)))
return value
def _assembleData(cursor, taskid):
"""
Assembles all stored results for a task into the canonical scan-data structure
{"success": True, "data": [{status, type, type_name, value}, ...], "error": [...]}.
Shared by the REST API endpoint /scan/<id>/data and the CLI --report-json writer so the two
produce identical output (the CLI report is this dict plus a 'meta' wrapper).
"""
json_data_message = list()
json_errors_message = list()
for status, content_type, value in cursor.execute("SELECT status, content_type, value FROM data WHERE taskid = ? ORDER BY id ASC", (taskid,)):
json_data_message.append({"status": status, "type": content_type, "type_name": CONTENT_TYPE_NAMES.get(content_type), "value": _sanitizeScanData(content_type, dejsonize(value))})
for error, in cursor.execute("SELECT error FROM errors WHERE taskid = ? ORDER BY id ASC", (taskid,)):
json_errors_message.append(error)
return {"success": True, "data": json_data_message, "error": json_errors_message}
def setupReportCollector():
"""
Creates an in-memory IPC-style database used to collect results for a CLI --report-json run.
Reuses the same Database/schema the REST API uses so capture+assembly logic is shared.
"""
collector = Database(":memory:")
collector.connect("report")
collector.init()
return collector
def writeReportJson(collector, filepath):
"""
Writes the collected results to filepath as JSON, in the same shape as the REST API's
/scan/<id>/data response, wrapped with a small 'meta' block for standalone consumers.
"""
result = _assembleData(collector, REPORT_TASKID)
result["meta"] = {
"api_version": int(RESTAPI_VERSION.split(".")[0]), # MAJOR only - the part that matters for client compatibility
"sqlmap_version": VERSION_STRING,
"url": conf.get("url"),
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
}
with openFile(filepath, "w+") as f:
f.write(getText(jsonize(result)))
# API objects
class Database(object):
filepath = None
@ -236,31 +428,7 @@ class StdDbOut(object):
def write(self, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None):
if self.messagetype == "stdout":
if content_type is None:
if kb.partRun is not None:
content_type = PART_RUN_CONTENT_TYPES.get(kb.partRun)
else:
# Ignore all non-relevant messages
return
output = conf.databaseCursor.execute("SELECT id, status, value FROM data WHERE taskid = ? AND content_type = ?", (self.taskid, content_type))
# Delete partial output from IPC database if we have got a complete output
if status == CONTENT_STATUS.COMPLETE:
if len(output) > 0:
for index in xrange(len(output)):
conf.databaseCursor.execute("DELETE FROM data WHERE id = ?", (output[index][0],))
conf.databaseCursor.execute("INSERT INTO data VALUES(NULL, ?, ?, ?, ?)", (self.taskid, status, content_type, jsonize(value)))
if kb.partRun:
kb.partRun = None
elif status == CONTENT_STATUS.IN_PROGRESS:
if len(output) == 0:
conf.databaseCursor.execute("INSERT INTO data VALUES(NULL, ?, ?, ?, ?)", (self.taskid, status, content_type, jsonize(value)))
else:
new_value = "%s%s" % (dejsonize(output[0][2]), value)
conf.databaseCursor.execute("UPDATE data SET value = ? WHERE id = ?", (jsonize(new_value), output[0][0]))
_storeData(conf.databaseCursor, self.taskid, value, status, content_type)
else:
conf.databaseCursor.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (self.taskid, str(value) if value else ""))
@ -429,9 +597,13 @@ def task_list(token=None):
"""
tasks = {}
for key in DataStore.tasks:
for key in list(DataStore.tasks):
if is_admin(token) or DataStore.tasks[key].remote_addr == request.remote_addr:
tasks[key] = dejsonize(scan_status(key))["status"]
# NOTE: tolerate a task being deleted concurrently (scan_status would then return an
# error envelope without a "status" key); skip it rather than raising KeyError
status = dejsonize(scan_status(key)).get("status")
if status is not None:
tasks[key] = status
logger.debug("(%s) Listed task pool (%s)" % (token, "admin" if is_admin(token) else request.remote_addr))
return jsonize({"success": True, "tasks": tasks, "tasks_num": len(tasks)})
@ -606,23 +778,15 @@ def scan_data(taskid):
Retrieve the data of a scan
"""
json_data_message = list()
json_errors_message = list()
if taskid not in DataStore.tasks:
logger.warning("[%s] Invalid task ID provided to scan_data()" % taskid)
return jsonize({"success": False, "message": "Invalid task ID"})
# Read all data from the IPC database for the taskid
for status, content_type, value in DataStore.current_db.execute("SELECT status, content_type, value FROM data WHERE taskid = ? ORDER BY id ASC", (taskid,)):
json_data_message.append({"status": status, "type": content_type, "value": dejsonize(value)})
# Read all error messages from the IPC database
for error, in DataStore.current_db.execute("SELECT error FROM errors WHERE taskid = ? ORDER BY id ASC", (taskid,)):
json_errors_message.append(error)
# Read all data and error messages from the IPC database (shared assembler - same output as --report-json)
result = _assembleData(DataStore.current_db, taskid)
logger.debug("(%s) Retrieved scan data and error messages" % taskid)
return jsonize({"success": True, "data": json_data_message, "error": json_errors_message})
return jsonize(result)
# Functions to handle scans' logs
@get("/scan/<taskid>/log/<start>/<end>")
@ -702,7 +866,7 @@ def version(token=None):
"""
logger.debug("Fetched version (%s)" % ("admin" if is_admin(token) else request.remote_addr))
return jsonize({"success": True, "version": VERSION_STRING.split('/')[-1]})
return jsonize({"success": True, "version": VERSION_STRING.split('/')[-1], "api_version": int(RESTAPI_VERSION.split(".")[0])})
def server(host=RESTAPI_DEFAULT_ADDRESS, port=RESTAPI_DEFAULT_PORT, adapter=RESTAPI_DEFAULT_ADAPTER, username=None, password=None, database=None):
"""