diff --git a/lib/controller/controller.py b/lib/controller/controller.py index b92fdb96281..21df0f75072 100644 --- a/lib/controller/controller.py +++ b/lib/controller/controller.py @@ -376,6 +376,11 @@ def start(): for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets: targetCount += 1 + # --report-json: give each target its own taskid in the shared collector, so a multi-target + # run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones (see _reportData) + if conf.reportJson: + kb.reportTaskId = targetCount + try: if conf.checkInternet: infoMsg = "checking for Internet connection" diff --git a/lib/core/common.py b/lib/core/common.py index fb4ec825dde..ad437291a24 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -997,7 +997,7 @@ def setColor(message, color=None, bold=False, level=None, istty=None): if bold or color: retVal = colored(message, color=color, on_color=None, attrs=("bold",) if bold else None) - elif level: + elif level and hasattr(LOGGER_HANDLER, "colorize"): try: level = getattr(logging, level, None) except: diff --git a/lib/core/dump.py b/lib/core/dump.py index f8b134a5440..f1b2514c76c 100644 --- a/lib/core/dump.py +++ b/lib/core/dump.py @@ -108,11 +108,15 @@ def _reportData(self, data, content_type): 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. + + Keyed by kb.reportTaskId rather than the fixed REPORT_TASKID so that a multi-target run + (e.g. '-m' bulk file) keeps each target's results separate instead of later targets + overwriting earlier ones under the same content_type. """ 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) + _storeData(conf.reportCollector, kb.get("reportTaskId", REPORT_TASKID), stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type) def flush(self): if self._outputFP: diff --git a/lib/core/settings.py b/lib/core/settings.py index 951488122a9..176d9825241 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.9.12" +VERSION = "1.10.9.14" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/utils/api.py b/lib/utils/api.py index cf29f396dc4..76cef47321e 100644 --- a/lib/utils/api.py +++ b/lib/utils/api.py @@ -265,9 +265,19 @@ def writeReportJson(collector, filepath): """ Writes the collected results to filepath as JSON, in the same shape as the REST API's /scan//data response, wrapped with a small 'meta' block for standalone consumers. + + A multi-target run (e.g. '-m' bulk file) stores each target under its own taskid (see + kb.reportTaskId), so every taskid present in the collector is assembled here; a single-target + run still yields exactly one taskid and keeps the flat {success, data, error} shape. """ - result = _assembleData(collector, REPORT_TASKID) + taskids = sorted(row[0] for row in collector.execute("SELECT DISTINCT taskid FROM data")) or [REPORT_TASKID] + + if len(taskids) > 1: + result = {"success": True, "targets": [_assembleData(collector, taskid) for taskid in taskids]} + else: + result = _assembleData(collector, taskids[0]) + result["meta"] = { "api_version": int(RESTAPI_VERSION.split(".")[0]), # MAJOR only - the part that matters for client compatibility "sqlmap_version": VERSION_STRING, @@ -467,7 +477,8 @@ def __init__(self, collector): def emit(self, record): try: - self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (REPORT_TASKID, str(record.msg % record.args if record.args else record.msg))) + taskid = kb.get("reportTaskId", REPORT_TASKID) + self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (taskid, str(record.msg % record.args if record.args else record.msg))) except Exception: pass diff --git a/tests/test_common.py b/tests/test_common.py index be4ad2d616a..ddede42ab29 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -113,6 +113,7 @@ safeSQLIdentificatorNaming, saveConfig, serializeObject, + setColor, setTechnique, splitFields, trimAlphaNum, @@ -1784,6 +1785,49 @@ def test_no_old_options_is_noop(self): self.assertIsNone(checkOldOptions(["-u", "http://test.invalid/?id=1", "--banner"])) +class TestSetColorHandlerMismatch(unittest.TestCase): + """ + Regression test for issue #6122: setColor() decided whether to colorize purely from + conf.disableColoring/IS_TTY, independent of whether the installed LOGGER_HANDLER actually + supports colorize() (a plain logging.StreamHandler - installed for --disable-coloring, or as + the ansistrm-unavailable fallback - never defines it). A multiprocessing hash-cracking worker + hit exactly this mismatch (conf.disableColoring not carried over into the worker) and crashed + with an AttributeError that got misreported as "there was a problem while hashing entry". + """ + + def setUp(self): + import lib.core.common as common_mod + self._common_mod = common_mod + self._saved_handler = common_mod.LOGGER_HANDLER + self._saved_disableColoring = conf.get("disableColoring") + + def tearDown(self): + self._common_mod.LOGGER_HANDLER = self._saved_handler + conf.disableColoring = self._saved_disableColoring + + def test_plain_handler_without_colorize_does_not_raise(self): + import logging + self._common_mod.LOGGER_HANDLER = logging.StreamHandler() # no .colorize(), like the --disable-coloring handler + conf.disableColoring = False # the desync: coloring "should" apply, but handler can't + result = setColor("[INFO] current status: abcde", istty=True) # must not raise + self.assertIsInstance(result, str) + + def test_colorizing_handler_still_used(self): + # sanity check: a handler that DOES define colorize() is unaffected by the guard + calls = [] + + class _FakeColorizingHandler(object): + def colorize(self, message, levelno, force=False): + calls.append((message, levelno, force)) + return "COLORIZED" + + self._common_mod.LOGGER_HANDLER = _FakeColorizingHandler() + conf.disableColoring = False + result = setColor("[INFO] current status: abcde", istty=True) + self.assertEqual(result, "COLORIZED") + self.assertEqual(len(calls), 1) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_report.py b/tests/test_report.py index d5dade14161..9130dfb2a25 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -222,5 +222,98 @@ def test_file_is_valid_json_with_meta(self): os.remove(path) +class TestMultiTargetTaskId(_CollectorCase): + """ + Regression coverage for issue #6123: a '-m' bulk-file run shares ONE process (and thus one + report collector) across many targets. _storeData()'s COMPLETE-status branch deletes any + existing row for a (taskid, content_type) key before inserting the new one, so if every + target reused the same fixed REPORT_TASKID, a later target's TARGET/TECHNIQUES write would + silently delete an earlier target's row of the same content_type. The fix keys each target's + writes by kb.reportTaskId instead. + """ + + def setUp(self): + super(TestMultiTargetTaskId, self).setUp() + from lib.core.dump import Dump + self._saved_reportTaskId = kb.get("reportTaskId") + self._saved_dumper = conf.get("dumper") + self._saved_reportCollector = conf.get("reportCollector") + conf.dumper = Dump() + conf.reportCollector = self.c + + def tearDown(self): + kb.reportTaskId = self._saved_reportTaskId + conf.dumper = self._saved_dumper + conf.reportCollector = self._saved_reportCollector + super(TestMultiTargetTaskId, self).tearDown() + + def test_second_target_does_not_overwrite_first(self): + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + + kb.reportTaskId = 2 + conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET) + + first = api._assembleData(self.c, 1)["data"] + second = api._assembleData(self.c, 2)["data"] + self.assertEqual(first[0]["value"]["url"], "http://host1/?id=1") # not clobbered by target #2 + self.assertEqual(second[0]["value"]["url"], "http://host2/?id=1") + + def test_write_report_json_wraps_multiple_targets(self): + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + kb.reportTaskId = 2 + conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET) + + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + api.writeReportJson(self.c, path) + with io.open(path, encoding="utf-8") as f: + loaded = json.load(f) + self.assertIn("targets", loaded) + self.assertEqual(len(loaded["targets"]), 2) + urls = [t["data"][0]["value"]["url"] for t in loaded["targets"]] + self.assertEqual(urls, ["http://host1/?id=1", "http://host2/?id=1"]) + finally: + os.remove(path) + + def test_single_target_report_keeps_flat_shape(self): + # backward compatibility: exactly one taskid -> no 'targets' wrapper, same shape as before + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + api.writeReportJson(self.c, path) + with io.open(path, encoding="utf-8") as f: + loaded = json.load(f) + self.assertNotIn("targets", loaded) + self.assertEqual(loaded["data"][0]["value"]["url"], "http://host1/?id=1") + finally: + os.remove(path) + + def test_error_recorded_under_active_target(self): + import logging + from lib.core.data import logger + + saved_level = logger.level + logger.setLevel(logging.ERROR) + # mute pre-existing handlers (e.g. console) but not the ReportErrorRecorder added by setUp + muted = [(handler, handler.level) for handler in logger.handlers if not isinstance(handler, api.ReportErrorRecorder)] + for handler, _ in muted: + handler.setLevel(logging.CRITICAL + 1) + try: + kb.reportTaskId = 2 + logger.error("boom for target 2") + self.assertTrue(any("boom for target 2" in _ for _ in api._assembleData(self.c, 2)["error"])) + self.assertEqual(api._assembleData(self.c, 1)["error"], []) # not attributed to target #1 + finally: + logger.setLevel(saved_level) + for handler, level in muted: + handler.setLevel(level) + + if __name__ == "__main__": unittest.main(verbosity=2)