From 2b7d7eedffd309154c457685576d849f3dd9a32f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 10 Apr 2025 16:40:52 -0400 Subject: [PATCH 01/19] adding domino module --- bbot/modules/domino.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 bbot/modules/domino.py diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py new file mode 100644 index 0000000000..e49cd9f911 --- /dev/null +++ b/bbot/modules/domino.py @@ -0,0 +1,65 @@ +from .base import BaseModule + +import asyncio +import logging +from domino.DOMino import Domino +from playwright.async_api import async_playwright + +class domino(BaseModule): + watched_events = ["URL"] + produced_events = ["FINDING", "VULNERABILITY"] + flags = ["active", "safe"] + meta = { + "description": "Check for Client-side Web Vulnerabilities with DOMino", + "created_date": "2025-04-08", + "author": "@liquidsec", + } + module_threads = 2 + deps_pip = ["playwright", "d0m1n0"] + + async def setup(self): + import asyncio.base_subprocess + def quiet_transport_del(self): + try: + self.close() + except Exception: + pass + asyncio.base_subprocess.BaseSubprocessTransport.__del__ = quiet_transport_del + + self.playwright = await async_playwright().start() + self.browser_instance = await self.playwright.chromium.launch(headless=True) + self.logger = logging.getLogger("domino") + self.preset.core.logger.include_logger(self.logger) + return True + + async def handle_event(self, event): + d = Domino(url=event.data, logger=self.logger, json_mode=True) + results = await d.run(self.playwright, self.browser_instance) + + if not results: + return + + for result in results: + details = result.get("details", []) + details_string = f" Details: [{','.join(details)}]" if details else "" + + interactions = result.get("interactions", []) + interactions_string = f"Interactions: [{','.join(interactions)}]" if interactions else "" + + data = { + "description": f"{result['rule_name']}. Description: {result['description']}.{details_string} Detection URL: [{result['detection_url']}] {interactions_string}", + "host": str(event.host) + } + + if result["severity"] == "high": + data["severity"] = "high" + await self.emit_event(data, "VULNERABILITY", event) + else: + await self.emit_event(data, "FINDING", event) + + async def finish(self): + + await self.browser_instance.close() + await self.playwright.stop() + await asyncio.sleep(0.5) + From ab24e53c9273829173e949d72d631175fc5ca516 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 16 Apr 2025 13:51:30 -0400 Subject: [PATCH 02/19] adding rules option, handling for bad rules used --- bbot/modules/domino.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index e49cd9f911..44b05f5463 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -3,8 +3,10 @@ import asyncio import logging from domino.DOMino import Domino +from domino.lib.errors import DominoError from playwright.async_api import async_playwright + class domino(BaseModule): watched_events = ["URL"] produced_events = ["FINDING", "VULNERABILITY"] @@ -14,18 +16,31 @@ class domino(BaseModule): "created_date": "2025-04-08", "author": "@liquidsec", } - module_threads = 2 + module_threads = 3 deps_pip = ["playwright", "d0m1n0"] + options = {"rules": None} + options_desc = { + "rules": "Comma-separated list of rules to run. 'None' for all rules (default).", + } async def setup(self): import asyncio.base_subprocess + def quiet_transport_del(self): try: self.close() except Exception: pass + asyncio.base_subprocess.BaseSubprocessTransport.__del__ = quiet_transport_del + # Process rules + rules = self.config.get("rules") + if rules is not None: + self.rules = [r.strip() for r in rules.split(",")] + else: + self.rules = None + self.playwright = await async_playwright().start() self.browser_instance = await self.playwright.chromium.launch(headless=True) self.logger = logging.getLogger("domino") @@ -33,24 +48,29 @@ def quiet_transport_del(self): return True async def handle_event(self, event): - d = Domino(url=event.data, logger=self.logger, json_mode=True) - results = await d.run(self.playwright, self.browser_instance) - + try: + d = Domino(url=event.data, logger=self.logger, json_mode=True, selected_rules=self.rules) + results = await d.run(self.playwright, self.browser_instance) + except DominoError as e: + self.hugewarning(f"Error running Domino, setting error state: {e}") + self.errored = True + return + if not results: return - + for result in results: details = result.get("details", []) details_string = f" Details: [{','.join(details)}]" if details else "" - + interactions = result.get("interactions", []) interactions_string = f"Interactions: [{','.join(interactions)}]" if interactions else "" - + data = { "description": f"{result['rule_name']}. Description: {result['description']}.{details_string} Detection URL: [{result['detection_url']}] {interactions_string}", - "host": str(event.host) + "host": str(event.host), } - + if result["severity"] == "high": data["severity"] = "high" await self.emit_event(data, "VULNERABILITY", event) @@ -58,8 +78,6 @@ async def handle_event(self, event): await self.emit_event(data, "FINDING", event) async def finish(self): - await self.browser_instance.close() await self.playwright.stop() await asyncio.sleep(0.5) - From 4cea01dae752c11b0702b07d69601c9490835de2 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 16 Apr 2025 15:10:01 -0400 Subject: [PATCH 03/19] status code filter --- bbot/modules/domino.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 44b05f5463..2a075029e4 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -81,3 +81,10 @@ async def finish(self): await self.browser_instance.close() await self.playwright.stop() await asyncio.sleep(0.5) + + + async def filter_event(self, event): + if "status-200" not in event.tags: + self.debug(f"Rejecting URL {event.data} due to lack of 200 status code. Tags: {event.tags}") + return False + return True From 21deab2d60a878396addc71c45be2bcee2405da6 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 25 Apr 2025 16:59:32 -0400 Subject: [PATCH 04/19] adding domino presets --- bbot/presets/web/domino-heavy.yml | 16 ++++++++++++++++ bbot/presets/web/domino-light.yml | 11 +++++++++++ bbot/presets/web/domino-medium.yml | 13 +++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 bbot/presets/web/domino-heavy.yml create mode 100644 bbot/presets/web/domino-light.yml create mode 100644 bbot/presets/web/domino-medium.yml diff --git a/bbot/presets/web/domino-heavy.yml b/bbot/presets/web/domino-heavy.yml new file mode 100644 index 0000000000..8b681875a7 --- /dev/null +++ b/bbot/presets/web/domino-heavy.yml @@ -0,0 +1,16 @@ +description: Run domino with all rules on a target, and use spider and wayback to find as many endpoints as possible + +include: + - spider + +modules: + - domino + - wayback + +config: + modules: + domino: + rules: + - reflection,xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,get-parameter-discovery,get-parameter-reflection,hash-decode,postmessage,prototype-pollution,remote-include,remote-include-get-parameters,jquery,eval + wayback: + urls: True diff --git a/bbot/presets/web/domino-light.yml b/bbot/presets/web/domino-light.yml new file mode 100644 index 0000000000..a87d5c17cc --- /dev/null +++ b/bbot/presets/web/domino-light.yml @@ -0,0 +1,11 @@ +description: Run domino with a minimal set of rules to only alert on confirmed vulnerabilities + + +modules: + - domino + +config: + modules: + domino: + rules: + - xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,prototype-pollution,remote-include \ No newline at end of file diff --git a/bbot/presets/web/domino-medium.yml b/bbot/presets/web/domino-medium.yml new file mode 100644 index 0000000000..a464edc6d9 --- /dev/null +++ b/bbot/presets/web/domino-medium.yml @@ -0,0 +1,13 @@ +description: Run domino with all but the most benign rules, utilize the spider to find additional endpoints + +include: + - spider + +modules: + - domino + +config: + modules: + domino: + rules: + - xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,get-parameter-discovery,prototype-pollution,remote-include,remote-include-get-parameters,jquery,eval \ No newline at end of file From 3c06e9d9e9b10c3e52ec15a7e06b83205b76e9e0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 25 Apr 2025 17:13:53 -0400 Subject: [PATCH 05/19] bug fix + preset adjust --- bbot/modules/domino.py | 2 +- bbot/presets/web/domino-heavy.yml | 13 ++++++++++++- bbot/presets/web/domino-light.yml | 12 +++++++++++- bbot/presets/web/domino-medium.yml | 16 +++++++++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 2a075029e4..6246be3749 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -37,7 +37,7 @@ def quiet_transport_del(self): # Process rules rules = self.config.get("rules") if rules is not None: - self.rules = [r.strip() for r in rules.split(",")] + self.rules = rules else: self.rules = None diff --git a/bbot/presets/web/domino-heavy.yml b/bbot/presets/web/domino-heavy.yml index 8b681875a7..c437a1f38c 100644 --- a/bbot/presets/web/domino-heavy.yml +++ b/bbot/presets/web/domino-heavy.yml @@ -4,6 +4,7 @@ include: - spider modules: + - httpx - domino - wayback @@ -11,6 +12,16 @@ config: modules: domino: rules: - - reflection,xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,get-parameter-discovery,get-parameter-reflection,hash-decode,postmessage,prototype-pollution,remote-include,remote-include-get-parameters,jquery,eval + - reflection + - xss-basic + - xss-onclick-getparam + - xss-onclick-hash + - xss-referer + - xss-windowname + - xss-interaction + - xss-postmessage + - xss-js + - get-parameter-discovery + - get-parameter-reflection wayback: urls: True diff --git a/bbot/presets/web/domino-light.yml b/bbot/presets/web/domino-light.yml index a87d5c17cc..536ec9469d 100644 --- a/bbot/presets/web/domino-light.yml +++ b/bbot/presets/web/domino-light.yml @@ -2,10 +2,20 @@ description: Run domino with a minimal set of rules to only alert on confirmed v modules: + - httpx - domino config: modules: domino: rules: - - xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,prototype-pollution,remote-include \ No newline at end of file + - xss-basic + - xss-onclick-getparam + - xss-onclick-hash + - xss-referer + - xss-windowname + - xss-interaction + - xss-postmessage + - xss-js + - prototype-pollution + - remote-include diff --git a/bbot/presets/web/domino-medium.yml b/bbot/presets/web/domino-medium.yml index a464edc6d9..0908482309 100644 --- a/bbot/presets/web/domino-medium.yml +++ b/bbot/presets/web/domino-medium.yml @@ -4,10 +4,24 @@ include: - spider modules: + - httpx - domino config: modules: domino: rules: - - xss-basic,xss-onclick-getparam,xss-onclick-hash,xss-referer,xss-windowname,xss-interaction,xss-postmessage,xss-js,get-parameter-discovery,prototype-pollution,remote-include,remote-include-get-parameters,jquery,eval \ No newline at end of file + - xss-basic + - xss-onclick-getparam + - xss-onclick-hash + - xss-referer + - xss-windowname + - xss-interaction + - xss-postmessage + - xss-js + - get-parameter-discovery + - prototype-pollution + - remote-include + - remote-include-get-parameters + - jquery + - eval \ No newline at end of file From 53e7a9b92f9e23ad24be79d4672ef1b2b87b950f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 28 Apr 2025 14:13:07 -0400 Subject: [PATCH 06/19] supress get parameter FINDING optionally --- bbot/modules/domino.py | 6 +++++- bbot/presets/web/domino-heavy.yml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 6246be3749..0a9753b782 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -18,9 +18,10 @@ class domino(BaseModule): } module_threads = 3 deps_pip = ["playwright", "d0m1n0"] - options = {"rules": None} + options = {"rules": None, "suppress_parameter_discovery_reports": True} options_desc = { "rules": "Comma-separated list of rules to run. 'None' for all rules (default).", + "suppress_parameter_discovery_reports": "Allow parameter discovery be used to drive rules but supress reporting the discovery itself", } async def setup(self): @@ -45,6 +46,7 @@ def quiet_transport_del(self): self.browser_instance = await self.playwright.chromium.launch(headless=True) self.logger = logging.getLogger("domino") self.preset.core.logger.include_logger(self.logger) + self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True async def handle_event(self, event): @@ -75,6 +77,8 @@ async def handle_event(self, event): data["severity"] = "high" await self.emit_event(data, "VULNERABILITY", event) else: + if self.suppress_parameter_discovery_reports and "GET Parameter Access" in result["rule_name"]: + continue await self.emit_event(data, "FINDING", event) async def finish(self): diff --git a/bbot/presets/web/domino-heavy.yml b/bbot/presets/web/domino-heavy.yml index c437a1f38c..b1f5e61ccd 100644 --- a/bbot/presets/web/domino-heavy.yml +++ b/bbot/presets/web/domino-heavy.yml @@ -23,5 +23,6 @@ config: - xss-js - get-parameter-discovery - get-parameter-reflection + suppress_parameter_discovery_reports: False wayback: urls: True From bd0e1bb48fe498c0629f57123bafabe9284e1e63 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 7 May 2025 12:41:45 -0400 Subject: [PATCH 07/19] fixing logger --- bbot/core/config/logger.py | 8 ---- bbot/modules/baddns.py | 7 +++- bbot/modules/baddns_direct.py | 7 +++- bbot/modules/domino.py | 69 ++++++++++++++++++----------------- 4 files changed, 48 insertions(+), 43 deletions(-) diff --git a/bbot/core/config/logger.py b/bbot/core/config/logger.py index c5773a3a0c..7430c2d07a 100644 --- a/bbot/core/config/logger.py +++ b/bbot/core/config/logger.py @@ -183,14 +183,6 @@ def remove_log_handler(self, handler): new_handlers.remove(handler) self.listener.handlers = tuple(new_handlers) - def include_logger(self, logger): - if logger not in self.loggers: - self.loggers.append(logger) - if self.log_level is not None: - logger.setLevel(self.log_level) - for handler in self.log_handlers.values(): - self.add_log_handler(handler) - def stderr_filter(self, record): if record.levelno == logging.TRACE and self.log_level > logging.DEBUG: return False diff --git a/bbot/modules/baddns.py b/bbot/modules/baddns.py index 807cb9b4ad..1d6abb1baf 100644 --- a/bbot/modules/baddns.py +++ b/bbot/modules/baddns.py @@ -37,7 +37,6 @@ def set_modules(self): self.enabled_submodules = ["CNAME", "MX", "TXT"] async def setup(self): - self.preset.core.logger.include_logger(logging.getLogger("baddns")) self.custom_nameservers = self.config.get("custom_nameservers", []) or None if self.custom_nameservers: self.custom_nameservers = self.helpers.chain_lists(self.custom_nameservers) @@ -54,6 +53,12 @@ async def setup(self): self.debug(f"Enabled BadDNS Submodules: [{','.join(self.enabled_submodules)}]") return True + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(f"bbot.modules.{self.name}") + return self._log + async def handle_event(self, event): tasks = [] for ModuleClass in self.select_modules(): diff --git a/bbot/modules/baddns_direct.py b/bbot/modules/baddns_direct.py index 7e29ca6a55..b0ab941ba4 100644 --- a/bbot/modules/baddns_direct.py +++ b/bbot/modules/baddns_direct.py @@ -24,7 +24,6 @@ class baddns_direct(BaseModule): scope_distance_modifier = 1 async def setup(self): - self.preset.core.logger.include_logger(logging.getLogger("baddns")) self.custom_nameservers = self.config.get("custom_nameservers", []) or None if self.custom_nameservers: self.custom_nameservers = self.helpers.chain_lists(self.custom_nameservers) @@ -32,6 +31,12 @@ async def setup(self): self.signatures = load_signatures() return True + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(f"bbot.modules.{self.name}") + return self._log + def select_modules(self): selected_modules = [] for m in get_all_modules(): diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 0a9753b782..5ff47dde76 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -1,6 +1,5 @@ from .base import BaseModule -import asyncio import logging from domino.DOMino import Domino from domino.lib.errors import DominoError @@ -43,49 +42,53 @@ def quiet_transport_del(self): self.rules = None self.playwright = await async_playwright().start() - self.browser_instance = await self.playwright.chromium.launch(headless=True) - self.logger = logging.getLogger("domino") - self.preset.core.logger.include_logger(self.logger) + self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(f"bbot.modules.{self.name}") + return self._log + async def handle_event(self, event): + browser_instance = await self.playwright.chromium.launch(headless=True) + self.debug(f"Domino starting browser instance for {event.data}") try: - d = Domino(url=event.data, logger=self.logger, json_mode=True, selected_rules=self.rules) - results = await d.run(self.playwright, self.browser_instance) + d = Domino(url=event.data, logger=self.log, json_mode=True, selected_rules=self.rules) + results = await d.run(self.playwright, browser_instance) except DominoError as e: self.hugewarning(f"Error running Domino, setting error state: {e}") self.errored = True return - if not results: - return - - for result in results: - details = result.get("details", []) - details_string = f" Details: [{','.join(details)}]" if details else "" - - interactions = result.get("interactions", []) - interactions_string = f"Interactions: [{','.join(interactions)}]" if interactions else "" - - data = { - "description": f"{result['rule_name']}. Description: {result['description']}.{details_string} Detection URL: [{result['detection_url']}] {interactions_string}", - "host": str(event.host), - } - - if result["severity"] == "high": - data["severity"] = "high" - await self.emit_event(data, "VULNERABILITY", event) - else: - if self.suppress_parameter_discovery_reports and "GET Parameter Access" in result["rule_name"]: - continue - await self.emit_event(data, "FINDING", event) - - async def finish(self): - await self.browser_instance.close() + if results: + for result in results: + details = result.get("details", []) + details_string = f" Details: [{','.join(details)}]" if details else "" + + interactions = result.get("interactions", []) + interactions_string = f"Interactions: [{','.join(interactions)}]" if interactions else "" + + data = { + "description": f"{result['rule_name']}. Description: {result['description']}.{details_string} Detection URL: [{result['detection_url']}] {interactions_string}", + "host": str(event.host), + } + + if result["severity"] == "high": + data["severity"] = "high" + await self.emit_event(data, "VULNERABILITY", event) + else: + if self.suppress_parameter_discovery_reports and "GET Parameter Access" in result["rule_name"]: + continue + await self.emit_event(data, "FINDING", event) + self.debug(f"Domino browsers instance shutting down for {event.data}") + await browser_instance.close() + self.debug(f"DOMino browser shutdown complete for {event.data}") + + async def cleanup(self): await self.playwright.stop() - await asyncio.sleep(0.5) - async def filter_event(self, event): if "status-200" not in event.tags: From d85284d0a2ac95f4158172257c3163dbbe0d5164 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 8 May 2025 14:18:36 -0400 Subject: [PATCH 08/19] preset-fix --- bbot/presets/web/domino-heavy.yml | 10 ++++++++++ bbot/presets/web/domino-medium.yml | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/bbot/presets/web/domino-heavy.yml b/bbot/presets/web/domino-heavy.yml index b1f5e61ccd..e6f831ce89 100644 --- a/bbot/presets/web/domino-heavy.yml +++ b/bbot/presets/web/domino-heavy.yml @@ -23,6 +23,16 @@ config: - xss-js - get-parameter-discovery - get-parameter-reflection + - transform + - get-parameter-transform + - html-injection + - jquery + - eval + - hash-decode + - postmessage + - remote-include + - remote-include-get-parameters + - prototype-pollution suppress_parameter_discovery_reports: False wayback: urls: True diff --git a/bbot/presets/web/domino-medium.yml b/bbot/presets/web/domino-medium.yml index 0908482309..238da7d51c 100644 --- a/bbot/presets/web/domino-medium.yml +++ b/bbot/presets/web/domino-medium.yml @@ -24,4 +24,9 @@ config: - remote-include - remote-include-get-parameters - jquery - - eval \ No newline at end of file + - eval + - transform + - get-parameter-transform + - html-injection + - remote-include + - remote-include-get-parameters \ No newline at end of file From 64f541f39cf79200d7695bfb6439f95196ff7ad6 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 12 Jun 2026 17:43:09 -0400 Subject: [PATCH 09/19] update domino module for bbot 3.0 - Migrate options/options_desc to Pydantic Config class - Use event.url instead of event.data (now a dict) - Emit all findings as FINDING with severity/confidence/name - Remove redundant log property override - Rename domino-medium preset to domino (standard naming) - Update presets: httpx -> http --- bbot/modules/domino.py | 58 ++++++++++--------- bbot/presets/web/domino-heavy.yml | 2 +- bbot/presets/web/domino-light.yml | 2 +- .../web/{domino-medium.yml => domino.yml} | 2 +- 4 files changed, 34 insertions(+), 30 deletions(-) rename bbot/presets/web/{domino-medium.yml => domino.yml} (98%) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 5ff47dde76..3ec4f6dafe 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -1,6 +1,8 @@ from .base import BaseModule -import logging +from typing import Optional +from pydantic import Field +from bbot.core.config.models import BaseModuleConfig from domino.DOMino import Domino from domino.lib.errors import DominoError from playwright.async_api import async_playwright @@ -8,20 +10,26 @@ class domino(BaseModule): watched_events = ["URL"] - produced_events = ["FINDING", "VULNERABILITY"] + produced_events = ["FINDING"] flags = ["active", "safe"] meta = { "description": "Check for Client-side Web Vulnerabilities with DOMino", "created_date": "2025-04-08", "author": "@liquidsec", } + + class Config(BaseModuleConfig): + rules: Optional[list[str]] = Field( + default=None, + description="List of rules to run. None for all rules (default).", + ) + suppress_parameter_discovery_reports: bool = Field( + default=True, + description="Allow parameter discovery to drive rules but suppress reporting the discovery itself", + ) + module_threads = 3 deps_pip = ["playwright", "d0m1n0"] - options = {"rules": None, "suppress_parameter_discovery_reports": True} - options_desc = { - "rules": "Comma-separated list of rules to run. 'None' for all rules (default).", - "suppress_parameter_discovery_reports": "Allow parameter discovery be used to drive rules but supress reporting the discovery itself", - } async def setup(self): import asyncio.base_subprocess @@ -46,17 +54,12 @@ def quiet_transport_del(self): self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True - @property - def log(self): - if self._log is None: - self._log = logging.getLogger(f"bbot.modules.{self.name}") - return self._log - async def handle_event(self, event): + url = event.url browser_instance = await self.playwright.chromium.launch(headless=True) - self.debug(f"Domino starting browser instance for {event.data}") + self.debug(f"Domino starting browser instance for {url}") try: - d = Domino(url=event.data, logger=self.log, json_mode=True, selected_rules=self.rules) + d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) results = await d.run(self.playwright, browser_instance) except DominoError as e: self.hugewarning(f"Error running Domino, setting error state: {e}") @@ -65,27 +68,28 @@ async def handle_event(self, event): if results: for result in results: + if self.suppress_parameter_discovery_reports and "GET Parameter Access" in result["rule_name"]: + continue + details = result.get("details", []) details_string = f" Details: [{','.join(details)}]" if details else "" interactions = result.get("interactions", []) - interactions_string = f"Interactions: [{','.join(interactions)}]" if interactions else "" + interactions_string = f" Interactions: [{','.join(interactions)}]" if interactions else "" + severity = result.get("severity", "medium").upper() data = { - "description": f"{result['rule_name']}. Description: {result['description']}.{details_string} Detection URL: [{result['detection_url']}] {interactions_string}", + "name": result["rule_name"], + "description": f"{result['description']}.{details_string} Detection URL: [{result['detection_url']}]{interactions_string}", "host": str(event.host), + "url": result.get("detection_url"), + "severity": severity, + "confidence": "CONFIRMED", } - - if result["severity"] == "high": - data["severity"] = "high" - await self.emit_event(data, "VULNERABILITY", event) - else: - if self.suppress_parameter_discovery_reports and "GET Parameter Access" in result["rule_name"]: - continue - await self.emit_event(data, "FINDING", event) - self.debug(f"Domino browsers instance shutting down for {event.data}") + await self.emit_event(data, "FINDING", event) + self.debug(f"Domino browser instance shutting down for {url}") await browser_instance.close() - self.debug(f"DOMino browser shutdown complete for {event.data}") + self.debug(f"DOMino browser shutdown complete for {url}") async def cleanup(self): await self.playwright.stop() diff --git a/bbot/presets/web/domino-heavy.yml b/bbot/presets/web/domino-heavy.yml index e6f831ce89..7faff7a4dd 100644 --- a/bbot/presets/web/domino-heavy.yml +++ b/bbot/presets/web/domino-heavy.yml @@ -4,7 +4,7 @@ include: - spider modules: - - httpx + - http - domino - wayback diff --git a/bbot/presets/web/domino-light.yml b/bbot/presets/web/domino-light.yml index 536ec9469d..675a54722b 100644 --- a/bbot/presets/web/domino-light.yml +++ b/bbot/presets/web/domino-light.yml @@ -2,7 +2,7 @@ description: Run domino with a minimal set of rules to only alert on confirmed v modules: - - httpx + - http - domino config: diff --git a/bbot/presets/web/domino-medium.yml b/bbot/presets/web/domino.yml similarity index 98% rename from bbot/presets/web/domino-medium.yml rename to bbot/presets/web/domino.yml index 238da7d51c..49a6ba7e6c 100644 --- a/bbot/presets/web/domino-medium.yml +++ b/bbot/presets/web/domino.yml @@ -4,7 +4,7 @@ include: - spider modules: - - httpx + - http - domino config: From 6770df0c33411b71b8aa600b87934826fc0adf58 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 12 Jun 2026 23:03:28 -0400 Subject: [PATCH 10/19] fix: fall back to event url when domino detection_url is null --- bbot/modules/domino.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 3ec4f6dafe..24dc4bfa49 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -82,7 +82,7 @@ async def handle_event(self, event): "name": result["rule_name"], "description": f"{result['description']}.{details_string} Detection URL: [{result['detection_url']}]{interactions_string}", "host": str(event.host), - "url": result.get("detection_url"), + "url": result.get("detection_url") or event.url, "severity": severity, "confidence": "CONFIRMED", } From e32edfd376a76d103373e394106e8332f8920f43 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 12 Jun 2026 23:13:35 -0400 Subject: [PATCH 11/19] perf: reuse shared browser instance instead of launching per URL --- bbot/modules/domino.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 24dc4bfa49..bd2cb8a5c5 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -50,17 +50,17 @@ def quiet_transport_del(self): self.rules = None self.playwright = await async_playwright().start() + self.browser = await self.playwright.chromium.launch(headless=True) self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True async def handle_event(self, event): url = event.url - browser_instance = await self.playwright.chromium.launch(headless=True) - self.debug(f"Domino starting browser instance for {url}") + self.debug(f"Domino scanning {url}") try: d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) - results = await d.run(self.playwright, browser_instance) + results = await d.run(self.playwright, self.browser) except DominoError as e: self.hugewarning(f"Error running Domino, setting error state: {e}") self.errored = True @@ -87,11 +87,10 @@ async def handle_event(self, event): "confidence": "CONFIRMED", } await self.emit_event(data, "FINDING", event) - self.debug(f"Domino browser instance shutting down for {url}") - await browser_instance.close() - self.debug(f"DOMino browser shutdown complete for {url}") + self.debug(f"DOMino scan complete for {url}") async def cleanup(self): + await self.browser.close() await self.playwright.stop() async def filter_event(self, event): From d46161e940d01b28a222c336239e5731050dd7a5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 12 Jun 2026 23:14:31 -0400 Subject: [PATCH 12/19] fix: auto-relaunch browser if it crashes during scan --- bbot/modules/domino.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index bd2cb8a5c5..4bf8cbdb23 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -55,10 +55,16 @@ def quiet_transport_del(self): self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True + async def _ensure_browser(self): + if not self.browser.is_connected(): + self.warning("Browser crashed, relaunching") + self.browser = await self.playwright.chromium.launch(headless=True) + async def handle_event(self, event): url = event.url self.debug(f"Domino scanning {url}") try: + await self._ensure_browser() d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) results = await d.run(self.playwright, self.browser) except DominoError as e: From 55d1b11845cd61c6f7e7b6111b9c31ad4c1dd5b1 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 13 Jun 2026 09:50:47 -0400 Subject: [PATCH 13/19] perf: browser pool with configurable instances and timeout failsafe Replace single shared browser with a pool of dedicated browser instances (one per thread) to prevent deadlocking under concurrent load. Add 120s timeout per URL that kills and replaces hung browsers. Make instance count configurable via browser_instances config option (default 2) with memory usage warning on startup. Add domino-heavy to kitchen-sink preset. --- bbot/modules/domino.py | 48 ++++++++++++++++++++++++++++------- bbot/presets/kitchen-sink.yml | 1 + 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 4bf8cbdb23..758ae5c65a 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -1,5 +1,6 @@ from .base import BaseModule +import asyncio from typing import Optional from pydantic import Field from bbot.core.config.models import BaseModuleConfig @@ -27,8 +28,11 @@ class Config(BaseModuleConfig): default=True, description="Allow parameter discovery to drive rules but suppress reporting the discovery itself", ) + browser_instances: int = Field( + default=2, + description="Number of browser instances to run concurrently. Each instance uses ~800-1600 MB of memory under load.", + ) - module_threads = 3 deps_pip = ["playwright", "d0m1n0"] async def setup(self): @@ -42,35 +46,59 @@ def quiet_transport_del(self): asyncio.base_subprocess.BaseSubprocessTransport.__del__ = quiet_transport_del - # Process rules rules = self.config.get("rules") if rules is not None: self.rules = rules else: self.rules = None + self._browser_count = self.config.get("browser_instances", 2) + self.module_threads = self._browser_count + low_estimate = self._browser_count * 800 + high_estimate = self._browser_count * 1600 + self.warning( + f"The domino module uses Chromium, which consumes a significant amount of memory. " + f"Your current settings will launch {self._browser_count} instances, for an estimated " + f"{low_estimate}-{high_estimate} MB. Lower with -c modules.domino.browser_instances=1" + ) + self.playwright = await async_playwright().start() - self.browser = await self.playwright.chromium.launch(headless=True) + self._browser_pool = asyncio.Queue() + for _ in range(self._browser_count): + browser = await self.playwright.chromium.launch(headless=True) + await self._browser_pool.put(browser) self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True - async def _ensure_browser(self): - if not self.browser.is_connected(): + async def _get_browser(self): + browser = await self._browser_pool.get() + if not browser.is_connected(): self.warning("Browser crashed, relaunching") - self.browser = await self.playwright.chromium.launch(headless=True) + browser = await self.playwright.chromium.launch(headless=True) + return browser async def handle_event(self, event): url = event.url self.debug(f"Domino scanning {url}") + browser = await self._get_browser() try: - await self._ensure_browser() d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) - results = await d.run(self.playwright, self.browser) + results = await asyncio.wait_for(d.run(self.playwright, browser), timeout=120) + except asyncio.TimeoutError: + self.warning(f"Domino scan timed out after 120s for {url}, killing browser") + try: + await browser.close() + except Exception: + pass + browser = await self.playwright.chromium.launch(headless=True) + return except DominoError as e: self.hugewarning(f"Error running Domino, setting error state: {e}") self.errored = True return + finally: + await self._browser_pool.put(browser) if results: for result in results: @@ -96,7 +124,9 @@ async def handle_event(self, event): self.debug(f"DOMino scan complete for {url}") async def cleanup(self): - await self.browser.close() + while not self._browser_pool.empty(): + browser = await self._browser_pool.get() + await browser.close() await self.playwright.stop() async def filter_event(self, event): diff --git a/bbot/presets/kitchen-sink.yml b/bbot/presets/kitchen-sink.yml index c578c54247..f8aeb9618c 100644 --- a/bbot/presets/kitchen-sink.yml +++ b/bbot/presets/kitchen-sink.yml @@ -11,6 +11,7 @@ include: - dirbust-light - web-screenshots - baddns-heavy + - domino-heavy config: modules: From d09ba11d050e2245738a64d96109c46e0f27009c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 13 Jun 2026 11:21:56 -0400 Subject: [PATCH 14/19] feat: expose http_body_hash on URL events, deduplicate domino by content hash Attach body_mmh3 hash to URL events in HTTP module so downstream consumers can access it. Use it in domino's _incoming_dedup_hash to skip URLs with identical response bodies on the same host, avoiding redundant scans of templated pages (e.g. product listings). --- bbot/core/event/base.py | 8 ++++++++ bbot/modules/domino.py | 6 ++++++ bbot/modules/http.py | 1 + 3 files changed, 15 insertions(+) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index a00fd59bf5..53c486fb3e 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1529,6 +1529,14 @@ def redirect_location(self): def redirect_location(self, value): self.data["redirect_location"] = value + @property + def http_body_hash(self): + return self.data.get("http_body_hash", "") + + @http_body_hash.setter + def http_body_hash(self, value): + self.data["http_body_hash"] = value + class URL(URL_UNVERIFIED): def __init__(self, *args, **kwargs): diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 758ae5c65a..e6a991d56b 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -129,6 +129,12 @@ async def cleanup(self): await browser.close() await self.playwright.stop() + def _incoming_dedup_hash(self, event): + body_hash = getattr(event, "http_body_hash", "") + if body_hash: + return hash((event.host, body_hash)), f"body_hash={body_hash}" + return hash(event), "" + async def filter_event(self, event): if "status-200" not in event.tags: self.debug(f"Rejecting URL {event.data} due to lack of 200 status code. Tags: {event.tags}") diff --git a/bbot/modules/http.py b/bbot/modules/http.py index 5a3d6d866f..f2715257f6 100644 --- a/bbot/modules/http.py +++ b/bbot/modules/http.py @@ -145,6 +145,7 @@ async def _process_result(self, result, parent_event): location = j.get("location", "") if location: url_event.redirect_location = location + url_event.http_body_hash = j.get("hash", {}).get("body_mmh3", "") if url_event != parent_event: await self.emit_event(url_event) content_type = j.get("header", {}).get("content_type", "unspecified").split(";")[0] From 71e9e0562f64ad96678fe5d6cfffffd830ee3883 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 13 Jun 2026 12:15:33 -0400 Subject: [PATCH 15/19] fix: use property override for module_threads, workers spawn before setup() --- bbot/modules/domino.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index e6a991d56b..f4074cff6b 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -33,8 +33,13 @@ class Config(BaseModuleConfig): description="Number of browser instances to run concurrently. Each instance uses ~800-1600 MB of memory under load.", ) + _module_threads = 2 deps_pip = ["playwright", "d0m1n0"] + @property + def module_threads(self): + return self.config.get("browser_instances", 2) + async def setup(self): import asyncio.base_subprocess @@ -53,7 +58,6 @@ def quiet_transport_del(self): self.rules = None self._browser_count = self.config.get("browser_instances", 2) - self.module_threads = self._browser_count low_estimate = self._browser_count * 800 high_estimate = self._browser_count * 1600 self.warning( From 374492472260b2176a7d4bc665db67f3ac83dd2f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 16 Jun 2026 11:15:37 -0400 Subject: [PATCH 16/19] fix: launch fresh browser per handle_event to prevent OOM Chromium accumulates internal state (IPC buffers, renderer state) when reused across thousands of URLs, ballooning to 48GB+ virtual memory. Launch and close a browser per URL instead. --- bbot/modules/domino.py | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index f4074cff6b..47f8c61698 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -30,7 +30,7 @@ class Config(BaseModuleConfig): ) browser_instances: int = Field( default=2, - description="Number of browser instances to run concurrently. Each instance uses ~800-1600 MB of memory under load.", + description="Number of concurrent browser instances. Each uses ~800-1600 MB of memory under load.", ) _module_threads = 2 @@ -67,42 +67,28 @@ def quiet_transport_del(self): ) self.playwright = await async_playwright().start() - self._browser_pool = asyncio.Queue() - for _ in range(self._browser_count): - browser = await self.playwright.chromium.launch(headless=True) - await self._browser_pool.put(browser) - self.suppress_parameter_discovery_reports = self.config.get("suppress_parameter_discovery_reports", True) return True - async def _get_browser(self): - browser = await self._browser_pool.get() - if not browser.is_connected(): - self.warning("Browser crashed, relaunching") - browser = await self.playwright.chromium.launch(headless=True) - return browser - async def handle_event(self, event): url = event.url self.debug(f"Domino scanning {url}") - browser = await self._get_browser() + browser = await self.playwright.chromium.launch(headless=True) try: d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) results = await asyncio.wait_for(d.run(self.playwright, browser), timeout=120) except asyncio.TimeoutError: - self.warning(f"Domino scan timed out after 120s for {url}, killing browser") - try: - await browser.close() - except Exception: - pass - browser = await self.playwright.chromium.launch(headless=True) + self.warning(f"Domino scan timed out after 120s for {url}") return except DominoError as e: self.hugewarning(f"Error running Domino, setting error state: {e}") self.errored = True return finally: - await self._browser_pool.put(browser) + try: + await browser.close() + except Exception: + pass if results: for result in results: @@ -128,9 +114,6 @@ async def handle_event(self, event): self.debug(f"DOMino scan complete for {url}") async def cleanup(self): - while not self._browser_pool.empty(): - browser = await self._browser_pool.get() - await browser.close() await self.playwright.stop() def _incoming_dedup_hash(self, event): From 431f78b8690243230b29b79d767095e276124fea Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 16 Jun 2026 18:17:08 -0400 Subject: [PATCH 17/19] fix: catch Playwright driver death so the scan survives EPIPE --- bbot/modules/domino.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 47f8c61698..1036b6c861 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -73,8 +73,9 @@ def quiet_transport_del(self): async def handle_event(self, event): url = event.url self.debug(f"Domino scanning {url}") - browser = await self.playwright.chromium.launch(headless=True) + browser = None try: + browser = await self.playwright.chromium.launch(headless=True) d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) results = await asyncio.wait_for(d.run(self.playwright, browser), timeout=120) except asyncio.TimeoutError: @@ -84,11 +85,16 @@ async def handle_event(self, event): self.hugewarning(f"Error running Domino, setting error state: {e}") self.errored = True return + except Exception as e: + self.hugewarning(f"Playwright/Domino fatal error ({type(e).__name__}: {e}), disabling module") + self.errored = True + return finally: - try: - await browser.close() - except Exception: - pass + if browser is not None: + try: + await browser.close() + except Exception: + pass if results: for result in results: From 6c1c949392178b05ae27f3261f62fa2b1e71f1d4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Jun 2026 09:22:00 -0400 Subject: [PATCH 18/19] Restore include_logger, fix domino asyncio + body hash field --- bbot/core/config/logger.py | 8 ++++++++ bbot/modules/domino.py | 15 ++++++--------- bbot/modules/http.py | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/bbot/core/config/logger.py b/bbot/core/config/logger.py index dddaaa7db9..dda240a8f6 100644 --- a/bbot/core/config/logger.py +++ b/bbot/core/config/logger.py @@ -184,6 +184,14 @@ def remove_log_handler(self, handler): new_handlers.remove(handler) self.listener.handlers = tuple(new_handlers) + def include_logger(self, logger): + if logger not in self.loggers: + self.loggers.append(logger) + if self.log_level is not None: + logger.setLevel(self.log_level) + for handler in self.log_handlers.values(): + self.add_log_handler(handler) + def stderr_filter(self, record): if record.levelno == logging.TRACE and self.log_level > logging.DEBUG: return False diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 1036b6c861..2007feab6f 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -1,6 +1,7 @@ +from asyncio import wait_for, TimeoutError as AsyncTimeoutError + from .base import BaseModule -import asyncio from typing import Optional from pydantic import Field from bbot.core.config.models import BaseModuleConfig @@ -41,7 +42,7 @@ def module_threads(self): return self.config.get("browser_instances", 2) async def setup(self): - import asyncio.base_subprocess + import asyncio.base_subprocess # noqa: used for monkey-patch below def quiet_transport_del(self): try: @@ -51,11 +52,7 @@ def quiet_transport_del(self): asyncio.base_subprocess.BaseSubprocessTransport.__del__ = quiet_transport_del - rules = self.config.get("rules") - if rules is not None: - self.rules = rules - else: - self.rules = None + self.rules = self.config.get("rules") self._browser_count = self.config.get("browser_instances", 2) low_estimate = self._browser_count * 800 @@ -77,8 +74,8 @@ async def handle_event(self, event): try: browser = await self.playwright.chromium.launch(headless=True) d = Domino(url=url, logger=self.log, json_mode=True, selected_rules=self.rules) - results = await asyncio.wait_for(d.run(self.playwright, browser), timeout=120) - except asyncio.TimeoutError: + results = await wait_for(d.run(self.playwright, browser), timeout=120) + except AsyncTimeoutError: self.warning(f"Domino scan timed out after 120s for {url}") return except DominoError as e: diff --git a/bbot/modules/http.py b/bbot/modules/http.py index cb5a3c8fb6..2b1f5587eb 100644 --- a/bbot/modules/http.py +++ b/bbot/modules/http.py @@ -235,7 +235,7 @@ async def _process_result(self, result, parent_event): response_hash = j.get("hash") if response_hash: url_event.data["hash"] = response_hash - url_event.http_body_hash = j.get("hash", {}).get("body_mmh3", "") + url_event.http_body_hash = (j.get("hash") or {}).get("body_sha256", "") if url_event != parent_event: await self.emit_event(url_event) content_type = j.get("header", {}).get("content_type", "unspecified").split(";")[0] From 380965640515d3df53ec4fb7ee666beed41f9168 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Jun 2026 09:22:46 -0400 Subject: [PATCH 19/19] Fix noqa directive in domino.py --- bbot/modules/domino.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/domino.py b/bbot/modules/domino.py index 2007feab6f..7820f62982 100644 --- a/bbot/modules/domino.py +++ b/bbot/modules/domino.py @@ -42,7 +42,7 @@ def module_threads(self): return self.config.get("browser_instances", 2) async def setup(self): - import asyncio.base_subprocess # noqa: used for monkey-patch below + import asyncio.base_subprocess # noqa: E402 def quiet_transport_del(self): try: