From 382b73d99ec8ab69bf5dc6ec231e237fadeca30d Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 10 Apr 2024 11:17:59 -0400 Subject: [PATCH 001/220] fix nuclei budget bug --- bbot/modules/deadly/nuclei.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bbot/modules/deadly/nuclei.py b/bbot/modules/deadly/nuclei.py index 04be1aa95..2e40b4bfd 100644 --- a/bbot/modules/deadly/nuclei.py +++ b/bbot/modules/deadly/nuclei.py @@ -322,13 +322,15 @@ def get_yaml_request_attr(self, yf, attr): raw = r.get("raw") if not raw: res = r.get(attr) - yield res + if res is not None: + yield res def get_yaml_info_attr(self, yf, attr): p = self.parse_yaml(yf) info = p.get("info", []) res = info.get(attr) - yield res + if res is not None: + yield res # Parse through all templates and locate those which match the conditions necessary to collapse down to the budget setting def find_collapsible_templates(self): From b037471586ec026a3d643354b9dad9b3efd89cd1 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 23 Apr 2024 12:24:56 -0400 Subject: [PATCH 002/220] remove resolved/unresolved tags as they are redundant --- bbot/modules/anubisdb.py | 2 +- bbot/modules/internal/dns.py | 4 +--- bbot/test/test_step_1/test_dns.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/bbot/modules/anubisdb.py b/bbot/modules/anubisdb.py index 9864e3c6d..bf4c88e93 100644 --- a/bbot/modules/anubisdb.py +++ b/bbot/modules/anubisdb.py @@ -30,7 +30,7 @@ def abort_if_pre(self, hostname): async def abort_if(self, event): # abort if dns name is unresolved - if not "resolved" in event.tags: + if event.type == "DNS_NAME_UNRESOLVED": return True, "DNS name is unresolved" return await super().abort_if(event) diff --git a/bbot/modules/internal/dns.py b/bbot/modules/internal/dns.py index ea5e4efcf..b96b9b19c 100644 --- a/bbot/modules/internal/dns.py +++ b/bbot/modules/internal/dns.py @@ -94,9 +94,7 @@ async def handle_event(self, event, kwargs): if rdtype not in dns_children: dns_tags.add(f"{rdtype.lower()}-error") - if dns_children: - dns_tags.add("resolved") - elif not event_is_ip: + if not dns_children and not event_is_ip: dns_tags.add("unresolved") for rdtype, children in dns_children.items(): diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 05796e464..aa2a27907 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -238,7 +238,6 @@ async def test_wildcards(bbot_scanner): "a-record", "target", "aaaa-wildcard", - "resolved", "in-scope", "subdomain", "aaaa-record", @@ -260,7 +259,7 @@ async def test_wildcards(bbot_scanner): for e in events if e.type == "DNS_NAME" and e.data == "asdfl.gashdgkjsadgsdf.github.io" - and all(t in e.tags for t in ("a-record", "target", "resolved", "in-scope", "subdomain", "aaaa-record")) + and all(t in e.tags for t in ("a-record", "target", "in-scope", "subdomain", "aaaa-record")) and not any(t in e.tags for t in ("wildcard", "a-wildcard", "aaaa-wildcard")) ] ) From 37a5889e7f3c97b4d5829f9dcfe0e7a3d6bc15fa Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Fri, 19 Apr 2024 13:52:50 -0400 Subject: [PATCH 003/220] tests for custom target types --- bbot/test/test_step_1/test_cli.py | 8 ++++++++ bbot/test/test_step_1/test_python_api.py | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index db2a5316d..d879d863d 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -190,6 +190,14 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): assert "| active" in caplog.text assert not "| passive" in caplog.text + # custom target type + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-t", "ORG:evilcorp"]) + result = await cli._main() + assert result == True + assert "[ORG_STUB] evilcorp TARGET" in caplog.text + # no args caplog.clear() assert not caplog.text diff --git a/bbot/test/test_step_1/test_python_api.py b/bbot/test/test_step_1/test_python_api.py index 14c2fae1d..0155dcfb3 100644 --- a/bbot/test/test_step_1/test_python_api.py +++ b/bbot/test/test_step_1/test_python_api.py @@ -45,6 +45,11 @@ async def test_python_api(): Scanner("127.0.0.1", config={"home": bbot_home}) assert os.environ["BBOT_TOOLS"] == str(Path(bbot_home) / "tools") + # custom target types + custom_target_scan = Scanner("ORG:evilcorp") + events = [e async for e in custom_target_scan.async_start()] + assert 1 == len([e for e in events if e.type == "ORG_STUB" and e.data == "evilcorp" and "target" in e.tags]) + def test_python_api_sync(): from bbot.scanner import Scanner From b06355bc1cbbd8714786d11b08f66b59b11d583d Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 23 Apr 2024 12:09:10 -0400 Subject: [PATCH 004/220] fix small cli bug and add tests for it --- bbot/scanner/preset/preset.py | 6 ++++-- bbot/test/test_step_1/test_cli.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 60dac85dd..57eb11a1a 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -381,8 +381,10 @@ def bake(self): for flag in baked_preset.flags: for module, preloaded in baked_preset.module_loader.preloaded().items(): module_flags = preloaded.get("flags", []) + module_type = preloaded.get("type", "scan") if flag in module_flags: - baked_preset.add_module(module, raise_error=False) + self.log_debug(f'Enabling module "{module}" because it has flag "{flag}"') + baked_preset.add_module(module, module_type, raise_error=False) # ensure we have output modules if not baked_preset.output_modules: @@ -433,7 +435,7 @@ def internal_modules(self): return [m for m in self.modules if self.preloaded_module(m).get("type", "scan") == "internal"] def add_module(self, module_name, module_type="scan", raise_error=True): - self.log_debug(f'Adding module "{module_name}"') + self.log_debug(f'Adding module "{module_name}" of type "{module_type}"') is_valid, reason, preloaded = self._is_valid_module(module_name, module_type, raise_error=raise_error) if not is_valid: self.log_debug(f'Unable to add {module_type} module "{module_name}": {reason}') diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index d879d863d..0a34b4faa 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -198,6 +198,13 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): assert result == True assert "[ORG_STUB] evilcorp TARGET" in caplog.text + # activate modules by flag + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-f", "passive"]) + result = await cli._main() + assert result == True + # no args caplog.clear() assert not caplog.text From 84df8298e49f6f7e68723829ddece6a70c43e71e Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 17 Apr 2024 16:38:24 -0400 Subject: [PATCH 005/220] fix attribute error --- bbot/core/helpers/command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bbot/core/helpers/command.py b/bbot/core/helpers/command.py index 14a788f8a..06fc8a91f 100644 --- a/bbot/core/helpers/command.py +++ b/bbot/core/helpers/command.py @@ -185,7 +185,8 @@ async def _write_proc_line(proc, chunk): proc.stdin.write(smart_encode(chunk) + b"\n") await proc.stdin.drain() except Exception as e: - command = " ".join([str(s) for s in proc.args]) + proc_args = [str(s) for s in getattr(proc, "args", [])] + command = " ".join(proc_args) log.warning(f"Error writing line to stdin for command: {command}: {e}") log.trace(traceback.format_exc()) From 355a5bee6ee60b4c664ab25a56e5be0dc1f80e38 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 23 Apr 2024 11:50:21 -0400 Subject: [PATCH 006/220] Better debugging during scan cancellation --- bbot/core/helpers/misc.py | 2 ++ bbot/scanner/scanner.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index a4378069d..206fc50f0 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -1078,6 +1078,7 @@ def kill_children(parent_pid=None, sig=None): parent = psutil.Process(parent_pid) except psutil.NoSuchProcess: log.debug(f"No such PID: {parent_pid}") + return log.debug(f"Killing children of process ID {parent.pid}") children = parent.children(recursive=True) for child in children: @@ -1089,6 +1090,7 @@ def kill_children(parent_pid=None, sig=None): log.debug(f"No such PID: {child.pid}") except psutil.AccessDenied: log.debug(f"Error killing PID: {child.pid} - access denied") + log.debug(f"Finished killing children of process ID {parent.pid}") def str_or_file(s): diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index e01f85ce2..9f444d3cb 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -351,7 +351,13 @@ async def async_start(self): self.critical(f"Unexpected error during scan:\n{traceback.format_exc()}") finally: - self._cancel_tasks() + tasks = self._cancel_tasks() + self.debug(f"Awaiting {len(tasks):,} tasks") + for task in tasks: + self.debug(f"Awaiting {task}") + with contextlib.suppress(BaseException): + await task + self.debug(f"Awaited {len(tasks):,} tasks") await self._report() await self._cleanup() @@ -663,13 +669,14 @@ def stop(self): if not self._stopping: self._stopping = True self.status = "ABORTING" - self.hugewarning(f"Aborting scan") + self.hugewarning("Aborting scan") self.trace() self._cancel_tasks() self._drain_queues() self.helpers.kill_children() self._drain_queues() self.helpers.kill_children() + self.debug("Finished aborting scan") async def finish(self): """Finalizes the scan by invoking the `finished()` method on all active modules if new activity is detected. @@ -729,6 +736,7 @@ def _cancel_tasks(self): Returns: None """ + self.debug("Cancelling all scan tasks") tasks = [] # module workers for m in self.modules.values(): @@ -746,6 +754,8 @@ def _cancel_tasks(self): self.helpers.cancel_tasks_sync(tasks) # process pool self.helpers.process_pool.shutdown(cancel_futures=True) + self.debug("Finished cancelling all scan tasks") + return tasks async def _report(self): """Asynchronously executes the `report()` method for each module in the scan. From 616fe2e70ea6f2cf52ea0be4cce2a5b6d50b5d32 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 23 Apr 2024 12:53:57 -0400 Subject: [PATCH 007/220] better engine error handling during scan cancellation --- bbot/core/engine.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bbot/core/engine.py b/bbot/core/engine.py index c72eecbb3..24781ab3b 100644 --- a/bbot/core/engine.py +++ b/bbot/core/engine.py @@ -153,19 +153,26 @@ async def run_and_return(self, client_id, command_fn, **kwargs): error = f"Unhandled error in {self.name}.{command_fn.__name__}({kwargs}): {e}" trace = traceback.format_exc() result = {"_e": (error, trace)} - await self.socket.send_multipart([client_id, pickle.dumps(result)]) + await self.send_socket_multipart([client_id, pickle.dumps(result)]) async def run_and_yield(self, client_id, command_fn, **kwargs): self.log.debug(f"{self.name} run-and-yield {command_fn.__name__}({kwargs})") try: async for _ in command_fn(**kwargs): - await self.socket.send_multipart([client_id, pickle.dumps(_)]) - await self.socket.send_multipart([client_id, pickle.dumps({"_s": None})]) + await self.send_socket_multipart([client_id, pickle.dumps(_)]) + await self.send_socket_multipart([client_id, pickle.dumps({"_s": None})]) except Exception as e: error = f"Unhandled error in {self.name}.{command_fn.__name__}({kwargs}): {e}" trace = traceback.format_exc() result = {"_e": (error, trace)} - await self.socket.send_multipart([client_id, pickle.dumps(result)]) + await self.send_socket_multipart([client_id, pickle.dumps(result)]) + + async def send_socket_multipart(self, *args, **kwargs): + try: + await self.socket.send_multipart(*args, **kwargs) + except Exception as e: + self.log.warning(f"Error sending ZMQ message: {e}") + self.log.trace(traceback.format_exc()) async def worker(self): try: From 820c15dd9432e186604ec3211bd2b1b27770d356 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 13:42:30 -0400 Subject: [PATCH 008/220] wip dnsbrute rework --- bbot/core/helpers/dns/dns.py | 11 + bbot/core/helpers/helper.py | 4 +- bbot/modules/dnscommonsrv.py | 32 +- bbot/modules/massdns.py | 417 ------------------ bbot/modules/templates/subdomain_enum.py | 47 +- bbot/scanner/preset/preset.py | 6 +- bbot/scanner/target.py | 6 +- .../module_tests/test_module_dastardly.py | 2 +- .../module_tests/test_module_dnscommonsrv.py | 45 +- .../module_tests/test_module_massdns.py | 10 - 10 files changed, 87 insertions(+), 493 deletions(-) delete mode 100644 bbot/modules/massdns.py delete mode 100644 bbot/test/test_step_2/module_tests/test_module_massdns.py diff --git a/bbot/core/helpers/dns/dns.py b/bbot/core/helpers/dns/dns.py index 7f775483c..a4d626e5c 100644 --- a/bbot/core/helpers/dns/dns.py +++ b/bbot/core/helpers/dns/dns.py @@ -73,6 +73,9 @@ def __init__(self, parent_helper): # TODO: DNS server speed test (start in background task) self.resolver_file = self.parent_helper.tempfile(self.system_resolvers, pipe=False) + # brute force helper + self._brute = None + async def resolve(self, query, **kwargs): return await self.run_and_return("resolve", query=query, **kwargs) @@ -84,6 +87,14 @@ async def resolve_raw_batch(self, queries): async for _ in self.run_and_yield("resolve_raw_batch", queries=queries): yield _ + @property + def brute(self): + if self._brute is None: + from .brute import DNSBrute + + self._brute = DNSBrute(self.parent_helper) + return self._brute + async def is_wildcard(self, query, ips=None, rdtype=None): """ Use this method to check whether a *host* is a wildcard entry diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index 16afc05cd..e4dc09326 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -110,8 +110,8 @@ def clean_old_scans(self): _filter = lambda x: x.is_dir() and self.regexes.scan_name_regex.match(x.name) self.clean_old(self.scans_dir, keep=self.keep_old_scans, filter=_filter) - def make_target(self, *events): - return Target(*events) + def make_target(self, *events, **kwargs): + return Target(*events, **kwargs) @property def config(self): diff --git a/bbot/modules/dnscommonsrv.py b/bbot/modules/dnscommonsrv.py index eef8e2d8c..ae4f39c88 100644 --- a/bbot/modules/dnscommonsrv.py +++ b/bbot/modules/dnscommonsrv.py @@ -1,4 +1,4 @@ -from bbot.modules.base import BaseModule +from bbot.modules.templates.subdomain_enum import subdomain_enum # the following are the result of a 1-day internet survey to find the top SRV records # the scan resulted in 36,282 SRV records. the count for each one is shown. @@ -149,33 +149,15 @@ ] -class dnscommonsrv(BaseModule): +class dnscommonsrv(subdomain_enum): watched_events = ["DNS_NAME"] produced_events = ["DNS_NAME"] flags = ["subdomain-enum", "passive", "safe"] meta = {"description": "Check for common SRV records"} - options = {"top": 50, "max_event_handlers": 10} - options_desc = { - "top": "How many of the top SRV records to check", - "max_event_handlers": "How many instances of the module to run concurrently", - } - _max_event_handlers = 10 - - def _incoming_dedup_hash(self, event): - # dedupe by parent - parent_domain = self.helpers.parent_domain(event.data) - return hash(parent_domain), "already processed parent domain" - - async def filter_event(self, event): - # skip SRV wildcards - if "SRV" in await self.helpers.is_wildcard(event.host): - return False - return True async def handle_event(self, event): - top = int(self.config.get("top", 50)) - parent_domain = self.helpers.parent_domain(event.data) - queries = [f"{srv}.{parent_domain}" for srv in common_srvs[:top]] - async for query, results in self.helpers.resolve_batch(queries, type="srv"): - if results: - await self.emit_event(query, "DNS_NAME", tags=["srv-record"], source=event) + self.hugesuccess(event) + query = self.make_query(event) + self.verbose(f'Brute-forcing SRV records for "{query}"') + for hostname in await self.helpers.dns.brute(self, query, common_srvs, type="SRV"): + await self.emit_event(hostname, "DNS_NAME", source=event) diff --git a/bbot/modules/massdns.py b/bbot/modules/massdns.py deleted file mode 100644 index ffacb8c64..000000000 --- a/bbot/modules/massdns.py +++ /dev/null @@ -1,417 +0,0 @@ -import json -import random -import subprocess -import regex as re - -from bbot.modules.templates.subdomain_enum import subdomain_enum - - -class massdns(subdomain_enum): - """ - This is BBOT's flagship subdomain enumeration module. - - It uses massdns to brute-force subdomains. - At the end of a scan, it will leverage BBOT's word cloud to recursively discover target-specific subdomain mutations. - - Each subdomain discovered via mutations is tagged with the "mutation" tag. This tag indicates the depth at which - the mutation was found. I.e. the first mutation will be tagged "mutation-1". The second one (a mutation of a - mutation) will be "mutation-2". Mutations of mutations of mutations will be "mutation-3", etc. - - This is especially use for bug bounties because it enables you to recognize distant/rare subdomains at a glance. - Subdomains with higher mutation levels are more likely to be distant/rare or never-before-seen. - """ - - flags = ["subdomain-enum", "passive", "aggressive"] - watched_events = ["DNS_NAME"] - produced_events = ["DNS_NAME"] - meta = {"description": "Brute-force subdomains with massdns (highly effective)"} - options = { - "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", - "max_resolvers": 1000, - "max_mutations": 500, - "max_depth": 5, - } - options_desc = { - "wordlist": "Subdomain wordlist URL", - "max_resolvers": "Number of concurrent massdns resolvers", - "max_mutations": "Max number of smart mutations per subdomain", - "max_depth": "How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com", - } - subdomain_file = None - deps_common = ["massdns"] - reject_wildcards = "strict" - _qsize = 10000 - - digit_regex = re.compile(r"\d+") - - async def setup(self): - self.found = dict() - self.mutations_tried = set() - self.source_events = self.helpers.make_target() - self.subdomain_file = await self.helpers.wordlist(self.config.get("wordlist")) - self.subdomain_list = set(self.helpers.read_file(self.subdomain_file)) - - ms_on_prem_string_file = self.helpers.wordlist_dir / "ms_on_prem_subdomains.txt" - ms_on_prem_strings = set(self.helpers.read_file(ms_on_prem_string_file)) - self.subdomain_list.update(ms_on_prem_strings) - - self.max_resolvers = self.config.get("max_resolvers", 1000) - self.max_mutations = self.config.get("max_mutations", 500) - self.max_depth = max(1, self.config.get("max_depth", 5)) - nameservers_url = ( - "https://raw.githubusercontent.com/blacklanternsecurity/public-dns-servers/master/nameservers.txt" - ) - self.resolver_file = await self.helpers.wordlist( - nameservers_url, - cache_hrs=24 * 7, - ) - self.devops_mutations = list(self.helpers.word_cloud.devops_mutations) - self._mutation_run = 1 - - return await super().setup() - - async def filter_event(self, event): - query = self.make_query(event) - eligible, reason = await self.eligible_for_enumeration(event) - - # limit brute force depth - subdomain_depth = self.helpers.subdomain_depth(query) + 1 - if subdomain_depth > self.max_depth: - eligible = False - reason = f"subdomain depth of *.{query} ({subdomain_depth}) > max_depth ({self.max_depth})" - - # don't brute-force things that look like autogenerated PTRs - if self.helpers.is_ptr(query): - eligible = False - reason = f'"{query}" looks like an autogenerated PTR' - - if eligible: - self.add_found(event) - # reject if already processed - if self.already_processed(query): - return False, f'Query "{query}" was already processed' - - if eligible: - self.processed.add(hash(query)) - return True, reason - return False, reason - - async def handle_event(self, event): - query = self.make_query(event) - self.source_events.add_target(event) - self.info(f"Brute-forcing subdomains for {query} (source: {event.data})") - for hostname in await self.massdns(query, self.subdomain_list): - await self.emit_result(hostname, event, query) - - def abort_if(self, event): - if not event.scope_distance == 0: - return True, "event is not in scope" - if "wildcard" in event.tags: - return True, "event is a wildcard" - if "unresolved" in event.tags: - return True, "event is unresolved" - return False, "" - - async def emit_result(self, result, source_event, query, tags=None): - if not result == source_event: - kwargs = {"abort_if": self.abort_if} - if tags is not None: - kwargs["tags"] = tags - await self.emit_event(result, "DNS_NAME", source_event, **kwargs) - - def already_processed(self, hostname): - if hash(hostname) in self.processed: - return True - return False - - async def massdns(self, domain, subdomains): - subdomains = list(subdomains) - - domain_wildcard_rdtypes = set() - for _domain, rdtypes in (await self.helpers.is_wildcard_domain(domain)).items(): - for rdtype, results in rdtypes.items(): - if results: - domain_wildcard_rdtypes.add(rdtype) - if any([r in domain_wildcard_rdtypes for r in ("A", "CNAME")]): - self.info( - f"Aborting massdns on {domain} because it's a wildcard domain ({','.join(domain_wildcard_rdtypes)})" - ) - self.found.pop(domain, None) - return [] - else: - self.log.trace(f"{domain}: A is not in domain_wildcard_rdtypes:{domain_wildcard_rdtypes}") - - # before we start, do a canary check for wildcards - abort_msg = f"Aborting massdns on {domain} due to false positive" - canary_result = await self._canary_check(domain) - if canary_result: - self.info(abort_msg + f": {canary_result}") - return [] - else: - self.log.trace(f"Canary result for {domain}: {canary_result}") - - results = [] - async for hostname, ip, rdtype in self._massdns(domain, subdomains): - # allow brute-forcing of wildcard domains - # this is dead code but it's kinda cool so it can live here - if rdtype in domain_wildcard_rdtypes: - # skip wildcard checking on multi-level subdomains for performance reasons - stem = hostname.split(domain)[0].strip(".") - if "." in stem: - self.debug(f"Skipping {hostname}:A because it may be a wildcard (reason: performance)") - continue - wildcard_rdtypes = await self.helpers.is_wildcard(hostname, ips=(ip,), rdtype=rdtype) - if rdtype in wildcard_rdtypes: - self.debug(f"Skipping {hostname}:{rdtype} because it's a wildcard") - continue - results.append(hostname) - - # do another canary check for good measure - if len(results) > 50: - canary_result = await self._canary_check(domain) - if canary_result: - self.info(abort_msg + f": {canary_result}") - return [] - else: - self.log.trace(f"Canary result for {domain}: {canary_result}") - - # abort if there are a suspiciously high number of results - # (the results are over 2000, and this is more than 20 percent of the input size) - if len(results) > 2000: - if len(results) / len(subdomains) > 0.2: - self.info( - f"Aborting because the number of results ({len(results):,}) is suspiciously high for the length of the wordlist ({len(subdomains):,})" - ) - return [] - else: - self.info( - f"{len(results):,} results returned from massdns against {domain} (wordlist size = {len(subdomains):,})" - ) - - # everything checks out - return results - - async def _canary_check(self, domain, num_checks=50): - random_subdomains = list(self.gen_random_subdomains(num_checks)) - self.verbose(f"Testing {len(random_subdomains):,} canaries against {domain}") - canary_results = [h async for h, d, r in self._massdns(domain, random_subdomains)] - self.log.trace(f"canary results for {domain}: {canary_results}") - resolved_canaries = self.helpers.resolve_batch(canary_results) - self.log.trace(f"resolved canary results for {domain}: {canary_results}") - async for query, result in resolved_canaries: - if result: - await resolved_canaries.aclose() - result = f"{query}:{result}" - self.log.trace(f"Found false positive: {result}") - return result - self.log.trace(f"Passed canary check for {domain}") - return False - - async def _massdns(self, domain, subdomains): - """ - { - "name": "www.blacklanternsecurity.com.", - "type": "A", - "class": "IN", - "status": "NOERROR", - "data": { - "answers": [ - { - "ttl": 3600, - "type": "CNAME", - "class": "IN", - "name": "www.blacklanternsecurity.com.", - "data": "blacklanternsecurity.github.io." - }, - { - "ttl": 3600, - "type": "A", - "class": "IN", - "name": "blacklanternsecurity.github.io.", - "data": "185.199.108.153" - } - ] - }, - "resolver": "168.215.165.186:53" - } - """ - if self.scan.stopping: - return - - command = ( - "massdns", - "-r", - self.resolver_file, - "-s", - self.max_resolvers, - "-t", - "A", - "-o", - "J", - "-q", - ) - subdomains = self.gen_subdomains(subdomains, domain) - hosts_yielded = set() - async for line in self.run_process_live(command, stderr=subprocess.DEVNULL, input=subdomains): - try: - j = json.loads(line) - except json.decoder.JSONDecodeError: - self.debug(f"Failed to decode line: {line}") - continue - answers = j.get("data", {}).get("answers", []) - if type(answers) == list and len(answers) > 0: - answer = answers[0] - hostname = answer.get("name", "").strip(".").lower() - if hostname.endswith(f".{domain}"): - data = answer.get("data", "") - rdtype = answer.get("type", "").upper() - # avoid garbage answers like this: - # 8AAAA queries have been locally blocked by dnscrypt-proxy/Set block_ipv6 to false to disable this feature - if data and rdtype and not " " in data: - hostname_hash = hash(hostname) - if hostname_hash not in hosts_yielded: - hosts_yielded.add(hostname_hash) - yield hostname, data, rdtype - - async def finish(self): - found = sorted(self.found.items(), key=lambda x: len(x[-1]), reverse=True) - # if we have a lot of rounds to make, don't try mutations on less-populated domains - trimmed_found = [] - if found: - avg_subdomains = sum([len(subdomains) for domain, subdomains in found[:50]]) / len(found[:50]) - for i, (domain, subdomains) in enumerate(found): - # accept domains that are in the top 50 or have more than 5 percent of the average number of subdomains - if i < 50 or (len(subdomains) > 1 and len(subdomains) >= (avg_subdomains * 0.05)): - trimmed_found.append((domain, subdomains)) - else: - self.verbose( - f"Skipping mutations on {domain} because it only has {len(subdomains):,} subdomain(s) (avg: {avg_subdomains:,})" - ) - - base_mutations = set() - found_mutations = False - try: - for i, (domain, subdomains) in enumerate(trimmed_found): - self.verbose(f"{domain} has {len(subdomains):,} subdomains") - # keep looping as long as we're finding things - while 1: - max_mem_percent = 90 - mem_status = self.helpers.memory_status() - # abort if we don't have the memory - mem_percent = mem_status.percent - if mem_percent > max_mem_percent: - free_memory = mem_status.available - free_memory_human = self.helpers.bytes_to_human(free_memory) - assert ( - False - ), f"Cannot proceed with DNS mutations because system memory is at {mem_percent:.1f}% ({free_memory_human} remaining)" - - query = domain - domain_hash = hash(domain) - if self.scan.stopping: - return - - mutations = set(base_mutations) - - def add_mutation(_domain_hash, m): - h = hash((_domain_hash, m)) - if h not in self.mutations_tried: - self.mutations_tried.add(h) - mutations.add(m) - - # try every subdomain everywhere else - for _domain, _subdomains in found: - if _domain == domain: - continue - for s in _subdomains: - first_segment = s.split(".")[0] - # skip stuff with lots of numbers (e.g. PTRs) - digits = self.digit_regex.findall(first_segment) - excessive_digits = len(digits) > 2 - long_digits = any(len(d) > 3 for d in digits) - if excessive_digits or long_digits: - continue - add_mutation(domain_hash, first_segment) - for word in self.helpers.extract_words( - first_segment, word_regexes=self.helpers.word_cloud.dns_mutator.extract_word_regexes - ): - add_mutation(domain_hash, word) - - # numbers + devops mutations - for mutation in self.helpers.word_cloud.mutations( - subdomains, cloud=False, numbers=3, number_padding=1 - ): - for delimiter in ("", ".", "-"): - m = delimiter.join(mutation).lower() - add_mutation(domain_hash, m) - - # special dns mutator - for subdomain in self.helpers.word_cloud.dns_mutator.mutations( - subdomains, max_mutations=self.max_mutations - ): - add_mutation(domain_hash, subdomain) - - if mutations: - self.info(f"Trying {len(mutations):,} mutations against {domain} ({i+1}/{len(found)})") - results = list(await self.massdns(query, mutations)) - for hostname in results: - source_event = self.source_events.get(hostname) - if source_event is None: - self.warning(f"Could not correlate source event from: {hostname}") - source_event = self.scan.root_event - await self.emit_result( - hostname, source_event, query, tags=[f"mutation-{self._mutation_run}"] - ) - if results: - found_mutations = True - continue - break - except AssertionError as e: - self.warning(e) - - if found_mutations: - self._mutation_run += 1 - - def add_found(self, host): - if not isinstance(host, str): - host = host.data - if self.helpers.is_subdomain(host): - subdomain, domain = host.split(".", 1) - is_ptr = self.helpers.is_ptr(subdomain) - in_scope = self.scan.in_scope(domain) - if in_scope and not is_ptr: - try: - self.found[domain].add(subdomain) - except KeyError: - self.found[domain] = set((subdomain,)) - - async def gen_subdomains(self, prefixes, domain): - for p in prefixes: - d = f"{p}.{domain}" - yield d - - def gen_random_subdomains(self, n=50): - delimiters = (".", "-") - lengths = list(range(3, 8)) - for i in range(0, max(0, n - 5)): - d = delimiters[i % len(delimiters)] - l = lengths[i % len(lengths)] - segments = list(random.choice(self.devops_mutations) for _ in range(l)) - segments.append(self.helpers.rand_string(length=8, digits=False)) - subdomain = d.join(segments) - yield subdomain - for _ in range(5): - yield self.helpers.rand_string(length=8, digits=False) - - def has_excessive_digits(self, d): - """ - Identifies dns names with excessive numbers, e.g.: - - w1-2-3.evilcorp.com - - ptr1234.evilcorp.com - """ - digits = self.digit_regex.findall(d) - excessive_digits = len(digits) > 2 - long_digits = any(len(d) > 3 for d in digits) - if excessive_digits or long_digits: - return True - return False diff --git a/bbot/modules/templates/subdomain_enum.py b/bbot/modules/templates/subdomain_enum.py index 790b35515..3c65dfa34 100644 --- a/bbot/modules/templates/subdomain_enum.py +++ b/bbot/modules/templates/subdomain_enum.py @@ -16,16 +16,39 @@ class subdomain_enum(BaseModule): # set module error state after this many failed requests in a row abort_after_failures = 5 + # whether to reject wildcard DNS_NAMEs reject_wildcards = "strict" - # this helps combat rate limiting by ensuring that a query doesn't execute + + # set qsize to 1. this helps combat rate limiting by ensuring that a query doesn't execute # until the queue is ready to receive its results _qsize = 1 + # how to deduplicate incoming events + # options: + # "root_domain": if a dns name has already been tried, don't try any of its children + # "parent_domain": always try a domain unless its direct parent has already been tried + dedup_strategy = "root_domain" + async def setup(self): - self.processed = set() + strict_scope = self.dedup_strategy == "parent_domain" + self.processed = self.helpers.make_target(strict_scope=strict_scope) return True + async def filter_event(self, event): + """ + This filter_event is used across many modules + """ + query = self.make_query(event) + # reject if already processed + if query in self.processed: + return False, "Event was already processed" + eligible, reason = await self.eligible_for_enumeration(event) + if eligible: + self.processed.add(query) + return True, reason + return False, reason + async def handle_event(self, event): query = self.make_query(event) results = await self.query(query) @@ -91,20 +114,6 @@ async def _is_wildcard(self, query): return True return False - async def filter_event(self, event): - """ - This filter_event is used across many modules - """ - query = self.make_query(event) - # reject if already processed - if self.already_processed(query): - return False, "Event was already processed" - eligible, reason = await self.eligible_for_enumeration(event) - if eligible: - self.processed.add(hash(query)) - return True, reason - return False, reason - async def eligible_for_enumeration(self, event): query = self.make_query(event) # check if wildcard @@ -128,12 +137,6 @@ async def eligible_for_enumeration(self, event): return False, "Event is both a cloud resource and a wildcard domain" return True, "" - def already_processed(self, hostname): - for parent in self.helpers.domain_parents(hostname, include_self=True): - if hash(parent) in self.processed: - return True - return False - async def abort_if(self, event): # this helps weed out unwanted results when scanning IP_RANGES and wildcard domains if "in-scope" not in event.tags: diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 57eb11a1a..9055d03ba 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -303,9 +303,9 @@ def merge(self, other): self.explicit_output_modules.update(other.explicit_output_modules) self.flags.update(other.flags) # scope - self.target.add_target(other.target) - self.whitelist.add_target(other.whitelist) - self.blacklist.add_target(other.blacklist) + self.target.add(other.target) + self.whitelist.add(other.whitelist) + self.blacklist.add(other.blacklist) self.strict_scope = self.strict_scope or other.strict_scope for t in (self.target, self.whitelist): t.strict_scope = self.strict_scope diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index b19d1b6a6..878e80846 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -93,11 +93,11 @@ def __init__(self, *targets, strict_scope=False): if len(targets) > 0: log.verbose(f"Creating events from {len(targets):,} targets") for t in targets: - self.add_target(t) + self.add(t) self._hash = None - def add_target(self, t, event_type=None): + def add(self, t, event_type=None): """ Add a target or merge events from another Target object into this Target. @@ -108,7 +108,7 @@ def add_target(self, t, event_type=None): _events (dict): The dictionary is updated to include the new target's events. Examples: - >>> target.add_target('example.com') + >>> target.add('example.com') Notes: - If `t` is of the same class as this Target, all its events are merged. diff --git a/bbot/test/test_step_2/module_tests/test_module_dastardly.py b/bbot/test/test_step_2/module_tests/test_module_dastardly.py index ed4c20e5c..fe9de5d6c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dastardly.py +++ b/bbot/test/test_step_2/module_tests/test_module_dastardly.py @@ -44,7 +44,7 @@ async def setup_after_prep(self, module_test): # get docker IP docker_ip = await self.get_docker_ip(module_test) - module_test.scan.target.add_target(docker_ip) + module_test.scan.target.add(docker_ip) # replace 127.0.0.1 with docker host IP to allow dastardly access to local http server old_filter_event = module_test.module.filter_event diff --git a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py index 5850fbd49..3d3d670e1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py @@ -2,22 +2,47 @@ class TestDNSCommonSRV(ModuleTestBase): - targets = ["blacklanternsecurity.notreal"] config_overrides = {"dns_resolution": True} async def setup_after_prep(self, module_test): + + old_run_live = module_test.scan.helpers.run_live + + async def new_run_live(*command, check=False, text=True, **kwargs): + if "massdns" in command[:2]: + yield """{"name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","type":"SRV","class":"IN","status":"NOERROR","rx_ts":1713974911725326170,"data":{"answers":[{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."},{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."}]},"flags":["rd","ra"],"resolver":"195.226.187.130:53","proto":"UDP"}""" + else: + async for _ in old_run_live(*command, check=False, text=True, **kwargs): + yield _ + + module_test.monkeypatch.setattr(module_test.scan.helpers, "run_live", new_run_live) + await module_test.mock_dns( { - "_ldap._tcp.gc._msdcs.blacklanternsecurity.notreal": { - "SRV": ["0 100 3268 asdf.blacklanternsecurity.notreal"] - }, - "asdf.blacklanternsecurity.notreal": {"A": "1.2.3.4"}, + "blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "_ldap._tcp.gc._msdcs.blacklanternsecurity.com": {"SRV": ["0 100 3268 asdf.blacklanternsecurity.com"]}, + "asdf.blacklanternsecurity.com": {"A": ["1.2.3.5"]}, } ) def check(self, module_test, events): - assert any( - e.data == "_ldap._tcp.gc._msdcs.blacklanternsecurity.notreal" for e in events - ), "Failed to detect subdomain" - assert any(e.data == "asdf.blacklanternsecurity.notreal" for e in events), "Failed to detect subdomain" - assert not any(e.data == "_ldap._tcp.dc._msdcs.blacklanternsecurity.notreal" for e in events), "False positive" + assert len(events) == 4 + assert 1 == len([e for e in events if e.type == "DNS_NAME" and e.data == "blacklanternsecurity.com"]) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "_ldap._tcp.gc._msdcs.blacklanternsecurity.com" + and str(e.module) == "dnscommonsrv" + ] + ), "Failed to detect subdomain 1" + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "asdf.blacklanternsecurity.com" + and str(e.module) != "dnscommonsrv" + ] + ), "Failed to detect subdomain 2" diff --git a/bbot/test/test_step_2/module_tests/test_module_massdns.py b/bbot/test/test_step_2/module_tests/test_module_massdns.py deleted file mode 100644 index 1b4543788..000000000 --- a/bbot/test/test_step_2/module_tests/test_module_massdns.py +++ /dev/null @@ -1,10 +0,0 @@ -from .base import ModuleTestBase, tempwordlist - - -class TestMassdns(ModuleTestBase): - subdomain_wordlist = tempwordlist(["www", "asdf"]) - config_overrides = {"modules": {"massdns": {"wordlist": str(subdomain_wordlist)}}} - - def check(self, module_test, events): - assert any(e.data == "www.blacklanternsecurity.com" for e in events) - assert not any(e.data == "asdf.blacklanternsecurity.com" for e in events) From f5ad756f269eae60c8f0b413d9abafbbda3561a2 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 13:42:49 -0400 Subject: [PATCH 009/220] wip dnsbrute rework --- bbot/core/helpers/dns/brute.py | 180 ++++++++++++++++++ bbot/modules/dnsbrute.py | 50 +++++ .../module_tests/test_module_dnsbrute.py | 78 ++++++++ 3 files changed, 308 insertions(+) create mode 100644 bbot/core/helpers/dns/brute.py create mode 100644 bbot/modules/dnsbrute.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_dnsbrute.py diff --git a/bbot/core/helpers/dns/brute.py b/bbot/core/helpers/dns/brute.py new file mode 100644 index 000000000..5bfa7b2e0 --- /dev/null +++ b/bbot/core/helpers/dns/brute.py @@ -0,0 +1,180 @@ +import json +import random +import asyncio +import logging +import subprocess + + +class DNSBrute: + """ + Helper for DNS brute-forcing. + + Examples: + >>> domain = "evilcorp.com" + >>> subdomains = ["www", "mail"] + >>> results = await self.helpers.dns.brute(self, domain, subdomains) + """ + + nameservers_url = ( + "https://raw.githubusercontent.com/blacklanternsecurity/public-dns-servers/master/nameservers.txt" + ) + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + self.log = logging.getLogger("bbot.helper.dns.brute") + self.num_canaries = 100 + self.max_resolvers = 1000 + self.devops_mutations = list(self.parent_helper.word_cloud.devops_mutations) + self.digit_regex = self.parent_helper.re.compile(r"\d+") + self._resolver_file = None + self._dnsbrute_lock = asyncio.Lock() + + async def __call__(self, *args, **kwargs): + return await self.dnsbrute(*args, **kwargs) + + async def dnsbrute(self, module, domain, subdomains, type=None): + subdomains = list(subdomains) + + if type is None: + type = "A" + type = str(type).strip().upper() + + domain_wildcard_rdtypes = set() + for _domain, rdtypes in (await self.parent_helper.dns.is_wildcard_domain(domain)).items(): + for rdtype, results in rdtypes.items(): + if results: + domain_wildcard_rdtypes.add(rdtype) + if any([r in domain_wildcard_rdtypes for r in (type, "CNAME")]): + self.log.info( + f"Aborting massdns on {domain} because it's a wildcard domain ({','.join(domain_wildcard_rdtypes)})" + ) + return [] + else: + self.log.trace(f"{domain}: A is not in domain_wildcard_rdtypes:{domain_wildcard_rdtypes}") + + canaries = self.gen_random_subdomains(self.num_canaries) + canaries_list = list(canaries) + canaries_pre = canaries_list[: int(self.num_canaries / 2)] + canaries_post = canaries_list[int(self.num_canaries / 2) :] + # sandwich subdomains between canaries + subdomains = canaries_pre + subdomains + canaries_post + + results = [] + canaries_triggered = [] + async for hostname, ip, rdtype in self._massdns(module, domain, subdomains, rdtype=type): + sub = hostname.split(domain)[0] + if sub in canaries: + canaries_triggered.append(sub) + else: + results.append(hostname) + + if canaries_triggered > 5: + self.log.info( + f"Aborting massdns on {domain} due to false positive: ({len(canaries_triggered):,} canaries triggered - {','.join(canaries_triggered)})" + ) + return [] + + # everything checks out + return results + + async def _massdns(self, module, domain, subdomains, rdtype): + """ + { + "name": "www.blacklanternsecurity.com.", + "type": "A", + "class": "IN", + "status": "NOERROR", + "data": { + "answers": [ + { + "ttl": 3600, + "type": "CNAME", + "class": "IN", + "name": "www.blacklanternsecurity.com.", + "data": "blacklanternsecurity.github.io." + }, + { + "ttl": 3600, + "type": "A", + "class": "IN", + "name": "blacklanternsecurity.github.io.", + "data": "185.199.108.153" + } + ] + }, + "resolver": "168.215.165.186:53" + } + """ + resolver_file = await self.resolver_file() + command = ( + "massdns", + "-r", + resolver_file, + "-s", + self.max_resolvers, + "-t", + rdtype, + "-o", + "J", + "-q", + ) + subdomains = self.gen_subdomains(subdomains, domain) + hosts_yielded = set() + async with self._dnsbrute_lock: + async for line in module.run_process_live(*command, stderr=subprocess.DEVNULL, input=subdomains): + self.log.critical(line) + try: + j = json.loads(line) + except json.decoder.JSONDecodeError: + self.log.debug(f"Failed to decode line: {line}") + continue + answers = j.get("data", {}).get("answers", []) + if type(answers) == list and len(answers) > 0: + answer = answers[0] + hostname = answer.get("name", "").strip(".").lower() + if hostname.endswith(f".{domain}"): + data = answer.get("data", "") + rdtype = answer.get("type", "").upper() + if data and rdtype: + hostname_hash = hash(hostname) + if hostname_hash not in hosts_yielded: + hosts_yielded.add(hostname_hash) + yield hostname, data, rdtype + + async def gen_subdomains(self, prefixes, domain): + for p in prefixes: + d = f"{p}.{domain}" + yield d + + async def resolver_file(self): + if self._resolver_file is None: + self._resolver_file = await self.parent_helper.wordlist( + self.nameservers_url, + cache_hrs=24 * 7, + ) + return self._resolver_file + + def gen_random_subdomains(self, n=50): + delimiters = (".", "-") + lengths = list(range(3, 8)) + for i in range(0, max(0, n - 5)): + d = delimiters[i % len(delimiters)] + l = lengths[i % len(lengths)] + segments = list(random.choice(self.devops_mutations) for _ in range(l)) + segments.append(self.parent_helper.rand_string(length=8, digits=False)) + subdomain = d.join(segments) + yield subdomain + for _ in range(5): + yield self.parent_helper.rand_string(length=8, digits=False) + + def has_excessive_digits(self, d): + """ + Identifies dns names with excessive numbers, e.g.: + - w1-2-3.evilcorp.com + - ptr1234.evilcorp.com + """ + is_ptr = self.parent_helper.is_ptr(d) + digits = self.digit_regex.findall(d) + excessive_digits = len(digits) > 2 + long_digits = any(len(d) > 3 for d in digits) + return is_ptr or excessive_digits or long_digits diff --git a/bbot/modules/dnsbrute.py b/bbot/modules/dnsbrute.py new file mode 100644 index 000000000..2df3a48d6 --- /dev/null +++ b/bbot/modules/dnsbrute.py @@ -0,0 +1,50 @@ +from bbot.modules.templates.subdomain_enum import subdomain_enum + + +class dnsbrute(subdomain_enum): + flags = ["subdomain-enum", "passive", "aggressive"] + watched_events = ["DNS_NAME"] + produced_events = ["DNS_NAME"] + meta = {"description": "Brute-force subdomains with massdns + static wordlist"} + options = { + "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "max_depth": 5, + } + options_desc = { + "wordlist": "Subdomain wordlist URL", + "max_depth": "How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com", + } + deps_common = ["massdns"] + reject_wildcards = "strict" + dedup_strategy = "parent_domain" + _qsize = 10000 + + async def setup(self): + self.max_depth = max(1, self.config.get("max_depth", 5)) + self.subdomain_file = await self.helpers.wordlist(self.config.get("wordlist")) + self.subdomain_list = set(self.helpers.read_file(self.subdomain_file)) + return await super().setup() + + async def eligible_for_enumeration(self, event): + eligible, reason = await super().eligible_for_enumeration(event) + query = self.make_query(event) + + # limit brute force depth + subdomain_depth = self.helpers.subdomain_depth(query) + 1 + if subdomain_depth > self.max_depth: + eligible = False + reason = f"subdomain depth of *.{query} ({subdomain_depth}) > max_depth ({self.max_depth})" + + # don't brute-force things that look like autogenerated PTRs + if self.helpers.dns.brute.has_excessive_digits(query): + eligible = False + reason = f'"{query}" looks like an autogenerated PTR' + + return eligible, reason + + async def handle_event(self, event): + self.hugewarning(event) + query = self.make_query(event) + self.info(f"Brute-forcing subdomains for {query} (source: {event.data})") + for hostname in await self.helpers.dns.brute(self, query, self.subdomain_list): + await self.emit_event(hostname, "DNS_NAME", source=event) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py new file mode 100644 index 000000000..664539bb4 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py @@ -0,0 +1,78 @@ +from .base import ModuleTestBase, tempwordlist + + +class TestDnsbrute(ModuleTestBase): + subdomain_wordlist = tempwordlist(["www", "asdf"]) + config_overrides = {"modules": {"massdns": {"wordlist": str(subdomain_wordlist), "max_depth": 3}}} + + async def setup_after_prep(self, module_test): + + old_run_live = module_test.scan.helpers.run_live + + async def new_run_live(*command, check=False, text=True, **kwargs): + if "massdns" in command[:2]: + yield """{"name": "www-test.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "www-test.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + else: + async for _ in old_run_live(*command, check=False, text=True, **kwargs): + yield _ + + module_test.monkeypatch.setattr(module_test.scan.helpers, "run_live", new_run_live) + + await module_test.mock_dns( + { + "www-test.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + } + ) + + # test recursive brute-force event filtering + event = module_test.scan.make_event("evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == True + assert "evilcorp.com" in module_test.module.processed + assert not "com" in module_test.module.processed + event = module_test.scan.make_event("evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == False + assert reason == "Event was already processed" + event = module_test.scan.make_event("www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == False + assert reason == "Event was already processed" + event = module_test.scan.make_event("test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == True + event = module_test.scan.make_event("test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == False + assert reason == "Event was already processed" + event = module_test.scan.make_event( + "asdf.test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + ) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == True + event = module_test.scan.make_event( + "wat.asdf.test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + ) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == False + assert reason == f"subdomain depth of *.asdf.test.www.evilcorp.com (4) > max_depth (3)" + event = module_test.scan.make_event( + "hmmm.ptr1234.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + ) + event.scope_distance = 0 + result, reason = await module_test.module.filter_event(event) + assert result == False + assert reason == f'"ptr1234.evilcorp.com" looks like an autogenerated PTR' + + def check(self, module_test, events): + assert len(events) == 3 + assert 1 == len( + [e for e in events if e.data == "www-test.blacklanternsecurity.com" and str(e.module) == "massdns"] + ) From 8bfb557505b030178d50279e7e3a9b9153fe77c5 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 14:19:04 -0400 Subject: [PATCH 010/220] rename massdns --> dnsbrute --- bbot/test/test_step_1/test_cli.py | 32 +++++++++---------- bbot/test/test_step_1/test_modules_basic.py | 14 ++++---- bbot/test/test_step_1/test_presets.py | 30 ++++++++--------- .../module_tests/test_module_dnsbrute.py | 4 +-- docs/comparison.md | 2 +- docs/scanning/presets.md | 3 -- 6 files changed, 41 insertions(+), 44 deletions(-) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index 0a34b4faa..32e761679 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -133,7 +133,7 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): assert "| bool" in caplog.text assert "| emit URLs in addition to DNS_NAMEs" in caplog.text assert "| False" in caplog.text - assert "| modules.massdns.wordlist" in caplog.text + assert "| modules.dnsbrute.wordlist" in caplog.text assert "| modules.robots.include_allow" in caplog.text # list module options by flag @@ -146,17 +146,17 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): assert "| bool" in caplog.text assert "| emit URLs in addition to DNS_NAMEs" in caplog.text assert "| False" in caplog.text - assert "| modules.massdns.wordlist" in caplog.text + assert "| modules.dnsbrute.wordlist" in caplog.text assert not "| modules.robots.include_allow" in caplog.text # list module options by module caplog.clear() assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-m", "massdns", "-lmo"]) + monkeypatch.setattr("sys.argv", ["bbot", "-m", "dnsbrute", "-lmo"]) result = await cli._main() assert result == None assert not "| modules.wayback.urls" in caplog.text - assert "| modules.massdns.wordlist" in caplog.text + assert "| modules.dnsbrute.wordlist" in caplog.text assert not "| modules.robots.include_allow" in caplog.text # list flags @@ -219,7 +219,7 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): monkeypatch.setattr("sys.argv", ["bbot", "-l"]) result = await cli._main() assert result == None - assert "| massdns" in caplog.text + assert "| dnsbrute" in caplog.text assert "| httpx" in caplog.text assert "| robots" in caplog.text @@ -229,7 +229,7 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-l"]) result = await cli._main() assert result == None - assert "| massdns" in caplog.text + assert "| dnsbrute" in caplog.text assert "| httpx" in caplog.text assert not "| robots" in caplog.text @@ -238,7 +238,7 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-rf", "passive", "-l"]) result = await cli._main() assert result == None - assert "| massdns" in caplog.text + assert "| dnsbrute" in caplog.text assert not "| httpx" in caplog.text # list modules by flag + excluded flag @@ -247,16 +247,16 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-ef", "active", "-l"]) result = await cli._main() assert result == None - assert "| massdns" in caplog.text + assert "| dnsbrute" in caplog.text assert not "| httpx" in caplog.text # list modules by flag + excluded module caplog.clear() assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "massdns", "-l"]) + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "-l"]) result = await cli._main() assert result == None - assert not "| massdns" in caplog.text + assert not "| dnsbrute" in caplog.text assert "| httpx" in caplog.text # unconsoleable output module @@ -343,18 +343,18 @@ def test_cli_module_validation(monkeypatch, caplog): # incorrect module caplog.clear() assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-m", "massdnss"]) + monkeypatch.setattr("sys.argv", ["bbot", "-m", "dnsbrutes"]) cli.main() - assert 'Could not find scan module "massdnss"' in caplog.text - assert 'Did you mean "massdns"?' in caplog.text + assert 'Could not find scan module "dnsbrutes"' in caplog.text + assert 'Did you mean "dnsbrute"?' in caplog.text # incorrect excluded module caplog.clear() assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-em", "massdnss"]) + monkeypatch.setattr("sys.argv", ["bbot", "-em", "dnsbrutes"]) cli.main() - assert 'Could not find module "massdnss"' in caplog.text - assert 'Did you mean "massdns"?' in caplog.text + assert 'Could not find module "dnsbrutes"' in caplog.text + assert 'Did you mean "dnsbrute"?' in caplog.text # incorrect output module caplog.clear() diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 03273c0a7..08fd16eec 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -100,15 +100,15 @@ async def test_modules_basic(scan, helpers, events, bbot_scanner, httpx_mock): # module preloading all_preloaded = DEFAULT_PRESET.module_loader.preloaded() - assert "massdns" in all_preloaded - assert "DNS_NAME" in all_preloaded["massdns"]["watched_events"] - assert "DNS_NAME" in all_preloaded["massdns"]["produced_events"] - assert "subdomain-enum" in all_preloaded["massdns"]["flags"] - assert "wordlist" in all_preloaded["massdns"]["config"] - assert type(all_preloaded["massdns"]["config"]["max_resolvers"]) == int + assert "dnsbrute" in all_preloaded + assert "DNS_NAME" in all_preloaded["dnsbrute"]["watched_events"] + assert "DNS_NAME" in all_preloaded["dnsbrute"]["produced_events"] + assert "subdomain-enum" in all_preloaded["dnsbrute"]["flags"] + assert "wordlist" in all_preloaded["dnsbrute"]["config"] + assert type(all_preloaded["dnsbrute"]["config"]["max_depth"]) == int assert all_preloaded["sslcert"]["deps"]["pip"] assert all_preloaded["sslcert"]["deps"]["apt"] - assert all_preloaded["massdns"]["deps"]["common"] + assert all_preloaded["dnsbrute"]["deps"]["common"] assert all_preloaded["gowitness"]["deps"]["ansible"] all_flags = set() diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index d84244e4f..6f0b9773f 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -584,13 +584,13 @@ def get_module_flags(p): preset = Preset(flags=["subdomain-enum"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - massdns_flags = preset.preloaded_module("massdns").get("flags", []) - assert "subdomain-enum" in massdns_flags - assert "passive" in massdns_flags - assert not "active" in massdns_flags - assert "aggressive" in massdns_flags - assert not "safe" in massdns_flags - assert "massdns" in [x[0] for x in module_flags] + dnsbrute_flags = preset.preloaded_module("dnsbrute").get("flags", []) + assert "subdomain-enum" in dnsbrute_flags + assert "passive" in dnsbrute_flags + assert not "active" in dnsbrute_flags + assert "aggressive" in dnsbrute_flags + assert not "safe" in dnsbrute_flags + assert "dnsbrute" in [x[0] for x in module_flags] assert "certspotter" in [x[0] for x in module_flags] assert "c99" in [x[0] for x in module_flags] assert any("passive" in flags for module, flags in module_flags) @@ -602,7 +602,7 @@ def get_module_flags(p): preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert "massdns" in [x[0] for x in module_flags] + assert "dnsbrute" in [x[0] for x in module_flags] assert all("passive" in flags for module, flags in module_flags) assert not any("active" in flags for module, flags in module_flags) assert any("safe" in flags for module, flags in module_flags) @@ -612,17 +612,17 @@ def get_module_flags(p): preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert "massdns" in [x[0] for x in module_flags] + assert "dnsbrute" in [x[0] for x in module_flags] assert all("passive" in flags for module, flags in module_flags) assert not any("active" in flags for module, flags in module_flags) assert any("safe" in flags for module, flags in module_flags) assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one excluded module - preset = Preset(flags=["subdomain-enum"], exclude_modules=["massdns"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert not "massdns" in [x[0] for x in module_flags] + assert not "dnsbrute" in [x[0] for x in module_flags] assert any("passive" in flags for module, flags in module_flags) assert any("active" in flags for module, flags in module_flags) assert any("safe" in flags for module, flags in module_flags) @@ -632,7 +632,7 @@ def get_module_flags(p): preset = Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert not "massdns" in [x[0] for x in module_flags] + assert not "dnsbrute" in [x[0] for x in module_flags] assert all("passive" in flags and "safe" in flags for module, flags in module_flags) assert all("active" not in flags and "aggressive" not in flags for module, flags in module_flags) assert not any("active" in flags for module, flags in module_flags) @@ -642,17 +642,17 @@ def get_module_flags(p): preset = Preset(flags=["subdomain-enum"], exclude_flags=["aggressive", "active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert not "massdns" in [x[0] for x in module_flags] + assert not "dnsbrute" in [x[0] for x in module_flags] assert all("passive" in flags and "safe" in flags for module, flags in module_flags) assert all("active" not in flags and "aggressive" not in flags for module, flags in module_flags) assert not any("active" in flags for module, flags in module_flags) assert not any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple excluded modules - preset = Preset(flags=["subdomain-enum"], exclude_modules=["massdns", "c99"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute", "c99"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) - assert not "massdns" in [x[0] for x in module_flags] + assert not "dnsbrute" in [x[0] for x in module_flags] assert "certspotter" in [x[0] for x in module_flags] assert not "c99" in [x[0] for x in module_flags] assert any("passive" in flags for module, flags in module_flags) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py index 664539bb4..2d301da94 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py @@ -3,7 +3,7 @@ class TestDnsbrute(ModuleTestBase): subdomain_wordlist = tempwordlist(["www", "asdf"]) - config_overrides = {"modules": {"massdns": {"wordlist": str(subdomain_wordlist), "max_depth": 3}}} + config_overrides = {"modules": {"dnsbrute": {"wordlist": str(subdomain_wordlist), "max_depth": 3}}} async def setup_after_prep(self, module_test): @@ -74,5 +74,5 @@ async def new_run_live(*command, check=False, text=True, **kwargs): def check(self, module_test, events): assert len(events) == 3 assert 1 == len( - [e for e in events if e.data == "www-test.blacklanternsecurity.com" and str(e.module) == "massdns"] + [e for e in events if e.data == "www-test.blacklanternsecurity.com" and str(e.module) == "dnsbrute"] ) diff --git a/docs/comparison.md b/docs/comparison.md index 3226036f1..183e84319 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -2,7 +2,7 @@ BBOT does a lot more than just subdomain enumeration. However, subdomain enumeration is arguably the most important part of OSINT, and since there's so many subdomain enumeration tools out there, they're the easiest class of tool to compare it to. -Thanks to BBOT's recursive nature (and its `massdns` module with its NLP-powered subdomain mutations), it typically finds about 20-25% more than other tools such as `Amass` or `theHarvester`. This holds true even for larger targets like `delta.com` (1000+ subdomains): +Thanks to BBOT's recursive nature (and its `dnsbrute_mutations` module with its NLP-powered subdomain mutations), it typically finds about 20-25% more than other tools such as `Amass` or `theHarvester`. This holds true especially for larger targets like `delta.com` (1000+ subdomains): ### Subdomains Found diff --git a/docs/scanning/presets.md b/docs/scanning/presets.md index f19e27550..3d8f47a9b 100644 --- a/docs/scanning/presets.md +++ b/docs/scanning/presets.md @@ -86,9 +86,6 @@ config: api_key: 21a270d5f59c9b05813a72bb41707266 virustotal: api_key: 4f41243847da693a4f356c0486114bc6 - # other module config options - massdns: - max_resolvers: 5000 ``` To execute your custom preset, you do: From 7e6a8edc4a889a2307b596f641776b7924e7be6b Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 14:20:32 -0400 Subject: [PATCH 011/220] fix tests --- bbot/core/helpers/dns/brute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/core/helpers/dns/brute.py b/bbot/core/helpers/dns/brute.py index 5bfa7b2e0..da0ca0931 100644 --- a/bbot/core/helpers/dns/brute.py +++ b/bbot/core/helpers/dns/brute.py @@ -68,7 +68,7 @@ async def dnsbrute(self, module, domain, subdomains, type=None): else: results.append(hostname) - if canaries_triggered > 5: + if len(canaries_triggered) > 5: self.log.info( f"Aborting massdns on {domain} due to false positive: ({len(canaries_triggered):,} canaries triggered - {','.join(canaries_triggered)})" ) From 2aea9b109464111dad7b89014d88a6d3ca2cd733 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 16:54:13 -0400 Subject: [PATCH 012/220] add bloom filter --- bbot/core/helpers/bloom.py | 45 ++++++++++++++ bbot/core/helpers/helper.py | 5 ++ bbot/test/test_step_1/test_bloom_filter.py | 71 ++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 bbot/core/helpers/bloom.py create mode 100644 bbot/test/test_step_1/test_bloom_filter.py diff --git a/bbot/core/helpers/bloom.py b/bbot/core/helpers/bloom.py new file mode 100644 index 000000000..1f454ce0c --- /dev/null +++ b/bbot/core/helpers/bloom.py @@ -0,0 +1,45 @@ +import mmh3 +from bitarray import bitarray + + +class BloomFilter: + """ + Simple bloom filter implementation capable of rougly 200K lookups/s. + + BBOT uses bloom filters in scenarios like dns brute-forcing, where it's useful to keep track + of which mutations have been tried so far. + + A 100-megabyte bloom filter (800M bits) can store 10M entries with a .01% false-positive rate. + A python hash is 36 bytes. So if you wanted to store these in a set, this would take up + 36 * 10M * 2 (key+value) == 720 megabytes. So we save rougly 7 times the space. + """ + + def __init__(self, size=2**16): + self.size = size + self.bit_array = bitarray(size) + self.bit_array.setall(0) # Initialize all bits to 0 + + def _hashes(self, item): + item_str = str(item).encode("utf-8") + return [ + abs(hash(item)) % self.size, + abs(mmh3.hash(item_str)) % self.size, + abs(self._fnv1a_hash(item_str)) % self.size, + ] + + def _fnv1a_hash(self, data): + hash = 0x811C9DC5 # 2166136261 + for byte in data: + hash ^= byte + hash = (hash * 0x01000193) % 2**32 # 16777619 + return hash + + def add(self, item): + for hash_value in self._hashes(item): + self.bit_array[hash_value] = 1 + + def check(self, item): + return all(self.bit_array[hash_value] for hash_value in self._hashes(item)) + + def __contains__(self, item): + return self.check(item) diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index e4dc09326..56b9c3bbd 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -91,6 +91,11 @@ def __init__(self, preset): self.word_cloud = WordCloud(self) self.dummy_modules = {} + def bloom_filter(self, size): + from .bloom import BloomFilter + + return BloomFilter(size) + def interactsh(self, *args, **kwargs): return Interactsh(self, *args, **kwargs) diff --git a/bbot/test/test_step_1/test_bloom_filter.py b/bbot/test/test_step_1/test_bloom_filter.py new file mode 100644 index 000000000..089f4af82 --- /dev/null +++ b/bbot/test/test_step_1/test_bloom_filter.py @@ -0,0 +1,71 @@ +import sys +import time +import string +import random + + +def test_bloom_filter(): + + def generate_random_strings(n, length=10): + """Generate a list of n random strings.""" + return ["".join(random.choices(string.ascii_letters + string.digits, k=length)) for _ in range(n)] + + from bbot.scanner import Scanner + + scan = Scanner() + + n_items_to_add = 100000 + n_items_to_test = 100000 + bloom_filter_size = 8000000 + + # Initialize the simple bloom filter and the set + bloom_filter = scan.helpers.bloom_filter(size=bloom_filter_size) + test_set = set() + + mem_size = sys.getsizeof(bloom_filter.bit_array) + print(f"Size of bit array: {mem_size}") + + # size should be roughly 1MB + assert 900000 < mem_size < 1100000 + + # Generate random strings to add + print(f"Generating {n_items_to_add:,} items to add") + items_to_add = set(generate_random_strings(n_items_to_add)) + + # Generate random strings to test + print(f"Generating {n_items_to_test:,} items to test") + items_to_test = generate_random_strings(n_items_to_test) + + print("Adding items") + start = time.time() + for item in items_to_add: + bloom_filter.add(item) + test_set.add(hash(item)) + end = time.time() + elapsed = end - start + print(f"elapsed: {elapsed:.2f} ({int(n_items_to_test/elapsed)}/s)") + # this shouldn't take longer than 5 seconds + assert elapsed < 5 + + # make sure we have 100% accuracy + start = time.time() + for item in items_to_add: + assert item in bloom_filter + end = time.time() + elapsed = end - start + print(f"elapsed: {elapsed:.2f} ({int(n_items_to_test/elapsed)}/s)") + # this shouldn't take longer than 5 seconds + assert elapsed < 5 + + print("Measuring false positives") + # Check for false positives + false_positives = 0 + for item in items_to_test: + if bloom_filter.check(item) and hash(item) not in test_set: + false_positives += 1 + false_positive_rate = false_positives / len(items_to_test) + + print(f"False positive rate: {false_positive_rate * 100:.2f}% ({false_positives}/{len(items_to_test)})") + + # ensure false positives are less than .01 percent + assert 0 < false_positives < 10 From 6fd52718f807cc1d2b5fc6a9053da63dbb7cd32b Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 24 Apr 2024 17:16:21 -0400 Subject: [PATCH 013/220] wip dnsbrute mutations --- bbot/core/helpers/dns/brute.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/core/helpers/dns/brute.py b/bbot/core/helpers/dns/brute.py index da0ca0931..d8996f2f4 100644 --- a/bbot/core/helpers/dns/brute.py +++ b/bbot/core/helpers/dns/brute.py @@ -122,7 +122,6 @@ async def _massdns(self, module, domain, subdomains, rdtype): hosts_yielded = set() async with self._dnsbrute_lock: async for line in module.run_process_live(*command, stderr=subprocess.DEVNULL, input=subdomains): - self.log.critical(line) try: j = json.loads(line) except json.decoder.JSONDecodeError: From 1e4927184f11d4fc57fd6181851f22966d5b00ca Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 10:45:04 -0400 Subject: [PATCH 014/220] updates to bloom filter --- bbot/core/helpers/bloom.py | 64 ++++++++++----- bbot/modules/rapiddns.py | 2 +- bbot/test/test_step_1/test_bloom_filter.py | 16 ++-- poetry.lock | 94 +++++++++++++++++++++- pyproject.toml | 1 + 5 files changed, 145 insertions(+), 32 deletions(-) diff --git a/bbot/core/helpers/bloom.py b/bbot/core/helpers/bloom.py index 1f454ce0c..357c715c0 100644 --- a/bbot/core/helpers/bloom.py +++ b/bbot/core/helpers/bloom.py @@ -1,12 +1,13 @@ +import os import mmh3 -from bitarray import bitarray +import mmap class BloomFilter: """ - Simple bloom filter implementation capable of rougly 200K lookups/s. + Simple bloom filter implementation capable of rougly 400K lookups/s. - BBOT uses bloom filters in scenarios like dns brute-forcing, where it's useful to keep track + BBOT uses bloom filters in scenarios like DNS brute-forcing, where it's useful to keep track of which mutations have been tried so far. A 100-megabyte bloom filter (800M bits) can store 10M entries with a .01% false-positive rate. @@ -14,18 +15,47 @@ class BloomFilter: 36 * 10M * 2 (key+value) == 720 megabytes. So we save rougly 7 times the space. """ - def __init__(self, size=2**16): - self.size = size - self.bit_array = bitarray(size) - self.bit_array.setall(0) # Initialize all bits to 0 + def __init__(self, size=8000000): + self.size = size # total bits + self.byte_size = (size + 7) // 8 # calculate byte size needed for the given number of bits + + # Create an anonymous mmap region, compatible with both Windows and Unix + if os.name == "nt": # Windows + # -1 indicates an anonymous memory map in Windows + self.mmap_file = mmap.mmap(-1, self.byte_size) + else: # Unix/Linux + # Use MAP_ANONYMOUS along with MAP_SHARED + self.mmap_file = mmap.mmap(-1, self.byte_size, prot=mmap.PROT_WRITE, flags=mmap.MAP_ANON | mmap.MAP_SHARED) + + self.clear_all_bits() + + def add(self, item): + for hash_value in self._hashes(item): + index = hash_value // 8 + position = hash_value % 8 + current_byte = self.mmap_file[index] + self.mmap_file[index] = current_byte | (1 << position) + + def check(self, item): + for hash_value in self._hashes(item): + index = hash_value // 8 + position = hash_value % 8 + current_byte = self.mmap_file[index] + if not (current_byte & (1 << position)): + return False + return True + + def clear_all_bits(self): + self.mmap_file.seek(0) + # Write zeros across the entire mmap length + self.mmap_file.write(b"\x00" * self.byte_size) def _hashes(self, item): - item_str = str(item).encode("utf-8") - return [ - abs(hash(item)) % self.size, - abs(mmh3.hash(item_str)) % self.size, - abs(self._fnv1a_hash(item_str)) % self.size, - ] + if not isinstance(item, bytes): + if not isinstance(item, str): + item = str(item) + item = item.encode("utf-8") + return [abs(hash(item)) % self.size, abs(mmh3.hash(item)) % self.size, abs(self._fnv1a_hash(item)) % self.size] def _fnv1a_hash(self, data): hash = 0x811C9DC5 # 2166136261 @@ -34,12 +64,8 @@ def _fnv1a_hash(self, data): hash = (hash * 0x01000193) % 2**32 # 16777619 return hash - def add(self, item): - for hash_value in self._hashes(item): - self.bit_array[hash_value] = 1 - - def check(self, item): - return all(self.bit_array[hash_value] for hash_value in self._hashes(item)) + def __del__(self): + self.mmap_file.close() def __contains__(self, item): return self.check(item) diff --git a/bbot/modules/rapiddns.py b/bbot/modules/rapiddns.py index 088288ddb..7e634515b 100644 --- a/bbot/modules/rapiddns.py +++ b/bbot/modules/rapiddns.py @@ -11,7 +11,7 @@ class rapiddns(subdomain_enum): async def request_url(self, query): url = f"{self.base_url}/subdomain/{self.helpers.quote(query)}?full=1#result" - response = await self.request_with_fail_count(url) + response = await self.request_with_fail_count(url, timeout=self.http_timeout + 10) return response def parse_results(self, r, query): diff --git a/bbot/test/test_step_1/test_bloom_filter.py b/bbot/test/test_step_1/test_bloom_filter.py index 089f4af82..6d8e6918d 100644 --- a/bbot/test/test_step_1/test_bloom_filter.py +++ b/bbot/test/test_step_1/test_bloom_filter.py @@ -1,4 +1,3 @@ -import sys import time import string import random @@ -20,13 +19,8 @@ def generate_random_strings(n, length=10): # Initialize the simple bloom filter and the set bloom_filter = scan.helpers.bloom_filter(size=bloom_filter_size) - test_set = set() - - mem_size = sys.getsizeof(bloom_filter.bit_array) - print(f"Size of bit array: {mem_size}") - # size should be roughly 1MB - assert 900000 < mem_size < 1100000 + test_set = set() # Generate random strings to add print(f"Generating {n_items_to_add:,} items to add") @@ -63,9 +57,9 @@ def generate_random_strings(n, length=10): for item in items_to_test: if bloom_filter.check(item) and hash(item) not in test_set: false_positives += 1 - false_positive_rate = false_positives / len(items_to_test) + false_positive_percent = false_positives / len(items_to_test) * 100 - print(f"False positive rate: {false_positive_rate * 100:.2f}% ({false_positives}/{len(items_to_test)})") + print(f"False positive rate: {false_positive_percent:.2f}% ({false_positives}/{len(items_to_test)})") - # ensure false positives are less than .01 percent - assert 0 < false_positives < 10 + # ensure false positives are less than .02 percent + assert false_positive_percent < 0.02 diff --git a/poetry.lock b/poetry.lock index be6fea410..e54a2c530 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1295,6 +1295,98 @@ files = [ griffe = ">=0.44" mkdocstrings = ">=0.24.2" +[[package]] +name = "mmh3" +version = "4.1.0" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = "*" +files = [ + {file = "mmh3-4.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be5ac76a8b0cd8095784e51e4c1c9c318c19edcd1709a06eb14979c8d850c31a"}, + {file = "mmh3-4.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98a49121afdfab67cd80e912b36404139d7deceb6773a83620137aaa0da5714c"}, + {file = "mmh3-4.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5259ac0535874366e7d1a5423ef746e0d36a9e3c14509ce6511614bdc5a7ef5b"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5950827ca0453a2be357696da509ab39646044e3fa15cad364eb65d78797437"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dd0f652ae99585b9dd26de458e5f08571522f0402155809fd1dc8852a613a39"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99d25548070942fab1e4a6f04d1626d67e66d0b81ed6571ecfca511f3edf07e6"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53db8d9bad3cb66c8f35cbc894f336273f63489ce4ac416634932e3cbe79eb5b"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75da0f615eb55295a437264cc0b736753f830b09d102aa4c2a7d719bc445ec05"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b926b07fd678ea84b3a2afc1fa22ce50aeb627839c44382f3d0291e945621e1a"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c5b053334f9b0af8559d6da9dc72cef0a65b325ebb3e630c680012323c950bb6"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bf33dc43cd6de2cb86e0aa73a1cc6530f557854bbbe5d59f41ef6de2e353d7b"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fa7eacd2b830727ba3dd65a365bed8a5c992ecd0c8348cf39a05cc77d22f4970"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:42dfd6742b9e3eec599f85270617debfa0bbb913c545bb980c8a4fa7b2d047da"}, + {file = "mmh3-4.1.0-cp310-cp310-win32.whl", hash = "sha256:2974ad343f0d39dcc88e93ee6afa96cedc35a9883bc067febd7ff736e207fa47"}, + {file = "mmh3-4.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:74699a8984ded645c1a24d6078351a056f5a5f1fe5838870412a68ac5e28d865"}, + {file = "mmh3-4.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f0dc874cedc23d46fc488a987faa6ad08ffa79e44fb08e3cd4d4cf2877c00a00"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3280a463855b0eae64b681cd5b9ddd9464b73f81151e87bb7c91a811d25619e6"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:97ac57c6c3301769e757d444fa7c973ceb002cb66534b39cbab5e38de61cd896"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b6502cdb4dbd880244818ab363c8770a48cdccecf6d729ade0241b736b5ec0"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ba2da04671a9621580ddabf72f06f0e72c1c9c3b7b608849b58b11080d8f14"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a5fef4c4ecc782e6e43fbeab09cff1bac82c998a1773d3a5ee6a3605cde343e"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5135358a7e00991f73b88cdc8eda5203bf9de22120d10a834c5761dbeb07dd13"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cff9ae76a54f7c6fe0167c9c4028c12c1f6de52d68a31d11b6790bb2ae685560"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f02576a4d106d7830ca90278868bf0983554dd69183b7bbe09f2fcd51cf54f"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:073d57425a23721730d3ff5485e2da489dd3c90b04e86243dd7211f889898106"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:71e32ddec7f573a1a0feb8d2cf2af474c50ec21e7a8263026e8d3b4b629805db"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7cbb20b29d57e76a58b40fd8b13a9130db495a12d678d651b459bf61c0714cea"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:a42ad267e131d7847076bb7e31050f6c4378cd38e8f1bf7a0edd32f30224d5c9"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4a013979fc9390abadc445ea2527426a0e7a4495c19b74589204f9b71bcaafeb"}, + {file = "mmh3-4.1.0-cp311-cp311-win32.whl", hash = "sha256:1d3b1cdad7c71b7b88966301789a478af142bddcb3a2bee563f7a7d40519a00f"}, + {file = "mmh3-4.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0dc6dc32eb03727467da8e17deffe004fbb65e8b5ee2b502d36250d7a3f4e2ec"}, + {file = "mmh3-4.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9ae3a5c1b32dda121c7dc26f9597ef7b01b4c56a98319a7fe86c35b8bc459ae6"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0033d60c7939168ef65ddc396611077a7268bde024f2c23bdc283a19123f9e9c"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d6af3e2287644b2b08b5924ed3a88c97b87b44ad08e79ca9f93d3470a54a41c5"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d82eb4defa245e02bb0b0dc4f1e7ee284f8d212633389c91f7fba99ba993f0a2"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba245e94b8d54765e14c2d7b6214e832557e7856d5183bc522e17884cab2f45d"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb04e2feeabaad6231e89cd43b3d01a4403579aa792c9ab6fdeef45cc58d4ec0"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3b1a27def545ce11e36158ba5d5390cdbc300cfe456a942cc89d649cf7e3b2"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce0ab79ff736d7044e5e9b3bfe73958a55f79a4ae672e6213e92492ad5e734d5"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b02268be6e0a8eeb8a924d7db85f28e47344f35c438c1e149878bb1c47b1cd3"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:deb887f5fcdaf57cf646b1e062d56b06ef2f23421c80885fce18b37143cba828"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99dd564e9e2b512eb117bd0cbf0f79a50c45d961c2a02402787d581cec5448d5"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:08373082dfaa38fe97aa78753d1efd21a1969e51079056ff552e687764eafdfe"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:54b9c6a2ea571b714e4fe28d3e4e2db37abfd03c787a58074ea21ee9a8fd1740"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a7b1edf24c69e3513f879722b97ca85e52f9032f24a52284746877f6a7304086"}, + {file = "mmh3-4.1.0-cp312-cp312-win32.whl", hash = "sha256:411da64b951f635e1e2284b71d81a5a83580cea24994b328f8910d40bed67276"}, + {file = "mmh3-4.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bebc3ecb6ba18292e3d40c8712482b4477abd6981c2ebf0e60869bd90f8ac3a9"}, + {file = "mmh3-4.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:168473dd608ade6a8d2ba069600b35199a9af837d96177d3088ca91f2b3798e3"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:372f4b7e1dcde175507640679a2a8790185bb71f3640fc28a4690f73da986a3b"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:438584b97f6fe13e944faf590c90fc127682b57ae969f73334040d9fa1c7ffa5"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6e27931b232fc676675fac8641c6ec6b596daa64d82170e8597f5a5b8bdcd3b6"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:571a92bad859d7b0330e47cfd1850b76c39b615a8d8e7aa5853c1f971fd0c4b1"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a69d6afe3190fa08f9e3a58e5145549f71f1f3fff27bd0800313426929c7068"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afb127be0be946b7630220908dbea0cee0d9d3c583fa9114a07156f98566dc28"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:940d86522f36348ef1a494cbf7248ab3f4a1638b84b59e6c9e90408bd11ad729"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3dcccc4935686619a8e3d1f7b6e97e3bd89a4a796247930ee97d35ea1a39341"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01bb9b90d61854dfc2407c5e5192bfb47222d74f29d140cb2dd2a69f2353f7cc"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bcb1b8b951a2c0b0fb8a5426c62a22557e2ffc52539e0a7cc46eb667b5d606a9"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6477a05d5e5ab3168e82e8b106e316210ac954134f46ec529356607900aea82a"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:da5892287e5bea6977364b15712a2573c16d134bc5fdcdd4cf460006cf849278"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:99180d7fd2327a6fffbaff270f760576839dc6ee66d045fa3a450f3490fda7f5"}, + {file = "mmh3-4.1.0-cp38-cp38-win32.whl", hash = "sha256:9b0d4f3949913a9f9a8fb1bb4cc6ecd52879730aab5ff8c5a3d8f5b593594b73"}, + {file = "mmh3-4.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:598c352da1d945108aee0c3c3cfdd0e9b3edef74108f53b49d481d3990402169"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:475d6d1445dd080f18f0f766277e1237fa2914e5fe3307a3b2a3044f30892103"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ca07c41e6a2880991431ac717c2a049056fff497651a76e26fc22224e8b5732"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ebe052fef4bbe30c0548d12ee46d09f1b69035ca5208a7075e55adfe091be44"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaefd42e85afb70f2b855a011f7b4d8a3c7e19c3f2681fa13118e4d8627378c5"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0ae43caae5a47afe1b63a1ae3f0986dde54b5fb2d6c29786adbfb8edc9edfb"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6218666f74c8c013c221e7f5f8a693ac9cf68e5ac9a03f2373b32d77c48904de"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac59294a536ba447b5037f62d8367d7d93b696f80671c2c45645fa9f1109413c"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:086844830fcd1e5c84fec7017ea1ee8491487cfc877847d96f86f68881569d2e"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e42b38fad664f56f77f6fbca22d08450f2464baa68acdbf24841bf900eb98e87"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d08b790a63a9a1cde3b5d7d733ed97d4eb884bfbc92f075a091652d6bfd7709a"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:73ea4cc55e8aea28c86799ecacebca09e5f86500414870a8abaedfcbaf74d288"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:f90938ff137130e47bcec8dc1f4ceb02f10178c766e2ef58a9f657ff1f62d124"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:aa1f13e94b8631c8cd53259250556edcf1de71738936b60febba95750d9632bd"}, + {file = "mmh3-4.1.0-cp39-cp39-win32.whl", hash = "sha256:a3b680b471c181490cf82da2142029edb4298e1bdfcb67c76922dedef789868d"}, + {file = "mmh3-4.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:fefef92e9c544a8dbc08f77a8d1b6d48006a750c4375bbcd5ff8199d761e263b"}, + {file = "mmh3-4.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:8e2c1f6a2b41723a4f82bd5a762a777836d29d664fc0095f17910bea0adfd4a6"}, + {file = "mmh3-4.1.0.tar.gz", hash = "sha256:a1cf25348b9acd229dda464a094d6170f47d2850a1fcb762a3b6172d2ce6ca4a"}, +] + +[package.extras] +test = ["mypy (>=1.0)", "pytest (>=7.0.0)"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -2637,4 +2729,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "ed8bb07e4ff5a5f665402db33f9016409547bef1ccb6a8c2c626c44fde075abb" +content-hash = "baf84bb8d915bbcec435bf66a227dc0aac2dad1acc2e3f7028a19cd23f87bf1b" diff --git a/pyproject.toml b/pyproject.toml index 7ba00c488..4ca394188 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ regex = "^2024.4.16" unidecode = "^1.3.8" radixtarget = "^1.0.0.15" cloudcheck = "^5.0.0.350" +mmh3 = "^4.1.0" [tool.poetry.group.dev.dependencies] flake8 = ">=6,<8" From 949f9c79737decf603286fcdf3e0102f35fce219 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 11:30:18 -0400 Subject: [PATCH 015/220] better error handling in intercept modules --- bbot/modules/base.py | 4 ++++ bbot/test/test_step_1/test_target.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bbot/modules/base.py b/bbot/modules/base.py index c102b138d..a55ff2ae4 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -1481,6 +1481,10 @@ async def _worker(self): except asyncio.CancelledError: self.log.trace("Worker cancelled") raise + except BaseException as e: + self.critical(f"Critical failure in intercept module {self.name}: {e}") + self.critical(traceback.format_exc()) + self.scan.stop() self.log.trace(f"Worker stopped") async def get_incoming_event(self): diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index ed5c1b7ef..7d8117d52 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -51,12 +51,12 @@ def test_target(bbot_scanner): assert not "www.evilcorp.com" in strict_target target = Target() - target.add_target("evilcorp.com") + target.add("evilcorp.com") assert not "com" in target assert "evilcorp.com" in target assert "www.evilcorp.com" in target strict_target = Target(strict_scope=True) - strict_target.add_target("evilcorp.com") + strict_target.add("evilcorp.com") assert not "com" in strict_target assert "evilcorp.com" in strict_target assert not "www.evilcorp.com" in strict_target From c44be0b7dad6b48779ec135480564839cea99d2f Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 15:53:36 -0400 Subject: [PATCH 016/220] add dnsbrute_mutations module --- bbot/modules/dnsbrute_mutations.py | 140 ++++++++++++++++++ .../test_module_dnsbrute_mutations.py | 70 +++++++++ 2 files changed, 210 insertions(+) create mode 100644 bbot/modules/dnsbrute_mutations.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py diff --git a/bbot/modules/dnsbrute_mutations.py b/bbot/modules/dnsbrute_mutations.py new file mode 100644 index 000000000..08e00d878 --- /dev/null +++ b/bbot/modules/dnsbrute_mutations.py @@ -0,0 +1,140 @@ +from bbot.modules.base import BaseModule + + +class dnsbrute_mutations(BaseModule): + flags = ["subdomain-enum", "passive", "aggressive", "slow"] + watched_events = ["DNS_NAME"] + produced_events = ["DNS_NAME"] + meta = {"description": "Brute-force subdomains with massdns + target-specific mutations"} + options = { + "max_mutations": 100, + } + options_desc = { + "max_mutations": 100, + } + deps_common = ["massdns"] + _qsize = 10000 + + async def setup(self): + self.found = {} + self.source_events = self.helpers.make_target() + self.max_mutations = self.config.get("max_mutations", 500) + # 800M bits == 100MB bloom filter == 10M entries before false positives start emerging + self.mutations_tried = self.helpers.bloom_filter(800000000) + self._mutation_run = 1 + return True + + async def handle_event(self, event): + # here we don't brute-force, we just add the subdomain to our end-of-scan TODO + self.add_found(event) + + def add_found(self, event): + self.source_events.add(event) + host = str(event.host) + if self.helpers.is_subdomain(host): + subdomain, domain = host.split(".", 1) + if not self.helpers.dns.brute.has_excessive_digits(subdomain): + try: + self.found[domain].add(subdomain) + except KeyError: + self.found[domain] = {subdomain} + + async def finish(self): + found = sorted(self.found.items(), key=lambda x: len(x[-1]), reverse=True) + # if we have a lot of rounds to make, don't try mutations on less-populated domains + trimmed_found = [] + if found: + avg_subdomains = sum([len(subdomains) for domain, subdomains in found[:50]]) / len(found[:50]) + for i, (domain, subdomains) in enumerate(found): + # accept domains that are in the top 50 or have more than 5 percent of the average number of subdomains + if i < 50 or (len(subdomains) > 1 and len(subdomains) >= (avg_subdomains * 0.05)): + trimmed_found.append((domain, subdomains)) + else: + self.verbose( + f"Skipping mutations on {domain} because it only has {len(subdomains):,} subdomain(s) (avg: {avg_subdomains:,})" + ) + + base_mutations = set() + found_mutations = False + try: + for i, (domain, subdomains) in enumerate(trimmed_found): + self.verbose(f"{domain} has {len(subdomains):,} subdomains") + # keep looping as long as we're finding things + while 1: + query = domain + + mutations = set(base_mutations) + + def add_mutation(m): + h = f"{m}.{domain}" + if h not in self.mutations_tried: + self.mutations_tried.add(h) + mutations.add(m) + + # try every subdomain everywhere else + for _domain, _subdomains in found: + if _domain == domain: + continue + for s in _subdomains: + first_segment = s.split(".")[0] + # skip stuff with lots of numbers (e.g. PTRs) + if self.helpers.dns.brute.has_excessive_digits(first_segment): + continue + add_mutation(first_segment) + for word in self.helpers.extract_words( + first_segment, word_regexes=self.helpers.word_cloud.dns_mutator.extract_word_regexes + ): + add_mutation(word) + + # numbers + devops mutations + for mutation in self.helpers.word_cloud.mutations( + subdomains, cloud=False, numbers=3, number_padding=1 + ): + for delimiter in ("", ".", "-"): + m = delimiter.join(mutation).lower() + add_mutation(m) + + # special dns mutator + for subdomain in self.helpers.word_cloud.dns_mutator.mutations( + subdomains, max_mutations=self.max_mutations + ): + add_mutation(subdomain) + + if mutations: + self.info(f"Trying {len(mutations):,} mutations against {domain} ({i+1}/{len(trimmed_found)})") + results = await self.helpers.dns.brute(self, query, mutations) + for hostname in results: + source_event = self.source_events.get(hostname) + if source_event is None: + self.warning(f"Could not correlate source event from: {hostname}") + self.warning(self.source_events._radix.dns_tree.root.children) + self.warning(self.source_events._radix.dns_tree.root.children["com"].children) + self.warning( + self.source_events._radix.dns_tree.root.children["com"].children["tesla"].children + ) + source_event = self.scan.root_event + await self.emit_event( + hostname, + "DNS_NAME", + source=source_event, + tags=[f"mutation-{self._mutation_run}"], + abort_if=self.abort_if, + ) + if results: + found_mutations = True + continue + break + except AssertionError as e: + self.warning(e) + + if found_mutations: + self._mutation_run += 1 + + def abort_if(self, event): + if not event.scope_distance == 0: + return True, "event is not in scope" + if "wildcard" in event.tags: + return True, "event is a wildcard" + if "unresolved" in event.tags: + return True, "event is unresolved" + return False, "" diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py new file mode 100644 index 000000000..2a56b2b65 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py @@ -0,0 +1,70 @@ +from .base import ModuleTestBase + + +class TestDnsbrute_mutations(ModuleTestBase): + targets = [ + "blacklanternsecurity.com", + "rrrr.blacklanternsecurity.com", + "asdff-ffdsa.blacklanternsecurity.com", + "hmmmm.test1.blacklanternsecurity.com", + "uuuuu.test2.blacklanternsecurity.com", + ] + + async def setup_after_prep(self, module_test): + + old_run_live = module_test.scan.helpers.run_live + + async def new_run_live(*command, check=False, text=True, **kwargs): + if "massdns" in command[:2]: + _input = [l async for l in kwargs["input"]] + if "rrrr-test.blacklanternsecurity.com" in _input: + yield """{"name": "rrrr-test.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "rrrr-test.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + if "rrrr-ffdsa.blacklanternsecurity.com" in _input: + yield """{"name": "rrrr-ffdsa.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "rrrr-ffdsa.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + if "hmmmm.test2.blacklanternsecurity.com" in _input: + yield """{"name": "hmmmm.test2.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "hmmmm.test2.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + else: + async for _ in old_run_live(*command, check=False, text=True, **kwargs): + yield _ + + module_test.monkeypatch.setattr(module_test.scan.helpers, "run_live", new_run_live) + + await module_test.mock_dns( + { + # targets + "rrrr.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "asdff-ffdsa.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "hmmmm.test1.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "uuuuu.test2.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + # devops mutation + "rrrr-test.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + # target-specific dns mutation + "rrrr-ffdsa.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + # subdomain from one subdomain on a different subdomain + "hmmmm.test2.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + } + ) + + def check(self, module_test, events): + assert len(events) == 9 + assert 1 == len( + [ + e + for e in events + if e.data == "rrrr-test.blacklanternsecurity.com" and str(e.module) == "dnsbrute_mutations" + ] + ), "Failed to find devops mutation (word_cloud)" + assert 1 == len( + [ + e + for e in events + if e.data == "rrrr-ffdsa.blacklanternsecurity.com" and str(e.module) == "dnsbrute_mutations" + ] + ), "Failed to find target-specific mutation (word_cloud.dns_mutator)" + assert 1 == len( + [ + e + for e in events + if e.data == "hmmmm.test2.blacklanternsecurity.com" and str(e.module) == "dnsbrute_mutations" + ] + ), "Failed to find subdomain taken from another subdomain" From e94556b594db8551f3bd3453418df10a30bf1338 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 16:03:59 -0400 Subject: [PATCH 017/220] fix tests --- bbot/modules/dnsbrute_mutations.py | 2 +- bbot/test/test_step_1/test_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/dnsbrute_mutations.py b/bbot/modules/dnsbrute_mutations.py index 08e00d878..06fd03689 100644 --- a/bbot/modules/dnsbrute_mutations.py +++ b/bbot/modules/dnsbrute_mutations.py @@ -10,7 +10,7 @@ class dnsbrute_mutations(BaseModule): "max_mutations": 100, } options_desc = { - "max_mutations": 100, + "max_mutations": "Maximum number of target-specific mutations to try per subdomain", } deps_common = ["massdns"] _qsize = 10000 diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index 32e761679..32e9bcadb 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -253,7 +253,7 @@ async def test_cli_args(monkeypatch, caplog, clean_default_config): # list modules by flag + excluded module caplog.clear() assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "-l"]) + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "dnsbrute_mutations", "-l"]) result = await cli._main() assert result == None assert not "| dnsbrute" in caplog.text From a2669b0b825f81546af63955bc16a0c8e7a005de Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 16:52:07 -0400 Subject: [PATCH 018/220] fix dnsbrute tests --- bbot/test/test_step_2/module_tests/test_module_dnsbrute.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py index 2d301da94..fab736cca 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py @@ -11,7 +11,7 @@ async def setup_after_prep(self, module_test): async def new_run_live(*command, check=False, text=True, **kwargs): if "massdns" in command[:2]: - yield """{"name": "www-test.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "www-test.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + yield """{"name": "asdf.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "asdf.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" else: async for _ in old_run_live(*command, check=False, text=True, **kwargs): yield _ @@ -20,7 +20,7 @@ async def new_run_live(*command, check=False, text=True, **kwargs): await module_test.mock_dns( { - "www-test.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "asdf.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, } ) @@ -74,5 +74,5 @@ async def new_run_live(*command, check=False, text=True, **kwargs): def check(self, module_test, events): assert len(events) == 3 assert 1 == len( - [e for e in events if e.data == "www-test.blacklanternsecurity.com" and str(e.module) == "dnsbrute"] + [e for e in events if e.data == "asdf.blacklanternsecurity.com" and str(e.module) == "dnsbrute"] ) From c5de1360e8e5ccba04b23035f675a529282b7dc2 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 25 Apr 2024 17:02:37 -0400 Subject: [PATCH 019/220] remove debug message --- bbot/modules/dnsbrute.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/modules/dnsbrute.py b/bbot/modules/dnsbrute.py index 2df3a48d6..8dc014084 100644 --- a/bbot/modules/dnsbrute.py +++ b/bbot/modules/dnsbrute.py @@ -43,7 +43,6 @@ async def eligible_for_enumeration(self, event): return eligible, reason async def handle_event(self, event): - self.hugewarning(event) query = self.make_query(event) self.info(f"Brute-forcing subdomains for {query} (source: {event.data})") for hostname in await self.helpers.dns.brute(self, query, self.subdomain_list): From a86c9d4a5d4175ed257e05ca0040868df9011edc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Apr 2024 04:32:52 +0000 Subject: [PATCH 020/220] Bump pydantic from 2.7.0 to 2.7.1 Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.7.0 to 2.7.1. - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](https://github.com/pydantic/pydantic/compare/v2.7.0...v2.7.1) --- updated-dependencies: - dependency-name: pydantic dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 170 ++++++++++++++++++++++++++-------------------------- 1 file changed, 86 insertions(+), 84 deletions(-) diff --git a/poetry.lock b/poetry.lock index d47650bf4..f740cc616 100644 --- a/poetry.lock +++ b/poetry.lock @@ -942,6 +942,7 @@ files = [ {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0"}, {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1"}, {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863"}, + {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6241d4eee5f89453307c2f2bfa03b50362052ca0af1efecf9fef9a41a22bb4f"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218"}, @@ -1564,18 +1565,18 @@ files = [ [[package]] name = "pydantic" -version = "2.7.0" +version = "2.7.1" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"}, - {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"}, + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.1" +pydantic-core = "2.18.2" typing-extensions = ">=4.6.1" [package.extras] @@ -1583,90 +1584,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.1" +version = "2.18.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"}, - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"}, - {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"}, - {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"}, - {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"}, - {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"}, - {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"}, - {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"}, - {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"}, - {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"}, - {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"}, - {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"}, - {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, ] [package.dependencies] @@ -2109,6 +2110,7 @@ optional = false python-versions = "*" files = [ {file = "requests-file-2.0.0.tar.gz", hash = "sha256:20c5931629c558fda566cacc10cfe2cd502433e628f568c34c80d96a0cc95972"}, + {file = "requests_file-2.0.0-py2.py3-none-any.whl", hash = "sha256:3e493d390adb44aa102ebea827a48717336d5268968c370eaf19abaf5cae13bf"}, ] [package.dependencies] From 24a2fd3b91d253092ed0d13de6fcd784436995f0 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 08:22:19 -0400 Subject: [PATCH 021/220] remove DNS_NAME_UNRESOLVED exception for targets --- bbot/modules/internal/dns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/internal/dns.py b/bbot/modules/internal/dns.py index b96b9b19c..29e060626 100644 --- a/bbot/modules/internal/dns.py +++ b/bbot/modules/internal/dns.py @@ -164,7 +164,7 @@ async def handle_event(self, event, kwargs): event.add_tag(tag) # If the event is unresolved, change its type to DNS_NAME_UNRESOLVED - if event.type == "DNS_NAME" and "unresolved" in event.tags and not "target" in event.tags: + if event.type == "DNS_NAME" and "unresolved" in event.tags: event.type = "DNS_NAME_UNRESOLVED" # speculate DNS_NAMES and IP_ADDRESSes from other event types From bc0786a6b01e11ddbf46d174f588fe816556edc7 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 08:35:01 -0400 Subject: [PATCH 022/220] always emit targets --- bbot/modules/output/base.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index 9459a880f..d0365f079 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -33,10 +33,14 @@ def _event_precheck(self, event): if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: return False, "its extension was listed in url_extension_httpx_only" - # output module specific stuff - # omitted events such as HTTP_RESPONSE etc. - if event._omit and not event.type in self.get_watched_events(): - return False, "_omit is True" + ### begin output-module specific ### + + # events from omit_event_types, e.g. HTTP_RESPONSE, DNS_NAME_UNRESOLVED, etc. + # if the output module specifically requests a certain event type, we let it through anyway + # always_emit overrides _omit. + if event._omit: + if not event.always_emit and not event.type in self.get_watched_events(): + return False, "_omit is True" # force-output certain events to the graph if self._is_graph_important(event): From 0301442ff314c1f2bec451fa4b79b8b61220b238 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 08:37:09 -0400 Subject: [PATCH 023/220] targets only --- bbot/modules/output/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index d0365f079..c9a41eebd 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -37,9 +37,9 @@ def _event_precheck(self, event): # events from omit_event_types, e.g. HTTP_RESPONSE, DNS_NAME_UNRESOLVED, etc. # if the output module specifically requests a certain event type, we let it through anyway - # always_emit overrides _omit. + # an exception is also made for targets if event._omit: - if not event.always_emit and not event.type in self.get_watched_events(): + if not "target" in event.tags and not event.type in self.get_watched_events(): return False, "_omit is True" # force-output certain events to the graph From c53d57875644a6e9aad1658f3b896448cb1e7918 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Mon, 29 Apr 2024 16:42:20 +0100 Subject: [PATCH 024/220] Inital add --- bbot/modules/github_workflows.py | 144 +++++ .../test_module_github_workflows.py | 573 ++++++++++++++++++ 2 files changed, 717 insertions(+) create mode 100644 bbot/modules/github_workflows.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_github_workflows.py diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py new file mode 100644 index 000000000..5719153c2 --- /dev/null +++ b/bbot/modules/github_workflows.py @@ -0,0 +1,144 @@ +from bbot.modules.templates.github import github + + +class github_workflows(github): + watched_events = ["CODE_REPOSITORY"] + produced_events = ["HTTP_RESPONSE"] + flags = ["passive", "safe"] + meta = {"description": "Query Github's API for the repositories workflow logs"} + options = {"api_key": ""} + options_desc = { + "api_key": "Github token", + } + + # scope_distance_modifier = 2 + + async def setup(self): + return await super().setup() + + async def filter_event(self, event): + if event.type == "CODE_REPOSITORY": + if "git" not in event.tags: + return False, "event is not a git repository" + return True + + async def handle_event(self, event): + # repo_url = event.data.get("url") + owner = "blacklanternsecurity" + repo = "bbot" + for workflow in await self.get_workflows(owner, repo): + workflow_name = workflow.get("name") + workflow_id = workflow.get("id") + self.log.debug(f"Looking up runs for {workflow_name} in {owner}/{repo}") + for run in await self.get_workflow_runs(owner, repo, workflow_id): + run_id = run.get("id") + self.log.debug(f"Looking up jobs for {workflow_name}/{run_id} in {owner}/{repo}") + for job in await self.get_run_jobs(owner, repo, run_id): + job_id = job.get("id") + commit_id = job.get("head_sha") + steps = job.get("steps", []) + for step in steps: + if step.get("conclusion") == "success": + step_name = step.get("name") + number = step.get("number") + self.log.debug( + f"Requesting {workflow_name}/run {run_id}/job {job_id}/{step_name} log for {owner}/{repo}" + ) + # Request log step from the html_url as that bypasses the admin restrictions from using the API + response = await self.helpers.request( + f"https://github.com/{owner}/{repo}/commit/{commit_id}/checks/{job_id}/logs/{number}", + follow_redirects=True, + ) + if response: + blob_url = response.headers.get("Location", "") + if blob_url: + url_event = self.make_event( + blob_url, "URL_UNVERIFIED", source=event, tags=["httpx-safe"] + ) + if not url_event: + continue + url_event.scope_distance = event.scope_distance + await self.emit_event(url_event) + + async def get_workflows(self, owner, repo): + workflows = [] + url = f"{self.base_url}/repos/{owner}/{repo}/actions/workflows?per_page=100&page=" + "{page}" + agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) + try: + async for r in agen: + if r is None: + break + status_code = getattr(r, "status_code", 0) + if status_code == 403: + self.warning("Github is rate-limiting us (HTTP status: 403)") + break + if status_code != 200: + break + try: + j = r.json() + except Exception as e: + self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") + break + if not j: + break + for item in j.get("workflows", []): + workflows.append(item) + finally: + agen.aclose() + return workflows + + async def get_workflow_runs(self, owner, repo, workflow_id): + runs = [] + url = ( + f"{self.base_url}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs?per_page=100&page=" + "{page}" + ) + agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) + try: + async for r in agen: + if r is None: + break + status_code = getattr(r, "status_code", 0) + if status_code == 403: + self.warning("Github is rate-limiting us (HTTP status: 403)") + break + if status_code != 200: + break + try: + j = r.json() + except Exception as e: + self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") + break + if not j: + break + for item in j.get("workflow_runs", []): + runs.append(item) + finally: + agen.aclose() + return runs + + async def get_run_jobs(self, owner, repo, run_id): + jobs = [] + url = f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100&page=" + "{page}" + agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) + try: + async for r in agen: + if r is None: + break + status_code = getattr(r, "status_code", 0) + if status_code == 403: + self.warning("Github is rate-limiting us (HTTP status: 403)") + break + if status_code != 200: + break + try: + j = r.json() + except Exception as e: + self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") + break + if not j: + break + for item in j.get("jobs", []): + jobs.append(item) + finally: + agen.aclose() + return jobs diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py new file mode 100644 index 000000000..ee2f93a49 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -0,0 +1,573 @@ +from .base import ModuleTestBase + + +class TestGithub_Workflows(ModuleTestBase): + config_overrides = {"modules": {"github_org": {"api_key": "asdf"}}} + modules_overrides = ["github_workflows", "github_org", "speculate"] + + async def setup_before_prep(self, module_test): + module_test.httpx_mock.add_response(url="https://api.github.com/zen") + module_test.httpx_mock.add_response( + url="https://api.github.com/orgs/blacklanternsecurity", + json={ + "login": "blacklanternsecurity", + "id": 25311592, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI1MzExNTky", + "url": "https://api.github.com/orgs/blacklanternsecurity", + "repos_url": "https://api.github.com/orgs/blacklanternsecurity/repos", + "events_url": "https://api.github.com/orgs/blacklanternsecurity/events", + "hooks_url": "https://api.github.com/orgs/blacklanternsecurity/hooks", + "issues_url": "https://api.github.com/orgs/blacklanternsecurity/issues", + "members_url": "https://api.github.com/orgs/blacklanternsecurity/members{/member}", + "public_members_url": "https://api.github.com/orgs/blacklanternsecurity/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/25311592?v=4", + "description": "Security Organization", + "name": "Black Lantern Security", + "company": None, + "blog": "www.blacklanternsecurity.com", + "location": "Charleston, SC", + "email": None, + "twitter_username": None, + "is_verified": False, + "has_organization_projects": True, + "has_repository_projects": True, + "public_repos": 70, + "public_gists": 0, + "followers": 415, + "following": 0, + "html_url": "https://github.com/blacklanternsecurity", + "created_at": "2017-01-24T00:14:46Z", + "updated_at": "2022-03-28T11:39:03Z", + "archived_at": None, + "type": "Organization", + }, + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/orgs/blacklanternsecurity/repos?per_page=100&page=1", + json=[ + { + "id": 459780477, + "node_id": "R_kgDOG2exfQ", + "name": "test_keys", + "full_name": "blacklanternsecurity/test_keys", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 79229934, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjc5MjI5OTM0", + "avatar_url": "https://avatars.githubusercontent.com/u/79229934?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/test_keys", + "description": None, + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/test_keys", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/test_keys/deployments", + "created_at": "2022-02-15T23:10:51Z", + "updated_at": "2023-09-02T12:20:13Z", + "pushed_at": "2023-10-19T02:56:46Z", + "git_url": "git://github.com/blacklanternsecurity/test_keys.git", + "ssh_url": "git@github.com:blacklanternsecurity/test_keys.git", + "clone_url": "https://github.com/blacklanternsecurity/test_keys.git", + "svn_url": "https://github.com/blacklanternsecurity/test_keys", + "homepage": None, + "size": 2, + "stargazers_count": 2, + "watchers_count": 2, + "language": None, + "has_issues": True, + "has_projects": True, + "has_downloads": True, + "has_wiki": True, + "has_pages": False, + "has_discussions": False, + "forks_count": 32, + "mirror_url": None, + "archived": False, + "disabled": False, + "open_issues_count": 2, + "license": None, + "allow_forking": True, + "is_template": False, + "web_commit_signoff_required": False, + "topics": [], + "visibility": "public", + "forks": 32, + "open_issues": 2, + "watchers": 2, + "default_branch": "main", + "permissions": {"admin": False, "maintain": False, "push": False, "triage": False, "pull": True}, + } + ], + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows?per_page=100&page=1", + json={ + "total_count": 3, + "workflows": [ + { + "id": 22452226, + "node_id": "W_kwDOG_O3ns4BVpgC", + "name": "tests", + "path": ".github/workflows/tests.yml", + "state": "active", + "created_at": "2022-03-23T15:09:22.000Z", + "updated_at": "2022-09-27T17:49:34.000Z", + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226", + "html_url": "https://github.com/blacklanternsecurity/bbot/blob/stable/.github/workflows/tests.yml", + "badge_url": "https://github.com/blacklanternsecurity/bbot/workflows/tests/badge.svg", + }, + ], + }, + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?per_page=100&page=1", + json={ + "total_count": 2993, + "workflow_runs": [ + { + "id": 8839360698, + "name": "tests", + "node_id": "WFR_kwLOG_O3ns8AAAACDt3wug", + "head_branch": "dnsbrute-helperify", + "head_sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "path": ".github/workflows/tests.yml", + "display_title": "Helperify Massdns", + "run_number": 4520, + "event": "pull_request", + "status": "completed", + "conclusion": "failure", + "workflow_id": 22452226, + "check_suite_id": 23162098295, + "check_suite_node_id": "CS_kwDOG_O3ns8AAAAFZJGSdw", + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698", + "html_url": "https://github.com/blacklanternsecurity/bbot/actions/runs/8839360698", + "pull_requests": [ + { + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls/1303", + "id": 1839332952, + "number": 1303, + "head": { + "ref": "dnsbrute-helperify", + "sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "repo": { + "id": 468957086, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "name": "bbot", + }, + }, + "base": { + "ref": "faster-regexes", + "sha": "7baf219c7f3a4ba165639c5ddb62322453a8aea8", + "repo": { + "id": 468957086, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "name": "bbot", + }, + }, + } + ], + "created_at": "2024-04-25T21:04:32Z", + "updated_at": "2024-04-25T21:19:43Z", + "actor": { + "login": "TheTechromancer", + "id": 20261699, + "node_id": "MDQ6VXNlcjIwMjYxNjk5", + "avatar_url": "https://avatars.githubusercontent.com/u/20261699?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheTechromancer", + "html_url": "https://github.com/TheTechromancer", + "followers_url": "https://api.github.com/users/TheTechromancer/followers", + "following_url": "https://api.github.com/users/TheTechromancer/following{/other_user}", + "gists_url": "https://api.github.com/users/TheTechromancer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheTechromancer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheTechromancer/subscriptions", + "organizations_url": "https://api.github.com/users/TheTechromancer/orgs", + "repos_url": "https://api.github.com/users/TheTechromancer/repos", + "events_url": "https://api.github.com/users/TheTechromancer/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheTechromancer/received_events", + "type": "User", + "site_admin": False, + }, + "run_attempt": 1, + "referenced_workflows": [], + "run_started_at": "2024-04-25T21:04:32Z", + "triggering_actor": { + "login": "TheTechromancer", + "id": 20261699, + "node_id": "MDQ6VXNlcjIwMjYxNjk5", + "avatar_url": "https://avatars.githubusercontent.com/u/20261699?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheTechromancer", + "html_url": "https://github.com/TheTechromancer", + "followers_url": "https://api.github.com/users/TheTechromancer/followers", + "following_url": "https://api.github.com/users/TheTechromancer/following{/other_user}", + "gists_url": "https://api.github.com/users/TheTechromancer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheTechromancer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheTechromancer/subscriptions", + "organizations_url": "https://api.github.com/users/TheTechromancer/orgs", + "repos_url": "https://api.github.com/users/TheTechromancer/repos", + "events_url": "https://api.github.com/users/TheTechromancer/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheTechromancer/received_events", + "type": "User", + "site_admin": False, + }, + "jobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/jobs", + "logs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/logs", + "check_suite_url": "https://api.github.com/repos/blacklanternsecurity/bbot/check-suites/23162098295", + "artifacts_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/artifacts", + "cancel_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/cancel", + "rerun_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/rerun", + "previous_attempt_url": None, + "workflow_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226", + "head_commit": { + "id": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "tree_id": "fe9b345c0745a5bbacb806225e92e1c48fccf35c", + "message": "remove debug message", + "timestamp": "2024-04-25T21:02:37Z", + "author": {"name": "TheTechromancer", "email": "thetechromancer@protonmail.com"}, + "committer": {"name": "TheTechromancer", "email": "thetechromancer@protonmail.com"}, + }, + "repository": { + "id": 468957086, + "node_id": "R_kgDOG_O3ng", + "name": "bbot", + "full_name": "blacklanternsecurity/bbot", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 25311592, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI1MzExNTky", + "avatar_url": "https://avatars.githubusercontent.com/u/25311592?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/bbot", + "description": "A recursive internet scanner for hackers.", + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/bbot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/bbot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/bbot/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/bbot/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/bbot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/bbot/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/bbot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/bbot/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/bbot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/bbot/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/bbot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/bbot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/bbot/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/bbot/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/deployments", + }, + "head_repository": { + "id": 468957086, + "node_id": "R_kgDOG_O3ng", + "name": "bbot", + "full_name": "blacklanternsecurity/bbot", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 25311592, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI1MzExNTky", + "avatar_url": "https://avatars.githubusercontent.com/u/25311592?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/bbot", + "description": "A recursive internet scanner for hackers.", + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/bbot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/bbot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/bbot/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/bbot/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/bbot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/bbot/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/bbot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/bbot/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/bbot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/bbot/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/bbot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/bbot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/bbot/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/bbot/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/deployments", + }, + }, + ], + }, + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/jobs?per_page=100&page=1", + json={ + "total_count": 8, + "jobs": [ + { + "id": 24272553740, + "run_id": 8839360698, + "workflow_name": "tests", + "head_branch": "dnsbrute-helperify", + "run_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698", + "run_attempt": 1, + "node_id": "CR_kwDOG_O3ns8AAAAFpsHHDA", + "head_sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/jobs/24272553740", + "html_url": "https://github.com/blacklanternsecurity/bbot/actions/runs/8839360698/job/24272553740", + "status": "completed", + "conclusion": "success", + "created_at": "2024-04-25T21:04:54Z", + "started_at": "2024-04-25T21:05:01Z", + "completed_at": "2024-04-25T21:05:18Z", + "name": "lint", + "steps": [ + { + "name": "Set up job", + "status": "completed", + "conclusion": "success", + "number": 1, + "started_at": "2024-04-25T21:05:00.000Z", + "completed_at": "2024-04-25T21:05:02.000Z", + }, + { + "name": "Run actions/checkout@v3", + "status": "completed", + "conclusion": "success", + "number": 2, + "started_at": "2024-04-25T21:05:02.000Z", + "completed_at": "2024-04-25T21:05:04.000Z", + }, + { + "name": "Run psf/black@stable", + "status": "completed", + "conclusion": "success", + "number": 3, + "started_at": "2024-04-25T21:05:04.000Z", + "completed_at": "2024-04-25T21:05:10.000Z", + }, + { + "name": "Install Python 3", + "status": "completed", + "conclusion": "success", + "number": 4, + "started_at": "2024-04-25T21:05:10.000Z", + "completed_at": "2024-04-25T21:05:11.000Z", + }, + { + "name": "Install dependencies", + "status": "completed", + "conclusion": "success", + "number": 5, + "started_at": "2024-04-25T21:05:11.000Z", + "completed_at": "2024-04-25T21:05:12.000Z", + }, + { + "name": "flake8", + "status": "completed", + "conclusion": "success", + "number": 6, + "started_at": "2024-04-25T21:05:12.000Z", + "completed_at": "2024-04-25T21:05:14.000Z", + }, + { + "name": "Post Install Python 3", + "status": "completed", + "conclusion": "success", + "number": 11, + "started_at": "2024-04-25T21:05:15.000Z", + "completed_at": "2024-04-25T21:05:15.000Z", + }, + { + "name": "Post Run actions/checkout@v3", + "status": "completed", + "conclusion": "success", + "number": 12, + "started_at": "2024-04-25T21:05:15.000Z", + "completed_at": "2024-04-25T21:05:15.000Z", + }, + { + "name": "Complete job", + "status": "completed", + "conclusion": "success", + "number": 13, + "started_at": "2024-04-25T21:05:15.000Z", + "completed_at": "2024-04-25T21:05:15.000Z", + }, + ], + "check_run_url": "https://api.github.com/repos/blacklanternsecurity/bbot/check-runs/24272553740", + "labels": ["ubuntu-latest"], + "runner_id": 60, + "runner_name": "GitHub Actions 60", + "runner_group_id": 2, + "runner_group_name": "GitHub Actions", + }, + ], + }, + ) + module_test.httpx_mock.add_response( + url="https://github.com/blacklanternsecurity/bbot/commit/c5de1360e8e5ccba04b23035f675a529282b7dc2/checks/24272553740/logs/1", + headers={ + "location": "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" + }, + status_code=302, + ) + + def check(self, module_test, events): + assert len(events) == 5 + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" and e.data == "blacklanternsecurity.com" and e.scope_distance == 0 + ] + ), "Failed to emit target DNS_NAME" + assert 1 == len( + [e for e in events if e.type == "ORG_STUB" and e.data == "blacklanternsecurity" and e.scope_distance == 1] + ), "Failed to find ORG_STUB" + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and "github-org" in e.tags + and e.scope_distance == 1 + ] + ), "Failed to find blacklanternsecurity github" + assert 1 == len( + [ + e + for e in events + if e.type == "CODE_REPOSITORY" + and "git" in e.tags + and e.data["url"] == "https://github.com/blacklanternsecurity/test_keys" + and e.scope_distance == 1 + ] + ), "Failed to find blacklanternsecurity github repo" + assert 1 == len( + [ + e + for e in events + if e.type == "URL_UNVERIFIED" + and e.data + == "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" + and e.scope_distance == 1 + ] + ), "Failed to obtain redirect to the blob" From 9b944542a7a001f9b549e40a5201f8d81d5025d6 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Mon, 29 Apr 2024 18:03:32 +0100 Subject: [PATCH 025/220] Changed produced event to `FILESYSTEM` --- bbot/modules/github_workflows.py | 90 +++++-------- .../test_module_github_workflows.py | 118 ++---------------- 2 files changed, 41 insertions(+), 167 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 5719153c2..08ca162c5 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -1,19 +1,25 @@ +from datetime import date, timedelta + from bbot.modules.templates.github import github class github_workflows(github): watched_events = ["CODE_REPOSITORY"] - produced_events = ["HTTP_RESPONSE"] + produced_events = ["FILESYSTEM"] flags = ["passive", "safe"] - meta = {"description": "Query Github's API for the repositories workflow logs"} - options = {"api_key": ""} + meta = {"description": "Download a github repositories workflow logs"} + options = {"api_key": "", "historical_logs": 7} options_desc = { "api_key": "Github token", + "historical_logs": "Fetch logs that are at most this many days old (default: 7)", } # scope_distance_modifier = 2 async def setup(self): + self.historical_logs = int(self.options.get("historical_logs", 7)) + self.output_dir = self.scan.home / "workflow_logs" + self.helpers.mkdir(self.output_dir) return await super().setup() async def filter_event(self, event): @@ -32,33 +38,15 @@ async def handle_event(self, event): self.log.debug(f"Looking up runs for {workflow_name} in {owner}/{repo}") for run in await self.get_workflow_runs(owner, repo, workflow_id): run_id = run.get("id") - self.log.debug(f"Looking up jobs for {workflow_name}/{run_id} in {owner}/{repo}") - for job in await self.get_run_jobs(owner, repo, run_id): - job_id = job.get("id") - commit_id = job.get("head_sha") - steps = job.get("steps", []) - for step in steps: - if step.get("conclusion") == "success": - step_name = step.get("name") - number = step.get("number") - self.log.debug( - f"Requesting {workflow_name}/run {run_id}/job {job_id}/{step_name} log for {owner}/{repo}" - ) - # Request log step from the html_url as that bypasses the admin restrictions from using the API - response = await self.helpers.request( - f"https://github.com/{owner}/{repo}/commit/{commit_id}/checks/{job_id}/logs/{number}", - follow_redirects=True, - ) - if response: - blob_url = response.headers.get("Location", "") - if blob_url: - url_event = self.make_event( - blob_url, "URL_UNVERIFIED", source=event, tags=["httpx-safe"] - ) - if not url_event: - continue - url_event.scope_distance = event.scope_distance - await self.emit_event(url_event) + self.log.debug(f"Downloading logs for {workflow_name}/{run_id} in {owner}/{repo}") + log_path = await self.download_run_logs(owner, repo, run_id) + if log_path: + self.verbose(f"Downloaded repository workflow logs to {log_path}") + logfile_event = self.make_event( + {"path": str(log_path)}, "FILESYSTEM", tags=["zipfile"], source=event + ) + logfile_event.scope_distance = event.scope_distance + await self.emit_event(logfile_event) async def get_workflows(self, owner, repo): workflows = [] @@ -89,8 +77,11 @@ async def get_workflows(self, owner, repo): async def get_workflow_runs(self, owner, repo, workflow_id): runs = [] + created_date = date.today() - timedelta(days=self.historical_logs) + formated_date = created_date.strftime("%Y-%m-%d") url = ( - f"{self.base_url}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs?per_page=100&page=" + "{page}" + f"{self.base_url}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs?created=>{formated_date}&per_page=100&page=" + + "{page}" ) agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) try: @@ -116,29 +107,14 @@ async def get_workflow_runs(self, owner, repo, workflow_id): agen.aclose() return runs - async def get_run_jobs(self, owner, repo, run_id): - jobs = [] - url = f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100&page=" + "{page}" - agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) - try: - async for r in agen: - if r is None: - break - status_code = getattr(r, "status_code", 0) - if status_code == 403: - self.warning("Github is rate-limiting us (HTTP status: 403)") - break - if status_code != 200: - break - try: - j = r.json() - except Exception as e: - self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") - break - if not j: - break - for item in j.get("jobs", []): - jobs.append(item) - finally: - agen.aclose() - return jobs + async def download_run_logs(self, owner, repo, run_id): + file_destination = self.output_dir / f"{owner}_{repo}_run_{run_id}.zip" + result = await self.helpers.download( + f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", filename=file_destination + ) + if result: + self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") + return file_destination + else: + self.warning(f"Failed to download logs for {owner}/{repo}/{run_id}") + return None diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index ee2f93a49..ed4ce921b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -415,118 +415,16 @@ async def setup_before_prep(self, module_test): }, ) module_test.httpx_mock.add_response( - url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/jobs?per_page=100&page=1", - json={ - "total_count": 8, - "jobs": [ - { - "id": 24272553740, - "run_id": 8839360698, - "workflow_name": "tests", - "head_branch": "dnsbrute-helperify", - "run_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698", - "run_attempt": 1, - "node_id": "CR_kwDOG_O3ns8AAAAFpsHHDA", - "head_sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", - "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/jobs/24272553740", - "html_url": "https://github.com/blacklanternsecurity/bbot/actions/runs/8839360698/job/24272553740", - "status": "completed", - "conclusion": "success", - "created_at": "2024-04-25T21:04:54Z", - "started_at": "2024-04-25T21:05:01Z", - "completed_at": "2024-04-25T21:05:18Z", - "name": "lint", - "steps": [ - { - "name": "Set up job", - "status": "completed", - "conclusion": "success", - "number": 1, - "started_at": "2024-04-25T21:05:00.000Z", - "completed_at": "2024-04-25T21:05:02.000Z", - }, - { - "name": "Run actions/checkout@v3", - "status": "completed", - "conclusion": "success", - "number": 2, - "started_at": "2024-04-25T21:05:02.000Z", - "completed_at": "2024-04-25T21:05:04.000Z", - }, - { - "name": "Run psf/black@stable", - "status": "completed", - "conclusion": "success", - "number": 3, - "started_at": "2024-04-25T21:05:04.000Z", - "completed_at": "2024-04-25T21:05:10.000Z", - }, - { - "name": "Install Python 3", - "status": "completed", - "conclusion": "success", - "number": 4, - "started_at": "2024-04-25T21:05:10.000Z", - "completed_at": "2024-04-25T21:05:11.000Z", - }, - { - "name": "Install dependencies", - "status": "completed", - "conclusion": "success", - "number": 5, - "started_at": "2024-04-25T21:05:11.000Z", - "completed_at": "2024-04-25T21:05:12.000Z", - }, - { - "name": "flake8", - "status": "completed", - "conclusion": "success", - "number": 6, - "started_at": "2024-04-25T21:05:12.000Z", - "completed_at": "2024-04-25T21:05:14.000Z", - }, - { - "name": "Post Install Python 3", - "status": "completed", - "conclusion": "success", - "number": 11, - "started_at": "2024-04-25T21:05:15.000Z", - "completed_at": "2024-04-25T21:05:15.000Z", - }, - { - "name": "Post Run actions/checkout@v3", - "status": "completed", - "conclusion": "success", - "number": 12, - "started_at": "2024-04-25T21:05:15.000Z", - "completed_at": "2024-04-25T21:05:15.000Z", - }, - { - "name": "Complete job", - "status": "completed", - "conclusion": "success", - "number": 13, - "started_at": "2024-04-25T21:05:15.000Z", - "completed_at": "2024-04-25T21:05:15.000Z", - }, - ], - "check_run_url": "https://api.github.com/repos/blacklanternsecurity/bbot/check-runs/24272553740", - "labels": ["ubuntu-latest"], - "runner_id": 60, - "runner_name": "GitHub Actions 60", - "runner_group_id": 2, - "runner_group_name": "GitHub Actions", - }, - ], - }, - ) - module_test.httpx_mock.add_response( - url="https://github.com/blacklanternsecurity/bbot/commit/c5de1360e8e5ccba04b23035f675a529282b7dc2/checks/24272553740/logs/1", + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/logs", headers={ "location": "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" }, status_code=302, ) + module_test.httpx_mock.add_response( + url="https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02", + content=self.zip_content, + ) def check(self, module_test, events): assert len(events) == 5 @@ -565,9 +463,9 @@ def check(self, module_test, events): [ e for e in events - if e.type == "URL_UNVERIFIED" - and e.data - == "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" + if e.type == "FILESYSTEM" + and "blacklanternsecurity/bbot/run_8839360698.zip" in e.data + and "zipfile" in e.tags and e.scope_distance == 1 ] ), "Failed to obtain redirect to the blob" From 8aacd711de1b8fc4330c8c2001ac202ee4dd67e6 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 13:07:04 -0400 Subject: [PATCH 026/220] fix tests --- bbot/test/test_step_1/test_manager_deduplication.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bbot/test/test_step_1/test_manager_deduplication.py b/bbot/test/test_step_1/test_manager_deduplication.py index a1d7f8596..3f88576f9 100644 --- a/bbot/test/test_step_1/test_manager_deduplication.py +++ b/bbot/test/test_step_1/test_manager_deduplication.py @@ -75,6 +75,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) ) dns_mock_chain = { + "test.notreal": {"A": ["127.0.0.3"]}, "default_module.test.notreal": {"A": ["127.0.0.3"]}, "everything_module.test.notreal": {"A": ["127.0.0.4"]}, "no_suppress_dupes.test.notreal": {"A": ["127.0.0.5"]}, @@ -116,7 +117,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in default_events if e.type == "DNS_NAME" and e.data == "per_hostport_only.test.notreal" and str(e.module) == "per_hostport_only"]) assert 1 == len([e for e in default_events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.source.data]) - assert len(all_events) == 26 + assert len(all_events) == 27 assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "accept_dupes.test.notreal" and str(e.module) == "accept_dupes"]) assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "default_module.test.notreal" and str(e.module) == "default_module"]) assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes" and e.source.data == "accept_dupes.test.notreal"]) @@ -127,6 +128,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "per_domain_only.test.notreal" and str(e.module) == "per_domain_only"]) assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "per_hostport_only.test.notreal" and str(e.module) == "per_hostport_only"]) assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.source.data]) + assert 1 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.3" and str(e.module) == "A" and e.source.data == "test.notreal"]) assert 1 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.3" and str(e.module) == "A" and e.source.data == "default_module.test.notreal"]) assert 1 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.5" and str(e.module) == "A" and e.source.data == "no_suppress_dupes.test.notreal"]) assert 1 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.6" and str(e.module) == "A" and e.source.data == "accept_dupes.test.notreal"]) From 7078612a3b2a2193fcc3821ba96c7270f58b4f5f Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Mon, 29 Apr 2024 18:14:26 +0100 Subject: [PATCH 027/220] Changed to grab owner and repo from repo_url --- bbot/modules/github_workflows.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 08ca162c5..a018adfa1 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -24,14 +24,14 @@ async def setup(self): async def filter_event(self, event): if event.type == "CODE_REPOSITORY": - if "git" not in event.tags: + if "git" not in event.tags and "github" not in event.data.get("url", ""): return False, "event is not a git repository" return True async def handle_event(self, event): - # repo_url = event.data.get("url") - owner = "blacklanternsecurity" - repo = "bbot" + repo_url = event.data.get("url") + owner = repo_url.split("/")[-2] + repo = repo_url.split("/")[-1] for workflow in await self.get_workflows(owner, repo): workflow_name = workflow.get("name") workflow_id = workflow.get("id") From cdeb6a14ec917b196e5bacfd72655c81a1881e4d Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 13:30:41 -0400 Subject: [PATCH 028/220] fix azure realm tests --- bbot/test/test_step_2/module_tests/test_module_azure_realm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/test/test_step_2/module_tests/test_module_azure_realm.py b/bbot/test/test_step_2/module_tests/test_module_azure_realm.py index 7ab5463c1..8f5cbcedf 100644 --- a/bbot/test/test_step_2/module_tests/test_module_azure_realm.py +++ b/bbot/test/test_step_2/module_tests/test_module_azure_realm.py @@ -20,6 +20,7 @@ class TestAzure_Realm(ModuleTestBase): } async def setup_after_prep(self, module_test): + await module_test.mock_dns({"evilcorp.com": {"A": ["127.0.0.5"]}}) module_test.httpx_mock.add_response( url=f"https://login.microsoftonline.com/getuserrealm.srf?login=test@evilcorp.com", json=self.response_json, From 5f3948a6e7f8e13505f220ca9579c763d7e832ab Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 16:14:18 -0400 Subject: [PATCH 029/220] fix dnsbrute tests, fix https://github.com/blacklanternsecurity/bbot/issues/1315 --- bbot/modules/dnsbrute.py | 6 +- bbot/modules/templates/subdomain_enum.py | 41 +++++-------- .../module_tests/test_module_dnsbrute.py | 61 ++++++++++++------- 3 files changed, 57 insertions(+), 51 deletions(-) diff --git a/bbot/modules/dnsbrute.py b/bbot/modules/dnsbrute.py index 8dc014084..429a55620 100644 --- a/bbot/modules/dnsbrute.py +++ b/bbot/modules/dnsbrute.py @@ -16,7 +16,7 @@ class dnsbrute(subdomain_enum): } deps_common = ["massdns"] reject_wildcards = "strict" - dedup_strategy = "parent_domain" + dedup_strategy = "lowest_parent" _qsize = 10000 async def setup(self): @@ -25,8 +25,8 @@ async def setup(self): self.subdomain_list = set(self.helpers.read_file(self.subdomain_file)) return await super().setup() - async def eligible_for_enumeration(self, event): - eligible, reason = await super().eligible_for_enumeration(event) + async def filter_event(self, event): + eligible, reason = await super().filter_event(event) query = self.make_query(event) # limit brute force depth diff --git a/bbot/modules/templates/subdomain_enum.py b/bbot/modules/templates/subdomain_enum.py index 3c65dfa34..18243c393 100644 --- a/bbot/modules/templates/subdomain_enum.py +++ b/bbot/modules/templates/subdomain_enum.py @@ -26,28 +26,9 @@ class subdomain_enum(BaseModule): # how to deduplicate incoming events # options: - # "root_domain": if a dns name has already been tried, don't try any of its children - # "parent_domain": always try a domain unless its direct parent has already been tried - dedup_strategy = "root_domain" - - async def setup(self): - strict_scope = self.dedup_strategy == "parent_domain" - self.processed = self.helpers.make_target(strict_scope=strict_scope) - return True - - async def filter_event(self, event): - """ - This filter_event is used across many modules - """ - query = self.make_query(event) - # reject if already processed - if query in self.processed: - return False, "Event was already processed" - eligible, reason = await self.eligible_for_enumeration(event) - if eligible: - self.processed.add(query) - return True, reason - return False, reason + # "highest_parent": dedupe by highest parent (highest parent of www.api.test.evilcorp.com is evilcorp.com) + # "lowest_parent": dedupe by lowest parent (lowest parent of www.api.test.evilcorp.com is api.test.evilcorp.com) + dedup_strategy = "highest_parent" async def handle_event(self, event): query = self.make_query(event) @@ -68,10 +49,18 @@ async def request_url(self, query): return await self.request_with_fail_count(url) def make_query(self, event): - if "target" in event.tags: - query = str(event.data) + query = event.data + parents = list(self.helpers.domain_parents(event.data)) + if self.dedup_strategy == "highest_parent": + parents = list(reversed(parents)) + elif self.dedup_strategy == "lowest_parent": + pass else: - query = self.helpers.parent_domain(event.data).lower() + raise ValueError('self.dedup_strategy attribute must be set to either "highest_parent" or "lowest_parent"') + for p in parents: + if self.scan.in_scope(p): + query = p + break return ".".join([s for s in query.split(".") if s != "_wildcard"]) def parse_results(self, r, query=None): @@ -114,7 +103,7 @@ async def _is_wildcard(self, query): return True return False - async def eligible_for_enumeration(self, event): + async def filter_event(self, event): query = self.make_query(event) # check if wildcard is_wildcard = await self._is_wildcard(query) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py index fab736cca..bdbd2f6cb 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py @@ -3,6 +3,7 @@ class TestDnsbrute(ModuleTestBase): subdomain_wordlist = tempwordlist(["www", "asdf"]) + blacklist = ["api.asdf.blacklanternsecurity.com"] config_overrides = {"modules": {"dnsbrute": {"wordlist": str(subdomain_wordlist), "max_depth": 3}}} async def setup_after_prep(self, module_test): @@ -24,52 +25,68 @@ async def new_run_live(*command, check=False, text=True, **kwargs): } ) + module = module_test.module + scan = module_test.scan + + # test query logic + event = scan.make_event("blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + event = scan.make_event("asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + event = scan.make_event("api.asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "asdf.blacklanternsecurity.com" + event = scan.make_event("test.api.asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "asdf.blacklanternsecurity.com" + + assert module.dedup_strategy == "lowest_parent" + module.dedup_strategy = "highest_parent" + event = scan.make_event("blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + event = scan.make_event("asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + event = scan.make_event("api.asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + event = scan.make_event("test.api.asdf.blacklanternsecurity.com", "DNS_NAME", dummy=True) + assert module.make_query(event) == "blacklanternsecurity.com" + module.dedup_strategy = "lowest_parent" + # test recursive brute-force event filtering - event = module_test.scan.make_event("evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event = module_test.scan.make_event("blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) assert result == True - assert "evilcorp.com" in module_test.module.processed - assert not "com" in module_test.module.processed - event = module_test.scan.make_event("evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) - event.scope_distance = 0 - result, reason = await module_test.module.filter_event(event) - assert result == False - assert reason == "Event was already processed" - event = module_test.scan.make_event("www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) - event.scope_distance = 0 - result, reason = await module_test.module.filter_event(event) - assert result == False - assert reason == "Event was already processed" - event = module_test.scan.make_event("test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event = module_test.scan.make_event( + "www.blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event + ) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) assert result == True - event = module_test.scan.make_event("test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event) + event = module_test.scan.make_event( + "test.www.blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event + ) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) - assert result == False - assert reason == "Event was already processed" + assert result == True event = module_test.scan.make_event( - "asdf.test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + "asdf.test.www.blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event ) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) assert result == True event = module_test.scan.make_event( - "wat.asdf.test.www.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + "wat.asdf.test.www.blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event ) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) assert result == False - assert reason == f"subdomain depth of *.asdf.test.www.evilcorp.com (4) > max_depth (3)" + assert reason == f"subdomain depth of *.asdf.test.www.blacklanternsecurity.com (4) > max_depth (3)" event = module_test.scan.make_event( - "hmmm.ptr1234.evilcorp.com", "DNS_NAME", source=module_test.scan.root_event + "hmmm.ptr1234.blacklanternsecurity.com", "DNS_NAME", source=module_test.scan.root_event ) event.scope_distance = 0 result, reason = await module_test.module.filter_event(event) assert result == False - assert reason == f'"ptr1234.evilcorp.com" looks like an autogenerated PTR' + assert reason == f'"ptr1234.blacklanternsecurity.com" looks like an autogenerated PTR' def check(self, module_test, events): assert len(events) == 3 From c7a78aeed4fbeeec50448234e4b1025f49d5b723 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Mon, 29 Apr 2024 21:14:55 +0100 Subject: [PATCH 030/220] Break out of api_page_iter properly --- bbot/modules/github_workflows.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index a018adfa1..a151217e9 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -14,7 +14,7 @@ class github_workflows(github): "historical_logs": "Fetch logs that are at most this many days old (default: 7)", } - # scope_distance_modifier = 2 + scope_distance_modifier = 2 async def setup(self): self.historical_logs = int(self.options.get("historical_logs", 7)) @@ -63,13 +63,13 @@ async def get_workflows(self, owner, repo): if status_code != 200: break try: - j = r.json() + j = r.json().get("workflows", []) except Exception as e: self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") break if not j: break - for item in j.get("workflows", []): + for item in j: workflows.append(item) finally: agen.aclose() @@ -95,13 +95,13 @@ async def get_workflow_runs(self, owner, repo, workflow_id): if status_code != 200: break try: - j = r.json() + j = r.json().get("workflow_runs", []) except Exception as e: self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") break if not j: break - for item in j.get("workflow_runs", []): + for item in j: runs.append(item) finally: agen.aclose() From e66d0d054ba26f17f4b19471440564c3003d64d9 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 16:32:10 -0400 Subject: [PATCH 031/220] fix more tests --- bbot/test/test_step_2/module_tests/test_module_baddns_zone.py | 2 +- bbot/test/test_step_2/module_tests/test_module_csv.py | 2 +- bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_baddns_zone.py b/bbot/test/test_step_2/module_tests/test_module_baddns_zone.py index b3810e75a..2e0328320 100644 --- a/bbot/test/test_step_2/module_tests/test_module_baddns_zone.py +++ b/bbot/test/test_step_2/module_tests/test_module_baddns_zone.py @@ -48,7 +48,7 @@ async def setup_after_prep(self, module_test): await module_test.mock_dns( { - "bad.dns": {"NSEC": ["asdf.bad.dns"]}, + "bad.dns": {"A": ["127.0.0.5"], "NSEC": ["asdf.bad.dns"]}, "asdf.bad.dns": {"NSEC": ["zzzz.bad.dns"]}, "zzzz.bad.dns": {"NSEC": ["xyz.bad.dns"]}, } diff --git a/bbot/test/test_step_2/module_tests/test_module_csv.py b/bbot/test/test_step_2/module_tests/test_module_csv.py index 0d6e326a9..9e906cba2 100644 --- a/bbot/test/test_step_2/module_tests/test_module_csv.py +++ b/bbot/test/test_step_2/module_tests/test_module_csv.py @@ -3,7 +3,7 @@ class TestCSV(ModuleTestBase): async def setup_after_prep(self, module_test): - await module_test.mock_dns({}) + await module_test.mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.5"]}}) def check(self, module_test, events): csv_file = module_test.scan.home / "output.csv" diff --git a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py index 5850fbd49..c44e3d90a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py @@ -8,6 +8,7 @@ class TestDNSCommonSRV(ModuleTestBase): async def setup_after_prep(self, module_test): await module_test.mock_dns( { + "blacklanternsecurity.notreal": {"A": ["127.0.0.5"]}, "_ldap._tcp.gc._msdcs.blacklanternsecurity.notreal": { "SRV": ["0 100 3268 asdf.blacklanternsecurity.notreal"] }, From 906b4440ef8bea4efd9700fe7c3e7bfa5040406c Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 29 Apr 2024 16:42:27 -0400 Subject: [PATCH 032/220] fix more tests --- bbot/test/test_step_2/module_tests/test_module_csv.py | 2 +- bbot/test/test_step_2/module_tests/test_module_oauth.py | 1 + .../test/test_step_2/module_tests/test_module_postman.py | 4 +++- .../test_step_2/module_tests/test_module_sitedossier.py | 9 +++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_csv.py b/bbot/test/test_step_2/module_tests/test_module_csv.py index 9e906cba2..6dc3f7cf1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_csv.py +++ b/bbot/test/test_step_2/module_tests/test_module_csv.py @@ -8,4 +8,4 @@ async def setup_after_prep(self, module_test): def check(self, module_test, events): csv_file = module_test.scan.home / "output.csv" with open(csv_file) as f: - assert "DNS_NAME,blacklanternsecurity.com,,TARGET" in f.read() + assert "blacklanternsecurity.com,127.0.0.5,TARGET" in f.read() diff --git a/bbot/test/test_step_2/module_tests/test_module_oauth.py b/bbot/test/test_step_2/module_tests/test_module_oauth.py index eac2db2ac..562f90c84 100644 --- a/bbot/test/test_step_2/module_tests/test_module_oauth.py +++ b/bbot/test/test_step_2/module_tests/test_module_oauth.py @@ -165,6 +165,7 @@ class TestOAUTH(ModuleTestBase): } async def setup_after_prep(self, module_test): + await module_test.mock_dns({"evilcorp.com": {"A": ["127.0.0.1"]}}) module_test.httpx_mock.add_response( url=f"https://login.microsoftonline.com/getuserrealm.srf?login=test@evilcorp.com", json=Azure_Realm.response_json, diff --git a/bbot/test/test_step_2/module_tests/test_module_postman.py b/bbot/test/test_step_2/module_tests/test_module_postman.py index 8e9c0f3bf..a15ac7ede 100644 --- a/bbot/test/test_step_2/module_tests/test_module_postman.py +++ b/bbot/test/test_step_2/module_tests/test_module_postman.py @@ -235,7 +235,9 @@ async def new_emit_event(event_data, event_type, **kwargs): await old_emit_event(event_data, event_type, **kwargs) module_test.monkeypatch.setattr(module_test.module, "emit_event", new_emit_event) - await module_test.mock_dns({"asdf.blacklanternsecurity.com": {"A": ["127.0.0.1"]}}) + await module_test.mock_dns( + {"blacklanternsecurity.com": {"A": ["127.0.0.1"]}, "asdf.blacklanternsecurity.com": {"A": ["127.0.0.1"]}} + ) request_args = dict(uri="/_api/request/28129865-987c8ac8-bfa9-4bab-ade9-88ccf0597862") respond_args = dict(response_data="https://asdf.blacklanternsecurity.com") diff --git a/bbot/test/test_step_2/module_tests/test_module_sitedossier.py b/bbot/test/test_step_2/module_tests/test_module_sitedossier.py index 2156a5ae7..a5b57b800 100644 --- a/bbot/test/test_step_2/module_tests/test_module_sitedossier.py +++ b/bbot/test/test_step_2/module_tests/test_module_sitedossier.py @@ -126,6 +126,15 @@ class TestSitedossier(ModuleTestBase): targets = ["evilcorp.com"] async def setup_after_prep(self, module_test): + await module_test.mock_dns( + { + "evilcorp.com": {"A": ["127.0.0.1"]}, + "asdf.evilcorp.com": {"A": ["127.0.0.1"]}, + "zzzz.evilcorp.com": {"A": ["127.0.0.1"]}, + "xxxx.evilcorp.com": {"A": ["127.0.0.1"]}, + "ffff.evilcorp.com": {"A": ["127.0.0.1"]}, + } + ) module_test.httpx_mock.add_response( url=f"http://www.sitedossier.com/parentdomain/evilcorp.com", text=page1, From 46f1ddd3ee7937d83bb4d5dac8c434bc6afb6a53 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 08:55:29 +0100 Subject: [PATCH 033/220] Add workflow logs into their own folder --- bbot/modules/github_workflows.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index a151217e9..edfd4b403 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -108,9 +108,12 @@ async def get_workflow_runs(self, owner, repo, workflow_id): return runs async def download_run_logs(self, owner, repo, run_id): - file_destination = self.output_dir / f"{owner}_{repo}_run_{run_id}.zip" + folder = self.output_dir / owner / repo + self.helpers.mkdir(folder) + filename = f"run_{run_id}.zip" + file_destination = folder / filename result = await self.helpers.download( - f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", filename=file_destination + f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", filename=file_destination, headers=self.headers ) if result: self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") From 40f99ddeaac075b698e71bc95810a26a422d871d Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 10:19:36 +0100 Subject: [PATCH 034/220] Corrected tests and formatted --- bbot/modules/github_workflows.py | 4 +- .../test_module_github_workflows.py | 50 ++++++++++++------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index edfd4b403..170db1190 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -113,7 +113,9 @@ async def download_run_logs(self, owner, repo, run_id): filename = f"run_{run_id}.zip" file_destination = folder / filename result = await self.helpers.download( - f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", filename=file_destination, headers=self.headers + f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", + filename=file_destination, + headers=self.headers, ) if result: self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index ed4ce921b..18997b910 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -1,3 +1,7 @@ +import io +import zipfile +from pathlib import Path + from .base import ModuleTestBase @@ -5,6 +9,13 @@ class TestGithub_Workflows(ModuleTestBase): config_overrides = {"modules": {"github_org": {"api_key": "asdf"}}} modules_overrides = ["github_workflows", "github_org", "speculate"] + data = io.BytesIO() + with zipfile.ZipFile(data, mode="w", compression=zipfile.ZIP_DEFLATED) as zipfile: + zipfile.writestr("test.txt", "This is some test data") + data.seek(0) + + zip_content = data.getvalue() + async def setup_before_prep(self, module_test): module_test.httpx_mock.add_response(url="https://api.github.com/zen") module_test.httpx_mock.add_response( @@ -71,7 +82,7 @@ async def setup_before_prep(self, module_test): "type": "Organization", "site_admin": False, }, - "html_url": "https://github.com/blacklanternsecurity/test_keys", + "html_url": "https://github.com/blacklanternsecurity/bbot", "description": None, "fork": False, "url": "https://api.github.com/repos/blacklanternsecurity/test_keys", @@ -116,8 +127,8 @@ async def setup_before_prep(self, module_test): "pushed_at": "2023-10-19T02:56:46Z", "git_url": "git://github.com/blacklanternsecurity/test_keys.git", "ssh_url": "git@github.com:blacklanternsecurity/test_keys.git", - "clone_url": "https://github.com/blacklanternsecurity/test_keys.git", - "svn_url": "https://github.com/blacklanternsecurity/test_keys", + "clone_url": "https://github.com/blacklanternsecurity/bbot.git", + "svn_url": "https://github.com/blacklanternsecurity/bbot", "homepage": None, "size": 2, "stargazers_count": 2, @@ -169,7 +180,7 @@ async def setup_before_prep(self, module_test): }, ) module_test.httpx_mock.add_response( - url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?per_page=100&page=1", + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?created=>2024-04-23&per_page=100&page=1", json={ "total_count": 2993, "workflow_runs": [ @@ -415,7 +426,7 @@ async def setup_before_prep(self, module_test): }, ) module_test.httpx_mock.add_response( - url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/logs", + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/logs", headers={ "location": "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" }, @@ -427,7 +438,7 @@ async def setup_before_prep(self, module_test): ) def check(self, module_test, events): - assert len(events) == 5 + assert len(events) == 6 assert 1 == len( [ e @@ -455,17 +466,22 @@ def check(self, module_test, events): for e in events if e.type == "CODE_REPOSITORY" and "git" in e.tags - and e.data["url"] == "https://github.com/blacklanternsecurity/test_keys" + and e.data["url"] == "https://github.com/blacklanternsecurity/bbot" and e.scope_distance == 1 ] ), "Failed to find blacklanternsecurity github repo" - assert 1 == len( - [ - e - for e in events - if e.type == "FILESYSTEM" - and "blacklanternsecurity/bbot/run_8839360698.zip" in e.data - and "zipfile" in e.tags - and e.scope_distance == 1 - ] - ), "Failed to obtain redirect to the blob" + filesystem_events = [ + e + for e in events + if e.type == "FILESYSTEM" + and "workflow_logs/blacklanternsecurity/bbot/run_8839360698.zip" in e.data["path"] + and "zipfile" in e.tags + and e.scope_distance == 1 + ] + assert 1 == len(filesystem_events), "Failed to download workflow logs" + filesystem_event = filesystem_events[0] + file = Path(filesystem_event.data["path"]) + assert file.is_file(), "Destination zip does not exist" + with zipfile.ZipFile(file, "r") as zip_ref: + assert "test.txt" in zip_ref.namelist(), "test.txt not in zip" + assert zip_ref.read("test.txt") == b"This is some test data", "test.txt contents incorrect" From ca56eb33aa3cef418640e858e12ea1b6ee3685f5 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 10:50:54 +0100 Subject: [PATCH 035/220] Match URL using regex --- .../module_tests/test_module_github_workflows.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index 18997b910..8961b63c0 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -1,4 +1,5 @@ import io +import re import zipfile from pathlib import Path @@ -13,7 +14,6 @@ class TestGithub_Workflows(ModuleTestBase): with zipfile.ZipFile(data, mode="w", compression=zipfile.ZIP_DEFLATED) as zipfile: zipfile.writestr("test.txt", "This is some test data") data.seek(0) - zip_content = data.getvalue() async def setup_before_prep(self, module_test): @@ -180,7 +180,9 @@ async def setup_before_prep(self, module_test): }, ) module_test.httpx_mock.add_response( - url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?created=>2024-04-23&per_page=100&page=1", + url=re.compile( + r"https://api\.github\.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs\?created=.*&per_page=100&page=1" + ), json={ "total_count": 2993, "workflow_runs": [ From 98c3281f3a5e642e397aa65a5f83e8baf4ac082e Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 11:47:30 +0100 Subject: [PATCH 036/220] Change the workflow logs obtained to be configurable --- bbot/modules/github_workflows.py | 56 ++++++++----------- .../test_module_github_workflows.py | 7 +-- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 170db1190..dc0f63ed7 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -1,5 +1,3 @@ -from datetime import date, timedelta - from bbot.modules.templates.github import github @@ -8,16 +6,19 @@ class github_workflows(github): produced_events = ["FILESYSTEM"] flags = ["passive", "safe"] meta = {"description": "Download a github repositories workflow logs"} - options = {"api_key": "", "historical_logs": 7} + options = {"api_key": "", "num_logs": 1} options_desc = { "api_key": "Github token", - "historical_logs": "Fetch logs that are at most this many days old (default: 7)", + "num_logs": "For each workflow fetch the last N successful runs logs (max 100)", } scope_distance_modifier = 2 async def setup(self): - self.historical_logs = int(self.options.get("historical_logs", 7)) + self.num_logs = int(self.options.get("num_logs", 1)) + if self.num_logs > 100: + self.log.error("num_logs option is capped at 100") + return False self.output_dir = self.scan.home / "workflow_logs" self.helpers.mkdir(self.output_dir) return await super().setup() @@ -77,34 +78,25 @@ async def get_workflows(self, owner, repo): async def get_workflow_runs(self, owner, repo, workflow_id): runs = [] - created_date = date.today() - timedelta(days=self.historical_logs) - formated_date = created_date.strftime("%Y-%m-%d") - url = ( - f"{self.base_url}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs?created=>{formated_date}&per_page=100&page=" - + "{page}" - ) - agen = self.helpers.api_page_iter(url, headers=self.headers, json=False) + url = f"{self.base_url}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs?status=success&per_page={self.num_logs}" + r = await self.helpers.request(url, headers=self.headers) + if r is None: + return runs + status_code = getattr(r, "status_code", 0) + if status_code == 403: + self.warning("Github is rate-limiting us (HTTP status: 403)") + return runs + if status_code != 200: + return runs try: - async for r in agen: - if r is None: - break - status_code = getattr(r, "status_code", 0) - if status_code == 403: - self.warning("Github is rate-limiting us (HTTP status: 403)") - break - if status_code != 200: - break - try: - j = r.json().get("workflow_runs", []) - except Exception as e: - self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") - break - if not j: - break - for item in j: - runs.append(item) - finally: - agen.aclose() + j = r.json().get("workflow_runs", []) + except Exception as e: + self.warning(f"Failed to decode JSON for {r.url} (HTTP status: {status_code}): {e}") + return runs + if not j: + return runs + for item in j: + runs.append(item) return runs async def download_run_logs(self, owner, repo, run_id): diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index 8961b63c0..76bd55814 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -1,5 +1,4 @@ import io -import re import zipfile from pathlib import Path @@ -180,9 +179,7 @@ async def setup_before_prep(self, module_test): }, ) module_test.httpx_mock.add_response( - url=re.compile( - r"https://api\.github\.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs\?created=.*&per_page=100&page=1" - ), + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?status=success&per_page=1", json={ "total_count": 2993, "workflow_runs": [ @@ -197,7 +194,7 @@ async def setup_before_prep(self, module_test): "run_number": 4520, "event": "pull_request", "status": "completed", - "conclusion": "failure", + "conclusion": "success", "workflow_id": 22452226, "check_suite_id": 23162098295, "check_suite_node_id": "CS_kwDOG_O3ns8AAAAFZJGSdw", From 795a4c9faeb407fe9ed006cd3c748af66e4020fb Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 12:09:32 +0100 Subject: [PATCH 037/220] Change config to download a set number of logs --- bbot/modules/github_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index dc0f63ed7..b4762ccbd 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -15,7 +15,7 @@ class github_workflows(github): scope_distance_modifier = 2 async def setup(self): - self.num_logs = int(self.options.get("num_logs", 1)) + self.num_logs = int(self.config.get("num_logs", 1)) if self.num_logs > 100: self.log.error("num_logs option is capped at 100") return False From 0fb1c30bab7ed30c8322701df2f9b678d3ad101e Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 30 Apr 2024 12:25:28 +0100 Subject: [PATCH 038/220] Changed error message --- bbot/modules/github_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index b4762ccbd..35609232c 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -113,5 +113,5 @@ async def download_run_logs(self, owner, repo, run_id): self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") return file_destination else: - self.warning(f"Failed to download logs for {owner}/{repo}/{run_id}") + self.warning(f"The logs for {owner}/{repo}/{run_id} have expired and are no longer available.") return None From c36bcae8cdaa98df658ec212c77abc407798126a Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 30 Apr 2024 17:11:46 -0400 Subject: [PATCH 039/220] fix event type omissions, make exceptions for explicit targets --- bbot/core/event/base.py | 65 ++++++------------- bbot/modules/base.py | 3 +- bbot/modules/bypass403.py | 6 +- bbot/modules/deadly/dastardly.py | 2 +- bbot/modules/deadly/ffuf.py | 2 +- bbot/modules/deadly/vhost.py | 12 ++-- bbot/modules/ffuf_shortnames.py | 10 +-- bbot/modules/generic_ssrf.py | 4 +- bbot/modules/gitlab.py | 2 +- bbot/modules/httpx.py | 2 +- bbot/modules/internal/excavate.py | 6 +- bbot/modules/ntlm.py | 2 +- bbot/modules/output/base.py | 24 ++++--- bbot/modules/output/web_report.py | 4 +- bbot/modules/robots.py | 2 +- bbot/modules/secretsdb.py | 2 +- bbot/modules/url_manipulation.py | 6 +- bbot/modules/wafw00f.py | 2 +- bbot/scanner/manager.py | 21 +++++- bbot/scanner/preset/args.py | 2 +- bbot/scanner/scanner.py | 7 ++ bbot/test/test_step_1/test_events.py | 2 +- .../module_tests/test_module_dastardly.py | 2 +- 23 files changed, 97 insertions(+), 93 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index d7eabd6db..abacbf132 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -15,7 +15,6 @@ from bbot.errors import * from bbot.core.helpers import ( extract_words, - get_file_extension, is_domain, is_subdomain, is_ip, @@ -99,8 +98,6 @@ class BaseEvent: _quick_emit = False # Whether this event has been retroactively marked as part of an important discovery chain _graph_important = False - # Exclude from output modules - _omit = False # Disables certain data validations _dummy = False # Data validation, if data is a dictionary @@ -149,6 +146,7 @@ def __init__( self._hash = None self.__host = None self._port = None + self._omit = False self.__words = None self._priority = None self._host_original = None @@ -185,9 +183,6 @@ def __init__( if self.scan: self.scans = list(set([self.scan.id] + self.scans)) - # check type blacklist - self._check_omit() - self._scope_distance = -1 try: @@ -297,12 +292,12 @@ def host_original(self): @property def port(self): self.host - if getattr(self, "parsed", None): - if self.parsed.port is not None: - return self.parsed.port - elif self.parsed.scheme == "https": + if getattr(self, "parsed_url", None): + if self.parsed_url.port is not None: + return self.parsed_url.port + elif self.parsed_url.scheme == "https": return 443 - elif self.parsed.scheme == "http": + elif self.parsed_url.scheme == "http": return 80 return self._port @@ -453,9 +448,9 @@ def source_id(self): def get_source(self): """ - Takes into account events with the _omit flag + Takes into account events with the omit flag """ - if getattr(self.source, "_omit", False): + if getattr(self.source, "omit", False): return self.source.get_source() return self.source @@ -669,7 +664,7 @@ def module_sequence(self): str: The module sequence in human-friendly format. """ module_name = getattr(self.module, "name", "") - if getattr(self.source, "_omit", False): + if getattr(self.source, "omit", False): module_name = f"{self.source.module_sequence}->{module_name}" return module_name @@ -704,13 +699,6 @@ def type(self, val): self._type = val self._hash = None self._id = None - self._check_omit() - - def _check_omit(self): - if self.scan is not None: - omit_event_types = self.scan.config.get("omit_event_types", []) - if omit_event_types and self.type in omit_event_types: - self._omit = True def __iter__(self): """ @@ -770,7 +758,7 @@ class DictEvent(BaseEvent): def sanitize_data(self, data): url = data.get("url", "") if url: - self.parsed = validators.validate_url_parsed(url) + self.parsed_url = validators.validate_url_parsed(url) return data def _data_load(self, data): @@ -784,7 +772,7 @@ def _host(self): if isinstance(self.data, dict) and "host" in self.data: return make_ip_type(self.data["host"]) else: - parsed = getattr(self, "parsed") + parsed = getattr(self, "parsed_url", None) if parsed is not None: return make_ip_type(parsed.hostname) @@ -903,44 +891,29 @@ def __init__(self, *args, **kwargs): self.num_redirects = getattr(self.source, "num_redirects", 0) def sanitize_data(self, data): - self.parsed = validators.validate_url_parsed(data) + self.parsed_url = validators.validate_url_parsed(data) # tag as dir or endpoint - if str(self.parsed.path).endswith("/"): + if str(self.parsed_url.path).endswith("/"): self.add_tag("dir") else: self.add_tag("endpoint") - parsed_path_lower = str(self.parsed.path).lower() - - scan = getattr(self, "scan", None) - url_extension_blacklist = getattr(scan, "url_extension_blacklist", []) - url_extension_httpx_only = getattr(scan, "url_extension_httpx_only", []) - - extension = get_file_extension(parsed_path_lower) - if extension: - self.add_tag(f"extension-{extension}") - if extension in url_extension_blacklist: - self.add_tag("blacklisted") - if extension in url_extension_httpx_only: - self.add_tag("httpx-only") - self._omit = True - - data = self.parsed.geturl() + data = self.parsed_url.geturl() return data def with_port(self): netloc_with_port = make_netloc(self.host, self.port) - return self.parsed._replace(netloc=netloc_with_port) + return self.parsed_url._replace(netloc=netloc_with_port) def _words(self): - first_elem = self.parsed.path.lstrip("/").split("/")[0] + first_elem = self.parsed_url.path.lstrip("/").split("/")[0] if not "." in first_elem: return extract_words(first_elem) return set() def _host(self): - return make_ip_type(self.parsed.hostname) + return make_ip_type(self.parsed_url.hostname) def _data_id(self): # consider spider-danger tag when deduping @@ -1020,8 +993,8 @@ def __init__(self, *args, **kwargs): def sanitize_data(self, data): url = data.get("url", "") - self.parsed = validators.validate_url_parsed(url) - data["url"] = self.parsed.geturl() + self.parsed_url = validators.validate_url_parsed(url) + data["url"] = self.parsed_url.geturl() header_dict = {} for i in data.get("raw_header", "").splitlines(): diff --git a/bbot/modules/base.py b/bbot/modules/base.py index d1349818c..92e341e18 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -684,6 +684,7 @@ def _event_precheck(self, event): if self.target_only: if "target" not in event.tags: return False, "it did not meet target_only filter criteria" + # exclude certain URLs (e.g. javascript): # TODO: revisit this after httpx rework if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: @@ -952,7 +953,7 @@ def get_per_hostport_hash(self, event): >>> event = self.make_event("https://example.com:8443") >>> self.get_per_hostport_hash(event) """ - parsed = getattr(event, "parsed", None) + parsed = getattr(event, "parsed_url", None) if parsed is None: to_hash = self.helpers.make_netloc(event.host, event.port) else: diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 0ce3df899..95f3ef551 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -164,10 +164,10 @@ async def filter_event(self, event): def format_signature(self, sig, event): if sig[3] == True: - cleaned_path = event.parsed.path.strip("/") + cleaned_path = event.parsed_url.path.strip("/") else: - cleaned_path = event.parsed.path.lstrip("/") - kwargs = {"scheme": event.parsed.scheme, "netloc": event.parsed.netloc, "path": cleaned_path} + cleaned_path = event.parsed_url.path.lstrip("/") + kwargs = {"scheme": event.parsed_url.scheme, "netloc": event.parsed_url.netloc, "path": cleaned_path} formatted_url = sig[1].format(**kwargs) if sig[2] != None: formatted_headers = {k: v.format(**kwargs) for k, v in sig[2].items()} diff --git a/bbot/modules/deadly/dastardly.py b/bbot/modules/deadly/dastardly.py index 837b4a2c2..742fca77f 100644 --- a/bbot/modules/deadly/dastardly.py +++ b/bbot/modules/deadly/dastardly.py @@ -27,7 +27,7 @@ async def filter_event(self, event): return True async def handle_event(self, event): - host = event.parsed._replace(path="/").geturl() + host = event.parsed_url._replace(path="/").geturl() self.verbose(f"Running Dastardly scan against {host}") command, output_file = self.construct_command(host) finished_proc = await self.run_process(command, sudo=True) diff --git a/bbot/modules/deadly/ffuf.py b/bbot/modules/deadly/ffuf.py index a56c73506..94e42d6be 100644 --- a/bbot/modules/deadly/ffuf.py +++ b/bbot/modules/deadly/ffuf.py @@ -57,7 +57,7 @@ async def handle_event(self, event): return # only FFUF against a directory - if "." in event.parsed.path.split("/")[-1]: + if "." in event.parsed_url.path.split("/")[-1]: self.debug("Aborting FFUF as period was detected in right-most path segment (likely a file)") return else: diff --git a/bbot/modules/deadly/vhost.py b/bbot/modules/deadly/vhost.py index cf7be1f67..101f41536 100644 --- a/bbot/modules/deadly/vhost.py +++ b/bbot/modules/deadly/vhost.py @@ -33,7 +33,7 @@ async def setup(self): async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): - host = f"{event.parsed.scheme}://{event.parsed.netloc}" + host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" if host in self.scanned_hosts.keys(): return else: @@ -44,7 +44,7 @@ async def handle_event(self, event): if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(event.parsed.netloc) + basehost = self.helpers.parent_domain(event.parsed_url.netloc) self.debug(f"Using basehost: {basehost}") async for vhost in self.ffuf_vhost(host, f".{basehost}", event): @@ -55,7 +55,7 @@ async def handle_event(self, event): # check existing host for mutations self.verbose("Checking for vhost mutations on main host") async for vhost in self.ffuf_vhost( - host, f".{basehost}", event, wordlist=self.mutations_check(event.parsed.netloc.split(".")[0]) + host, f".{basehost}", event, wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]) ): pass @@ -81,7 +81,7 @@ async def ffuf_vhost(self, host, basehost, event, wordlist=None, skip_dns_host=F ): found_vhost_b64 = r["input"]["FUZZ"] vhost_dict = {"host": str(event.host), "url": host, "vhost": base64.b64decode(found_vhost_b64).decode()} - if f"{vhost_dict['vhost']}{basehost}" != event.parsed.netloc: + if f"{vhost_dict['vhost']}{basehost}" != event.parsed_url.netloc: await self.emit_event(vhost_dict, "VHOST", source=event) if skip_dns_host == False: await self.emit_event(f"{vhost_dict['vhost']}{basehost}", "DNS_NAME", source=event, tags=["vhost"]) @@ -102,13 +102,13 @@ async def finish(self): for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: - event.parsed = urlparse(host) + event.parsed_url = urlparse(host) self.verbose("Checking main host with wordcloud") if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(event.parsed.netloc) + basehost = self.helpers.parent_domain(event.parsed_url.netloc) async for vhost in self.ffuf_vhost(host, f".{basehost}", event, wordlist=tempfile): pass diff --git a/bbot/modules/ffuf_shortnames.py b/bbot/modules/ffuf_shortnames.py index cfc58cba4..49cb8a779 100644 --- a/bbot/modules/ffuf_shortnames.py +++ b/bbot/modules/ffuf_shortnames.py @@ -92,7 +92,7 @@ async def setup(self): def build_extension_list(self, event): used_extensions = [] - extension_hint = event.parsed.path.rsplit(".", 1)[1].lower().strip() + extension_hint = event.parsed_url.path.rsplit(".", 1)[1].lower().strip() if len(extension_hint) == 3: with open(self.wordlist_extensions) as f: for l in f: @@ -117,9 +117,9 @@ async def filter_event(self, event): return True async def handle_event(self, event): - filename_hint = re.sub(r"~\d", "", event.parsed.path.rsplit(".", 1)[0].split("/")[-1]).lower() + filename_hint = re.sub(r"~\d", "", event.parsed_url.path.rsplit(".", 1)[0].split("/")[-1]).lower() - host = f"{event.source.parsed.scheme}://{event.source.parsed.netloc}/" + host = f"{event.source.parsed_url.scheme}://{event.source.parsed_url.netloc}/" if host not in self.per_host_collection.keys(): self.per_host_collection[host] = [(filename_hint, event.source.data)] @@ -128,8 +128,8 @@ async def handle_event(self, event): self.shortname_to_event[filename_hint] = event - root_stub = "/".join(event.parsed.path.split("/")[:-1]) - root_url = f"{event.parsed.scheme}://{event.parsed.netloc}{root_stub}/" + root_stub = "/".join(event.parsed_url.path.split("/")[:-1]) + root_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}{root_stub}/" if "shortname-file" in event.tags: used_extensions = self.build_extension_list(event) diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 42efa5050..efad29f76 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -44,7 +44,7 @@ def __init__(self, parent_module): self.test_paths = self.create_paths() def set_base_url(self, event): - return f"{event.parsed.scheme}://{event.parsed.netloc}" + return f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" def create_paths(self): return self.paths @@ -140,7 +140,7 @@ async def test(self, event): ]> &{rand_entity};""" - test_url = f"{event.parsed.scheme}://{event.parsed.netloc}/" + test_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" r = await self.parent_module.helpers.curl( url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} ) diff --git a/bbot/modules/gitlab.py b/bbot/modules/gitlab.py index 6f4892580..3675adfd3 100644 --- a/bbot/modules/gitlab.py +++ b/bbot/modules/gitlab.py @@ -47,7 +47,7 @@ async def handle_http_response(self, event): # HTTP_RESPONSE --> FINDING headers = event.data.get("header", {}) if "x_gitlab_meta" in headers: - url = event.parsed._replace(path="/").geturl() + url = event.parsed_url._replace(path="/").geturl() await self.emit_event( {"host": str(event.host), "technology": "GitLab", "url": url}, "TECHNOLOGY", source=event ) diff --git a/bbot/modules/httpx.py b/bbot/modules/httpx.py index 0f74fbcfc..c5e1a42f3 100644 --- a/bbot/modules/httpx.py +++ b/bbot/modules/httpx.py @@ -84,7 +84,7 @@ async def handle_batch(self, *events): if e.type.startswith("URL"): # we NEED the port, otherwise httpx will try HTTPS even for HTTP URLs url = e.with_port().geturl() - if e.parsed.path == "/": + if e.parsed_url.path == "/": url_hash = hash((e.host, e.port)) else: url = str(e.data) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index 6b819c0d2..134124333 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -122,7 +122,7 @@ async def search(self, content, event, **kwargs): urls_found += 1 async def _search(self, content, event, **kwargs): - parsed = getattr(event, "parsed", None) + parsed = getattr(event, "parsed_url", None) for name, regex in self.compiled_regexes.items(): # yield to event loop await self.excavate.helpers.sleep(0) @@ -142,7 +142,7 @@ async def _search(self, content, event, **kwargs): continue if not self.compiled_regexes["fullurl"].match(path): - source_url = event.parsed.geturl() + source_url = event.parsed_url.geturl() result = urljoin(source_url, path) # this is necessary to weed out mailto: and such if not self.compiled_regexes["fullurl"].match(result): @@ -167,7 +167,7 @@ async def report(self, result, name, event, **kwargs): # these findings are pretty mundane so don't bother with them if they aren't in scope abort_if = lambda e: e.scope_distance > 0 event_data = {"host": str(host), "description": f"Non-HTTP URI: {result}"} - parsed_url = getattr(event, "parsed", None) + parsed_url = getattr(event, "parsed_url", None) if parsed_url: event_data["url"] = parsed_url.geturl() await self.excavate.emit_event( diff --git a/bbot/modules/ntlm.py b/bbot/modules/ntlm.py index 93f622566..b08fe4784 100644 --- a/bbot/modules/ntlm.py +++ b/bbot/modules/ntlm.py @@ -122,7 +122,7 @@ async def handle_url(self, event): } if self.try_all: for endpoint in ntlm_discovery_endpoints: - urls.add(f"{event.parsed.scheme}://{event.parsed.netloc}/{endpoint}") + urls.add(f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/{endpoint}") tasks = [] for url in urls: diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index c9a41eebd..4a00af74d 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -29,23 +29,27 @@ def _event_precheck(self, event): if self.target_only: if "target" not in event.tags: return False, "it did not meet target_only filter criteria" - # exclude certain URLs (e.g. javascript): - if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: - return False, "its extension was listed in url_extension_httpx_only" ### begin output-module specific ### - # events from omit_event_types, e.g. HTTP_RESPONSE, DNS_NAME_UNRESOLVED, etc. - # if the output module specifically requests a certain event type, we let it through anyway - # an exception is also made for targets - if event._omit: - if not "target" in event.tags and not event.type in self.get_watched_events(): - return False, "_omit is True" - # force-output certain events to the graph if self._is_graph_important(event): return True, "event is critical to the graph" + # exclude certain URLs (e.g. javascript): + # TODO: revisit this after httpx rework + if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: + return False, (f"Omitting {event} from output because it's marked as httpx-only") + + # omit certain event types + if event.type in self.scan.omitted_event_types: + if "target" in event.tags: + self.debug(f"Allowing omitted event: {event} because it's a target") + elif event.type in self.get_watched_events(): + self.debug(f"Allowing omitted event: {event} because its type is explicitly in watched_events") + else: + return False, f"its type is omitted in the config" + # internal events like those from speculate, ipneighbor # or events that are over our report distance if event._internal: diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index 793f26c32..246704cc5 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -34,7 +34,7 @@ async def setup(self): async def handle_event(self, event): if event.type == "URL": - parsed = event.parsed + parsed = event.parsed_url host = f"{parsed.scheme}://{parsed.netloc}/" if host not in self.web_assets.keys(): self.web_assets[host] = {"URL": []} @@ -60,7 +60,7 @@ async def handle_event(self, event): parsed = None while 1: if current_parent.type == "URL": - parsed = current_parent.parsed + parsed = current_parent.parsed_url break current_parent = current_parent.source if current_parent.source.type == "SCAN": diff --git a/bbot/modules/robots.py b/bbot/modules/robots.py index d801a755e..f23b89bf7 100644 --- a/bbot/modules/robots.py +++ b/bbot/modules/robots.py @@ -21,7 +21,7 @@ async def setup(self): return True async def handle_event(self, event): - host = f"{event.parsed.scheme}://{event.parsed.netloc}/" + host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" result = None url = f"{host}robots.txt" result = await self.helpers.request(url) diff --git a/bbot/modules/secretsdb.py b/bbot/modules/secretsdb.py index d94a3b0a2..38a2b644f 100644 --- a/bbot/modules/secretsdb.py +++ b/bbot/modules/secretsdb.py @@ -51,7 +51,7 @@ async def handle_event(self, event): matches = [m.string[m.start() : m.end()] for m in matches] description = f"Possible secret ({name}): {matches}" event_data = {"host": str(event.host), "description": description} - parsed_url = getattr(event, "parsed", None) + parsed_url = getattr(event, "parsed_url", None) if parsed_url: event_data["url"] = parsed_url.geturl() await self.emit_event( diff --git a/bbot/modules/url_manipulation.py b/bbot/modules/url_manipulation.py index 74b702eaa..ca6ea1bad 100644 --- a/bbot/modules/url_manipulation.py +++ b/bbot/modules/url_manipulation.py @@ -94,10 +94,10 @@ async def filter_event(self, event): def format_signature(self, sig, event): if sig[2] == True: - cleaned_path = event.parsed.path.strip("/") + cleaned_path = event.parsed_url.path.strip("/") else: - cleaned_path = event.parsed.path.lstrip("/") + cleaned_path = event.parsed_url.path.lstrip("/") - kwargs = {"scheme": event.parsed.scheme, "netloc": event.parsed.netloc, "path": cleaned_path} + kwargs = {"scheme": event.parsed_url.scheme, "netloc": event.parsed_url.netloc, "path": cleaned_path} formatted_url = sig[1].format(**kwargs) return (sig[0], formatted_url) diff --git a/bbot/modules/wafw00f.py b/bbot/modules/wafw00f.py index b8786e494..5cd6bc4e8 100644 --- a/bbot/modules/wafw00f.py +++ b/bbot/modules/wafw00f.py @@ -33,7 +33,7 @@ async def filter_event(self, event): return True, "" async def handle_event(self, event): - url = f"{event.parsed.scheme}://{event.parsed.netloc}/" + url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" WW = await self.helpers.run_in_executor(wafw00f_main.WAFW00F, url, followredirect=False) waf_detections = await self.helpers.run_in_executor(WW.identwaf) if waf_detections: diff --git a/bbot/scanner/manager.py b/bbot/scanner/manager.py index 011b174b8..dbea08f7f 100644 --- a/bbot/scanner/manager.py +++ b/bbot/scanner/manager.py @@ -72,8 +72,27 @@ async def handle_event(self, event, kwargs): # update event's scope distance based on its parent event.scope_distance = event.source.scope_distance + 1 - # blacklist rejections + # special handling of URL extensions + parsed_url = getattr(event, "parsed_url", None) + if parsed_url is not None: + url_path = parsed_url.path + if url_path: + parsed_path_lower = str(url_path).lower() + extension = self.helpers.get_file_extension(parsed_path_lower) + if extension: + event.add_tag(f"extension-{extension}") + if extension in self.scan.url_extension_httpx_only: + event.add_tag("httpx-only") + event._omit = True + + # blacklist by extension + if extension in self.scan.url_extension_blacklist: + event.add_tag("blacklisted") + + # main scan blacklist event_blacklisted = self.scan.blacklisted(event) + + # reject all blacklisted events if event_blacklisted or "blacklisted" in event.tags: return False, f"Omitting blacklisted event: {event}" diff --git a/bbot/scanner/preset/args.py b/bbot/scanner/preset/args.py index 75588b0eb..cb4e140e3 100644 --- a/bbot/scanner/preset/args.py +++ b/bbot/scanner/preset/args.py @@ -338,4 +338,4 @@ def validate(self): if self.exclude_from_validation.match(c): continue # otherwise, ensure it exists as a module option - raise ValidationError(get_closest_match(c, all_options, msg="module option")) + raise ValidationError(get_closest_match(c, all_options, msg="config option")) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 83118ac9e..db1382946 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -197,6 +197,7 @@ def __init__( self._finished_init = False self._new_activity = False self._cleanedup = False + self._omitted_event_types = None self.__loop = None self._manager_worker_loop_tasks = [] @@ -861,6 +862,12 @@ def aborting(self): def status(self): return self._status + @property + def omitted_event_types(self): + if self._omitted_event_types is None: + self._omitted_event_types = self.config.get("omit_event_types", []) + return self._omitted_event_types + @status.setter def status(self, status): """ diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index 3b8ec29bf..d2cc2bf11 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -100,7 +100,7 @@ async def test_events(events, scan, helpers): # http response assert events.http_response.host == "example.com" assert events.http_response.port == 80 - assert events.http_response.parsed.scheme == "http" + assert events.http_response.parsed_url.scheme == "http" assert events.http_response.with_port().geturl() == "http://example.com:80/" http_response = scan.make_event( diff --git a/bbot/test/test_step_2/module_tests/test_module_dastardly.py b/bbot/test/test_step_2/module_tests/test_module_dastardly.py index ed4c20e5c..6ab3a36a2 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dastardly.py +++ b/bbot/test/test_step_2/module_tests/test_module_dastardly.py @@ -52,7 +52,7 @@ async def setup_after_prep(self, module_test): def new_filter_event(event): self.new_url = f"http://{docker_ip}:5556/" event.data["url"] = self.new_url - event.parsed = module_test.scan.helpers.urlparse(self.new_url) + event.parsed_url = module_test.scan.helpers.urlparse(self.new_url) return old_filter_event(event) module_test.monkeypatch.setattr(module_test.module, "filter_event", new_filter_event) From 1d0ccf46d5d9014e13727418ae789c0a1b65342d Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Wed, 1 May 2024 13:20:38 +0100 Subject: [PATCH 040/220] Added description of the `FILESYSTEM` path --- bbot/modules/github_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 35609232c..8c8c2c35e 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -44,7 +44,7 @@ async def handle_event(self, event): if log_path: self.verbose(f"Downloaded repository workflow logs to {log_path}") logfile_event = self.make_event( - {"path": str(log_path)}, "FILESYSTEM", tags=["zipfile"], source=event + {"path": str(log_path), "description": f"Workflow run logs from https://github.com/{owner}/{repo}/actions/runs/{run_id}"}, "FILESYSTEM", tags=["zipfile"], source=event ) logfile_event.scope_distance = event.scope_distance await self.emit_event(logfile_event) From f4cec98faf89030c146741004bf2b59f486b1304 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Wed, 1 May 2024 13:21:44 +0100 Subject: [PATCH 041/220] Lint --- bbot/modules/github_workflows.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 8c8c2c35e..7c3150f68 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -44,7 +44,13 @@ async def handle_event(self, event): if log_path: self.verbose(f"Downloaded repository workflow logs to {log_path}") logfile_event = self.make_event( - {"path": str(log_path), "description": f"Workflow run logs from https://github.com/{owner}/{repo}/actions/runs/{run_id}"}, "FILESYSTEM", tags=["zipfile"], source=event + { + "path": str(log_path), + "description": f"Workflow run logs from https://github.com/{owner}/{repo}/actions/runs/{run_id}", + }, + "FILESYSTEM", + tags=["zipfile"], + source=event, ) logfile_event.scope_distance = event.scope_distance await self.emit_event(logfile_event) From 7ed127eea91d89f0d045b2735bde540ad08d72c4 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 08:37:59 -0400 Subject: [PATCH 042/220] fix cli tests --- bbot/test/test_step_1/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index 5b4452b98..02d7d5b7b 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -325,7 +325,7 @@ def test_cli_config_validation(monkeypatch, caplog): assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-c", "modules.ipnegibhor.num_bits=4"]) cli.main() - assert 'Could not find module option "modules.ipnegibhor.num_bits"' in caplog.text + assert 'Could not find config option "modules.ipnegibhor.num_bits"' in caplog.text assert 'Did you mean "modules.ipneighbor.num_bits"?' in caplog.text # incorrect global option @@ -333,7 +333,7 @@ def test_cli_config_validation(monkeypatch, caplog): assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-c", "web_spier_distance=4"]) cli.main() - assert 'Could not find module option "web_spier_distance"' in caplog.text + assert 'Could not find config option "web_spier_distance"' in caplog.text assert 'Did you mean "web_spider_distance"?' in caplog.text From 6414d632d39e407d204f566bbbc9d53f971fdc1d Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 08:47:01 -0400 Subject: [PATCH 043/220] update documentation --- bbot/core/helpers/dns/brute.py | 7 ++++--- bbot/core/helpers/wordcloud.py | 2 +- bbot/defaults.yml | 4 ++++ docs/scanning/tips_and_tricks.md | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/bbot/core/helpers/dns/brute.py b/bbot/core/helpers/dns/brute.py index d8996f2f4..c34e96610 100644 --- a/bbot/core/helpers/dns/brute.py +++ b/bbot/core/helpers/dns/brute.py @@ -23,7 +23,7 @@ def __init__(self, parent_helper): self.parent_helper = parent_helper self.log = logging.getLogger("bbot.helper.dns.brute") self.num_canaries = 100 - self.max_resolvers = 1000 + self.max_resolvers = self.parent_helper.config.get("dns", {}).get("brute_threads", 1000) self.devops_mutations = list(self.parent_helper.word_cloud.devops_mutations) self.digit_regex = self.parent_helper.re.compile(r"\d+") self._resolver_file = None @@ -142,8 +142,9 @@ async def _massdns(self, module, domain, subdomains, rdtype): async def gen_subdomains(self, prefixes, domain): for p in prefixes: - d = f"{p}.{domain}" - yield d + if domain: + p = f"{p}.{domain}" + yield p async def resolver_file(self): if self._resolver_file is None: diff --git a/bbot/core/helpers/wordcloud.py b/bbot/core/helpers/wordcloud.py index 5eafb00c5..fbd4e7593 100644 --- a/bbot/core/helpers/wordcloud.py +++ b/bbot/core/helpers/wordcloud.py @@ -451,7 +451,7 @@ def add_word(self, word): class DNSMutator(Mutator): """ - DNS-specific mutator used by the `massdns` module to generate target-specific subdomain mutations. + DNS-specific mutator used by the `dnsbrute_mutations` module to generate target-specific subdomain mutations. This class extends the Mutator base class to add DNS-specific logic for generating subdomain mutations based on input words. It utilizes custom word extraction patterns diff --git a/bbot/defaults.yml b/bbot/defaults.yml index 4b9b5210d..42cd265f0 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -24,6 +24,10 @@ deps: ffuf: version: "2.1.0" +dns: + # Number of concurrent massdns lookups (-s) + brute_threads: 1000 + ### WEB SPIDER ### # Set the maximum number of HTTP links that can be followed in a row (0 == no spidering allowed) diff --git a/docs/scanning/tips_and_tricks.md b/docs/scanning/tips_and_tricks.md index 885e461dc..f019f742a 100644 --- a/docs/scanning/tips_and_tricks.md +++ b/docs/scanning/tips_and_tricks.md @@ -30,13 +30,13 @@ To change the number of instances, you can set a module's `max_event_handlers` i bbot -t evilcorp.com -m baddns -c modules.baddns.max_event_handlers=20 ``` -### Boost Massdns Thread Count +### Boost DNS Brute-force Speed If you have a fast internet connection or are running BBOT from a cloud VM, you can speed up subdomain enumeration by cranking the threads for `massdns`. The default is `1000`, which is about 1MB/s of DNS traffic: ```bash # massdns with 5000 resolvers, about 5MB/s -bbot -t evilcorp.com -f subdomain-enum -c modules.massdns.max_resolvers=5000 +bbot -t evilcorp.com -f subdomain-enum -c dns.brute_threads=5000 ``` ### Web Spider From 52a04fdfd36c6631e16b4f4a79908c7440291dd5 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 08:59:31 -0400 Subject: [PATCH 044/220] fix event tests --- bbot/core/event/base.py | 15 +++++++++++++-- bbot/scanner/manager.py | 19 +++++++------------ bbot/test/test_step_1/test_events.py | 11 +++++++++-- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index abacbf132..ccdd4be1e 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -28,6 +28,7 @@ split_host_port, tagify, validators, + get_file_extension, ) @@ -450,7 +451,7 @@ def get_source(self): """ Takes into account events with the omit flag """ - if getattr(self.source, "omit", False): + if getattr(self.source, "_omit", False): return self.source.get_source() return self.source @@ -893,6 +894,16 @@ def __init__(self, *args, **kwargs): def sanitize_data(self, data): self.parsed_url = validators.validate_url_parsed(data) + # special handling of URL extensions + if self.parsed_url is not None: + url_path = self.parsed_url.path + if url_path: + parsed_path_lower = str(url_path).lower() + extension = get_file_extension(parsed_path_lower) + if extension: + self.url_extension = extension + self.add_tag(f"extension-{extension}") + # tag as dir or endpoint if str(self.parsed_url.path).endswith("/"): self.add_tag("dir") @@ -1042,7 +1053,7 @@ def redirect_location(self): # if there's no scheme (i.e. it's a relative redirect) if not scheme: # then join the location with the current url - location = urljoin(self.parsed.geturl(), location) + location = urljoin(self.parsed_url.geturl(), location) return location diff --git a/bbot/scanner/manager.py b/bbot/scanner/manager.py index dbea08f7f..7c83e9205 100644 --- a/bbot/scanner/manager.py +++ b/bbot/scanner/manager.py @@ -73,20 +73,15 @@ async def handle_event(self, event, kwargs): event.scope_distance = event.source.scope_distance + 1 # special handling of URL extensions - parsed_url = getattr(event, "parsed_url", None) - if parsed_url is not None: - url_path = parsed_url.path - if url_path: - parsed_path_lower = str(url_path).lower() - extension = self.helpers.get_file_extension(parsed_path_lower) - if extension: - event.add_tag(f"extension-{extension}") - if extension in self.scan.url_extension_httpx_only: - event.add_tag("httpx-only") - event._omit = True + url_extension = getattr(event, "url_extension", None) + self.critical(f"{url_extension} in {self.scan.url_extension_httpx_only}?") + if url_extension is not None: + if url_extension in self.scan.url_extension_httpx_only: + event.add_tag("httpx-only") + event._omit = True # blacklist by extension - if extension in self.scan.url_extension_blacklist: + if url_extension in self.scan.url_extension_blacklist: event.add_tag("blacklisted") # main scan blacklist diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index d2cc2bf11..986cc6db9 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -6,7 +6,13 @@ @pytest.mark.asyncio -async def test_events(events, scan, helpers): +async def test_events(events, helpers): + + from bbot.scanner import Scanner + + scan = Scanner() + await scan._prep() + assert events.ipv4.type == "IP_ADDRESS" assert events.ipv6.type == "IP_ADDRESS" assert events.netv4.type == "IP_RANGE" @@ -159,8 +165,9 @@ async def test_events(events, scan, helpers): assert events.ipv6_url_unverified.host == ipaddress.ip_address("2001:4860:4860::8888") assert events.ipv6_url_unverified.port == 443 - javascript_event = scan.make_event("http://evilcorp.com/asdf/a.js?b=c#d", "URL_UNVERIFIED", dummy=True) + javascript_event = scan.make_event("http://evilcorp.com/asdf/a.js?b=c#d", "URL_UNVERIFIED", source=scan.root_event) assert "extension-js" in javascript_event.tags + await scan.ingress_module.handle_event(javascript_event, {}) assert "httpx-only" in javascript_event.tags # scope distance From 856c23657b36370e2f938594098d7f268290c7b0 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 09:33:21 -0400 Subject: [PATCH 045/220] small tweak to error handling --- bbot/modules/github_workflows.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 7c3150f68..fb39a0283 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -110,14 +110,25 @@ async def download_run_logs(self, owner, repo, run_id): self.helpers.mkdir(folder) filename = f"run_{run_id}.zip" file_destination = folder / filename - result = await self.helpers.download( - f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", - filename=file_destination, - headers=self.headers, - ) - if result: + try: + result = await self.helpers.download( + f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", + filename=file_destination, + headers=self.headers, + raise_error=True, + warn=False, + ) self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") return file_destination - else: - self.warning(f"The logs for {owner}/{repo}/{run_id} have expired and are no longer available.") - return None + except Exception as e: + response = getattr(e, "response", None) + status_code = getattr(response, "status_code", 0) + if status_code == 403: + self.warning( + f"The current access key does not have access to workflow {owner}/{repo}/{run_id} (status: {status_code})" + ) + else: + self.info( + f"The logs for {owner}/{repo}/{run_id} have expired and are no longer available (status: {status_code})" + ) + return None From 9e56987e678024eccdd99abd05bbddb8555282d3 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 09:36:55 -0400 Subject: [PATCH 046/220] flaked --- bbot/modules/github_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index fb39a0283..8999c41a3 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -111,7 +111,7 @@ async def download_run_logs(self, owner, repo, run_id): filename = f"run_{run_id}.zip" file_destination = folder / filename try: - result = await self.helpers.download( + await self.helpers.download( f"{self.base_url}/repos/{owner}/{repo}/actions/runs/{run_id}/logs", filename=file_destination, headers=self.headers, From 08de913b93b2c6dc244848ea14e8cf941795a199 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 10:36:56 -0400 Subject: [PATCH 047/220] fix event tests --- bbot/modules/output/base.py | 5 +- bbot/test/test_step_1/test_modules_basic.py | 63 +++++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index 4a00af74d..a934ec009 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -41,6 +41,9 @@ def _event_precheck(self, event): if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: return False, (f"Omitting {event} from output because it's marked as httpx-only") + if event._omit: + return False, "_omit is True" + # omit certain event types if event.type in self.scan.omitted_event_types: if "target" in event.tags: @@ -48,7 +51,7 @@ def _event_precheck(self, event): elif event.type in self.get_watched_events(): self.debug(f"Allowing omitted event: {event} because its type is explicitly in watched_events") else: - return False, f"its type is omitted in the config" + return False, "its type is omitted in the config" # internal events like those from speculate, ipneighbor # or events that are over our report distance diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 03273c0a7..249953365 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -9,32 +9,75 @@ @pytest.mark.asyncio -async def test_modules_basic(scan, helpers, events, bbot_scanner, httpx_mock): +async def test_modules_basic(helpers, events, bbot_scanner, httpx_mock): for http_method in ("GET", "CONNECT", "HEAD", "POST", "PUT", "TRACE", "DEBUG", "PATCH", "DELETE", "OPTIONS"): httpx_mock.add_response(method=http_method, url=re.compile(r".*"), json={"test": "test"}) + from bbot.scanner import Scanner + + scan = Scanner(config={"omit_event_types": ["URL_UNVERIFIED"]}) + assert "URL_UNVERIFIED" in scan.omitted_event_types + # output module specific event filtering tests base_output_module_1 = BaseOutputModule(scan) - base_output_module_1.watched_events = ["IP_ADDRESS"] + base_output_module_1.watched_events = ["IP_ADDRESS", "URL_UNVERIFIED"] localhost = scan.make_event("127.0.0.1", source=scan.root_event) - assert base_output_module_1._event_precheck(localhost)[0] == True + # ip addresses should be accepted + result, reason = base_output_module_1._event_precheck(localhost) + assert result == True + assert reason == "precheck succeeded" + # internal events should be rejected localhost._internal = True - assert base_output_module_1._event_precheck(localhost)[0] == False + result, reason = base_output_module_1._event_precheck(localhost) + assert result == False + assert reason == "_internal is True" localhost._internal = False - assert base_output_module_1._event_precheck(localhost)[0] == True + result, reason = base_output_module_1._event_precheck(localhost) + assert result == True + assert reason == "precheck succeeded" + # omitted events should be rejected localhost._omit = True - assert base_output_module_1._event_precheck(localhost)[0] == True + result, reason = base_output_module_1._event_precheck(localhost) + assert result == False + assert reason == "_omit is True" + # unwatched event types should be rejected + dns_name = scan.make_event("evilcorp.com", "DNS_NAME", source=scan.root_event) + result, reason = base_output_module_1._event_precheck(dns_name) + assert result == False + assert reason == "its type is not in watched_events" + # omitted event types matching watched events should be accepted + url_unverified = scan.make_event("http://127.0.0.1", "URL_UNVERIFIED", source=scan.root_event) + result, reason = base_output_module_1._event_precheck(url_unverified) + assert result == True + assert reason == "precheck succeeded" base_output_module_2 = BaseOutputModule(scan) base_output_module_2.watched_events = ["*"] + # normal events should be accepted localhost = scan.make_event("127.0.0.1", source=scan.root_event) - assert base_output_module_2._event_precheck(localhost)[0] == True + result, reason = base_output_module_2._event_precheck(localhost) + assert result == True + assert reason == "precheck succeeded" + # internal events should be rejected localhost._internal = True - assert base_output_module_2._event_precheck(localhost)[0] == False + result, reason = base_output_module_2._event_precheck(localhost) + assert result == False + assert reason == "_internal is True" localhost._internal = False - assert base_output_module_2._event_precheck(localhost)[0] == True + result, reason = base_output_module_2._event_precheck(localhost) + assert result == True + assert reason == "precheck succeeded" + # omitted events should be rejected localhost._omit = True - assert base_output_module_2._event_precheck(localhost)[0] == False + result, reason = base_output_module_2._event_precheck(localhost) + assert result == False + assert reason == "_omit is True" + # omitted event types should be rejected + url_unverified = scan.make_event("http://127.0.0.1", "URL_UNVERIFIED", source=scan.root_event) + result, reason = base_output_module_2._event_precheck(url_unverified) + log.critical(f"{url_unverified} / {result} / {reason}") + assert result == False + assert reason == "its type is omitted in the config" # common event filtering tests for module_class in (BaseModule, BaseOutputModule, BaseReportModule, BaseInternalModule): From f61232f927dfd96454958d37272609071862d5ff Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 11:08:11 -0400 Subject: [PATCH 048/220] fix module sequence --- bbot/core/event/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index ccdd4be1e..fc8cd3fdd 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -449,7 +449,7 @@ def source_id(self): def get_source(self): """ - Takes into account events with the omit flag + Takes into account events with the _omit flag """ if getattr(self.source, "_omit", False): return self.source.get_source() @@ -665,7 +665,7 @@ def module_sequence(self): str: The module sequence in human-friendly format. """ module_name = getattr(self.module, "name", "") - if getattr(self.source, "omit", False): + if getattr(self.source, "_omit", False): module_name = f"{self.source.module_sequence}->{module_name}" return module_name From a0aaf5feba545bcd0733287568788bdfb5ae0bc6 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Wed, 1 May 2024 16:53:21 +0100 Subject: [PATCH 049/220] Add the `FILESYSTEM` event type to trufflehog if it is neither a git or docker repo --- bbot/modules/trufflehog.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bbot/modules/trufflehog.py b/bbot/modules/trufflehog.py index 0bf27b413..bc0b2b230 100644 --- a/bbot/modules/trufflehog.py +++ b/bbot/modules/trufflehog.py @@ -9,7 +9,7 @@ class trufflehog(BaseModule): meta = {"description": "TruffleHog is a tool for finding credentials"} options = { - "version": "3.69.0", + "version": "3.74.0", "only_verified": True, "concurrency": 8, } @@ -37,18 +37,15 @@ async def setup(self): self.concurrency = int(self.config.get("concurrency", 8)) return True - async def filter_event(self, event): - if event.type == "FILESYSTEM": - if "git" not in event.tags and "docker" not in event.tags: - return False, "event is not a git repository or a docker image" - return True - async def handle_event(self, event): path = event.data["path"] + description = event.data.get("description", "") if "git" in event.tags: module = "git" elif "docker" in event.tags: module = "docker" + else: + module = "filesystem" async for decoder_name, detector_name, raw_result, verified, source_metadata in self.execute_trufflehog( module, path ): @@ -58,12 +55,16 @@ async def handle_event(self, event): "description": f"Verified Secret Found. Detector Type: [{detector_name}] Decoder Type: [{decoder_name}] Secret: [{raw_result}] Details: [{source_metadata}]", "host": str(event.source.host), } + if description: + data["description"] += f" Description: [{description}]" await self.emit_event(data, "VULNERABILITY", event) else: data = { "description": f"Potential Secret Found. Detector Type: [{detector_name}] Decoder Type: [{decoder_name}] Secret: [{raw_result}] Details: [{source_metadata}]", "host": str(event.source.host), } + if description: + data["description"] += f" Description: [{description}]" await self.emit_event(data, "FINDING", event) async def execute_trufflehog(self, module, path): @@ -80,7 +81,11 @@ async def execute_trufflehog(self, module, path): elif module == "docker": command.append("docker") command.append("--image=file://" + path) + elif module == "filesystem": + command.append("filesystem") + command.append(path) + emitted_raw_results = set() stats_file = self.helpers.tempfile_tail(callback=self.log_trufflehog_status) try: with open(stats_file, "w") as stats_fh: @@ -101,6 +106,10 @@ async def execute_trufflehog(self, module, path): source_metadata = j.get("SourceMetadata", {}) + if raw_result in emitted_raw_results: + continue + + emitted_raw_results.add(raw_result) yield (decoder_name, detector_name, raw_result, verified, source_metadata) finally: stats_file.unlink() From 50e283c64fdced28de57dac5a901817d1fe01775 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Wed, 1 May 2024 16:53:44 +0100 Subject: [PATCH 050/220] Add a module to test the modifications to trufflehog --- .../module_tests/test_module_trufflehog.py | 410 +++++++++++++++++- 1 file changed, 404 insertions(+), 6 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_trufflehog.py b/bbot/test/test_step_2/module_tests/test_module_trufflehog.py index bfbe131ff..827f76d6f 100644 --- a/bbot/test/test_step_2/module_tests/test_module_trufflehog.py +++ b/bbot/test/test_step_2/module_tests/test_module_trufflehog.py @@ -1,6 +1,7 @@ import subprocess import shutil import io +import zipfile import tarfile from pathlib import Path @@ -8,7 +9,15 @@ class TestTrufflehog(ModuleTestBase): - modules_overrides = ["github_org", "speculate", "git_clone", "dockerhub", "docker_pull", "trufflehog"] + modules_overrides = [ + "github_org", + "speculate", + "git_clone", + "github_workflows", + "dockerhub", + "docker_pull", + "trufflehog", + ] file_content = "Verifyable Secret:\nhttps://admin:admin@the-internet.herokuapp.com/basic_auth\n\nUnverifyable Secret:\nhttps://admin:admin@internal.host.com" @@ -152,9 +161,392 @@ async def setup_before_prep(self, module_test): "watchers": 2, "default_branch": "main", "permissions": {"admin": False, "maintain": False, "push": False, "triage": False, "pull": True}, - } + }, + { + "id": 459780477, + "node_id": "R_kgDOG2exfQ", + "name": "bbot", + "full_name": "blacklanternsecurity/bbot", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 79229934, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjc5MjI5OTM0", + "avatar_url": "https://avatars.githubusercontent.com/u/79229934?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/bbot", + "description": None, + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/bbot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/bbot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/bbot/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/bbot/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/bbot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/bbot/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/bbot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/bbot/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/bbot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/bbot/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/bbot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/bbot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/bbot/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/bbot/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/deployments", + "created_at": "2022-02-15T23:10:51Z", + "updated_at": "2023-09-02T12:20:13Z", + "pushed_at": "2023-10-19T02:56:46Z", + "git_url": "git://github.com/blacklanternsecurity/bbot.git", + "ssh_url": "git@github.com:blacklanternsecurity/bbot.git", + "clone_url": "https://github.com/blacklanternsecurity/bbot.git", + "svn_url": "https://github.com/blacklanternsecurity/bbot", + "homepage": None, + "size": 2, + "stargazers_count": 2, + "watchers_count": 2, + "language": None, + "has_issues": True, + "has_projects": True, + "has_downloads": True, + "has_wiki": True, + "has_pages": False, + "has_discussions": False, + "forks_count": 32, + "mirror_url": None, + "archived": False, + "disabled": False, + "open_issues_count": 2, + "license": None, + "allow_forking": True, + "is_template": False, + "web_commit_signoff_required": False, + "topics": [], + "visibility": "public", + "forks": 32, + "open_issues": 2, + "watchers": 2, + "default_branch": "main", + "permissions": {"admin": False, "maintain": False, "push": False, "triage": False, "pull": True}, + }, ], ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows?per_page=100&page=1", + json={ + "total_count": 3, + "workflows": [ + { + "id": 22452226, + "node_id": "W_kwDOG_O3ns4BVpgC", + "name": "tests", + "path": ".github/workflows/tests.yml", + "state": "active", + "created_at": "2022-03-23T15:09:22.000Z", + "updated_at": "2022-09-27T17:49:34.000Z", + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226", + "html_url": "https://github.com/blacklanternsecurity/bbot/blob/stable/.github/workflows/tests.yml", + "badge_url": "https://github.com/blacklanternsecurity/bbot/workflows/tests/badge.svg", + }, + ], + }, + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226/runs?status=success&per_page=1", + json={ + "total_count": 2993, + "workflow_runs": [ + { + "id": 8839360698, + "name": "tests", + "node_id": "WFR_kwLOG_O3ns8AAAACDt3wug", + "head_branch": "dnsbrute-helperify", + "head_sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "path": ".github/workflows/tests.yml", + "display_title": "Helperify Massdns", + "run_number": 4520, + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "workflow_id": 22452226, + "check_suite_id": 23162098295, + "check_suite_node_id": "CS_kwDOG_O3ns8AAAAFZJGSdw", + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698", + "html_url": "https://github.com/blacklanternsecurity/bbot/actions/runs/8839360698", + "pull_requests": [ + { + "url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls/1303", + "id": 1839332952, + "number": 1303, + "head": { + "ref": "dnsbrute-helperify", + "sha": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "repo": { + "id": 468957086, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "name": "bbot", + }, + }, + "base": { + "ref": "faster-regexes", + "sha": "7baf219c7f3a4ba165639c5ddb62322453a8aea8", + "repo": { + "id": 468957086, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "name": "bbot", + }, + }, + } + ], + "created_at": "2024-04-25T21:04:32Z", + "updated_at": "2024-04-25T21:19:43Z", + "actor": { + "login": "TheTechromancer", + "id": 20261699, + "node_id": "MDQ6VXNlcjIwMjYxNjk5", + "avatar_url": "https://avatars.githubusercontent.com/u/20261699?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheTechromancer", + "html_url": "https://github.com/TheTechromancer", + "followers_url": "https://api.github.com/users/TheTechromancer/followers", + "following_url": "https://api.github.com/users/TheTechromancer/following{/other_user}", + "gists_url": "https://api.github.com/users/TheTechromancer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheTechromancer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheTechromancer/subscriptions", + "organizations_url": "https://api.github.com/users/TheTechromancer/orgs", + "repos_url": "https://api.github.com/users/TheTechromancer/repos", + "events_url": "https://api.github.com/users/TheTechromancer/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheTechromancer/received_events", + "type": "User", + "site_admin": False, + }, + "run_attempt": 1, + "referenced_workflows": [], + "run_started_at": "2024-04-25T21:04:32Z", + "triggering_actor": { + "login": "TheTechromancer", + "id": 20261699, + "node_id": "MDQ6VXNlcjIwMjYxNjk5", + "avatar_url": "https://avatars.githubusercontent.com/u/20261699?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheTechromancer", + "html_url": "https://github.com/TheTechromancer", + "followers_url": "https://api.github.com/users/TheTechromancer/followers", + "following_url": "https://api.github.com/users/TheTechromancer/following{/other_user}", + "gists_url": "https://api.github.com/users/TheTechromancer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheTechromancer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheTechromancer/subscriptions", + "organizations_url": "https://api.github.com/users/TheTechromancer/orgs", + "repos_url": "https://api.github.com/users/TheTechromancer/repos", + "events_url": "https://api.github.com/users/TheTechromancer/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheTechromancer/received_events", + "type": "User", + "site_admin": False, + }, + "jobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/jobs", + "logs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/logs", + "check_suite_url": "https://api.github.com/repos/blacklanternsecurity/bbot/check-suites/23162098295", + "artifacts_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/artifacts", + "cancel_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/cancel", + "rerun_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/rerun", + "previous_attempt_url": None, + "workflow_url": "https://api.github.com/repos/blacklanternsecurity/bbot/actions/workflows/22452226", + "head_commit": { + "id": "c5de1360e8e5ccba04b23035f675a529282b7dc2", + "tree_id": "fe9b345c0745a5bbacb806225e92e1c48fccf35c", + "message": "remove debug message", + "timestamp": "2024-04-25T21:02:37Z", + "author": {"name": "TheTechromancer", "email": "thetechromancer@protonmail.com"}, + "committer": {"name": "TheTechromancer", "email": "thetechromancer@protonmail.com"}, + }, + "repository": { + "id": 468957086, + "node_id": "R_kgDOG_O3ng", + "name": "bbot", + "full_name": "blacklanternsecurity/bbot", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 25311592, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI1MzExNTky", + "avatar_url": "https://avatars.githubusercontent.com/u/25311592?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/bbot", + "description": "A recursive internet scanner for hackers.", + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/bbot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/bbot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/bbot/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/bbot/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/bbot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/bbot/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/bbot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/bbot/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/bbot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/bbot/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/bbot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/bbot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/bbot/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/bbot/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/deployments", + }, + "head_repository": { + "id": 468957086, + "node_id": "R_kgDOG_O3ng", + "name": "bbot", + "full_name": "blacklanternsecurity/bbot", + "private": False, + "owner": { + "login": "blacklanternsecurity", + "id": 25311592, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI1MzExNTky", + "avatar_url": "https://avatars.githubusercontent.com/u/25311592?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/blacklanternsecurity", + "html_url": "https://github.com/blacklanternsecurity", + "followers_url": "https://api.github.com/users/blacklanternsecurity/followers", + "following_url": "https://api.github.com/users/blacklanternsecurity/following{/other_user}", + "gists_url": "https://api.github.com/users/blacklanternsecurity/gists{/gist_id}", + "starred_url": "https://api.github.com/users/blacklanternsecurity/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/blacklanternsecurity/subscriptions", + "organizations_url": "https://api.github.com/users/blacklanternsecurity/orgs", + "repos_url": "https://api.github.com/users/blacklanternsecurity/repos", + "events_url": "https://api.github.com/users/blacklanternsecurity/events{/privacy}", + "received_events_url": "https://api.github.com/users/blacklanternsecurity/received_events", + "type": "Organization", + "site_admin": False, + }, + "html_url": "https://github.com/blacklanternsecurity/bbot", + "description": "A recursive internet scanner for hackers.", + "fork": False, + "url": "https://api.github.com/repos/blacklanternsecurity/bbot", + "forks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/forks", + "keys_url": "https://api.github.com/repos/blacklanternsecurity/bbot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/blacklanternsecurity/bbot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/blacklanternsecurity/bbot/teams", + "hooks_url": "https://api.github.com/repos/blacklanternsecurity/bbot/hooks", + "issue_events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/events{/number}", + "events_url": "https://api.github.com/repos/blacklanternsecurity/bbot/events", + "assignees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/assignees{/user}", + "branches_url": "https://api.github.com/repos/blacklanternsecurity/bbot/branches{/branch}", + "tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/tags", + "blobs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/blacklanternsecurity/bbot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/blacklanternsecurity/bbot/languages", + "stargazers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/stargazers", + "contributors_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contributors", + "subscribers_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscribers", + "subscription_url": "https://api.github.com/repos/blacklanternsecurity/bbot/subscription", + "commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/blacklanternsecurity/bbot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/blacklanternsecurity/bbot/contents/{+path}", + "compare_url": "https://api.github.com/repos/blacklanternsecurity/bbot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/blacklanternsecurity/bbot/merges", + "archive_url": "https://api.github.com/repos/blacklanternsecurity/bbot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/blacklanternsecurity/bbot/downloads", + "issues_url": "https://api.github.com/repos/blacklanternsecurity/bbot/issues{/number}", + "pulls_url": "https://api.github.com/repos/blacklanternsecurity/bbot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/blacklanternsecurity/bbot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/blacklanternsecurity/bbot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/blacklanternsecurity/bbot/labels{/name}", + "releases_url": "https://api.github.com/repos/blacklanternsecurity/bbot/releases{/id}", + "deployments_url": "https://api.github.com/repos/blacklanternsecurity/bbot/deployments", + }, + }, + ], + }, + ) + module_test.httpx_mock.add_response( + url="https://api.github.com/repos/blacklanternsecurity/bbot/actions/runs/8839360698/logs", + headers={ + "location": "https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02" + }, + status_code=302, + ) + data = io.BytesIO() + with zipfile.ZipFile(data, mode="w", compression=zipfile.ZIP_DEFLATED) as z: + z.writestr("test.txt", self.file_content) + data.seek(0) + zip_content = data.getvalue() + module_test.httpx_mock.add_response( + url="https://productionresultssa10.blob.core.windows.net/actions-results/7beb304e-f42c-4830-a027-4f5dec53107d/workflow-job-run-3a559e2a-952e-58d2-b8db-2e604a9266d7/logs/steps/step-logs-0e34a19a-18b0-4208-b27a-f8c031db2d17.txt?rsct=text%2Fplain&se=2024-04-26T16%3A25%3A39Z&sig=a%2FiN8dOw0e3tiBQZAfr80veI8OYChb9edJ1eFY136B4%3D&sp=r&spr=https&sr=b&st=2024-04-26T16%3A15%3A34Z&sv=2021-12-02", + content=zip_content, + ) module_test.httpx_mock.add_response( url="https://hub.docker.com/v2/users/blacklanternsecurity", json={ @@ -460,13 +852,16 @@ def check(self, module_test, events): and "Verified Secret Found." in e.data["description"] and "Secret: [https://admin:admin@the-internet.herokuapp.com]" in e.data["description"] ] - assert 2 == len(vuln_events), "Failed to find secret in events" - github_repo_event = [e for e in vuln_events if e.data["host"] == "github.com"][0].source + assert 3 == len(vuln_events), "Failed to find secret in events" + github_repo_event = [e for e in vuln_events if "test_keys" in e.data["description"]][0].source folder = Path(github_repo_event.data["path"]) assert folder.is_dir(), "Destination folder doesn't exist" with open(folder / "keys.txt") as f: content = f.read() assert content == self.file_content, "File content doesn't match" + github_workflow_event = [e for e in vuln_events if "bbot" in e.data["description"]][0].source + file = Path(github_workflow_event.data["path"]) + assert file.is_file(), "Destination zip does not exist" docker_source_event = [e for e in vuln_events if e.data["host"] == "hub.docker.com"][0].source file = Path(docker_source_event.data["path"]) assert file.is_file(), "Destination image does not exist" @@ -484,13 +879,16 @@ def check(self, module_test, events): and "Potential Secret Found." in e.data["description"] and "Secret: [https://admin:admin@internal.host.com]" in e.data["description"] ] - assert 2 == len(finding_events), "Failed to find secret in events" - github_repo_event = [e for e in finding_events if e.data["host"] == "github.com"][0].source + assert 3 == len(finding_events), "Failed to find secret in events" + github_repo_event = [e for e in finding_events if "test_keys" in e.data["description"]][0].source folder = Path(github_repo_event.data["path"]) assert folder.is_dir(), "Destination folder doesn't exist" with open(folder / "keys.txt") as f: content = f.read() assert content == self.file_content, "File content doesn't match" + github_workflow_event = [e for e in finding_events if "bbot" in e.data["description"]][0].source + file = Path(github_workflow_event.data["path"]) + assert file.is_file(), "Destination zip does not exist" docker_source_event = [e for e in finding_events if e.data["host"] == "hub.docker.com"][0].source file = Path(docker_source_event.data["path"]) assert file.is_file(), "Destination image does not exist" From 629a047016387b49c4cfdfb776bc0791b4b59721 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Wed, 1 May 2024 16:54:19 +0100 Subject: [PATCH 051/220] Add a description to docker containers to make them the alerts more userfriendly --- bbot/modules/docker_pull.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bbot/modules/docker_pull.py b/bbot/modules/docker_pull.py index bcb731f4d..4515e6d4a 100644 --- a/bbot/modules/docker_pull.py +++ b/bbot/modules/docker_pull.py @@ -50,7 +50,10 @@ async def handle_event(self, event): if repo_path: self.verbose(f"Downloaded docker repository {repo_url} to {repo_path}") codebase_event = self.make_event( - {"path": str(repo_path)}, "FILESYSTEM", tags=["docker", "tarball"], source=event + {"path": str(repo_path), "description": f"Docker image: {repo_url}"}, + "FILESYSTEM", + tags=["docker", "tarball"], + source=event, ) codebase_event.scope_distance = event.scope_distance await self.emit_event(codebase_event) From 38fb669d6080d8f32e3de8dc8532fc0e79f9b326 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 12:27:52 -0400 Subject: [PATCH 052/220] tests for module_sequence --- bbot/test/test_step_1/test_events.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index 986cc6db9..f831391be 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -467,3 +467,28 @@ async def test_events(events, helpers): ) assert bucket_event.data["name"] == "asdf.s3.amazonaws.com" assert bucket_event.data["url"] == "https://asdf.s3.amazonaws.com/" + + # test module sequence + module = scan._make_dummy_module("mymodule") + source_event_1 = scan.make_event("127.0.0.1", module=module, source=scan.root_event) + assert str(source_event_1.module) == "mymodule" + assert str(source_event_1.module_sequence) == "mymodule" + source_event_2 = scan.make_event("127.0.0.2", module=module, source=source_event_1) + assert str(source_event_2.module) == "mymodule" + assert str(source_event_2.module_sequence) == "mymodule" + source_event_3 = scan.make_event("127.0.0.3", module=module, source=source_event_2) + assert str(source_event_3.module) == "mymodule" + assert str(source_event_3.module_sequence) == "mymodule" + + module = scan._make_dummy_module("mymodule") + source_event_1 = scan.make_event("127.0.0.1", module=module, source=scan.root_event) + source_event_1._omit = True + assert str(source_event_1.module) == "mymodule" + assert str(source_event_1.module_sequence) == "mymodule" + source_event_2 = scan.make_event("127.0.0.2", module=module, source=source_event_1) + source_event_2._omit = True + assert str(source_event_2.module) == "mymodule" + assert str(source_event_2.module_sequence) == "mymodule->mymodule" + source_event_3 = scan.make_event("127.0.0.3", module=module, source=source_event_2) + assert str(source_event_3.module) == "mymodule" + assert str(source_event_3.module_sequence) == "mymodule->mymodule->mymodule" From 5c9c1b8ad429d1a36399e8c876507dbaf28880da Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 1 May 2024 23:58:34 -0400 Subject: [PATCH 053/220] target perf optimization --- bbot/modules/dnsbrute_mutations.py | 2 +- bbot/modules/nmap.py | 2 +- bbot/scanner/target.py | 32 +++++++++++++++++++----------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bbot/modules/dnsbrute_mutations.py b/bbot/modules/dnsbrute_mutations.py index 06fd03689..a02ef7e9e 100644 --- a/bbot/modules/dnsbrute_mutations.py +++ b/bbot/modules/dnsbrute_mutations.py @@ -104,7 +104,7 @@ def add_mutation(m): self.info(f"Trying {len(mutations):,} mutations against {domain} ({i+1}/{len(trimmed_found)})") results = await self.helpers.dns.brute(self, query, mutations) for hostname in results: - source_event = self.source_events.get(hostname) + source_event = self.source_events.get_host(hostname) if source_event is None: self.warning(f"Could not correlate source event from: {hostname}") self.warning(self.source_events._radix.dns_tree.root.children) diff --git a/bbot/modules/nmap.py b/bbot/modules/nmap.py index ccdb0974e..4374155ab 100644 --- a/bbot/modules/nmap.py +++ b/bbot/modules/nmap.py @@ -43,7 +43,7 @@ async def handle_batch(self, *events): for host in self.parse_nmap_xml(output_file): source_event = None for h in [host.address] + host.hostnames: - source_event = target.get(h) + source_event = target.get_host(h) if source_event is not None: break if source_event is None: diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index 878e80846..9cca55ecc 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -7,6 +7,7 @@ from bbot.errors import * from bbot.modules.base import BaseModule +from bbot.core.helpers.misc import make_ip_type from bbot.core.event import make_event, is_event log = logging.getLogger("bbot.core.target") @@ -212,20 +213,27 @@ def get(self, host): """ try: - other = make_event(host, dummy=True) + event = make_event(host, dummy=True) except ValidationError: return - if other.host: - with suppress(KeyError, StopIteration): - result = self._radix.search(other.host) - if result is not None: - for event in result: - # if the result is a dns name and strict scope is enabled - if isinstance(event.host, str) and self.strict_scope: - # if the result doesn't exactly equal the host, abort - if event.host != other.host: - return - return event + if event.host: + return self.get_host(event.host) + + def get_host(self, host): + """ + A more efficient version of .get() that only accepts hostnames and IP addresses + """ + host = make_ip_type(host) + with suppress(KeyError, StopIteration): + result = self._radix.search(host) + if result is not None: + for event in result: + # if the result is a dns name and strict scope is enabled + if isinstance(event.host, str) and self.strict_scope: + # if the result doesn't exactly equal the host, abort + if event.host != host: + return + return event def _add_event(self, event): radix_data = self._radix.search(event.host) From 1cf85cdf8eeaf7d08038d712746eb1276d523310 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 2 May 2024 00:41:40 -0400 Subject: [PATCH 054/220] write tests for url extension handling --- bbot/scanner/manager.py | 6 ++++-- bbot/test/test_step_1/test_scan.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/bbot/scanner/manager.py b/bbot/scanner/manager.py index 7c83e9205..a1f8af0a2 100644 --- a/bbot/scanner/manager.py +++ b/bbot/scanner/manager.py @@ -74,7 +74,6 @@ async def handle_event(self, event, kwargs): # special handling of URL extensions url_extension = getattr(event, "url_extension", None) - self.critical(f"{url_extension} in {self.scan.url_extension_httpx_only}?") if url_extension is not None: if url_extension in self.scan.url_extension_httpx_only: event.add_tag("httpx-only") @@ -82,6 +81,9 @@ async def handle_event(self, event, kwargs): # blacklist by extension if url_extension in self.scan.url_extension_blacklist: + self.debug( + f"Blacklisting {event} because its extension (.{url_extension}) is blacklisted in the config" + ) event.add_tag("blacklisted") # main scan blacklist @@ -89,7 +91,7 @@ async def handle_event(self, event, kwargs): # reject all blacklisted events if event_blacklisted or "blacklisted" in event.tags: - return False, f"Omitting blacklisted event: {event}" + return False, "event is blacklisted" # Scope shepherding # here is where we make sure in-scope events are set to their proper scope distance diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index e5648c4ee..a2bb68bbe 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -73,3 +73,25 @@ async def test_scan( events.append(event) event_data = [e.data for e in events] assert "one.one.one.one" not in event_data + + +@pytest.mark.asyncio +async def test_url_extension_handling(bbot_scanner): + scan = bbot_scanner(config={"url_extension_blacklist": ["css"], "url_extension_httpx_only": ["js"]}) + await scan._prep() + assert scan.url_extension_blacklist == {"css"} + assert scan.url_extension_httpx_only == {"js"} + good_event = scan.make_event("https://evilcorp.com/a.txt", "URL", tags=["status-200"], source=scan.root_event) + bad_event = scan.make_event("https://evilcorp.com/a.css", "URL", tags=["status-200"], source=scan.root_event) + httpx_event = scan.make_event("https://evilcorp.com/a.js", "URL", tags=["status-200"], source=scan.root_event) + assert "blacklisted" not in bad_event.tags + assert "httpx-only" not in httpx_event.tags + result = await scan.ingress_module.handle_event(good_event, {}) + assert result == None + result, reason = await scan.ingress_module.handle_event(bad_event, {}) + assert result == False + assert reason == "event is blacklisted" + assert "blacklisted" in bad_event.tags + result = await scan.ingress_module.handle_event(httpx_event, {}) + assert result == None + assert "httpx-only" in httpx_event.tags From 886c189f26b3cc5b7af52191a255ebbdcd7afb10 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 2 May 2024 01:30:24 -0400 Subject: [PATCH 055/220] WIP http engine --- bbot/core/engine.py | 64 +++++--- bbot/core/helpers/web/__init__.py | 1 + bbot/core/helpers/web/engine.py | 225 +++++++++++++++++++++++++++++ bbot/core/helpers/{ => web}/web.py | 218 ++-------------------------- bbot/errors.py | 4 + bbot/test/test_step_1/test_web.py | 7 + 6 files changed, 286 insertions(+), 233 deletions(-) create mode 100644 bbot/core/helpers/web/__init__.py create mode 100644 bbot/core/helpers/web/engine.py rename bbot/core/helpers/{ => web}/web.py (74%) diff --git a/bbot/core/engine.py b/bbot/core/engine.py index 24781ab3b..2b31fa081 100644 --- a/bbot/core/engine.py +++ b/bbot/core/engine.py @@ -7,10 +7,12 @@ import tempfile import traceback import zmq.asyncio +import multiprocessing from pathlib import Path from contextlib import asynccontextmanager, suppress from bbot.core import CORE +from bbot.errors import BBOTEngineError from bbot.core.helpers.misc import rand_string CMD_EXIT = 1000 @@ -22,6 +24,7 @@ class EngineClient: def __init__(self, **kwargs): self.name = f"EngineClient {self.__class__.__name__}" + self.process_name = multiprocessing.current_process().name if self.SERVER_CLASS is None: raise ValueError(f"Must set EngineClient SERVER_CLASS, {self.SERVER_CLASS}") self.CMDS = dict(self.SERVER_CLASS.CMDS) @@ -35,9 +38,9 @@ def __init__(self, **kwargs): self.context = zmq.asyncio.Context() atexit.register(self.cleanup) - async def run_and_return(self, command, **kwargs): + async def run_and_return(self, command, *args, **kwargs): async with self.new_socket() as socket: - message = self.make_message(command, args=kwargs) + message = self.make_message(command, args=args, kwargs=kwargs) await socket.send(message) binary = await socket.recv() # self.log.debug(f"{self.name}.{command}({kwargs}) got binary: {binary}") @@ -48,8 +51,8 @@ async def run_and_return(self, command, **kwargs): return return message - async def run_and_yield(self, command, **kwargs): - message = self.make_message(command, args=kwargs) + async def run_and_yield(self, command, *args, **kwargs): + message = self.make_message(command, args=args, kwargs=kwargs) async with self.new_socket() as socket: await socket.send(message) while 1: @@ -75,28 +78,37 @@ def check_stop(self, message): return True return False - def make_message(self, command, args): + def make_message(self, command, args=None, kwargs=None): try: cmd_id = self.CMDS[command] except KeyError: raise KeyError(f'Command "{command}" not found. Available commands: {",".join(self.available_commands)}') - return pickle.dumps(dict(c=cmd_id, a=args)) + message = {"c": cmd_id} + if args: + message["a"] = args + if kwargs: + message["k"] = kwargs + return pickle.dumps(message) @property def available_commands(self): return [s for s in self.CMDS if isinstance(s, str)] def start_server(self): - process = CORE.create_process( - target=self.server_process, - args=( - self.SERVER_CLASS, - self.socket_path, - ), - kwargs=self.server_kwargs, - ) - process.start() - return process + self.log.critical(f"STARTING SERVER from {self.process_name}") + if self.process_name == "MainProcess": + process = CORE.create_process( + target=self.server_process, + args=( + self.SERVER_CLASS, + self.socket_path, + ), + kwargs=self.server_kwargs, + ) + process.start() + return process + else: + raise BBOTEngineError(f"Tried to start server from process {self.process_name}") @staticmethod def server_process(server_class, socket_path, **kwargs): @@ -145,20 +157,20 @@ def __init__(self, socket_path): # create socket file self.socket.bind(f"ipc://{socket_path}") - async def run_and_return(self, client_id, command_fn, **kwargs): + async def run_and_return(self, client_id, command_fn, *args, **kwargs): self.log.debug(f"{self.name} run-and-return {command_fn.__name__}({kwargs})") try: - result = await command_fn(**kwargs) + result = await command_fn(*args, **kwargs) except Exception as e: error = f"Unhandled error in {self.name}.{command_fn.__name__}({kwargs}): {e}" trace = traceback.format_exc() result = {"_e": (error, trace)} await self.send_socket_multipart([client_id, pickle.dumps(result)]) - async def run_and_yield(self, client_id, command_fn, **kwargs): + async def run_and_yield(self, client_id, command_fn, *args, **kwargs): self.log.debug(f"{self.name} run-and-yield {command_fn.__name__}({kwargs})") try: - async for _ in command_fn(**kwargs): + async for _ in command_fn(*args, **kwargs): await self.send_socket_multipart([client_id, pickle.dumps(_)]) await self.send_socket_multipart([client_id, pickle.dumps({"_s": None})]) except Exception as e: @@ -186,9 +198,13 @@ async def worker(self): self.log.warning(f"No command sent in message: {message}") continue - kwargs = message.get("a", {}) + args = message.get("a", ()) + if not isinstance(args, tuple): + self.log.warning(f"{self.name}: received invalid args of type {type(args)}, should be tuple") + continue + kwargs = message.get("k", {}) if not isinstance(kwargs, dict): - self.log.warning(f"{self.name}: received invalid message of type {type(kwargs)}, should be dict") + self.log.warning(f"{self.name}: received invalid kwargs of type {type(kwargs)}, should be dict") continue command_name = self.CMDS[cmd] @@ -199,9 +215,9 @@ async def worker(self): continue if inspect.isasyncgenfunction(command_fn): - coroutine = self.run_and_yield(client_id, command_fn, **kwargs) + coroutine = self.run_and_yield(client_id, command_fn, *args, **kwargs) else: - coroutine = self.run_and_return(client_id, command_fn, **kwargs) + coroutine = self.run_and_return(client_id, command_fn, *args, **kwargs) asyncio.create_task(coroutine) except Exception as e: diff --git a/bbot/core/helpers/web/__init__.py b/bbot/core/helpers/web/__init__.py new file mode 100644 index 000000000..8fcf82abb --- /dev/null +++ b/bbot/core/helpers/web/__init__.py @@ -0,0 +1 @@ +from .web import WebHelper diff --git a/bbot/core/helpers/web/engine.py b/bbot/core/helpers/web/engine.py new file mode 100644 index 000000000..9e30dbb8b --- /dev/null +++ b/bbot/core/helpers/web/engine.py @@ -0,0 +1,225 @@ +import ssl +import httpx +from httpx._models import Cookies +from contextlib import asynccontextmanager + +from bbot.core.engine import EngineServer + + +class DummyCookies(Cookies): + def extract_cookies(self, *args, **kwargs): + pass + + +class BBOTAsyncClient(httpx.AsyncClient): + """ + A subclass of httpx.AsyncClient tailored with BBOT-specific configurations and functionalities. + This class provides rate limiting, logging, configurable timeouts, user-agent customization, custom + headers, and proxy settings. Additionally, it allows the disabling of cookies, making it suitable + for use across an entire scan. + + Attributes: + _bbot_scan (object): BBOT scan object containing configuration details. + _persist_cookies (bool): Flag to determine whether cookies should be persisted across requests. + + Examples: + >>> async with BBOTAsyncClient(_bbot_scan=bbot_scan_object) as client: + >>> response = await client.request("GET", "https://example.com") + >>> print(response.status_code) + 200 + """ + + def __init__(self, *args, **kwargs): + self._config = kwargs.pop("_config") + web_requests_per_second = self._config.get("web_requests_per_second", 100) + + http_debug = self._config.get("http_debug", None) + if http_debug: + log.trace(f"Creating AsyncClient: {args}, {kwargs}") + + self._persist_cookies = kwargs.pop("persist_cookies", True) + + # timeout + http_timeout = self._config.get("http_timeout", 20) + if not "timeout" in kwargs: + kwargs["timeout"] = http_timeout + + # headers + headers = kwargs.get("headers", None) + if headers is None: + headers = {} + # user agent + user_agent = self._config.get("user_agent", "BBOT") + if "User-Agent" not in headers: + headers["User-Agent"] = user_agent + kwargs["headers"] = headers + # proxy + proxies = self._config.get("http_proxy", None) + kwargs["proxies"] = proxies + + super().__init__(*args, **kwargs) + if not self._persist_cookies: + self._cookies = DummyCookies() + + def build_request(self, *args, **kwargs): + request = super().build_request(*args, **kwargs) + # add custom headers if the URL is in-scope + # TODO: re-enable this + # if self._preset.in_scope(str(request.url)): + # for hk, hv in self._config.get("http_headers", {}).items(): + # # don't clobber headers + # if hk not in request.headers: + # request.headers[hk] = hv + return request + + def _merge_cookies(self, cookies): + if self._persist_cookies: + return super()._merge_cookies(cookies) + return cookies + + +class HTTPEngine(EngineServer): + + CMDS = { + 0: "request", + 1: "request_batch", + 2: "request_custom_batch", + 99: "_mock", + } + + client_only_options = ( + "retries", + "max_redirects", + ) + + def __init__(self, socket_path, config={}): + super().__init__(socket_path) + self.log.critical("doing") + self.config = config + self.http_debug = self.config.get("http_debug", False) + self._ssl_context_noverify = None + self.ssl_verify = self.config.get("ssl_verify", False) + if self.ssl_verify is False: + self.ssl_verify = self.ssl_context_noverify() + self.web_client = self.AsyncClient(persist_cookies=False) + + def AsyncClient(self, *args, **kwargs): + kwargs["_config"] = self.config + retries = kwargs.pop("retries", self.config.get("http_retries", 1)) + kwargs["transport"] = httpx.AsyncHTTPTransport(retries=retries, verify=self.ssl_verify) + kwargs["verify"] = self.ssl_verify + return BBOTAsyncClient(*args, **kwargs) + + async def request(self, *args, **kwargs): + self.log.critical(f"SERVER {args} / {kwargs}") + raise_error = kwargs.pop("raise_error", False) + # TODO: use this + cache_for = kwargs.pop("cache_for", None) # noqa + + client = kwargs.get("client", self.web_client) + + # allow vs follow, httpx why?? + allow_redirects = kwargs.pop("allow_redirects", None) + if allow_redirects is not None and "follow_redirects" not in kwargs: + kwargs["follow_redirects"] = allow_redirects + + # in case of URL only, assume GET request + if len(args) == 1: + kwargs["url"] = args[0] + args = [] + + url = kwargs.get("url", "") + + if not args and "method" not in kwargs: + kwargs["method"] = "GET" + + client_kwargs = {} + for k in list(kwargs): + if k in self.client_only_options: + v = kwargs.pop(k) + client_kwargs[k] = v + + if client_kwargs: + client = self.AsyncClient(**client_kwargs) + + async with self._acatch(url, raise_error): + if self.http_debug: + logstr = f"Web request: {str(args)}, {str(kwargs)}" + log.trace(logstr) + response = await client.request(*args, **kwargs) + if self.http_debug: + log.trace( + f"Web response from {url}: {response} (Length: {len(response.content)}) headers: {response.headers}" + ) + return response + + def ssl_context_noverify(self): + if self._ssl_context_noverify is None: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + ssl_context.options &= ~ssl.OP_NO_SSLv2 & ~ssl.OP_NO_SSLv3 + ssl_context.set_ciphers("ALL:@SECLEVEL=0") + ssl_context.options |= 0x4 # Add the OP_LEGACY_SERVER_CONNECT option + self._ssl_context_noverify = ssl_context + return self._ssl_context_noverify + + @asynccontextmanager + async def _acatch(self, url, raise_error): + """ + Asynchronous context manager to handle various httpx errors during a request. + + Yields: + None + + Note: + This function is internal and should generally not be used directly. + `url`, `args`, `kwargs`, and `raise_error` should be in the same context as this function. + """ + try: + yield + except httpx.TimeoutException: + if raise_error: + raise + else: + log.verbose(f"HTTP timeout to URL: {url}") + except httpx.ConnectError: + if raise_error: + raise + else: + log.debug(f"HTTP connect failed to URL: {url}") + except httpx.HTTPError as e: + if raise_error: + raise + else: + log.trace(f"Error with request to URL: {url}: {e}") + log.trace(traceback.format_exc()) + except ssl.SSLError as e: + msg = f"SSL error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except anyio.EndOfStream as e: + msg = f"AnyIO error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except SOCKSError as e: + msg = f"SOCKS error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except BaseException as e: + # don't log if the error is the result of an intentional cancellation + if not any( + isinstance(_e, asyncio.exceptions.CancelledError) for _e in self.parent_helper.get_exception_chain(e) + ): + log.trace(f"Unhandled exception with request to URL: {url}: {e}") + log.trace(traceback.format_exc()) + raise diff --git a/bbot/core/helpers/web.py b/bbot/core/helpers/web/web.py similarity index 74% rename from bbot/core/helpers/web.py rename to bbot/core/helpers/web/web.py index 26773bc9c..6d44cca61 100644 --- a/bbot/core/helpers/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,5 +1,4 @@ import re -import ssl import anyio import httpx import asyncio @@ -8,101 +7,28 @@ import traceback from pathlib import Path from bs4 import BeautifulSoup -from contextlib import asynccontextmanager -from httpx._models import Cookies from socksio.exceptions import SOCKSError +from bbot.core.engine import EngineClient from bbot.errors import WordlistError, CurlError from bbot.core.helpers.ratelimiter import RateLimiter from bs4 import MarkupResemblesLocatorWarning from bs4.builder import XMLParsedAsHTMLWarning +from .engine import HTTPEngine + warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) log = logging.getLogger("bbot.core.helpers.web") -class DummyCookies(Cookies): - def extract_cookies(self, *args, **kwargs): - pass - - -class BBOTAsyncClient(httpx.AsyncClient): - """ - A subclass of httpx.AsyncClient tailored with BBOT-specific configurations and functionalities. - This class provides rate limiting, logging, configurable timeouts, user-agent customization, custom - headers, and proxy settings. Additionally, it allows the disabling of cookies, making it suitable - for use across an entire scan. - - Attributes: - _bbot_scan (object): BBOT scan object containing configuration details. - _rate_limiter (RateLimiter): A rate limiter object to limit web requests. - _persist_cookies (bool): Flag to determine whether cookies should be persisted across requests. - - Examples: - >>> async with BBOTAsyncClient(_bbot_scan=bbot_scan_object) as client: - >>> response = await client.request("GET", "https://example.com") - >>> print(response.status_code) - 200 - """ +class WebHelper(EngineClient): - def __init__(self, *args, **kwargs): - self._preset = kwargs.pop("_preset") - web_requests_per_second = self._preset.config.get("web_requests_per_second", 100) - self._rate_limiter = RateLimiter(web_requests_per_second, "Web") - - http_debug = self._preset.config.get("http_debug", None) - if http_debug: - log.trace(f"Creating AsyncClient: {args}, {kwargs}") - - self._persist_cookies = kwargs.pop("persist_cookies", True) - - # timeout - http_timeout = self._preset.config.get("http_timeout", 20) - if not "timeout" in kwargs: - kwargs["timeout"] = http_timeout - - # headers - headers = kwargs.get("headers", None) - if headers is None: - headers = {} - # user agent - user_agent = self._preset.config.get("user_agent", "BBOT") - if "User-Agent" not in headers: - headers["User-Agent"] = user_agent - kwargs["headers"] = headers - # proxy - proxies = self._preset.config.get("http_proxy", None) - kwargs["proxies"] = proxies - - super().__init__(*args, **kwargs) - if not self._persist_cookies: - self._cookies = DummyCookies() + SERVER_CLASS = HTTPEngine - async def request(self, *args, **kwargs): - async with self._rate_limiter: - return await super().request(*args, **kwargs) - - def build_request(self, *args, **kwargs): - request = super().build_request(*args, **kwargs) - # add custom headers if the URL is in-scope - if self._preset.in_scope(str(request.url)): - for hk, hv in self._preset.config.get("http_headers", {}).items(): - # don't clobber headers - if hk not in request.headers: - request.headers[hk] = hv - return request - - def _merge_cookies(self, cookies): - if self._persist_cookies: - return super()._merge_cookies(cookies) - return cookies - - -class WebHelper: """ Main utility class for managing HTTP operations in BBOT. It serves as a wrapper around the BBOTAsyncClient, which itself is a subclass of httpx.AsyncClient. The class provides functionalities to make HTTP requests, @@ -126,26 +52,10 @@ class WebHelper: >>> filename = await self.helpers.wordlist("https://www.evilcorp.com/wordlist.txt") """ - client_only_options = ( - "retries", - "max_redirects", - ) - def __init__(self, parent_helper): self.parent_helper = parent_helper - self.http_debug = self.parent_helper.config.get("http_debug", False) - self._ssl_context_noverify = None - self.ssl_verify = self.parent_helper.config.get("ssl_verify", False) - if self.ssl_verify is False: - self.ssl_verify = self.ssl_context_noverify() - self.web_client = self.AsyncClient(persist_cookies=False) - - def AsyncClient(self, *args, **kwargs): - kwargs["_preset"] = self.parent_helper.preset - retries = kwargs.pop("retries", self.parent_helper.config.get("http_retries", 1)) - kwargs["transport"] = httpx.AsyncHTTPTransport(retries=retries, verify=self.ssl_verify) - kwargs["verify"] = self.ssl_verify - return BBOTAsyncClient(*args, **kwargs) + self.config = self.parent_helper.config + super().__init__(server_kwargs={"config": self.config}) async def request(self, *args, **kwargs): """ @@ -191,47 +101,8 @@ async def request(self, *args, **kwargs): Note: If the web request fails, it will return None unless `raise_error` is `True`. """ - - raise_error = kwargs.pop("raise_error", False) - # TODO: use this - cache_for = kwargs.pop("cache_for", None) # noqa - - client = kwargs.get("client", self.web_client) - - # allow vs follow, httpx why?? - allow_redirects = kwargs.pop("allow_redirects", None) - if allow_redirects is not None and "follow_redirects" not in kwargs: - kwargs["follow_redirects"] = allow_redirects - - # in case of URL only, assume GET request - if len(args) == 1: - kwargs["url"] = args[0] - args = [] - - url = kwargs.get("url", "") - - if not args and "method" not in kwargs: - kwargs["method"] = "GET" - - client_kwargs = {} - for k in list(kwargs): - if k in self.client_only_options: - v = kwargs.pop(k) - client_kwargs[k] = v - - if client_kwargs: - client = self.AsyncClient(**client_kwargs) - - async with self._acatch(url, raise_error): - if self.http_debug: - logstr = f"Web request: {str(args)}, {str(kwargs)}" - log.trace(logstr) - response = await client.request(*args, **kwargs) - if self.http_debug: - log.trace( - f"Web response from {url}: {response} (Length: {len(response.content)}) headers: {response.headers}" - ) - return response + self.log.critical(f"CLIENT {args} / {kwargs}") + return await self.run_and_return("request", *args, **kwargs) async def download(self, url, **kwargs): """ @@ -629,77 +500,6 @@ def beautifulsoup( log.debug(f"Error parsing beautifulsoup: {e}") return False - def ssl_context_noverify(self): - if self._ssl_context_noverify is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - ssl_context.options &= ~ssl.OP_NO_SSLv2 & ~ssl.OP_NO_SSLv3 - ssl_context.set_ciphers("ALL:@SECLEVEL=0") - ssl_context.options |= 0x4 # Add the OP_LEGACY_SERVER_CONNECT option - self._ssl_context_noverify = ssl_context - return self._ssl_context_noverify - - @asynccontextmanager - async def _acatch(self, url, raise_error): - """ - Asynchronous context manager to handle various httpx errors during a request. - - Yields: - None - - Note: - This function is internal and should generally not be used directly. - `url`, `args`, `kwargs`, and `raise_error` should be in the same context as this function. - """ - try: - yield - except httpx.TimeoutException: - if raise_error: - raise - else: - log.verbose(f"HTTP timeout to URL: {url}") - except httpx.ConnectError: - if raise_error: - raise - else: - log.debug(f"HTTP connect failed to URL: {url}") - except httpx.HTTPError as e: - if raise_error: - raise - else: - log.trace(f"Error with request to URL: {url}: {e}") - log.trace(traceback.format_exc()) - except ssl.SSLError as e: - msg = f"SSL error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except anyio.EndOfStream as e: - msg = f"AnyIO error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except SOCKSError as e: - msg = f"SOCKS error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except BaseException as e: - # don't log if the error is the result of an intentional cancellation - if not any( - isinstance(_e, asyncio.exceptions.CancelledError) for _e in self.parent_helper.get_exception_chain(e) - ): - log.trace(f"Unhandled exception with request to URL: {url}: {e}") - log.trace(traceback.format_exc()) - raise - user_keywords = [re.compile(r, re.I) for r in ["user", "login", "email"]] pass_keywords = [re.compile(r, re.I) for r in ["pass"]] diff --git a/bbot/errors.py b/bbot/errors.py index e50e581cd..153601813 100644 --- a/bbot/errors.py +++ b/bbot/errors.py @@ -72,3 +72,7 @@ class PresetConditionError(BBOTError): class PresetAbortError(PresetConditionError): pass + + +class BBOTEngineError(BBOTError): + pass diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 14da286d0..dc9116e0f 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -3,6 +3,13 @@ from ..bbot_fixtures import * +@pytest.mark.asyncio +async def test_web_engine(bbot_scanner): + scan = bbot_scanner() + response = await scan.helpers.request("http://example.com") + log.critical(response) + + @pytest.mark.asyncio async def test_web_helpers(bbot_scanner, bbot_httpserver): scan1 = bbot_scanner("8.8.8.8") From 27e9ae89136824ef6a44fe2b5365eb3f7a1e104e Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Thu, 2 May 2024 13:21:25 +0100 Subject: [PATCH 056/220] Remove de-duplicating in trufflehog module --- bbot/modules/trufflehog.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bbot/modules/trufflehog.py b/bbot/modules/trufflehog.py index bc0b2b230..0ec607457 100644 --- a/bbot/modules/trufflehog.py +++ b/bbot/modules/trufflehog.py @@ -85,7 +85,6 @@ async def execute_trufflehog(self, module, path): command.append("filesystem") command.append(path) - emitted_raw_results = set() stats_file = self.helpers.tempfile_tail(callback=self.log_trufflehog_status) try: with open(stats_file, "w") as stats_fh: @@ -106,10 +105,6 @@ async def execute_trufflehog(self, module, path): source_metadata = j.get("SourceMetadata", {}) - if raw_result in emitted_raw_results: - continue - - emitted_raw_results.add(raw_result) yield (decoder_name, detector_name, raw_result, verified, source_metadata) finally: stats_file.unlink() From c2e09ae361f4de202744b1917c266c2594ed6294 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Thu, 2 May 2024 13:24:29 +0100 Subject: [PATCH 057/220] Change description for docker_pull --- bbot/modules/docker_pull.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/docker_pull.py b/bbot/modules/docker_pull.py index 4515e6d4a..86c7a07a7 100644 --- a/bbot/modules/docker_pull.py +++ b/bbot/modules/docker_pull.py @@ -50,7 +50,7 @@ async def handle_event(self, event): if repo_path: self.verbose(f"Downloaded docker repository {repo_url} to {repo_path}") codebase_event = self.make_event( - {"path": str(repo_path), "description": f"Docker image: {repo_url}"}, + {"path": str(repo_path), "description": f"Docker image repository: {repo_url}"}, "FILESYSTEM", tags=["docker", "tarball"], source=event, From 25e971c030cad206e06c254879f6cd4a73573835 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Fri, 3 May 2024 10:49:18 -0400 Subject: [PATCH 058/220] more WIP web engine --- bbot/core/helpers/web/engine.py | 84 ++++++++++++++++++++++++++++--- bbot/core/helpers/web/web.py | 60 +++++----------------- bbot/test/test_step_1/test_web.py | 24 +++++++-- 3 files changed, 112 insertions(+), 56 deletions(-) diff --git a/bbot/core/helpers/web/engine.py b/bbot/core/helpers/web/engine.py index 9e30dbb8b..3a13bbb2d 100644 --- a/bbot/core/helpers/web/engine.py +++ b/bbot/core/helpers/web/engine.py @@ -1,9 +1,17 @@ import ssl +import anyio import httpx +import asyncio +import logging +import traceback from httpx._models import Cookies +from socksio.exceptions import SOCKSError from contextlib import asynccontextmanager from bbot.core.engine import EngineServer +from bbot.core.helpers.misc import bytes_to_human, human_to_bytes, get_exception_chain + +log = logging.getLogger("bbot.core.helpers.web.engine") class DummyCookies(Cookies): @@ -31,7 +39,6 @@ class BBOTAsyncClient(httpx.AsyncClient): def __init__(self, *args, **kwargs): self._config = kwargs.pop("_config") - web_requests_per_second = self._config.get("web_requests_per_second", 100) http_debug = self._config.get("http_debug", None) if http_debug: @@ -84,6 +91,7 @@ class HTTPEngine(EngineServer): 0: "request", 1: "request_batch", 2: "request_custom_batch", + 3: "download", 99: "_mock", } @@ -145,14 +153,80 @@ async def request(self, *args, **kwargs): async with self._acatch(url, raise_error): if self.http_debug: logstr = f"Web request: {str(args)}, {str(kwargs)}" - log.trace(logstr) + self.log.trace(logstr) response = await client.request(*args, **kwargs) if self.http_debug: - log.trace( + self.log.trace( f"Web response from {url}: {response} (Length: {len(response.content)}) headers: {response.headers}" ) return response + async def request_batch(self, urls, *args, threads=10, **kwargs): + tasks = {} + + def new_task(url): + task = asyncio.create_task(self.request(url, *args, **kwargs)) + tasks[task] = url + + urls = list(urls) + for _ in range(threads): # Start initial batch of tasks + if urls: # Ensure there are args to process + new_task(urls.pop(0)) + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + results = task.result() + url = tasks.pop(task) + + if results: + yield (url, results) + + if urls: # Start a new task for each one completed, if URLs remain + new_task(urls.pop(0)) + + async def download(self, url, **kwargs): + follow_redirects = kwargs.pop("follow_redirects", True) + filename = kwargs.pop("filename") + max_size = kwargs.pop("max_size", None) + warn = kwargs.pop("warn", True) + raise_error = kwargs.pop("raise_error", False) + if max_size is not None: + max_size = human_to_bytes(max_size) + kwargs["follow_redirects"] = follow_redirects + if not "method" in kwargs: + kwargs["method"] = "GET" + try: + total_size = 0 + chunk_size = 8192 + + async with self._acatch(url, raise_error=True), self.web_client.stream(url=url, **kwargs) as response: + status_code = getattr(response, "status_code", 0) + self.log.debug(f"Download result: HTTP {status_code}") + if status_code != 0: + response.raise_for_status() + with open(filename, "wb") as f: + agen = response.aiter_bytes(chunk_size=chunk_size) + async for chunk in agen: + if max_size is not None and total_size + chunk_size > max_size: + self.log.verbose( + f"Filesize of {url} exceeds {bytes_to_human(max_size)}, file will be truncated" + ) + agen.aclose() + break + total_size += chunk_size + f.write(chunk) + return True + except httpx.HTTPError as e: + log_fn = self.log.verbose + if warn: + log_fn = self.log.warning + log_fn(f"Failed to download {url}: {e}") + if raise_error: + raise + def ssl_context_noverify(self): if self._ssl_context_noverify is None: ssl_context = ssl.create_default_context() @@ -217,9 +291,7 @@ async def _acatch(self, url, raise_error): log.trace(traceback.format_exc()) except BaseException as e: # don't log if the error is the result of an intentional cancellation - if not any( - isinstance(_e, asyncio.exceptions.CancelledError) for _e in self.parent_helper.get_exception_chain(e) - ): + if not any(isinstance(_e, asyncio.exceptions.CancelledError) for _e in get_exception_chain(e)): log.trace(f"Unhandled exception with request to URL: {url}: {e}") log.trace(traceback.format_exc()) raise diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 6d44cca61..09bc3b581 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,18 +1,12 @@ import re -import anyio -import httpx -import asyncio import logging import warnings import traceback from pathlib import Path from bs4 import BeautifulSoup -from socksio.exceptions import SOCKSError - from bbot.core.engine import EngineClient from bbot.errors import WordlistError, CurlError -from bbot.core.helpers.ratelimiter import RateLimiter from bs4 import MarkupResemblesLocatorWarning from bs4.builder import XMLParsedAsHTMLWarning @@ -101,9 +95,16 @@ async def request(self, *args, **kwargs): Note: If the web request fails, it will return None unless `raise_error` is `True`. """ - self.log.critical(f"CLIENT {args} / {kwargs}") return await self.run_and_return("request", *args, **kwargs) + async def request_batch(self, urls, *args, **kwargs): + async for _ in self.run_and_yield("request_batch", urls, *args, **kwargs): + yield _ + + async def request_custom_batch(self, urls_and_args): + async for _ in self.run_and_yield("request_custom_batch", urls_and_args): + yield _ + async def download(self, url, **kwargs): """ Asynchronous function for downloading files from a given URL. Supports caching with an optional @@ -129,56 +130,21 @@ async def download(self, url, **kwargs): """ success = False filename = kwargs.pop("filename", self.parent_helper.cache_filename(url)) - follow_redirects = kwargs.pop("follow_redirects", True) + filename = Path(filename).resolve() + kwargs["filename"] = filename max_size = kwargs.pop("max_size", None) - warn = kwargs.pop("warn", True) - raise_error = kwargs.pop("raise_error", False) if max_size is not None: max_size = self.parent_helper.human_to_bytes(max_size) + kwargs["max_size"] = max_size cache_hrs = float(kwargs.pop("cache_hrs", -1)) - total_size = 0 - chunk_size = 8192 - log.debug(f"Downloading file from {url} with cache_hrs={cache_hrs}") if cache_hrs > 0 and self.parent_helper.is_cached(url): log.debug(f"{url} is cached at {self.parent_helper.cache_filename(url)}") success = True else: - # kwargs["raise_error"] = True - # kwargs["stream"] = True - kwargs["follow_redirects"] = follow_redirects - if not "method" in kwargs: - kwargs["method"] = "GET" - try: - async with self._acatch(url, raise_error=True), self.AsyncClient().stream( - url=url, **kwargs - ) as response: - status_code = getattr(response, "status_code", 0) - log.debug(f"Download result: HTTP {status_code}") - if status_code != 0: - response.raise_for_status() - with open(filename, "wb") as f: - agen = response.aiter_bytes(chunk_size=chunk_size) - async for chunk in agen: - if max_size is not None and total_size + chunk_size > max_size: - log.verbose( - f"Filesize of {url} exceeds {self.parent_helper.bytes_to_human(max_size)}, file will be truncated" - ) - agen.aclose() - break - total_size += chunk_size - f.write(chunk) - success = True - except httpx.HTTPError as e: - log_fn = log.verbose - if warn: - log_fn = log.warning - log_fn(f"Failed to download {url}: {e}") - if raise_error: - raise - return + success = await self.run_and_return("download", url, **kwargs) if success: - return filename.resolve() + return filename async def wordlist(self, path, lines=None, **kwargs): """ diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index dc9116e0f..aeac2ba2f 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -4,10 +4,28 @@ @pytest.mark.asyncio -async def test_web_engine(bbot_scanner): +async def test_web_engine(bbot_scanner, bbot_httpserver): + + url = bbot_httpserver.url_for("/test") + bbot_httpserver.expect_request(uri="/test").respond_with_data("hello_there") + scan = bbot_scanner() - response = await scan.helpers.request("http://example.com") - log.critical(response) + + # request + response = await scan.helpers.request(url) + assert response.status_code > 0 + assert response.text == "hello_there" + + # request_batch + responses = [r async for r in scan.helpers.request_batch([url] * 100)] + assert len(responses) == 100 + assert all([r[0] == url for r in responses]) + assert all([r[1].status_code > 0 and r[1].text == "hello_there" for r in responses]) + + # download + filename = await scan.helpers.download(url) + file_content = open(filename).read() + assert file_content == "hello_there" @pytest.mark.asyncio From 4c6362064f2e7bcec8762f1443b1f5569e162781 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Fri, 3 May 2024 18:21:25 +0100 Subject: [PATCH 059/220] emit the full event logs as `FILESYSTEM` events to be scanned to avoid duplication --- bbot/modules/github_workflows.py | 25 +++++++++++++------ .../test_module_github_workflows.py | 24 ++++++------------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index 8999c41a3..03d8e55f0 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -1,3 +1,6 @@ +import zipfile +import fnmatch + from bbot.modules.templates.github import github @@ -40,16 +43,14 @@ async def handle_event(self, event): for run in await self.get_workflow_runs(owner, repo, workflow_id): run_id = run.get("id") self.log.debug(f"Downloading logs for {workflow_name}/{run_id} in {owner}/{repo}") - log_path = await self.download_run_logs(owner, repo, run_id) - if log_path: - self.verbose(f"Downloaded repository workflow logs to {log_path}") + for log in await self.download_run_logs(owner, repo, run_id): logfile_event = self.make_event( { - "path": str(log_path), + "path": str(log), "description": f"Workflow run logs from https://github.com/{owner}/{repo}/actions/runs/{run_id}", }, "FILESYSTEM", - tags=["zipfile"], + tags=["textfile"], source=event, ) logfile_event.scope_distance = event.scope_distance @@ -119,8 +120,8 @@ async def download_run_logs(self, owner, repo, run_id): warn=False, ) self.info(f"Downloaded logs for {owner}/{repo}/{run_id} to {file_destination}") - return file_destination except Exception as e: + file_destination = None response = getattr(e, "response", None) status_code = getattr(response, "status_code", 0) if status_code == 403: @@ -131,4 +132,14 @@ async def download_run_logs(self, owner, repo, run_id): self.info( f"The logs for {owner}/{repo}/{run_id} have expired and are no longer available (status: {status_code})" ) - return None + # Secrets are duplicated in the individual workflow steps so just extract the main log files from the top folder + if file_destination: + main_logs = [] + with zipfile.ZipFile(file_destination, "r") as logzip: + for name in logzip.namelist(): + if fnmatch.fnmatch(name, "*.txt") and not "/" in name: + logzip.extract(name, folder) + main_logs.append(folder / name) + return main_logs + else: + return [] diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index 76bd55814..c5bdf6d07 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -12,6 +12,8 @@ class TestGithub_Workflows(ModuleTestBase): data = io.BytesIO() with zipfile.ZipFile(data, mode="w", compression=zipfile.ZIP_DEFLATED) as zipfile: zipfile.writestr("test.txt", "This is some test data") + zipfile.writestr("test2.txt", "This is some more test data") + zipfile.writestr("folder/test3.txt", "This is yet more test data") data.seek(0) zip_content = data.getvalue() @@ -437,7 +439,7 @@ async def setup_before_prep(self, module_test): ) def check(self, module_test, events): - assert len(events) == 6 + assert len(events) == 7 assert 1 == len( [ e @@ -469,18 +471,8 @@ def check(self, module_test, events): and e.scope_distance == 1 ] ), "Failed to find blacklanternsecurity github repo" - filesystem_events = [ - e - for e in events - if e.type == "FILESYSTEM" - and "workflow_logs/blacklanternsecurity/bbot/run_8839360698.zip" in e.data["path"] - and "zipfile" in e.tags - and e.scope_distance == 1 - ] - assert 1 == len(filesystem_events), "Failed to download workflow logs" - filesystem_event = filesystem_events[0] - file = Path(filesystem_event.data["path"]) - assert file.is_file(), "Destination zip does not exist" - with zipfile.ZipFile(file, "r") as zip_ref: - assert "test.txt" in zip_ref.namelist(), "test.txt not in zip" - assert zip_ref.read("test.txt") == b"This is some test data", "test.txt contents incorrect" + filesystem_events = [e for e in events if e.type == "FILESYSTEM"] + assert 2 == len(filesystem_events), filesystem_events + for filesystem_event in filesystem_events: + file = Path(filesystem_event.data["path"]) + assert file.is_file(), "Destination file does not exist" From 722e961e4cc2af279457c14bd30b1e64ea83a2dc Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Fri, 3 May 2024 17:06:57 -0400 Subject: [PATCH 060/220] remove unneeded filter --- bbot/modules/base.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bbot/modules/base.py b/bbot/modules/base.py index 92e341e18..15667a73b 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -730,13 +730,6 @@ async def _event_postcheck_inner(self, event): if self._is_graph_important(event): return True, "event is critical to the graph" - # don't send out-of-scope targets to active modules (excluding portscanners, because they can handle it) - # this only takes effect if your target and whitelist are different - # TODO: the logic here seems incomplete, it could probably use some work. - if "active" in self.flags and "portscan" not in self.flags: - if "target" in event.tags and event not in self.scan.whitelist: - return False, "it is not in whitelist and module has active flag" - # check scope distance filter_result, reason = self._scope_distance_check(event) if not filter_result: From ace01655f8254d517c23d5292106012e86cddde6 Mon Sep 17 00:00:00 2001 From: BBOT Docs Autopublish Date: Fri, 3 May 2024 21:26:19 +0000 Subject: [PATCH 061/220] Refresh module docs --- README.md | 4 ++-- docs/modules/list_of_modules.md | 2 +- docs/scanning/index.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 885d740ca..f97980608 100644 --- a/README.md +++ b/README.md @@ -267,9 +267,9 @@ For a full list of modules, including the data types consumed and emitted by eac | Flag | # Modules | Description | Modules | |------------------|-------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | safe | 80 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, filedownload, fingerprintx, fullhunt, git, git_clone, github_codesearch, github_org, gitlab, gowitness, hackertarget, httpx, hunt, hunterio, iis_shortnames, internetdb, ip2location, ipstack, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wappalyzer, wayback, zoomeye | -| passive | 59 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wayback, zoomeye | +| passive | 60 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wayback, zoomeye | | subdomain-enum | 45 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, massdns, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, sslcert, subdomaincenter, subdomains, threatminer, urlscan, virustotal, wayback, zoomeye | -| active | 43 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dockerhub, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer | +| active | 42 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer | | web-thorough | 29 | More advanced web scanning functionality | ajaxpro, azure_realm, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf_shortnames, filedownload, generic_ssrf, git, host_header, httpx, hunt, iis_shortnames, nmap, ntlm, oauth, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, wappalyzer | | aggressive | 20 | Generates a large amount of network traffic | bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, masscan, massdns, nmap, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f | | web-basic | 17 | Basic, non-intrusive web scan functionality | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | diff --git a/docs/modules/list_of_modules.md b/docs/modules/list_of_modules.md index bd3e58997..190b567e9 100644 --- a/docs/modules/list_of_modules.md +++ b/docs/modules/list_of_modules.md @@ -14,7 +14,6 @@ | bucket_google | scan | No | Check for Google object storage related to target | active, cloud-enum, safe, web-basic, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | | bypass403 | scan | No | Check 403 pages for common bypasses | active, aggressive, web-thorough | URL | FINDING | | dastardly | scan | No | Lightweight web application security scanner | active, aggressive, deadly, slow, web-thorough | HTTP_RESPONSE | FINDING, VULNERABILITY | -| dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | active, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | | dotnetnuke | scan | No | Scan for critical DotNetNuke (DNN) vulnerabilities | active, aggressive, web-thorough | HTTP_RESPONSE | TECHNOLOGY, VULNERABILITY | | ffuf | scan | No | A fast web fuzzer written in Go | active, aggressive, deadly | URL | URL_UNVERIFIED | | ffuf_shortnames | scan | No | Use ffuf in combination IIS shortnames | active, aggressive, iis-shortnames, web-thorough | URL_HINT | URL_UNVERIFIED | @@ -68,6 +67,7 @@ | dnscommonsrv | scan | No | Check for common SRV records | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | | dnsdumpster | scan | No | Query dnsdumpster for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | | docker_pull | scan | No | Download images from a docker repository | passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | +| dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | passive, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | | emailformat | scan | No | Query email-format.com for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | | fullhunt | scan | Yes | Query the fullhunt.io API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | | git_clone | scan | No | Clone code github repositories | passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | diff --git a/docs/scanning/index.md b/docs/scanning/index.md index 4d522a309..0cf499891 100644 --- a/docs/scanning/index.md +++ b/docs/scanning/index.md @@ -110,9 +110,9 @@ A single module can have multiple flags. For example, the `securitytrails` modul | Flag | # Modules | Description | Modules | |------------------|-------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | safe | 80 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, filedownload, fingerprintx, fullhunt, git, git_clone, github_codesearch, github_org, gitlab, gowitness, hackertarget, httpx, hunt, hunterio, iis_shortnames, internetdb, ip2location, ipstack, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wappalyzer, wayback, zoomeye | -| passive | 59 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wayback, zoomeye | +| passive | 60 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, urlscan, viewdns, virustotal, wayback, zoomeye | | subdomain-enum | 45 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, massdns, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, sslcert, subdomaincenter, subdomains, threatminer, urlscan, virustotal, wayback, zoomeye | -| active | 43 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dockerhub, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer | +| active | 42 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer | | web-thorough | 29 | More advanced web scanning functionality | ajaxpro, azure_realm, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf_shortnames, filedownload, generic_ssrf, git, host_header, httpx, hunt, iis_shortnames, nmap, ntlm, oauth, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, wappalyzer | | aggressive | 20 | Generates a large amount of network traffic | bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, masscan, massdns, nmap, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f | | web-basic | 17 | Basic, non-intrusive web scan functionality | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | From 76efe687db71498431b83bfd051f1fbd45f40929 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 15:13:43 -0400 Subject: [PATCH 062/220] Ensure bbot config --- bbot/core/config/files.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/core/config/files.py b/bbot/core/config/files.py index 6547d02ec..0f05c0b50 100644 --- a/bbot/core/config/files.py +++ b/bbot/core/config/files.py @@ -49,6 +49,7 @@ def _get_config(self, filename, name="config"): return OmegaConf.create() def get_custom_config(self): + self.ensure_config_file() return self._get_config(self.config_filename, name="config") def get_default_config(self): From 947c2d52dbe866daa9dcf8bc2c36fe8d5a3372fa Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 15:38:01 -0400 Subject: [PATCH 063/220] small boost in import speed --- bbot/core/config/files.py | 1 + bbot/core/core.py | 7 +++---- bbot/scanner/__init__.py | 1 - bbot/scanner/scanner.py | 10 ++++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/bbot/core/config/files.py b/bbot/core/config/files.py index 0f05c0b50..7bf568015 100644 --- a/bbot/core/config/files.py +++ b/bbot/core/config/files.py @@ -49,6 +49,7 @@ def _get_config(self, filename, name="config"): return OmegaConf.create() def get_custom_config(self): + assert False self.ensure_config_file() return self._get_config(self.config_filename, name="config") diff --git a/bbot/core/core.py b/bbot/core/core.py index 1c43e5035..d808f810f 100644 --- a/bbot/core/core.py +++ b/bbot/core/core.py @@ -52,10 +52,6 @@ def __init__(self): self._config = None self._custom_config = None - # ensure bbot home dir - if not "home" in self.config: - self.custom_config["home"] = "~/.bbot" - # bare minimum == logging self.logger self.log = logging.getLogger("bbot.core") @@ -105,6 +101,9 @@ def default_config(self): global DEFAULT_CONFIG if DEFAULT_CONFIG is None: self.default_config = self.files_config.get_default_config() + # ensure bbot home dir + if not "home" in self.default_config: + self.default_config["home"] = "~/.bbot" return DEFAULT_CONFIG @default_config.setter diff --git a/bbot/scanner/__init__.py b/bbot/scanner/__init__.py index 1622f4c20..cc993af8a 100644 --- a/bbot/scanner/__init__.py +++ b/bbot/scanner/__init__.py @@ -1,2 +1 @@ -from .preset import Preset from .scanner import Scanner diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 06f55c340..69b26c30f 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -11,10 +11,6 @@ from bbot import __version__ - -from .preset import Preset -from .stats import ScanStats -from .dispatcher import Dispatcher from bbot.core.event import make_event from .manager import ScanIngress, ScanEgress from bbot.core.helpers.misc import sha1, rand_string @@ -126,6 +122,8 @@ def __init__( preset = kwargs.pop("preset", None) kwargs["_log"] = True if preset is None: + from .preset import Preset + preset = Preset(*targets, **kwargs) else: if not isinstance(preset, Preset): @@ -168,11 +166,15 @@ def __init__( self.dummy_modules = {} if dispatcher is None: + from .dispatcher import Dispatcher + self.dispatcher = Dispatcher() else: self.dispatcher = dispatcher self.dispatcher.set_scan(self) + from .stats import ScanStats + self.stats = ScanStats(self) # scope distance From ad2e3286a6f57837a8f3a1014e011fd01f056eef Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 15:38:57 -0400 Subject: [PATCH 064/220] remove assertion --- bbot/core/config/files.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/core/config/files.py b/bbot/core/config/files.py index 7bf568015..0f05c0b50 100644 --- a/bbot/core/config/files.py +++ b/bbot/core/config/files.py @@ -49,7 +49,6 @@ def _get_config(self, filename, name="config"): return OmegaConf.create() def get_custom_config(self): - assert False self.ensure_config_file() return self._get_config(self.config_filename, name="config") From 8912aa22f169ceedf40a99633b7567826d7d0232 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 15:42:15 -0400 Subject: [PATCH 065/220] fix import error --- bbot/scanner/scanner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 69b26c30f..f7c22a859 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -121,8 +121,9 @@ def __init__( preset = kwargs.pop("preset", None) kwargs["_log"] = True + from .preset import Preset + if preset is None: - from .preset import Preset preset = Preset(*targets, **kwargs) else: From 3e63198008b1ecba08bbb8e9e16ab895bf9f3815 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 15:42:32 -0400 Subject: [PATCH 066/220] formatting --- bbot/scanner/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index f7c22a859..541c2d7a9 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -121,10 +121,10 @@ def __init__( preset = kwargs.pop("preset", None) kwargs["_log"] = True + from .preset import Preset if preset is None: - preset = Preset(*targets, **kwargs) else: if not isinstance(preset, Preset): From 4d8e7cdd9743b687070bbc04482a9af2313ba1a9 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 16:21:31 -0400 Subject: [PATCH 067/220] improve import friendliness --- bbot/__init__.py | 2 ++ bbot/scanner/__init__.py | 1 + bbot/scripts/docs.py | 2 +- bbot/test/test_step_1/test__module__tests.py | 2 +- bbot/test/test_step_1/test_python_api.py | 2 +- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bbot/__init__.py b/bbot/__init__.py index 8e016095f..8746d8131 100644 --- a/bbot/__init__.py +++ b/bbot/__init__.py @@ -1,2 +1,4 @@ # version placeholder (replaced by poetry-dynamic-versioning) __version__ = "v0.0.0" + +from .scanner import Scanner, Preset diff --git a/bbot/scanner/__init__.py b/bbot/scanner/__init__.py index cc993af8a..1622f4c20 100644 --- a/bbot/scanner/__init__.py +++ b/bbot/scanner/__init__.py @@ -1 +1,2 @@ +from .preset import Preset from .scanner import Scanner diff --git a/bbot/scripts/docs.py b/bbot/scripts/docs.py index f8a5050a3..98bd48a2b 100755 --- a/bbot/scripts/docs.py +++ b/bbot/scripts/docs.py @@ -5,7 +5,7 @@ import yaml from pathlib import Path -from bbot.scanner import Preset +from bbot import Preset DEFAULT_PRESET = Preset() diff --git a/bbot/test/test_step_1/test__module__tests.py b/bbot/test/test_step_1/test__module__tests.py index 9d88b1bcc..791e58f58 100644 --- a/bbot/test/test_step_1/test__module__tests.py +++ b/bbot/test/test_step_1/test__module__tests.py @@ -2,7 +2,7 @@ import importlib from pathlib import Path -from bbot.scanner import Preset +from bbot import Preset from ..test_step_2.module_tests.base import ModuleTestBase log = logging.getLogger("bbot.test.modules") diff --git a/bbot/test/test_step_1/test_python_api.py b/bbot/test/test_step_1/test_python_api.py index 19575e3ed..678593ed1 100644 --- a/bbot/test/test_step_1/test_python_api.py +++ b/bbot/test/test_step_1/test_python_api.py @@ -3,7 +3,7 @@ @pytest.mark.asyncio async def test_python_api(): - from bbot.scanner import Scanner + from bbot import Scanner # make sure events are properly yielded scan1 = Scanner("127.0.0.1") From 63afacbebc110db702a7ad9652c2ad26d7362d23 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 19:34:49 -0400 Subject: [PATCH 068/220] fix github_org tests --- .../module_tests/test_module_github_org.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_github_org.py b/bbot/test/test_step_2/module_tests/test_module_github_org.py index 5d999a721..4a368d908 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_org.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_org.py @@ -399,7 +399,7 @@ class TestGithub_Org_Custom_Target(TestGithub_Org): config_overrides = {"scope_report_distance": 10, "omit_event_types": [], "speculate": True} def check(self, module_test, events): - assert len(events) == 7 + assert len(events) == 8 assert 1 == len( [e for e in events if e.type == "ORG_STUB" and e.data == "blacklanternsecurity" and e.scope_distance == 1] ) @@ -411,6 +411,20 @@ def check(self, module_test, events): and e.data["platform"] == "github" and e.data["profile_name"] == "blacklanternsecurity" and e.scope_distance == 1 + and str(e.module) == "social" + and e.source.type == "URL_UNVERIFIED" + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and e.scope_distance == 1 + and str(e.module) == "github_org" + and e.source.type == "ORG_STUB" ] ) assert 1 == len( From 47161e5d5a225c6edce5c5bfd1cb1cba647a5a32 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 20:22:48 -0400 Subject: [PATCH 069/220] fix cli tests --- bbot/test/test_step_1/test_cli.py | 160 +++++++++++++++--------------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index 524557fac..63eb8334b 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -65,148 +65,148 @@ async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config): assert len(out.splitlines()) == 1 assert out.count(".") > 1 + # list modules + monkeypatch.setattr("sys.argv", ["bbot", "--list-modules"]) + result = await cli._main() + assert result == None + out, err = capsys.readouterr() # internal modules - assert "| excavate" in caplog.text + assert "| excavate" in out # output modules - assert "| csv" in caplog.text + assert "| csv" in out # scan modules - assert "| wayback" in caplog.text + assert "| wayback" in out + + # output dir and scan name + output_dir = bbot_test_dir / "bbot_cli_args_output" + scan_name = "bbot_cli_args_scan_name" + scan_dir = output_dir / scan_name + assert not output_dir.exists() + monkeypatch.setattr("sys.argv", ["bbot", "-o", str(output_dir), "-n", scan_name, "-y"]) + result = await cli._main() + assert result == True + assert output_dir.is_dir() + assert scan_dir.is_dir() + assert "[SCAN]" in open(scan_dir / "output.txt").read() + assert "[INFO]" in open(scan_dir / "scan.log").read() # list module options - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "--list-module-options"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| modules.wayback.urls" in caplog.text - assert "| bool" in caplog.text - assert "| emit URLs in addition to DNS_NAMEs" in caplog.text - assert "| False" in caplog.text - assert "| modules.dnsbrute.wordlist" in caplog.text - assert "| modules.robots.include_allow" in caplog.text + assert "| modules.wayback.urls" in out + assert "| bool" in out + assert "| emit URLs in addition to DNS_NAMEs" in out + assert "| False" in out + assert "| modules.dnsbrute.wordlist" in out + assert "| modules.robots.include_allow" in out # list module options by flag - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "--list-module-options"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| modules.wayback.urls" in caplog.text - assert "| bool" in caplog.text - assert "| emit URLs in addition to DNS_NAMEs" in caplog.text - assert "| False" in caplog.text - assert "| modules.dnsbrute.wordlist" in caplog.text - assert not "| modules.robots.include_allow" in caplog.text + assert "| modules.wayback.urls" in out + assert "| bool" in out + assert "| emit URLs in addition to DNS_NAMEs" in out + assert "| False" in out + assert "| modules.dnsbrute.wordlist" in out + assert not "| modules.robots.include_allow" in out # list module options by module - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-m", "dnsbrute", "-lmo"]) result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert out.count("modules.") == out.count("modules.dnsbrute.") + assert not "| modules.wayback.urls" in out + assert "| modules.dnsbrute.wordlist" in out + assert not "| modules.robots.include_allow" in out + + # list output module options by module + monkeypatch.setattr("sys.argv", ["bbot", "-om", "stdout", "-lmo"]) + result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert not "| modules.wayback.urls" in caplog.text - assert "| modules.dnsbrute.wordlist" in caplog.text - assert not "| modules.robots.include_allow" in caplog.text + assert out.count("modules.") == out.count("modules.stdout.") # list flags - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "--list-flags"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| safe" in caplog.text - assert "| Non-intrusive, safe to run" in caplog.text - assert "| active" in caplog.text - assert "| passive" in caplog.text + assert "| safe" in out + assert "| Non-intrusive, safe to run" in out + assert "| active" in out + assert "| passive" in out # list only a single flag - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "--list-flags"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert not "| safe" in caplog.text - assert "| active" in caplog.text - assert not "| passive" in caplog.text + assert not "| safe" in out + assert "| active" in out + assert not "| passive" in out # list multiple flags - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "safe", "--list-flags"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| safe" in caplog.text - assert "| active" in caplog.text - assert not "| passive" in caplog.text - - # custom target type - caplog.clear() - assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-t", "ORG:evilcorp"]) - result = await cli._main() - assert result == True - assert "[ORG_STUB] evilcorp TARGET" in caplog.text - - # activate modules by flag - caplog.clear() - assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-f", "passive"]) - result = await cli._main() - assert result == True + assert "| safe" in out + assert "| active" in out + assert not "| passive" in out # no args - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "Target:\n -t TARGET [TARGET ...]" in caplog.text + assert "Target:\n -t TARGET [TARGET ...]" in out # list modules - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-l"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| dnsbrute" in caplog.text - assert "| httpx" in caplog.text - assert "| robots" in caplog.text + assert "| dnsbrute " in out + assert "| httpx" in out + assert "| robots" in out # list modules by flag - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-l"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| dnsbrute" in caplog.text - assert "| httpx" in caplog.text - assert not "| robots" in caplog.text + assert "| dnsbrute " in out + assert "| httpx" in out + assert not "| robots" in out # list modules by flag + required flag - caplog.clear() monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-rf", "passive", "-l"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| dnsbrute" in caplog.text - assert not "| httpx" in caplog.text + assert "| dnsbrute " in out + assert not "| httpx" in out # list modules by flag + excluded flag - caplog.clear() - assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-ef", "active", "-l"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert "| dnsbrute" in caplog.text - assert not "| httpx" in caplog.text + assert "| dnsbrute " in out + assert not "| httpx" in out # list modules by flag + excluded module - caplog.clear() - assert not caplog.text - monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "dnsbrute_mutations", "-l"]) + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "-l"]) result = await cli._main() + out, err = capsys.readouterr() assert result == None - assert not "| dnsbrute" in caplog.text - assert "| httpx" in caplog.text + assert not "| dnsbrute " in out + assert "| httpx" in out # output modules override caplog.clear() From 07a061c4fb8f1015af0601f337f6e4d340e39a29 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 20:30:06 -0400 Subject: [PATCH 070/220] restore --install-all-deps test --- bbot/test/test_step_1/test_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index b658f36bc..0d36408c0 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -309,9 +309,9 @@ async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config): assert result == True, "-m nuclei failed to run with --allow-deadly" # install all deps - # monkeypatch.setattr("sys.argv", ["bbot", "--install-all-deps"]) - # success = await cli._main() - # assert success, "--install-all-deps failed for at least one module" + monkeypatch.setattr("sys.argv", ["bbot", "--install-all-deps"]) + success = await cli._main() + assert success == True, "--install-all-deps failed for at least one module" def test_cli_config_validation(monkeypatch, caplog): From 31fb52bb45ba43fde955d323f7cb18010e3a30ce Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sat, 4 May 2024 21:45:27 -0400 Subject: [PATCH 071/220] fix dnsbrute tests --- bbot/test/test_step_2/module_tests/test_module_dnsbrute.py | 5 ++++- .../module_tests/test_module_dnsbrute_mutations.py | 1 + .../test_step_2/module_tests/test_module_dnscommonsrv.py | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py index bdbd2f6cb..d1c5e5cc9 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute.py @@ -12,7 +12,9 @@ async def setup_after_prep(self, module_test): async def new_run_live(*command, check=False, text=True, **kwargs): if "massdns" in command[:2]: - yield """{"name": "asdf.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "asdf.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" + _input = [l async for l in kwargs["input"]] + if "asdf.blacklanternsecurity.com" in _input: + yield """{"name": "asdf.blacklanternsecurity.com.", "type": "A", "class": "IN", "status": "NOERROR", "rx_ts": 1713974911725326170, "data": {"answers": [{"ttl": 86400, "type": "A", "class": "IN", "name": "asdf.blacklanternsecurity.com.", "data": "1.2.3.4."}]}, "flags": ["rd", "ra"], "resolver": "195.226.187.130:53", "proto": "UDP"}""" else: async for _ in old_run_live(*command, check=False, text=True, **kwargs): yield _ @@ -21,6 +23,7 @@ async def new_run_live(*command, check=False, text=True, **kwargs): await module_test.mock_dns( { + "blacklanternsecurity.com": {"A": ["4.3.2.1"]}, "asdf.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, } ) diff --git a/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py b/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py index 2a56b2b65..0a7627f25 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnsbrute_mutations.py @@ -31,6 +31,7 @@ async def new_run_live(*command, check=False, text=True, **kwargs): await module_test.mock_dns( { + "blacklanternsecurity.com": {"A": ["1.2.3.4"]}, # targets "rrrr.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, "asdff-ffdsa.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py index 3d3d670e1..8d54f4e3a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py +++ b/bbot/test/test_step_2/module_tests/test_module_dnscommonsrv.py @@ -10,7 +10,9 @@ async def setup_after_prep(self, module_test): async def new_run_live(*command, check=False, text=True, **kwargs): if "massdns" in command[:2]: - yield """{"name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","type":"SRV","class":"IN","status":"NOERROR","rx_ts":1713974911725326170,"data":{"answers":[{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."},{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."}]},"flags":["rd","ra"],"resolver":"195.226.187.130:53","proto":"UDP"}""" + _input = [l async for l in kwargs["input"]] + if "_ldap._tcp.gc._msdcs.blacklanternsecurity.com" in _input: + yield """{"name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","type":"SRV","class":"IN","status":"NOERROR","rx_ts":1713974911725326170,"data":{"answers":[{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."},{"ttl":86400,"type":"SRV","class":"IN","name":"_ldap._tcp.gc._msdcs.blacklanternsecurity.com.","data":"10 10 1720 asdf.blacklanternsecurity.com."}]},"flags":["rd","ra"],"resolver":"195.226.187.130:53","proto":"UDP"}""" else: async for _ in old_run_live(*command, check=False, text=True, **kwargs): yield _ From 00759bc4a8adf74315263b21f76346d3d4a7dd99 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Sun, 5 May 2024 10:19:12 -0400 Subject: [PATCH 072/220] better pickle/unpickle error handling --- bbot/core/engine.py | 63 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/bbot/core/engine.py b/bbot/core/engine.py index 24781ab3b..269e7c645 100644 --- a/bbot/core/engine.py +++ b/bbot/core/engine.py @@ -16,18 +16,53 @@ CMD_EXIT = 1000 -class EngineClient: +error_sentinel = object() + + +class EngineBase: + def __init__(self): + self.log = logging.getLogger(f"bbot.core.{self.__class__.__name__.lower()}") + + def pickle(self, obj): + try: + return pickle.dumps(obj) + except Exception as e: + self.log.error(f"Error serializing object: {obj}: {e}") + self.log.trace(traceback.format_exc()) + return error_sentinel + + def unpickle(self, binary): + try: + return pickle.loads(binary) + except Exception as e: + self.log.error(f"Error deserializing binary: {e}") + self.log.trace(f"Offending binary: {binary}") + self.log.trace(traceback.format_exc()) + return error_sentinel + + def check_error(self, message): + if message is error_sentinel: + return True + if isinstance(message, dict) and len(message) == 1 and "_e" in message: + error, trace = message["_e"] + self.log.error(error) + self.log.trace(trace) + return True + return False + + +class EngineClient(EngineBase): SERVER_CLASS = None def __init__(self, **kwargs): + super().__init__() self.name = f"EngineClient {self.__class__.__name__}" if self.SERVER_CLASS is None: raise ValueError(f"Must set EngineClient SERVER_CLASS, {self.SERVER_CLASS}") self.CMDS = dict(self.SERVER_CLASS.CMDS) for k, v in list(self.CMDS.items()): self.CMDS[v] = k - self.log = logging.getLogger(f"bbot.core.{self.__class__.__name__.lower()}") self.socket_address = f"zmq_{rand_string(8)}.sock" self.socket_path = Path(tempfile.gettempdir()) / self.socket_address self.server_kwargs = kwargs.pop("server_kwargs", {}) @@ -38,10 +73,12 @@ def __init__(self, **kwargs): async def run_and_return(self, command, **kwargs): async with self.new_socket() as socket: message = self.make_message(command, args=kwargs) + if message is error_sentinel: + return await socket.send(message) binary = await socket.recv() # self.log.debug(f"{self.name}.{command}({kwargs}) got binary: {binary}") - message = pickle.loads(binary) + message = self.unpickle(binary) self.log.debug(f"{self.name}.{command}({kwargs}) got message: {message}") # error handling if self.check_error(message): @@ -50,26 +87,20 @@ async def run_and_return(self, command, **kwargs): async def run_and_yield(self, command, **kwargs): message = self.make_message(command, args=kwargs) + if message is error_sentinel: + return async with self.new_socket() as socket: await socket.send(message) while 1: binary = await socket.recv() # self.log.debug(f"{self.name}.{command}({kwargs}) got binary: {binary}") - message = pickle.loads(binary) + message = self.unpickle(binary) self.log.debug(f"{self.name}.{command}({kwargs}) got message: {message}") # error handling if self.check_error(message) or self.check_stop(message): break yield message - def check_error(self, message): - if isinstance(message, dict) and len(message) == 1 and "_e" in message: - error, trace = message["_e"] - self.log.error(error) - self.log.trace(trace) - return True - return False - def check_stop(self, message): if isinstance(message, dict) and len(message) == 1 and "_s" in message: return True @@ -130,12 +161,12 @@ def cleanup(self): self.socket_path.unlink(missing_ok=True) -class EngineServer: +class EngineServer(EngineBase): CMDS = {} def __init__(self, socket_path): - self.log = logging.getLogger(f"bbot.core.{self.__class__.__name__.lower()}") + super().__init__() self.name = f"EngineServer {self.__class__.__name__}" if socket_path is not None: # create ZeroMQ context @@ -178,8 +209,10 @@ async def worker(self): try: while 1: client_id, binary = await self.socket.recv_multipart() - message = pickle.loads(binary) + message = self.unpickle(binary) self.log.debug(f"{self.name} got message: {message}") + if self.check_error(message): + continue cmd = message.get("c", None) if not isinstance(cmd, int): From ce3bd7bdf1fa2092807d0c1bbc5e1e3d6a49a9ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 May 2024 04:25:23 +0000 Subject: [PATCH 073/220] Bump werkzeug from 3.0.2 to 3.0.3 Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.0.2 to 3.0.3. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: werkzeug dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index d47650bf4..1f6d2500e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -942,6 +942,7 @@ files = [ {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0"}, {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1"}, {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863"}, + {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6241d4eee5f89453307c2f2bfa03b50362052ca0af1efecf9fef9a41a22bb4f"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9"}, {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218"}, @@ -966,7 +967,6 @@ files = [ {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9e2addd2d1866fe112bc6f80117bcc6bc25191c5ed1bfbcf9f1386a884252ae8"}, {file = "lxml-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:f51969bac61441fd31f028d7b3b45962f3ecebf691a510495e5d2cd8c8092dbd"}, {file = "lxml-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b58fbfa1bf7367dde8a557994e3b1637294be6cf2169810375caf8571a085c"}, - {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3e183c6e3298a2ed5af9d7a356ea823bccaab4ec2349dc9ed83999fd289d14d5"}, {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:804f74efe22b6a227306dd890eecc4f8c59ff25ca35f1f14e7482bbce96ef10b"}, {file = "lxml-5.2.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:08802f0c56ed150cc6885ae0788a321b73505d2263ee56dad84d200cab11c07a"}, {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f8c09ed18ecb4ebf23e02b8e7a22a05d6411911e6fabef3a36e4f371f4f2585"}, @@ -2109,6 +2109,7 @@ optional = false python-versions = "*" files = [ {file = "requests-file-2.0.0.tar.gz", hash = "sha256:20c5931629c558fda566cacc10cfe2cd502433e628f568c34c80d96a0cc95972"}, + {file = "requests_file-2.0.0-py2.py3-none-any.whl", hash = "sha256:3e493d390adb44aa102ebea827a48717336d5268968c370eaf19abaf5cae13bf"}, ] [package.dependencies] @@ -2451,13 +2452,13 @@ files = [ [[package]] name = "werkzeug" -version = "3.0.2" +version = "3.0.3" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.8" files = [ - {file = "werkzeug-3.0.2-py3-none-any.whl", hash = "sha256:3aac3f5da756f93030740bc235d3e09449efcf65f2f55e3602e1d851b8f48795"}, - {file = "werkzeug-3.0.2.tar.gz", hash = "sha256:e39b645a6ac92822588e7b39a692e7828724ceae0b0d702ef96701f90e70128d"}, + {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, + {file = "werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18"}, ] [package.dependencies] From f80ccac1d09d3cd23f57161ec41facb289f39ff9 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Mon, 6 May 2024 12:59:05 -0400 Subject: [PATCH 074/220] misc tweaks --- bbot/core/event/base.py | 3 +- bbot/modules/base.py | 6 +- bbot/modules/internal/dns.py | 2 +- bbot/modules/output/base.py | 1 + bbot/scanner/scanner.py | 2 +- bbot/test/test_step_1/test_dns.py | 104 ++++++++++++++------ bbot/test/test_step_1/test_modules_basic.py | 1 - 7 files changed, 85 insertions(+), 34 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index ff0af03ac..ac9ca654d 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -631,6 +631,7 @@ def json(self, mode="json", siem_friendly=False): j["timestamp"] = self.timestamp.timestamp() if self.host: j["resolved_hosts"] = sorted(str(h) for h in self.resolved_hosts) + j["dns_children"] = {k: list(v) for k, v in self.dns_children.items()} source_id = self.source_id if source_id: j["source"] = source_id @@ -645,7 +646,7 @@ def json(self, mode="json", siem_friendly=False): for k, v in list(j.items()): if k == "data": continue - if type(v) not in (str, int, float, bool, list, type(None)): + if type(v) not in (str, int, float, bool, list, dict, type(None)): try: j[k] = json.dumps(v, sort_keys=True) except Exception: diff --git a/bbot/modules/base.py b/bbot/modules/base.py index 6c048c9a2..bf8d5b8fe 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -112,6 +112,7 @@ class BaseModule: _name = "base" _type = "scan" _intercept = False + _shuffle_incoming_queue = True def __init__(self, scan): """Initializes a module instance. @@ -1089,7 +1090,10 @@ def config(self): @property def incoming_event_queue(self): if self._incoming_event_queue is None: - self._incoming_event_queue = ShuffleQueue() + if self._shuffle_incoming_queue: + self._incoming_event_queue = ShuffleQueue() + else: + self._incoming_event_queue = asyncio.Queue() return self._incoming_event_queue @property diff --git a/bbot/modules/internal/dns.py b/bbot/modules/internal/dns.py index 29e060626..f501fb08f 100644 --- a/bbot/modules/internal/dns.py +++ b/bbot/modules/internal/dns.py @@ -220,7 +220,7 @@ async def handle_wildcard_event(self, event): event.add_tag(f"{rdtype.lower()}-{wildcard_tag}") # wildcard event modification (www.evilcorp.com --> _wildcard.evilcorp.com) - if wildcard_rdtypes: + if wildcard_rdtypes and not "target" in event.tags: # these are the rdtypes that successfully resolve resolved_rdtypes = set([c.upper() for c in event.dns_children]) # these are the rdtypes that have wildcards diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index a934ec009..8a6eba9eb 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -8,6 +8,7 @@ class BaseOutputModule(BaseModule): _type = "output" scope_distance_modifier = None _stats_exclude = True + _shuffle_incoming_queue = False def human_event_str(self, event): event_type = f"[{event.type}]" diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 2dbd46569..3beb98252 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -547,7 +547,7 @@ def queued_event_types(self): queues.add(module.outgoing_event_queue) for q in queues: - for item in q._queue: + for item in getattr(q, "_queue", []): try: event, _ = item except ValueError: diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index aa2a27907..0e8ca141b 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -222,44 +222,90 @@ async def test_wildcards(bbot_scanner): from bbot.scanner import Scanner # test with full scan - scan2 = Scanner("asdfl.gashdgkjsadgsdf.github.io", config={"dns_resolution": True}) + scan2 = Scanner("asdfl.gashdgkjsadgsdf.github.io", whitelist=["github.io"], config={"dns_resolution": True}) + await scan2._prep() + other_event = scan2.make_event( + "lkjg.sdfgsg.jgkhajshdsadf.github.io", module=scan2.modules["dns"], source=scan2.root_event + ) + await scan2.ingress_module.queue_event(other_event, {}) events = [e async for e in scan2.async_start()] - assert len(events) == 2 + assert len(events) == 3 assert 1 == len([e for e in events if e.type == "SCAN"]) + unmodified_wildcard_events = [ + e for e in events if e.type == "DNS_NAME" and e.data == "asdfl.gashdgkjsadgsdf.github.io" + ] + assert len(unmodified_wildcard_events) == 1 + assert unmodified_wildcard_events[0].tags == { + "a-record", + "target", + "aaaa-wildcard", + "in-scope", + "subdomain", + "cdn-github", + "aaaa-record", + "wildcard", + "a-wildcard", + } + modified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and e.data == "_wildcard.github.io"] + assert len(modified_wildcard_events) == 1 + assert modified_wildcard_events[0].tags == { + "a-record", + "aaaa-wildcard", + "in-scope", + "subdomain", + "cdn-github", + "aaaa-record", + "wildcard", + "a-wildcard", + } + assert modified_wildcard_events[0].host_original == "lkjg.sdfgsg.jgkhajshdsadf.github.io" + + # test with full scan (wildcard detection disabled for domain) + scan2 = Scanner( + "asdfl.gashdgkjsadgsdf.github.io", + whitelist=["github.io"], + config={"dns_wildcard_ignore": ["github.io"], "dns_resolution": True}, + ) + await scan2._prep() + other_event = scan2.make_event( + "lkjg.sdfgsg.jgkhajshdsadf.github.io", module=scan2.modules["dns"], source=scan2.root_event + ) + await scan2.ingress_module.queue_event(other_event, {}) + events = [e async for e in scan2.async_start()] + assert len(events) == 3 + assert 1 == len([e for e in events if e.type == "SCAN"]) + unmodified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and "_wildcard" not in e.data] + assert len(unmodified_wildcard_events) == 2 assert 1 == len( [ e - for e in events - if e.type == "DNS_NAME" - and e.data == "_wildcard.github.io" - and all( - t in e.tags - for t in ( - "a-record", - "target", - "aaaa-wildcard", - "in-scope", - "subdomain", - "aaaa-record", - "wildcard", - "a-wildcard", - ) - ) + for e in unmodified_wildcard_events + if e.data == "asdfl.gashdgkjsadgsdf.github.io" + and e.tags + == { + "target", + "a-record", + "in-scope", + "subdomain", + "cdn-github", + "aaaa-record", + } ] ) - - # test with full scan (wildcard detection disabled for domain) - scan2 = Scanner("asdfl.gashdgkjsadgsdf.github.io", config={"dns_wildcard_ignore": ["github.io"]}) - events = [e async for e in scan2.async_start()] - assert len(events) == 2 - assert 1 == len([e for e in events if e.type == "SCAN"]) assert 1 == len( [ e - for e in events - if e.type == "DNS_NAME" - and e.data == "asdfl.gashdgkjsadgsdf.github.io" - and all(t in e.tags for t in ("a-record", "target", "in-scope", "subdomain", "aaaa-record")) - and not any(t in e.tags for t in ("wildcard", "a-wildcard", "aaaa-wildcard")) + for e in unmodified_wildcard_events + if e.data == "lkjg.sdfgsg.jgkhajshdsadf.github.io" + and e.tags + == { + "a-record", + "in-scope", + "subdomain", + "cdn-github", + "aaaa-record", + } ] ) + modified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and e.data == "_wildcard.github.io"] + assert len(modified_wildcard_events) == 0 diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 249953365..7f7618ef9 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -75,7 +75,6 @@ async def test_modules_basic(helpers, events, bbot_scanner, httpx_mock): # omitted event types should be rejected url_unverified = scan.make_event("http://127.0.0.1", "URL_UNVERIFIED", source=scan.root_event) result, reason = base_output_module_2._event_precheck(url_unverified) - log.critical(f"{url_unverified} / {result} / {reason}") assert result == False assert reason == "its type is omitted in the config" From 73caf0a96a729fbd0243f85b33895a4f3522c503 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 6 May 2024 17:18:00 -0400 Subject: [PATCH 075/220] adding paramminer extract test --- .../test_module_paramminer_headers.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py b/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py index 7cc8114e6..4e287ead3 100644 --- a/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py +++ b/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py @@ -69,3 +69,49 @@ def check(self, module_test, events): and "[Paramminer] Header: [tracestate] Reasons: [body] Reflection: [False]" in e.data["description"] for e in events ) + + +class TestParamminer_Headers_extract(Paramminer_Headers): + headers_body = """ + + the title + + Click Me + + + """ + + headers_body_match = """ + + the title + + Click Me +

Secret param "foo" found with value: AAAAAAAAAAAAAA

+ + + """ + + async def setup_after_prep(self, module_test): + module_test.scan.modules["paramminer_headers"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.monkeypatch.setattr( + helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} + ) + expect_args = dict(headers={"foo": "AAAAAAAAAAAAAA"}) + respond_args = {"response_data": self.headers_body_match} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + respond_args = {"response_data": self.headers_body} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + for e in events: + + print(e) + print(e.type) + assert any( + e.type == "FINDING" + and "[Paramminer] Header: [foo] Reasons: [body] Reflection: [True]" in e.data["description"] + for e in events + ) + + From 8178cdb0db6a4fd0dfda636fa8c671c90e572f8d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 6 May 2024 19:45:37 -0400 Subject: [PATCH 076/220] adding param validation --- bbot/core/helpers/misc.py | 50 ++++++++++++++++--- bbot/modules/paramminer_headers.py | 7 +-- bbot/test/test_step_1/test_helpers.py | 49 ++++++++++++++++++ .../test_module_paramminer_headers.py | 3 +- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index b77aaa2b7..f635daa4e 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -972,15 +972,21 @@ def extract_params_xml(xml_data): return tags -def extract_params_html(html_data): +def extract_params_html(html_data, compare_mode="getparam"): """ - Extracts parameters from an HTML object, yielding them one at a time. + Extracts parameters from an HTML object, yielding them one at a time. This function filters + these parameters based on a specified mode that determines the type of validation + or comparison against rules for headers, GET parameters, or cookies. If no mode is specified, + it defaults to 'getparam', which is the least restrictive. Args: html_data (str): HTML-formatted string. + compare_mode (str, optional): The mode to compare extracted parameter names against. + Defaults to 'getparam'. Valid modes are 'header', 'getparam', 'cookie'. Yields: - str: A string containing the parameter found in HTML object. + str: A string containing the parameter found in the HTML object that meets the + criteria of the specified mode. Examples: >>> html_data = ''' @@ -996,20 +1002,46 @@ def extract_params_html(html_data): ... ... ''' >>> list(extract_params_html(html_data)) - ['user', 'param2', 'param3'] + ['user', 'param1', 'param2', 'param3'] """ + + def validate_param(param, compare_mode): + if len(param) > 100: + return False + + # Define valid characters for each mode based on RFCs + valid_chars = { + "header": "".join( + chr(c) for c in range(33, 127) if chr(c) not in '"(),;:\\' + ), # HTTP headers exclude CTLs, SP, DQUOTE, comma, semicolon, and backslash + "getparam": "".join( + chr(c) for c in range(33, 127) if chr(c) not in ":/?#[]@!$&'()*+,;=" + ), # URI reserved characters should be avoided or percent-encoded + "cookie": "".join( + chr(c) for c in range(33, 127) if chr(c) not in ' ",;=\\' + ), # Cookies exclude spaces, quotes, comma, semicolon, equals, and backslash + } + + if compare_mode not in valid_chars: + raise ValueError(f"Invalid compare_mode: {compare_mode}") + + allowed_chars = set(valid_chars[compare_mode]) + return set(param).issubset(allowed_chars) + input_tag = bbot_regexes.input_tag_regex.findall(html_data) for i in input_tag: log.debug(f"FOUND PARAM ({i}) IN INPUT TAGS") - yield i + if validate_param(i, compare_mode): + yield i # check for jquery get parameters jquery_get = bbot_regexes.jquery_get_regex.findall(html_data) for i in jquery_get: log.debug(f"FOUND PARAM ({i}) IN JQUERY GET PARAMS") - yield i + if validate_param(i, compare_mode): + yield i # check for jquery post parameters jquery_post = bbot_regexes.jquery_post_regex.findall(html_data) @@ -1018,12 +1050,14 @@ def extract_params_html(html_data): for x in i.split(","): s = x.split(":")[0].rstrip() log.debug(f"FOUND PARAM ({s}) IN A JQUERY POST PARAMS") - yield s + if validate_param(s, compare_mode): + yield s a_tag = bbot_regexes.a_tag_regex.findall(html_data) for s in a_tag: log.debug(f"FOUND PARAM ({s}) IN A TAG GET PARAMS") - yield s + if validate_param(s, compare_mode): + yield s def extract_words(data, acronyms=True, wordninja=True, model=None, max_length=100, word_regexes=None): diff --git a/bbot/modules/paramminer_headers.py b/bbot/modules/paramminer_headers.py index c7418912f..23955d8a8 100644 --- a/bbot/modules/paramminer_headers.py +++ b/bbot/modules/paramminer_headers.py @@ -199,11 +199,11 @@ def load_extracted_words(self, body, content_type): if not body: return None if content_type and "json" in content_type.lower(): - return extract_params_json(body) + return extract_params_json(body, self.compare_mode) elif content_type and "xml" in content_type.lower(): - return extract_params_xml(body) + return extract_params_xml(body, self.compare_mode) else: - return set(extract_params_html(body)) + return set(extract_params_html(body, self.compare_mode)) async def binary_search(self, compare_helper, url, group, reasons=None, reflection=False): if reasons is None: @@ -249,4 +249,5 @@ async def finish(self): results = await self.do_mining(untested_matches_copy, url, batch_size, compare_helper) except HttpCompareError as e: self.debug(f"Encountered HttpCompareError: [{e}] for URL [{url}]") + continue await self.process_results(event, results) diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index c8045e595..70cd090c4 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -745,3 +745,52 @@ def test_liststring_invalidfnchars(helpers): with pytest.raises(ValueError) as e: helpers.parse_list_string("hello,world,bbot|test") assert str(e.value) == "Invalid character in string: bbot|test" + + +# test extract_params_html +def test_extract_params_html(helpers): + html_tests = """ + + + Test Links for Parameter Names + + + + Universal Valid + + + Mixed Validity + Token Examples + + + Common Web Names + API Style Names + + + Terrible Names + + + """ + extract_results = helpers.extract_params_html(html_tests) + valid_params = {"name", "valid_name", "session_token", "user-name", "client.id"} + invalid_params = { + "valid-name", + "invalid,name", + "auth-token", + "access_token", + " @@ -762,35 +766,110 @@ def test_extract_params_html(helpers): Mixed Validity Token Examples - - Common Web Names + + Common Web Names API Style Names - Terrible Names + Invalid + Invalid + Invalid + ", + "###$$$", + "this_parameter_name_is_seriously_way_too_long_to_be_practical_but_hey_look_its_still_technically_valid_wow", + "parens()", + } + getparam_extracted_params = set(getparam_extract_results) + + # Check that all valid parameters are present + for expected_param in getparam_valid_params: + assert expected_param in getparam_extracted_params, f"Missing expected parameter: {expected_param}" + + # Check that no invalid parameters are present + for bad_param in getparam_invalid_params: + assert bad_param not in getparam_extracted_params, f"Invalid parameter found: {bad_param}" + + header_extract_results = set(helpers.extract_params_html(html_tests, "header")) + header_valid_params = { + "name", + "age", + "valid_name", + "valid-name", + "session_token", + "user-name", "auth-token", "access_token", + "abcd", + "jqueryget", + } + header_invalid_params = { + "user.id", + "client.id", + "invalid,name", " - - - - - - - - - - - - - - From 1247d711c1401098f76a0f49ed70590564b50c57 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 02:53:32 -0400 Subject: [PATCH 209/220] fix azure_tenant tests --- bbot/modules/azure_tenant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/azure_tenant.py b/bbot/modules/azure_tenant.py index 7e4d136f6..bd4a9b3dc 100644 --- a/bbot/modules/azure_tenant.py +++ b/bbot/modules/azure_tenant.py @@ -51,7 +51,7 @@ async def handle_event(self, event): if tenantname: tenant_names.add(tenantname) - tenant_names.sort() + tenant_names = sorted(tenant_names) event_data = {"tenant-names": tenant_names, "domains": sorted(domains)} tenant_names_str = ",".join(tenant_names) if tenant_id is not None: From be814ebdde3bdbf6a8f92210ee17c78f0c2665ba Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 09:10:46 -0400 Subject: [PATCH 210/220] fix gowitness tests --- .../module_tests/test_module_gowitness.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_gowitness.py b/bbot/test/test_step_2/module_tests/test_module_gowitness.py index 3c3ce5f0e..7dd72b41a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_gowitness.py +++ b/bbot/test/test_step_2/module_tests/test_module_gowitness.py @@ -40,8 +40,8 @@ def check(self, module_test, events): screenshots_path = self.home_dir / "scans" / module_test.scan.name / "gowitness" / "screenshots" screenshots = list(screenshots_path.glob("*.png")) assert ( - len(screenshots) == 2 - ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 2" + len(screenshots) == 1 + ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 1" assert 1 == len([e for e in events if e.type == "URL" and e.data == "http://127.0.0.1:8888/"]) assert 1 == len( [e for e in events if e.type == "URL_UNVERIFIED" and e.data == "https://fonts.googleapis.com/"] @@ -50,8 +50,22 @@ def check(self, module_test, events): assert 1 == len( [e for e in events if e.type == "SOCIAL" and e.data["url"] == "http://127.0.0.1:8888/blacklanternsecurity"] ) - assert 2 == len([e for e in events if e.type == "WEBSCREENSHOT"]) + assert 1 == len([e for e in events if e.type == "WEBSCREENSHOT"]) assert 1 == len([e for e in events if e.type == "WEBSCREENSHOT" and e.data["url"] == "http://127.0.0.1:8888/"]) + assert len([e for e in events if e.type == "TECHNOLOGY"]) + + +class TestGowitness_Social(TestGowitness): + config_overrides = dict(TestGowitness.config_overrides) + config_overrides.update({"modules": {"gowitness": {"social": True}}}) + + def check(self, module_test, events): + screenshots_path = self.home_dir / "scans" / module_test.scan.name / "gowitness" / "screenshots" + screenshots = list(screenshots_path.glob("*.png")) + assert ( + len(screenshots) == 2 + ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 2" + assert 2 == len([e for e in events if e.type == "WEBSCREENSHOT"]) assert 1 == len( [ e @@ -59,7 +73,6 @@ def check(self, module_test, events): if e.type == "WEBSCREENSHOT" and e.data["url"] == "http://127.0.0.1:8888/blacklanternsecurity" ] ) - assert len([e for e in events if e.type == "TECHNOLOGY"]) assert 1 == len( [ e From cd91ec119298ba7fc69e4916c27e3ff06c736022 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 13:45:12 -0400 Subject: [PATCH 211/220] fix telerik context --- bbot/modules/telerik.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/telerik.py b/bbot/modules/telerik.py index 0bd68153e..a64ef3c6f 100644 --- a/bbot/modules/telerik.py +++ b/bbot/modules/telerik.py @@ -250,7 +250,7 @@ async def handle_event(self, event): }, "VULNERABILITY", event, - context=f"{{module}} scanned {event.data} and identified critical {{event.type}}: description", + context=f"{{module}} scanned {event.data} and identified critical {{event.type}}: {description}", ) break From 80444552ee9b7354c386f2d281089775c7e165a2 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 19:55:56 -0400 Subject: [PATCH 212/220] fix minor asn bug --- bbot/modules/report/asn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index a89e04fbf..32590c0dd 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -106,7 +106,7 @@ async def get_asn(self, ip, retries=1): continue return res self.warning(f"Error retrieving ASN for {ip}") - return [] + return [], "" async def get_asn_ripe(self, ip): url = f"https://stat.ripe.net/data/network-info/data.json?resource={ip}" From 9db4b8ddbb544b5287f2c2aaeb7702a7d3825e22 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 19:59:52 -0400 Subject: [PATCH 213/220] fix duplicate sslcert messages --- bbot/modules/sslcert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/sslcert.py b/bbot/modules/sslcert.py index 12339af7b..13b868feb 100644 --- a/bbot/modules/sslcert.py +++ b/bbot/modules/sslcert.py @@ -74,7 +74,7 @@ async def handle_event(self, event): f"Skipping Subject Alternate Names (SANs) on {netloc} because number of hostnames ({len(dns_names):,}) exceeds threshold ({abort_threshold})" ) dns_names = dns_names[:1] + [n for n in dns_names[1:] if self.scan.in_scope(n)] - for event_type, results in (("DNS_NAME", dns_names), ("EMAIL_ADDRESS", emails)): + for event_type, results in (("DNS_NAME", set(dns_names)), ("EMAIL_ADDRESS", emails)): for event_data in results: if event_data is not None and event_data != event: self.debug(f"Discovered new {event_type} via SSL certificate parsing: [{event_data}]") From 1c19d0f1037dedbd5b14c8ac092e610ce6bddbb9 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 20:04:15 -0400 Subject: [PATCH 214/220] fix iis shortnames bug --- bbot/modules/iis_shortnames.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bbot/modules/iis_shortnames.py b/bbot/modules/iis_shortnames.py index b25d1ee12..51ea113d6 100644 --- a/bbot/modules/iis_shortnames.py +++ b/bbot/modules/iis_shortnames.py @@ -67,11 +67,10 @@ async def directory_confirm(self, target, method, url_hint, affirmative_status_c directory_confirm_result = await self.helpers.request( method=method, url=url, allow_redirects=False, retries=2, timeout=10 ) - - if directory_confirm_result.status_code == affirmative_status_code: - return True - else: - return False + if directory_configm_result is not None: + if directory_confirm_result.status_code == affirmative_status_code: + return True + return False async def duplicate_check(self, target, method, url_hint, affirmative_status_code): duplicates = [] From 907f5caa66bda6dcdc0ec2837a9a266d3df8a548 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 28 May 2024 20:41:09 -0400 Subject: [PATCH 215/220] handle long filenames --- bbot/core/helpers/misc.py | 37 ++++++++++++++++++++++++++- bbot/core/helpers/web.py | 2 ++ bbot/modules/iis_shortnames.py | 2 +- bbot/test/test_step_1/test_helpers.py | 10 ++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index b77aaa2b7..568d8924a 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -1454,7 +1454,7 @@ def search_dict_values(d, *regexes): ... ] ... } ... } - >>> url_regexes = re.compile(r'https?://[^\\s<>"]+|www\.[^\\s<>"]+') + >>> url_regexes = re.compile(r'https?://[^\\s<>"]+|www\\.[^\\s<>"]+') >>> list(search_dict_values(dict_to_search, url_regexes)) ["https://www.evilcorp.com"] """ @@ -2666,3 +2666,38 @@ async def as_completed(coros): for task in done: tasks.pop(task) yield task + + +def truncate_filename(file_path, max_length=255): + """ + Truncate the filename while preserving the file extension to ensure the total path length does not exceed the maximum length. + + Args: + file_path (str): The original file path. + max_length (int): The maximum allowed length for the total path. Default is 255. + + Returns: + pathlib.Path: A new Path object with the truncated filename. + + Raises: + ValueError: If the directory path is too long to accommodate any filename within the limit. + + Example: + >>> truncate_filename('/path/to/example_long_filename.txt', 20) + PosixPath('/path/to/example.txt') + """ + p = Path(file_path) + directory, stem, suffix = p.parent, p.stem, p.suffix + + max_filename_length = max_length - len(str(directory)) - len(suffix) - 1 # 1 for the '/' separator + + if max_filename_length <= 0: + raise ValueError("The directory path is too long to accommodate any filename within the limit.") + + if len(stem) > max_filename_length: + truncated_stem = stem[:max_filename_length] + else: + truncated_stem = stem + + new_path = directory / (truncated_stem + suffix) + return new_path diff --git a/bbot/core/helpers/web.py b/bbot/core/helpers/web.py index adcebcfee..f560ad791 100644 --- a/bbot/core/helpers/web.py +++ b/bbot/core/helpers/web.py @@ -13,6 +13,7 @@ from httpx._models import Cookies from socksio.exceptions import SOCKSError +from bbot.core.helpers.misc import truncate_filename from bbot.core.errors import WordlistError, CurlError from bbot.core.helpers.ratelimiter import RateLimiter @@ -258,6 +259,7 @@ async def download(self, url, **kwargs): """ success = False filename = kwargs.pop("filename", self.parent_helper.cache_filename(url)) + filename = truncate_filename(filename) follow_redirects = kwargs.pop("follow_redirects", True) max_size = kwargs.pop("max_size", None) warn = kwargs.pop("warn", True) diff --git a/bbot/modules/iis_shortnames.py b/bbot/modules/iis_shortnames.py index 51ea113d6..86a98dc86 100644 --- a/bbot/modules/iis_shortnames.py +++ b/bbot/modules/iis_shortnames.py @@ -67,7 +67,7 @@ async def directory_confirm(self, target, method, url_hint, affirmative_status_c directory_confirm_result = await self.helpers.request( method=method, url=url, allow_redirects=False, retries=2, timeout=10 ) - if directory_configm_result is not None: + if directory_confirm_result is not None: if directory_confirm_result.status_code == affirmative_status_code: return True return False diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index c8045e595..8f515961d 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -469,6 +469,16 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https await helpers.wordlist("/tmp/a9pseoysadf/asdkgjaosidf") test_file.unlink() + # filename truncation + super_long_filename = "/tmp/" + ("a" * 1024) + ".txt" + with pytest.raises(OSError): + with open(super_long_filename, "w") as f: + f.write("wat") + truncated_filename = helpers.truncate_filename(super_long_filename) + with open(truncated_filename, "w") as f: + f.write("wat") + truncated_filename.unlink() + # misc DNS helpers assert helpers.is_ptr("wsc-11-22-33-44-wat.evilcorp.com") == True assert helpers.is_ptr("wsc-11-22-33-wat.evilcorp.com") == False From 72d2f77416e0ea626fa2709005a97b961efbb242 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 29 May 2024 00:54:36 -0400 Subject: [PATCH 216/220] rolling baddns version --- bbot/modules/baddns.py | 2 +- bbot/modules/baddns_zone.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/baddns.py b/bbot/modules/baddns.py index 9abfebc84..1bfbdec33 100644 --- a/bbot/modules/baddns.py +++ b/bbot/modules/baddns.py @@ -20,7 +20,7 @@ class baddns(BaseModule): "only_high_confidence": "Do not emit low-confidence or generic detections", } max_event_handlers = 8 - deps_pip = ["baddns~=1.1.0"] + deps_pip = ["baddns~=1.1.789"] def select_modules(self): selected_modules = [] diff --git a/bbot/modules/baddns_zone.py b/bbot/modules/baddns_zone.py index a42fe2e21..2b6ac27ae 100644 --- a/bbot/modules/baddns_zone.py +++ b/bbot/modules/baddns_zone.py @@ -18,7 +18,7 @@ class baddns_zone(baddns_module): "only_high_confidence": "Do not emit low-confidence or generic detections", } max_event_handlers = 8 - deps_pip = ["baddns~=1.1.0"] + deps_pip = ["baddns~=1.1.789"] def select_modules(self): selected_modules = [] From 6917afc6a7fda6336aa3dbec2a0816356642984a Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 29 May 2024 11:50:13 -0400 Subject: [PATCH 217/220] revised excavate context --- bbot/modules/internal/excavate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index ed9751b1d..25b26717b 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -431,7 +431,7 @@ async def handle_event(self, event): url_event.web_spider_distance = parent_web_spider_distance await self.emit_event( url_event, - context="{module} extracted {event.type}: {event.data} from redirect destination (Location header)", + context="{module} looked in \"Location\" header and found {event.type}: {event.data}", ) else: self.verbose(f"Exceeded max HTTP redirects ({self.max_redirects}): {location}") From 325d2edf67de295d9151a58e965d0bf003ffba0e Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Wed, 29 May 2024 12:15:51 -0400 Subject: [PATCH 218/220] blacked --- bbot/modules/internal/excavate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index 25b26717b..eef4ab26a 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -431,7 +431,7 @@ async def handle_event(self, event): url_event.web_spider_distance = parent_web_spider_distance await self.emit_event( url_event, - context="{module} looked in \"Location\" header and found {event.type}: {event.data}", + context='{module} looked in "Location" header and found {event.type}: {event.data}', ) else: self.verbose(f"Exceeded max HTTP redirects ({self.max_redirects}): {location}") From 79c59ed66eb675e1aaf91e1ebc24f60a6b46c830 Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Thu, 30 May 2024 12:17:19 -0400 Subject: [PATCH 219/220] potential --- bbot/modules/bypass403.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 38e8b570f..4f3b51789 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -147,7 +147,7 @@ async def handle_event(self, event): }, "FINDING", parent=event, - context=f"{{module}} discovered multiple 403 bypasses ({{event.type}}) for {event.data}", + context=f"{{module}} discovered multiple potential 403 bypasses ({{event.type}}) for {event.data}", ) else: for description in results: @@ -155,7 +155,7 @@ async def handle_event(self, event): {"description": description, "host": str(event.host), "url": event.data}, "FINDING", parent=event, - context=f"{{module}} discovered 403 bypass ({{event.type}}) for {event.data}", + context=f"{{module}} discovered potential 403 bypass ({{event.type}}) for {event.data}", ) # When a WAF-check helper is available in the future, we will convert to HTTP_RESPONSE and check for the WAF string here. From b229d4520f419e022fdda3a63566a7b21c35e4ab Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Tue, 18 Jun 2024 10:31:57 -0400 Subject: [PATCH 220/220] don't shuffle portscan's incoming queue --- bbot/modules/portscan.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/modules/portscan.py b/bbot/modules/portscan.py index 6a971a569..7a18306e9 100644 --- a/bbot/modules/portscan.py +++ b/bbot/modules/portscan.py @@ -42,6 +42,7 @@ class portscan(BaseModule): } deps_shared = ["masscan"] batch_size = 1000000 + _shuffle_incoming_queue = False async def setup(self): self.top_ports = self.config.get("top_ports", 100)